feat(web_ui): defer operator /compact to turn end, run when idle too

This commit is contained in:
müde 2026-07-05 19:49:00 +02:00
commit faa7f982af
5 changed files with 77 additions and 79 deletions

View file

@ -150,8 +150,9 @@ pub enum TurnOutcome {
/// as `result_kind = "compacted"` in turn stats so the stats page can
/// distinguish normal turns from turns that triggered a compaction.
Compacted,
/// claude saw "Prompt is too long" — the session needs compacting.
/// Run `compact_session()` then retry the same wake-up prompt.
/// claude saw "Prompt is too long" and even a reactive compact + retry
/// (inside [`InfiniteSession::run`]) couldn't bring it back under the
/// window. Rare; the serve loop treats it like `Ok` (acks the turn).
PromptTooLong,
/// The Anthropic API refused the request due to a rate limit, per-account
/// usage cap, or exhausted credit balance. The serve loop should park for
@ -282,7 +283,7 @@ pub async fn drive_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutco
});
result = session.run(&config, prompt, &sink).await;
}
match result {
let outcome = match result {
Ok(progress) => {
if progress.created {
// Fresh session minted this turn → flag it so the bin loop
@ -299,7 +300,19 @@ pub async fn drive_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutco
}
}
Err(e) => error_to_turn(e),
};
// Operator `/compact` (`POST /api/compact`) deferred to the turn boundary:
// run it now that the turn is done, so it works mid-turn rather than only
// when the agent is idle. Only on a healthy turn — no point spawning a
// compaction after a rate-limited / auth-failed / crashed one.
if bus.take_compact() && matches!(outcome, TurnOutcome::Ok | TurnOutcome::Compacted) {
bus.emit(LiveEvent::Note {
text: "operator: /compact — running at turn end".into(),
});
let _ = session.compact(&config, &sink).await;
return TurnOutcome::Compacted;
}
outcome
}
/// Pre-turn auto-reset check. If context is large AND the prompt cache has
@ -377,30 +390,32 @@ pub fn emit_turn_end(bus: &Bus, outcome: &TurnOutcome) {
}
}
/// 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 {
/// Service a pending operator `/compact` (`Bus::request_compact`) while the
/// agent is idle — the serve loop calls this when a `recv` returns no message,
/// so a queued `/compact` runs even when no turn is driving. (The in-flight
/// case is handled at the end of [`drive_turn`].) Resume-only via
/// [`InfiniteSession::compact`]: a missing session is a harmless no-op. Returns
/// `true` if a compaction ran.
pub async fn run_pending_compact(files: &TurnFiles, bus: &Bus) -> bool {
if !bus.take_compact() {
return false;
}
bus.emit(LiveEvent::Note {
text: "running /compact on the session".into(),
text: "operator: /compact — running on idle session".into(),
});
bus.set_state(crate::events::TurnState::Compacting);
let config = claude_config(bus, files);
let sink = BusSink::new(bus);
match infinite_session(bus).compact(&config, &sink).await {
Ok(()) => {
bus.emit(LiveEvent::Note {
text: "/compact done".into(),
});
TurnOutcome::Compacted
}
Err(e) => {
bus.emit(LiveEvent::Note {
text: format!("/compact failed: {e}"),
});
error_to_turn(e)
}
Ok(()) => bus.emit(LiveEvent::Note {
text: "/compact done".into(),
}),
Err(e) => bus.emit(LiveEvent::Note {
text: format!("/compact failed: {e}"),
}),
}
bus.set_state(crate::events::TurnState::Idle);
true
}
/// The constant session title for this agent. `HIVE_SESSION_TITLE` overrides