feat(hive-claude): add InfiniteSession (name+store+compaction policy)
This commit is contained in:
parent
bf93a81e1d
commit
80d819e444
8 changed files with 501 additions and 283 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue