Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 84 additions & 7 deletions crates/tui/src/compaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,52 @@ impl Default for CompactionConfig {
/// Minimum non-whitespace characters for a usable successor summary.
/// Below this (or missing required section headings), treat as degenerate and
/// retry once rather than shipping amnesia (compactionidea failure ladder).
const COMPACTION_MIN_NON_WHITESPACE_CHARS: usize = 48;
const COMPACTION_LANGUAGE_CONTRACT: &str = "Use the natural language of the most recent \
substantive user message for reasoning and user-facing prose. Keep code, identifiers, paths, \
commands, logs, tool payloads, quotations, and the English structural labels verbatim. English \
scaffolding is not a request to switch languages.";

/// Placeholder / non-text summaries that must never replace real history.
const COMPACTION_PLACEHOLDER_SUMMARIES: &[&str] = &[
"(no summary available)",
"no summary available",
"n/a",
"none",
"null",
"undefined",
"...",
"…",
"tbd",
"todo",
"placeholder",
];

/// Whether a compaction summary is substantive enough to replace history.
#[must_use]
pub fn summary_is_usable(summary: &str) -> bool {
let trimmed = summary.trim();
if trimmed.is_empty() {
return false;
}
let lower = trimmed.to_ascii_lowercase();
if COMPACTION_PLACEHOLDER_SUMMARIES
.iter()
.any(|placeholder| lower == *placeholder)
{
return false;
}
let non_ws: String = trimmed.chars().filter(|ch| !ch.is_whitespace()).collect();
if non_ws.chars().count() < COMPACTION_MIN_NON_WHITESPACE_CHARS {
return false;
}
// Punctuation-only / symbol-only bodies are not handoff text.
if non_ws.chars().all(|ch| !ch.is_alphanumeric()) {
return false;
}
true
}

/// Failure kind for compaction LLM calls (deterministic vs transient).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompactionFailureKind {
Expand Down Expand Up @@ -405,8 +446,13 @@ fn estimate_retained_floor_conservative(
let retained_tokens = estimate_tokens(&retained).saturating_mul(3).div_ceil(2);
let framing = retained.len().saturating_mul(12).saturating_add(48);
let anchors = user_anchors_section(config.workspace.as_deref());
let summary_scaffolding_tokens =
estimate_text_tokens_conservative(&build_compaction_summary_block_text("", &anchors));
let summary_scaffolding_tokens = estimate_text_tokens_conservative(
&build_compaction_summary_block_text(
// Scaffolding-size probe only; never committed as a real summary.
"Progress checkpoint placeholder used only to size compaction scaffolding tokens for pressure estimates.",
&anchors,
),
);

// Post-compaction the committed summary is REPLACED, not stacked, so prior
// summary blocks must not inflate the floor. Count only the exact installed
Expand Down Expand Up @@ -955,10 +1001,21 @@ pub async fn compact_messages_safe(
return Ok(CompactionResult {
messages: sanitize_retained_messages(msgs),
summary_prompt: prompt,
// Includes quality-gate retries: each failed attempt
// advances `attempt` before a successful compact.
retries_used: attempt,
});
}
Err(e) => {
let message = e.to_string();
if message.contains("quality gate") {
logging::warn(format!(
"Compaction quality gate rejected summary on attempt {}: {message}",
attempt + 1
));
last_error = Some(e);
continue;
}
// Only retry on transient errors
if !is_transient_error(&e) {
return Err(e);
Expand All @@ -973,12 +1030,13 @@ pub async fn compact_messages_safe(
}

fn build_compaction_summary_block_text(summary: &str, anchors: &str) -> String {
// Callers must only pass usable summaries. Never substitute a placeholder
// that would erase real history under the guise of a successful compact.
let summary = summary.trim();
let summary = if summary.is_empty() {
"(no summary available)"
} else {
summary
};
debug_assert!(
summary_is_usable(summary),
"compaction must not commit an unusable summary"
);
let mut text = format!("{SUMMARY_HEADER}\n\n{summary}");
text.push_str(anchors);
text
Expand Down Expand Up @@ -1053,6 +1111,11 @@ pub async fn compact_messages(
}

let summary = create_summary(client, messages, config).await?;
if !summary_is_usable(&summary) {
anyhow::bail!(
"Compaction summary failed the quality gate (empty, placeholder, punctuation-only, or non-text); history was not replaced."
);
}
let anchors = user_anchors_section(config.workspace.as_deref());
let checkpoint_text = build_compaction_summary_block_text(&summary, &anchors);
let summary_block = SystemBlock {
Expand Down Expand Up @@ -2111,4 +2174,18 @@ mod tests {
assert_eq!(result.retries_used, 2);
assert!(result.messages.is_empty());
}

#[test]
fn unusable_compaction_summaries_fail_the_quality_gate() {
assert!(!summary_is_usable(""));
assert!(!summary_is_usable(" \n\t "));
assert!(!summary_is_usable("..."));
assert!(!summary_is_usable("!!! ??? ---"));
assert!(!summary_is_usable("(no summary available)"));
assert!(!summary_is_usable("n/a"));
assert!(!summary_is_usable("short"));
assert!(summary_is_usable(
"Current progress: migrated the auth middleware. Next: cover the refresh-token path and fix the flake in session resume."
));
}
}
9 changes: 9 additions & 0 deletions crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4158,6 +4158,15 @@ impl Engine {
self.session.total_usage.add(&turn.usage);
self.record_goal_usage_for_turn(&turn.usage, turn.elapsed());

// On turn cancellation, cancel and join turn-owned (non-detached)
// children before the mailbox seal / TurnComplete. Explicit
// detached=true children keep their own tokens. Successful turns leave
// turn-owned children running so completion sentinels can wake the
// parent on a later turn.
if status == TurnOutcomeStatus::Interrupted {
let mut manager = self.subagent_manager.write().await;
manager.cancel_and_join_turn_owned_agents().await;
}
// Seal and fully forward every accepted mailbox envelope before the
// terminal event. This is the durability barrier for child usage: an
// event can no longer arrive after `TurnComplete` and be mistaken for
Expand Down
12 changes: 8 additions & 4 deletions crates/tui/src/runtime_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -865,10 +865,14 @@ pub async fn run_http_server(
println!("Codewhale web enabled at http://{bound_addr}/");
let bootstrap_url = web::bootstrap_url(bound_addr, &bootstrap);
if let Err(error) = crate::utils::open_url(&bootstrap_url) {
scheduler_cancel.cancel();
scheduler_handle.abort();
return Err(error)
.context("Failed to open the Codewhale web client in the default browser");
// A missing or broken browser opener must not tear down a healthy
// Runtime. Print the single-use bootstrap URL for manual recovery.
eprintln!(
"warning: could not open the default browser ({error:#}). \
Runtime is still serving. Open this single-use bootstrap URL \
within ten minutes to authenticate the local web client:\n {bootstrap_url}"
);
println!("Manual bootstrap (single-use, expires in 10 minutes):\n {bootstrap_url}");
}
}
let is_loopback = options.host == "127.0.0.1" || options.host == "::1";
Expand Down
7 changes: 6 additions & 1 deletion crates/tui/src/runtime_api/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use super::RuntimeApiState;
const WEB_HTML: &str = include_str!("../runtime_web/index.html");
const WEB_CSS: &str = include_str!("../runtime_web/styles.css");
const WEB_JS: &str = include_str!("../runtime_web/app.mjs");
const BOOTSTRAP_TTL: Duration = Duration::from_secs(120);
const BOOTSTRAP_TTL: Duration = Duration::from_secs(10 * 60);
const WEB_SESSION_TTL: Duration = Duration::from_secs(12 * 60 * 60);
const BOOTSTRAP_PREFIX: &str = "cwwb_";
const WEB_SESSION_PREFIX: &str = "cwws_";
Expand Down Expand Up @@ -226,6 +226,11 @@ fn secure_headers(response: &mut Response, content_type: &'static str) {
mod tests {
use super::*;

#[test]
fn bootstrap_ttl_is_ten_minutes_for_manual_recovery() {
assert_eq!(BOOTSTRAP_TTL, Duration::from_secs(10 * 60));
}

#[test]
fn bootstrap_is_loopback_only_one_time_and_expires() {
let (state, nonce) =
Expand Down
Loading
Loading