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

View file

@ -9,7 +9,7 @@ use std::path::{Path, PathBuf};
use std::sync::Mutex;
use anyhow::Result;
use hive_claude::{Claude, Config, Session, Sink};
use hive_claude::{Config, InfiniteSession, PercentPolicy, Sink};
use serde_json::Value;
use crate::events::{Bus, LiveEvent, TokenUsage};
@ -59,6 +59,10 @@ const DEFAULT_RATE_LIMIT_SLEEP_SECS: u64 = 300;
/// `0` to disable (always resume).
const DEFAULT_CACHE_TTL_SECS: u64 = 3600;
/// Default proactive-compaction watermark, as a percent of the effective
/// context window. Overridable via `HIVE_COMPACT_WATERMARK_PERCENT`.
const DEFAULT_COMPACT_PERCENT: u8 = 75;
/// Synthetic wake prompt for the proactive notes-checkpoint turn. Not an
/// inbox message — the harness injects it directly so the agent gets one
/// turn to persist durable state before `/compact` collapses the
@ -213,42 +217,51 @@ fn cache_ttl_secs() -> u64 {
env_u64_positive("HIVE_CACHE_TTL_SECS", DEFAULT_CACHE_TTL_SECS)
}
/// Resolve the proactive-compaction watermark. Priority order:
/// 1. `HIVE_COMPACT_WATERMARK_TOKENS` env var (explicit override).
/// 2. 75% of `effective_context_window(bus)`.
///
/// `0` disables proactive compaction (reactive path still applies).
fn compact_watermark_tokens(bus: &Bus) -> u64 {
env_u64("HIVE_COMPACT_WATERMARK_TOKENS").unwrap_or_else(|| effective_context_window(bus) * 3 / 4)
/// Proactive-compaction watermark as a percent of the effective context
/// window (default [`DEFAULT_COMPACT_PERCENT`]). `0` disables proactive
/// compaction — the reactive on-overflow path still applies. Reads
/// `HIVE_COMPACT_WATERMARK_PERCENT`; the legacy `autoCompact = false` switch
/// (which sets `HIVE_COMPACT_WATERMARK_TOKENS=0`) is still honoured as disable.
fn compact_percent() -> u8 {
if env_u64("HIVE_COMPACT_WATERMARK_TOKENS") == Some(0) {
return 0;
}
let pct = env_u64("HIVE_COMPACT_WATERMARK_PERCENT").unwrap_or(u64::from(DEFAULT_COMPACT_PERCENT));
u8::try_from(pct.min(100)).unwrap_or(DEFAULT_COMPACT_PERCENT)
}
/// Drive one turn end-to-end. Three paths layer on top of the raw `run_turn`:
/// Build the agent's durable session: constant title + on-disk store + a
/// percent-of-window compaction policy that checkpoints (`CHECKPOINT_PROMPT`)
/// before compacting. `default_window` feeds the policy the effective window
/// for turns where the model didn't itself report one.
fn infinite_session(bus: &Bus) -> InfiniteSession<PercentPolicy> {
InfiniteSession::new(
session_title(),
session_store(),
PercentPolicy {
percent: compact_percent(),
default_window: Some(effective_context_window(bus)),
checkpoint_prompt: Some(CHECKPOINT_PROMPT.to_string()),
},
)
}
/// Drive one turn end-to-end. The durable [`InfiniteSession`] owns the
/// resume-or-create + compaction loop (reactive on overflow, and proactive per
/// the percent policy — including the pre-compaction checkpoint turn). This
/// layer wraps it with the two hyperhive-specific concerns:
///
/// - **Auto-reset (pre-turn)** — context is large AND the prompt cache has
/// gone cold (idle gap ≥ cache TTL). Resuming would re-upload the full
/// transcript uncached at the same cost as a fresh start. The harness
/// archives the current session (so the next `--resume <title>` misses and
/// self-heals into a fresh `--name <title>`); no checkpoint turn runs here
/// because any turn before the archive would just re-warm the cache.
/// - **Reactive (on overflow)** — `run_turn` returns `PromptTooLong`: the
/// session is already past the context window and *no* turn can run on it,
/// so we compact immediately and retry the same wake-up prompt once. No
/// notes-checkpoint turn is possible here — the detail is gone.
/// - **Proactive (post-turn)** — the turn finished cleanly but its context
/// size has crept past the watermark: while the session is still healthy we
/// give the agent one dedicated turn to checkpoint its `/state` notes, then
/// compact. This keeps a later turn from hitting the reactive path (where
/// there is no chance to save anything first). The graceful-stop path takes
/// the same proactive route — a checkpoint turn before `/compact` is cheap
/// insurance and keeps a later cold-start resume small.
/// - **Session reset (pre-turn)** — an operator reset (`/api/new-session`) or
/// the auto-reset heuristic (context large AND prompt cache gone cold)
/// archives the current session at this turn boundary so the run starts
/// fresh. The two are mutually exclusive. This is deliberately *not* part of
/// the infinite-session abstraction — it's the hive escape hatch.
/// - **401 retry** — a transient token-refresh race can 401 once and clear, so
/// the whole turn is retried a single time before bubbling `AuthFailed` to
/// the serve loop (which parks for re-login).
///
/// Called once per turn by the `hive` serve loop (every agent role).
/// Called once per turn by the `hive` serve loop.
pub async fn drive_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutcome {
// Start this turn on a fresh session when either trigger fires. Both
// archive the current session at this turn boundary (no claude is mid-
// write — one claude per container, serialized by the serve loop) and
// produce the same end state, so they're mutually exclusive: an explicit
// operator reset makes the auto-reset heuristic moot for this turn.
if bus.take_session_reset() {
// Operator-requested (deferred from `POST /api/new-session`).
bus.emit(LiveEvent::Note {
@ -259,120 +272,33 @@ pub async fn drive_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutco
// Heuristic: context large AND prompt cache gone cold.
maybe_auto_reset(bus);
}
let outcome = match run_turn(prompt, files, bus).await {
TurnOutcome::PromptTooLong => {
// Compact has its own three-flag surface (it's the same claude
// binary). Treat any non-Ok outcome the same as if `run_turn`
// had returned it — the serve loop already knows what to do
// with each variant, no point re-wrapping. PromptTooLong from
// /compact itself would be absurd recursion; bubble it up as
// a normal failure path.
match compact_session(files, bus).await {
TurnOutcome::Ok | TurnOutcome::Compacted => run_turn(prompt, files, bus).await,
other => return other,
let config = claude_config(bus, files);
let sink = BusSink::new(bus);
let session = infinite_session(bus);
let mut result = session.run(&config, prompt, &sink).await;
if matches!(result, Err(hive_claude::Error::AuthFailed)) {
bus.emit(LiveEvent::Note {
text: "got 401 — retrying once before parking for re-login".into(),
});
result = session.run(&config, prompt, &sink).await;
}
match result {
Ok(progress) => {
if progress.created {
// Fresh session minted this turn → flag it so the bin loop
// mints a `sessions` row + stamps its id onto this turn's stats.
bus.mark_fresh_session();
bus.emit(LiveEvent::Note {
text: format!("created fresh session titled \"{}\"", session_title()),
});
}
if progress.compacted {
TurnOutcome::Compacted
} else {
TurnOutcome::Ok
}
}
// Rate-limited: no point retrying immediately — bubble up so the
// serve loop can park + emit status before the next attempt.
TurnOutcome::RateLimited => return TurnOutcome::RateLimited,
// Auth failed: may be a transient token-refresh race or brief API
// hiccup. Retry once before bubbling up so the serve loop parks for
// re-login. The retry outcome is passed through unchanged — if it
// fails again the serve loop handles it as usual.
TurnOutcome::AuthFailed => {
bus.emit(LiveEvent::Note {
text: "got 401 — retrying once before parking for re-login".into(),
});
run_turn(prompt, files, bus).await
}
other => other,
};
// Proactive: a turn just completed on a still-healthy session. If its
// context crossed the watermark, run a separate notes-checkpoint turn so
// the agent can flush durable state, then compact before a later turn
// overflows into the reactive path. Best-effort — never changes the
// outcome of the turn that already succeeded, but records it as
// `Compacted` so turn stats can distinguish it from a plain `Ok`.
if matches!(outcome, TurnOutcome::Ok) && maybe_checkpoint_and_compact(files, bus).await {
return TurnOutcome::Compacted;
}
outcome
}
/// Proactive post-turn compaction. If the last inference's context size
/// has crossed the watermark, run one notes-checkpoint turn so the agent
/// can persist durable state, then `/compact`. Best-effort: a failed
/// checkpoint or compaction is logged + surfaced as a Note but never
/// fails the turn that already succeeded. Returns `true` if compaction
/// was attempted (watermark crossed), `false` if skipped.
async fn maybe_checkpoint_and_compact(files: &TurnFiles, bus: &Bus) -> bool {
let Some(used) = watermark_crossed(bus) else {
return false;
};
let watermark = compact_watermark_tokens(bus);
bus.emit(LiveEvent::Note {
text: format!(
"context at {used} tokens (watermark {watermark}) — running a \
notes-checkpoint turn before /compact"
),
});
// Give the agent one turn to flush durable state into /state. If the
// session is somehow already too far gone to run even this, fall
// through to compaction anyway — the checkpoint is best-effort.
match run_turn(CHECKPOINT_PROMPT, files, bus).await {
TurnOutcome::Ok | TurnOutcome::Compacted => {}
TurnOutcome::PromptTooLong => bus.emit(LiveEvent::Note {
text: "checkpoint turn overflowed the window — compacting without it".into(),
}),
TurnOutcome::RateLimited => bus.emit(LiveEvent::Note {
text: "checkpoint turn was rate-limited — compacting anyway".into(),
}),
TurnOutcome::AuthFailed => bus.emit(LiveEvent::Note {
text: "checkpoint turn hit 401 — skipping compaction, parking for re-login".into(),
}),
TurnOutcome::Failed(e) => bus.emit(LiveEvent::Note {
text: format!("checkpoint turn failed ({e:#}) — compacting anyway"),
}),
}
do_compact(files, bus).await;
true
}
/// Returns `Some(used_tokens)` when proactive compaction is enabled AND the
/// last inference's context size has reached the watermark; `None` when
/// compaction is disabled (watermark 0), there's no usage reading yet, or
/// the context is still below the watermark.
fn watermark_crossed(bus: &Bus) -> Option<u64> {
let watermark = compact_watermark_tokens(bus);
if watermark == 0 {
return None; // proactive compaction disabled
}
let used = bus.last_ctx_usage().map(|u| u.context_tokens())?;
(used >= watermark).then_some(used)
}
/// Run `/compact`, surfacing each failure mode as a best-effort Note.
/// Never changes the outcome of the turn that already succeeded — the
/// next real turn surfaces any underlying issue (rate-limit / 401 / etc.)
/// through the normal path anyway.
async fn do_compact(files: &TurnFiles, bus: &Bus) {
match compact_session(files, bus).await {
TurnOutcome::Ok | TurnOutcome::Compacted => {}
TurnOutcome::PromptTooLong => bus.emit(LiveEvent::Note {
text: "/compact unexpectedly returned PromptTooLong — next turn will retry".into(),
}),
TurnOutcome::RateLimited => bus.emit(LiveEvent::Note {
text: "/compact was rate-limited — next turn will park + retry".into(),
}),
TurnOutcome::AuthFailed => bus.emit(LiveEvent::Note {
text: "/compact hit 401 — next turn will trigger the re-login flow".into(),
}),
TurnOutcome::Failed(e) => {
tracing::warn!(error = %format!("{e:#}"), "post-checkpoint compact failed");
bus.emit(LiveEvent::Note {
text: format!("/compact after checkpoint failed: {e:#}"),
});
}
Err(e) => error_to_turn(e),
}
}
@ -451,86 +377,30 @@ pub fn emit_turn_end(bus: &Bus, outcome: &TurnOutcome) {
}
}
/// Run one turn against the constant-title session, resuming it or creating
/// it on first use (`hive_claude::run_resume_or_create`). The session is
/// pinned by a fixed `--resume`/`--name <title>` (NOT bare `--continue`, which
/// resumes the *latest* session in this cwd and lets a `choom` session hijack
/// the live harness context — a constant title is immune since choom won't
/// carry it). claude's in-session auto-compact is disabled via the managed
/// settings at `/etc/claude-code/managed-settings.json` so it doesn't stall
/// mid-turn — hyperhive owns compaction.
pub async fn run_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutcome {
let config = claude_config(bus, files);
let sink = BusSink::new(bus);
match Claude::run_resume_or_create(&config, &session_title(), prompt, &sink).await {
Ok(created) => {
if created {
// Fresh session minted this turn → flag it so the bin loop
// mints a `sessions` row + stamps its id onto this turn's stats.
bus.mark_fresh_session();
bus.emit(LiveEvent::Note {
text: format!("created fresh session titled \"{}\"", session_title()),
});
}
TurnOutcome::Ok
}
Err(e) => error_to_turn(e),
}
}
/// Run claude's built-in `/compact` slash command on the persistent
/// session. Takes the *same* params as `run_turn` because compact
/// re-initialises claude with the full session shape — same MCP
/// surface, same system prompt, same allowed-tools — so the post-
/// compact state matches a normal turn's. Only the prompt over stdin
/// differs (`/compact` vs the wake-up payload).
///
/// Returns the same `TurnOutcome` shape as `run_turn` so callers can
/// react identically to all three failure flags (`prompt_too_long`,
/// `rate_limited`, `auth_failed`). The reactive caller bubbles any
/// non-Ok outcome up so the serve loop's normal handling (park +
/// retry on rate-limit, flip to `needs_login` on 401, etc.) kicks in;
/// the proactive post-checkpoint caller stays best-effort and only
/// emits a Note for each failure mode.
/// Operator-initiated `/compact` on the durable session (from the web UI).
/// Resume-only via [`InfiniteSession::compact`]: a missing session is a
/// harmless no-op (never mints an empty session just to compact it). Surfaces
/// the result as a Note and the usual `TurnOutcome`.
pub async fn compact_session(files: &TurnFiles, bus: &Bus) -> TurnOutcome {
bus.emit(LiveEvent::Note {
text: "context overflow — running /compact on the persistent session".into(),
text: "running /compact on the session".into(),
});
// Resume-only: never create a session just to compact it. If the titled
// session doesn't exist there's genuinely nothing to compact — compacting
// a freshly-minted empty session would just print "not enough messages"
// (the historical version-B failure), so treat a session miss as a no-op Ok.
let config = claude_config(bus, files);
let sink = BusSink::new(bus);
let session = Session::Resume(session_title());
let outcome = match Claude::run(&config, &session, "/compact", &sink).await {
Ok(()) => TurnOutcome::Ok,
Err(hive_claude::Error::SessionNotFound) => {
match infinite_session(bus).compact(&config, &sink).await {
Ok(()) => {
bus.emit(LiveEvent::Note {
text: "no titled session to compact — skipping".into(),
text: "/compact done".into(),
});
TurnOutcome::Ok
TurnOutcome::Compacted
}
Err(e) => {
bus.emit(LiveEvent::Note {
text: format!("/compact failed: {e}"),
});
error_to_turn(e)
}
Err(e) => error_to_turn(e),
};
match &outcome {
TurnOutcome::Ok | TurnOutcome::Compacted => bus.emit(LiveEvent::Note {
text: "/compact done".into(),
}),
TurnOutcome::PromptTooLong => bus.emit(LiveEvent::Note {
text: "/compact reported PromptTooLong — bubbling up".into(),
}),
TurnOutcome::RateLimited => bus.emit(LiveEvent::Note {
text: "/compact was rate-limited — bubbling up".into(),
}),
TurnOutcome::AuthFailed => bus.emit(LiveEvent::Note {
text: "/compact hit 401 — bubbling up for re-login flow".into(),
}),
TurnOutcome::Failed(e) => bus.emit(LiveEvent::Note {
text: format!("/compact failed: {e:#}"),
}),
}
outcome
}
/// The constant session title for this agent. `HIVE_SESSION_TITLE` overrides