Make compaction transitions recoverable

This commit is contained in:
Georg Bauer
2026-07-26 10:56:19 +02:00
parent 6aa45b2cf0
commit 2a14b93335
13 changed files with 329 additions and 26 deletions

View File

@@ -20,13 +20,14 @@ pub(super) struct TitleRequest {
#[cfg(target_os = "macos")]
pub(super) enum PendingContinuation {
None,
User(String),
Tool(String),
}
#[cfg(target_os = "macos")]
pub(super) struct CompactionRequest {
active: ActiveGeneration,
pub(super) active: ActiveGeneration,
pending: PendingContinuation,
}
@@ -92,6 +93,16 @@ impl App {
return;
}
#[cfg(target_os = "macos")]
if prompt == "/compact" {
self.composer.clear();
if let Err(error) =
self.start_compaction(PendingContinuation::None, "manual /compact request")
{
self.error = Some(error);
}
return;
}
#[cfg(target_os = "macos")]
if !std::mem::take(&mut self.skip_compaction_once)
&& crate::compaction::should_compact(self.context_used, self.context_limit)
{
@@ -466,7 +477,7 @@ impl App {
}
#[cfg(target_os = "macos")]
fn start_compaction(
pub(super) fn start_compaction(
&mut self,
pending: PendingContinuation,
reason: &str,
@@ -506,6 +517,10 @@ impl App {
effective.turn,
messages,
reason,
session_compaction_checkpoint_path(
self.selected_session
.ok_or_else(|| "The active session is unavailable.".to_owned())?,
),
idle_timeout,
)?;
self.active_compaction = Some(CompactionRequest { active, pending });
@@ -537,6 +552,7 @@ impl App {
match result {
Ok(compacted) => {
if let Err(error) = self.apply_compaction(&compacted) {
let _ = fs::remove_file(&compacted.checkpoint);
self.generating = false;
self.activity = None;
self.error = Some(error);
@@ -546,6 +562,7 @@ impl App {
self.generating = false;
self.activity = None;
match request.pending {
PendingContinuation::None => {}
PendingContinuation::User(prompt) => {
self.composer = prompt;
self.skip_compaction_once = true;
@@ -595,7 +612,7 @@ impl App {
let session_id = self
.selected_session
.ok_or_else(|| "The active session is unavailable.".to_owned())?;
let tail = compacted
let mut tail = compacted
.tail
.iter()
.map(|message| crate::database::MessageDraft {
@@ -606,11 +623,32 @@ impl App {
content: message.content.clone(),
})
.collect::<Vec<_>>();
if let Some((tools_session, tools)) = &self.agent_tools
&& *tools_session == session_id
&& let Some(observation) = tools
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.compaction_observation()
{
tail.push(crate::database::MessageDraft {
user: false,
tool: true,
reasoning: None,
reasoning_complete: true,
content: observation,
});
}
let messages = self
.database
.as_mut()
.ok_or_else(|| "The project database is unavailable.".to_owned())?
.replace_with_compacted_transcript(session_id, &compacted.summary, &tail)
.replace_with_compacted_transcript(
session_id,
&compacted.summary,
&tail,
compacted.context_tokens,
self.context_limit,
)
.map_err(|error| format!("Could not save compacted conversation: {error}"))?;
self.conversation = messages.into_iter().map(ChatMessage::from).collect();
if let Some(session) = self
@@ -620,8 +658,16 @@ impl App {
.find(|session| session.id == session_id)
{
session.compacted_summary = Some(compacted.summary.clone());
session.context_used = compacted.context_tokens as i32;
session.context_limit = self.context_limit as i32;
session.last_tokens_per_second = None;
}
let _ = fs::remove_file(session_checkpoint_path(session_id));
let final_checkpoint = session_checkpoint_path(session_id);
fs::rename(&compacted.checkpoint, &final_checkpoint).map_err(|error| {
format!(
"The compacted conversation was saved, but its checkpoint could not be promoted: {error}. It will rebuild on next use."
)
})?;
Ok(())
}

View File

@@ -196,7 +196,14 @@ mod tests {
let directory =
std::env::temp_dir().join(format!("ds4-server-sweep-{}", std::process::id()));
std::fs::create_dir_all(&directory).unwrap();
for name in ["1.bin", "2.bin", "notes.bin", "7.bin"] {
for name in [
"1.bin",
"2.bin",
"notes.bin",
"7.bin",
"1.compacting",
"2.tmp",
] {
std::fs::write(directory.join(name), b"payload").unwrap();
}
let projects = vec![ProjectWithSessions {
@@ -213,6 +220,8 @@ mod tests {
// Not a session checkpoint, so not ours to delete.
assert!(directory.join("notes.bin").exists());
assert!(!directory.join("7.bin").exists());
assert!(!directory.join("1.compacting").exists());
assert!(!directory.join("2.tmp").exists());
// Nothing left to sweep on the next pass.
assert!(!super::super::sweep_orphan_checkpoints(
&directory, &projects

View File

@@ -446,6 +446,10 @@ impl App {
.on_press(Message::StartRenameSession(session.id)),
menu_action(ICON_SPARK, "Retitle with AI")
.on_press(Message::RetitleSession(session.id)),
menu_action(ICON_SPARK, "Compact context")
.on_press(Message::CompactSession(session.id)),
menu_action(ICON_ARCHIVE, "Rebuild context on next use")
.on_press(Message::RebuildSessionContext(session.id)),
]
.spacing(4);
actions = match state {