fix(hive-claude): report Progress.created on first-turn overflow, compacted only when compact succeeded

This commit is contained in:
müde 2026-07-05 23:42:57 +02:00
commit dd51d02bc2

View file

@ -66,19 +66,25 @@ impl<P: CompactionPolicy> InfiniteSession<P> {
/// [`Error::PromptTooLong`]; rate-limit / auth / hard failures propagate /// [`Error::PromptTooLong`]; rate-limit / auth / hard failures propagate
/// unchanged for the caller to handle. /// unchanged for the caller to handle.
pub async fn run(&self, config: &Config, prompt: &str, sink: &impl Sink) -> Result<Progress> { pub async fn run(&self, config: &Config, prompt: &str, sink: &impl Sink) -> Result<Progress> {
// Resolve resume-vs-create once, up front, so `created` reflects the
// session state at the START of the run: the reactive-retry path can't
// read it from the retry (the session exists by then).
let existed = self.store.find_by_title(&self.name).is_some();
let meter = TelemetrySink::new(sink); let meter = TelemetrySink::new(sink);
let created = match self.attempt(config, prompt, &meter).await { let created = match self.attempt(config, prompt, &meter, existed).await {
Ok(created) => created, Ok(created) => created,
Err(Error::PromptTooLong) => { Err(Error::PromptTooLong) => {
// The session is already past the window — no turn can run on // The session is already past the window — no turn can run on
// it and the detail is gone (no checkpoint possible). Compact, // it and the detail is gone (no checkpoint possible). Compact,
// then retry the same prompt once; the retry is the answering // then retry the same prompt once; the retry is the answering
// turn, so its telemetry is what we report. // turn, so its telemetry is what we report. The failed attempt
// created/resumed the session, so the retry resumes it, and
// `created` still reflects the pre-run state.
self.compact(config, sink).await?; self.compact(config, sink).await?;
let retry = TelemetrySink::new(sink); let retry = TelemetrySink::new(sink);
let created = self.attempt(config, prompt, &retry).await?; self.attempt(config, prompt, &retry, true).await?;
return Ok(Progress { return Ok(Progress {
created, created: !existed,
compacted: true, compacted: true,
telemetry: retry.snapshot(), telemetry: retry.snapshot(),
}); });
@ -91,12 +97,15 @@ impl<P: CompactionPolicy> InfiniteSession<P> {
// says it's due, checkpoint (best-effort) then compact. // says it's due, checkpoint (best-effort) then compact.
if self.policy.should_compact(telemetry.usage()) { if self.policy.should_compact(telemetry.usage()) {
if let Some(checkpoint) = self.policy.checkpoint_prompt() { if let Some(checkpoint) = self.policy.checkpoint_prompt() {
let _ = self.attempt(config, checkpoint, sink).await; let _ = self.attempt(config, checkpoint, sink, true).await;
} }
let _ = self.compact(config, sink).await; // Only claim a compaction if it actually succeeded — the flag feeds
// stats + the auto-reset watermark, so a failed best-effort
// `/compact` must not report that the context shrank.
let compacted = self.compact(config, sink).await.is_ok();
return Ok(Progress { return Ok(Progress {
created, created,
compacted: true, compacted,
telemetry, telemetry,
}); });
} }
@ -121,22 +130,29 @@ impl<P: CompactionPolicy> InfiniteSession<P> {
} }
} }
/// One resume-or-create turn. Uses the store to pick resume vs. create up /// One resume-or-create turn. `existed` is the caller's up-front
/// front (avoiding a wasted resume-miss spawn), and still self-heals if the /// resume-vs-create decision (whether the titled session was on disk before
/// backing file vanished between the check and the run. Returns whether a /// the run) — passing it in rather than re-checking keeps `created`
/// fresh session was created. /// reporting consistent across the reactive-retry path. Still self-heals if
async fn attempt(&self, config: &Config, prompt: &str, sink: &impl Sink) -> Result<bool> { /// the backing file vanished between the check and the run. Returns whether
let exists = self.store.find_by_title(&self.name).is_some(); /// a fresh session was created.
let attach = if exists { async fn attempt(
&self,
config: &Config,
prompt: &str,
sink: &impl Sink,
existed: bool,
) -> Result<bool> {
let attach = if existed {
Attach::Resume(self.name.clone()) Attach::Resume(self.name.clone())
} else { } else {
Attach::Create(self.name.clone()) Attach::Create(self.name.clone())
}; };
match Claude::run(config, &attach, prompt, sink).await { match Claude::run(config, &attach, prompt, sink).await {
Ok(()) => Ok(!exists), Ok(()) => Ok(!existed),
// We thought it existed but the resume missed (raced an archive) — // We thought it existed but the resume missed (raced an archive) —
// self-heal by creating. // self-heal by creating.
Err(Error::SessionNotFound) if exists => { Err(Error::SessionNotFound) if existed => {
Claude::run(config, &Attach::Create(self.name.clone()), prompt, sink).await?; Claude::run(config, &Attach::Create(self.name.clone()), prompt, sink).await?;
Ok(true) Ok(true)
} }