track compacted turns separately in stats

This commit is contained in:
damocles 2026-05-20 19:38:29 +02:00 committed by Mara
parent fceab658f4
commit 5951758b35
3 changed files with 23 additions and 11 deletions

View file

@ -220,7 +220,7 @@ async fn serve(
// compaction so it shouldn't reach here, but if it // compaction so it shouldn't reach here, but if it
// does we also skip the ack (safer to redeliver than // does we also skip the ack (safer to redeliver than
// to lose the message). // to lose the message).
if matches!(outcome, turn::TurnOutcome::Ok) { if matches!(outcome, turn::TurnOutcome::Ok | turn::TurnOutcome::Compacted) {
ack_turn(socket).await; ack_turn(socket).await;
} }
// Rate-limited: park until the quota resets, then requeue // Rate-limited: park until the quota resets, then requeue
@ -458,6 +458,7 @@ fn build_row(
}; };
let (result_kind, note) = match outcome { let (result_kind, note) = match outcome {
turn::TurnOutcome::Ok => ("ok", None), turn::TurnOutcome::Ok => ("ok", None),
turn::TurnOutcome::Compacted => ("compacted", None),
turn::TurnOutcome::PromptTooLong => ("prompt_too_long", None), turn::TurnOutcome::PromptTooLong => ("prompt_too_long", None),
turn::TurnOutcome::RateLimited => ("rate_limited", None), turn::TurnOutcome::RateLimited => ("rate_limited", None),
turn::TurnOutcome::Failed(e) => ("failed", Some(format!("{e:#}"))), turn::TurnOutcome::Failed(e) => ("failed", Some(format!("{e:#}"))),

View file

@ -186,7 +186,7 @@ async fn serve(
// Ack only on a clean turn-end; Failed / RateLimited leave // Ack only on a clean turn-end; Failed / RateLimited leave
// the popped ids in-flight for the next boot's requeue. // the popped ids in-flight for the next boot's requeue.
// Mirrors hive-ag3nt; see that loop for full rationale. // Mirrors hive-ag3nt; see that loop for full rationale.
if matches!(outcome, turn::TurnOutcome::Ok) { if matches!(outcome, turn::TurnOutcome::Ok | turn::TurnOutcome::Compacted) {
ack_turn(socket).await; ack_turn(socket).await;
} }
// Rate-limited: park until the quota resets, then requeue // Rate-limited: park until the quota resets, then requeue
@ -378,6 +378,7 @@ fn build_row(
}; };
let (result_kind, note) = match outcome { let (result_kind, note) = match outcome {
turn::TurnOutcome::Ok => ("ok", None), turn::TurnOutcome::Ok => ("ok", None),
turn::TurnOutcome::Compacted => ("compacted", None),
turn::TurnOutcome::PromptTooLong => ("prompt_too_long", None), turn::TurnOutcome::PromptTooLong => ("prompt_too_long", None),
turn::TurnOutcome::RateLimited => ("rate_limited", None), turn::TurnOutcome::RateLimited => ("rate_limited", None),
turn::TurnOutcome::Failed(e) => ("failed", Some(format!("{e:#}"))), turn::TurnOutcome::Failed(e) => ("failed", Some(format!("{e:#}"))),

View file

@ -168,6 +168,11 @@ pub async fn write_system_prompt(
#[derive(Debug)] #[derive(Debug)]
pub enum TurnOutcome { pub enum TurnOutcome {
Ok, Ok,
/// Turn completed and proactive context-size compaction fired afterwards.
/// Treated like `Ok` for ack and failure-notification purposes; recorded
/// 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. /// claude saw "Prompt is too long" — the session needs compacting.
/// Run `compact_session()` then retry the same wake-up prompt. /// Run `compact_session()` then retry the same wake-up prompt.
PromptTooLong, PromptTooLong,
@ -265,9 +270,12 @@ pub async fn drive_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutco
// Proactive: a turn just completed on a still-healthy session. If its // Proactive: a turn just completed on a still-healthy session. If its
// context crossed the watermark, checkpoint + compact before a later // context crossed the watermark, checkpoint + compact before a later
// turn overflows into the reactive path. Best-effort — never changes // turn overflows into the reactive path. Best-effort — never changes
// the outcome of the turn that already succeeded. // 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) { if matches!(outcome, TurnOutcome::Ok) {
maybe_checkpoint_and_compact(files, bus).await; if maybe_checkpoint_and_compact(files, bus).await {
return TurnOutcome::Compacted;
}
} }
outcome outcome
} }
@ -276,17 +284,18 @@ pub async fn drive_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutco
/// has crossed the watermark, run one notes-checkpoint turn so the agent /// has crossed the watermark, run one notes-checkpoint turn so the agent
/// can persist durable state, then `/compact`. Best-effort: a failed /// can persist durable state, then `/compact`. Best-effort: a failed
/// checkpoint or compaction is logged + surfaced as a Note but never /// checkpoint or compaction is logged + surfaced as a Note but never
/// fails the turn that already succeeded. /// fails the turn that already succeeded. Returns `true` if compaction
async fn maybe_checkpoint_and_compact(files: &TurnFiles, bus: &Bus) { /// was attempted (watermark crossed), `false` if skipped.
async fn maybe_checkpoint_and_compact(files: &TurnFiles, bus: &Bus) -> bool {
let watermark = compact_watermark_tokens(bus); let watermark = compact_watermark_tokens(bus);
if watermark == 0 { if watermark == 0 {
return; // proactive compaction disabled return false; // proactive compaction disabled
} }
let Some(used) = bus.last_ctx_usage().map(|u| u.context_tokens()) else { let Some(used) = bus.last_ctx_usage().map(|u| u.context_tokens()) else {
return; // no usage reading yet — nothing to compare against return false; // no usage reading yet — nothing to compare against
}; };
if used < watermark { if used < watermark {
return; return false;
} }
bus.emit(LiveEvent::Note { bus.emit(LiveEvent::Note {
text: format!( text: format!(
@ -298,7 +307,7 @@ async fn maybe_checkpoint_and_compact(files: &TurnFiles, bus: &Bus) {
// session is somehow already too far gone to run even this, fall // session is somehow already too far gone to run even this, fall
// through to compaction anyway — the checkpoint is best-effort. // through to compaction anyway — the checkpoint is best-effort.
match run_turn(CHECKPOINT_PROMPT, files, bus).await { match run_turn(CHECKPOINT_PROMPT, files, bus).await {
TurnOutcome::Ok => {} TurnOutcome::Ok | TurnOutcome::Compacted => {}
TurnOutcome::PromptTooLong => bus.emit(LiveEvent::Note { TurnOutcome::PromptTooLong => bus.emit(LiveEvent::Note {
text: "checkpoint turn overflowed the window — compacting without it".into(), text: "checkpoint turn overflowed the window — compacting without it".into(),
}), }),
@ -315,6 +324,7 @@ async fn maybe_checkpoint_and_compact(files: &TurnFiles, bus: &Bus) {
text: format!("/compact after checkpoint failed: {e:#}"), text: format!("/compact after checkpoint failed: {e:#}"),
}); });
} }
true
} }
/// Pre-turn auto-reset check. If context is large AND the prompt cache has /// Pre-turn auto-reset check. If context is large AND the prompt cache has
@ -360,7 +370,7 @@ fn maybe_auto_reset(bus: &Bus) {
/// agent and manager loops agree on outcome semantics. /// agent and manager loops agree on outcome semantics.
pub fn emit_turn_end(bus: &Bus, outcome: &TurnOutcome) { pub fn emit_turn_end(bus: &Bus, outcome: &TurnOutcome) {
match outcome { match outcome {
TurnOutcome::Ok | TurnOutcome::PromptTooLong => { TurnOutcome::Ok | TurnOutcome::Compacted | TurnOutcome::PromptTooLong => {
bus.emit(LiveEvent::TurnEnd { bus.emit(LiveEvent::TurnEnd {
ok: true, ok: true,
note: None, note: None,