From b647df3db8fcfd0181a9ee502c5bf335d022970b Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 25 May 2026 22:10:45 +0200 Subject: [PATCH] turn: propagate all three flags out of compact_session --- hive-ag3nt/src/turn.rs | 83 ++++++++++++++++++++++++++-------------- hive-ag3nt/src/web_ui.rs | 11 +++--- 2 files changed, 60 insertions(+), 34 deletions(-) diff --git a/hive-ag3nt/src/turn.rs b/hive-ag3nt/src/turn.rs index afc72c27..73fa2cbd 100644 --- a/hive-ag3nt/src/turn.rs +++ b/hive-ag3nt/src/turn.rs @@ -306,15 +306,18 @@ pub async fn drive_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutco 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 { - Ok(true) => return TurnOutcome::AuthFailed, - Ok(false) => {} - Err(e) => { - tracing::warn!(error = %format!("{e:#}"), "compact failed"); - return TurnOutcome::Failed(e); + TurnOutcome::Ok | TurnOutcome::Compacted => { + run_turn(prompt, files, bus).await } + other => return other, } - run_turn(prompt, files, bus).await } // Rate-limited: no point retrying immediately — bubble up so the // serve loop can park + emit status before the next attempt. @@ -374,12 +377,23 @@ async fn maybe_checkpoint_and_compact(files: &TurnFiles, bus: &Bus) -> bool { text: format!("checkpoint turn failed ({e:#}) — compacting anyway"), }), } + // Best-effort: never changes the outcome of the turn that already + // succeeded. Mirror the checkpoint-turn handling above — emit a Note + // for each failure mode and move on; the next real turn will surface + // the underlying issue (rate-limit / 401 / etc.) through the normal + // path anyway. match compact_session(files, bus).await { - Ok(true) => bus.emit(LiveEvent::Note { + 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(), }), - Ok(false) => {} - Err(e) => { + TurnOutcome::Failed(e) => { tracing::warn!(error = %format!("{e:#}"), "post-checkpoint compact failed"); bus.emit(LiveEvent::Note { text: format!("/compact after checkpoint failed: {e:#}"), @@ -516,31 +530,42 @@ pub async fn run_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutcome /// compact state matches a normal turn's. Only the prompt over stdin /// differs (`/compact` vs the wake-up payload). /// -/// Returns `true` if claude reported a 401 mid-compact — the caller -/// should bail into the re-login flow instead of treating compaction -/// as succeeded. (`prompt_too_long` / `rate_limited` are unlikely on -/// a slash command and are folded into the boolean by being ignored -/// — the next real turn surfaces them through the normal path.) -/// -/// # Errors -/// -/// Returns an error if the `claude --print /compact` invocation fails -/// (non-zero exit or I/O error). -pub async fn compact_session(files: &TurnFiles, bus: &Bus) -> Result { +/// 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. +pub async fn compact_session(files: &TurnFiles, bus: &Bus) -> TurnOutcome { bus.emit(LiveEvent::Note { text: "context overflow — running /compact on the persistent session".into(), }); - let (_, _, auth_failed) = run_claude("/compact", files, bus).await?; - if auth_failed { - bus.emit(LiveEvent::Note { - text: "/compact hit 401 — bubbling up for re-login flow".into(), - }); - } else { - bus.emit(LiveEvent::Note { + let outcome = match run_claude("/compact", files, bus).await { + Ok((true, _, _)) => TurnOutcome::PromptTooLong, + Ok((_, true, _)) => TurnOutcome::RateLimited, + Ok((_, _, true)) => TurnOutcome::AuthFailed, + Ok(_) => TurnOutcome::Ok, + Err(e) => TurnOutcome::Failed(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:#}"), + }), } - Ok(auth_failed) + outcome } #[allow(clippy::too_many_lines)] diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 14e02e39..d53c5637 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -851,12 +851,13 @@ async fn post_compact(State(state): State) -> Response { text: "operator: /compact — running on persistent session".into(), }); bus.set_state(crate::events::TurnState::Compacting); - let r = crate::turn::compact_session(&files, &bus).await; + let outcome = crate::turn::compact_session(&files, &bus).await; bus.set_state(crate::events::TurnState::Idle); - if let Err(e) = r { - bus.emit(crate::events::LiveEvent::Note { - text: format!("/compact failed: {e:#}"), - }); + // Best-effort manual /compact from the operator: compact_session + // already emits a Note per outcome, so we don't need to re-emit + // here — just record any underlying error to the harness log. + if let crate::turn::TurnOutcome::Failed(e) = outcome { + tracing::warn!(error = %format!("{e:#}"), "operator /compact failed"); } }); (axum::http::StatusCode::OK, "ok").into_response()