Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
107 changes: 75 additions & 32 deletions crates/tui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
use std::io::{self, IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;
use std::time::{Duration, Instant};

use anyhow::{Context, Result, anyhow, bail};
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
Expand Down Expand Up @@ -6470,37 +6470,11 @@ async fn run_interactive(
logging::warn(format!("Failed to install system skills: {e}"));
}

// Prune stale workspace snapshots from prior sessions (7-day default).
// Non-fatal: a flaky disk, missing `git`, or read-only home should
// never block the TUI from starting.
let snapshots = config.snapshots_config();
if snapshots.enabled {
session_manager::prune_workspace_snapshots(&workspace, snapshots.max_age());
}

// Prune stale tool-output spillover files (#422). Non-fatal: home
// missing or directory unreadable just means nothing got pruned;
// we never block startup. Runs unconditionally because the
// spillover store is created lazily on first write — there's no
// user-facing setting to gate.
match crate::tools::truncate::prune_older_than(crate::tools::truncate::SPILLOVER_MAX_AGE) {
Ok(0) => {}
Ok(n) => tracing::debug!(
target: "spillover",
"boot prune removed {n} spillover file(s)"
),
Err(err) => tracing::warn!(
target: "spillover",
?err,
"spillover prune skipped on boot"
),
}

// v0.8.44: prune managed sessions on boot to prevent unbounded growth.
// Keeps at most MAX_SESSIONS (50) recent sessions; non-fatal on error.
if let Ok(manager) = session_manager::SessionManager::default_location() {
let _ = manager.cleanup_old_sessions();
}
spawn_interactive_startup_maintenance(
workspace.clone(),
config.snapshots_config(),
resume_session_id.clone(),
);
Comment thread
nightt5879 marked this conversation as resolved.
Outdated

// The `deepseek` launcher forwards `--yolo` to this binary via the
// DEEPSEEK_YOLO env var (config.yolo), not as a CLI flag. Honour either.
Expand Down Expand Up @@ -6533,6 +6507,75 @@ async fn run_interactive(
.await
}

fn spawn_interactive_startup_maintenance(
workspace: PathBuf,
snapshots: crate::config::SnapshotsConfig,
protected_session_id: Option<String>,
) {
let spawn_result = std::thread::Builder::new()
.name("codewhale-startup-maintenance".to_string())
.spawn(move || {
Comment thread
nightt5879 marked this conversation as resolved.
// Keep the first interactive frame ahead of optional disk cleanup.
std::thread::sleep(Duration::from_millis(500));
Comment thread
nightt5879 marked this conversation as resolved.
run_interactive_startup_maintenance(
&workspace,
&snapshots,
protected_session_id.as_deref(),
);
});

if let Err(err) = spawn_result {
logging::warn(format!("Startup maintenance skipped: {err}"));
}
}

fn run_interactive_startup_maintenance(
workspace: &Path,
snapshots: &crate::config::SnapshotsConfig,
protected_session_id: Option<&str>,
) {
let started = Instant::now();

// Prune stale workspace snapshots from prior sessions (7-day default).
// Non-fatal: a flaky disk, missing `git`, or read-only home should
// never block the TUI from starting.
if snapshots.enabled {
session_manager::prune_workspace_snapshots(workspace, snapshots.max_age());
}

// Prune stale tool-output spillover files (#422). Non-fatal: home
// missing or directory unreadable just means nothing got pruned.
match crate::tools::truncate::prune_older_than(crate::tools::truncate::SPILLOVER_MAX_AGE) {
Comment thread
nightt5879 marked this conversation as resolved.
Ok(0) => {}
Ok(n) => tracing::debug!(
target: "spillover",
"boot prune removed {n} spillover file(s)"
),
Err(err) => tracing::warn!(
target: "spillover",
?err,
"spillover prune skipped on boot"
),
}

// v0.8.44: prune managed sessions to prevent unbounded growth.
// Keeps at most MAX_SESSIONS (50) recent sessions; non-fatal on error.
match session_manager::SessionManager::default_location() {
Ok(manager) => {
if let Err(err) = manager.cleanup_old_sessions_except(protected_session_id) {
tracing::warn!(target: "session", ?err, "session cleanup skipped on boot");
}
}
Err(err) => tracing::warn!(target: "session", ?err, "session cleanup skipped on boot"),
}

tracing::debug!(
target: "startup",
elapsed_ms = started.elapsed().as_millis(),
"startup maintenance finished"
);
}

#[derive(Debug)]
struct CliAutoRoute {
provider: crate::config::ApiProvider,
Expand Down
52 changes: 51 additions & 1 deletion crates/tui/src/session_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -489,11 +489,19 @@ impl SessionManager {

/// Clean up old sessions to stay within `MAX_SESSIONS` limit.
pub fn cleanup_old_sessions(&self) -> std::io::Result<()> {
self.cleanup_old_sessions_except(None)
}

/// Clean up old sessions while preserving an active/resumed session.
pub fn cleanup_old_sessions_except(&self, protected_id: Option<&str>) -> std::io::Result<()> {
let sessions = self.list_sessions()?;

if sessions.len() > MAX_SESSIONS {
// Delete oldest sessions
for session in sessions.iter().skip(MAX_SESSIONS) {
if protected_id.is_some_and(|id| id == session.id) {
continue;
}
let _ = self.delete_session(&session.id);
}
}
Expand Down Expand Up @@ -1108,6 +1116,23 @@ mod tests {
workspace: &Path,
updated_at: DateTime<Utc>,
) {
let session = make_session_record(id, workspace, updated_at);
manager.save_session(&session).expect("save");
}

fn write_session_record_without_cleanup(
manager: &SessionManager,
id: &str,
workspace: &Path,
updated_at: DateTime<Utc>,
) {
let session = make_session_record(id, workspace, updated_at);
let path = manager.validated_session_path(id).expect("path");
let content = serde_json::to_string_pretty(&session).expect("json");
fs::write(path, content).expect("write session");
}

fn make_session_record(id: &str, workspace: &Path, updated_at: DateTime<Utc>) -> SavedSession {
let session = SavedSession {
schema_version: CURRENT_SESSION_SCHEMA_VERSION,
messages: vec![make_test_message("user", "hi")],
Expand All @@ -1130,7 +1155,7 @@ mod tests {
context_references: Vec::new(),
artifacts: Vec::new(),
};
manager.save_session(&session).expect("save");
session
}

fn write_empty_session_record(
Expand Down Expand Up @@ -2184,6 +2209,31 @@ mod tests {
assert_eq!(loaded.artifacts, session.artifacts);
}

#[test]
fn cleanup_old_sessions_except_preserves_protected_old_session() {
let tmp = tempdir().expect("tempdir");
let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
let now = Utc::now();
for idx in 0..(MAX_SESSIONS + 2) {
write_session_record_without_cleanup(
&manager,
&format!("session-{idx:02}"),
Path::new("/tmp"),
now - chrono::Duration::minutes(idx as i64),
);
}

manager
.cleanup_old_sessions_except(Some("session-51"))
.expect("cleanup");
let sessions = manager.list_sessions().expect("list");
let ids: Vec<_> = sessions.iter().map(|session| session.id.as_str()).collect();

assert!(ids.contains(&"session-51"));
assert!(!ids.contains(&"session-50"));
assert_eq!(sessions.len(), MAX_SESSIONS + 1);
}

// ---- #406 prune_sessions_older_than ----
//
// The helper is a building block for the auto-archive design: it
Expand Down
27 changes: 25 additions & 2 deletions crates/tui/src/snapshot/repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
//! directory".

use std::collections::HashSet;
use std::fs::OpenOptions;
use std::io;
use std::path::{Component, Path, PathBuf};
use std::process::Output;
Expand Down Expand Up @@ -51,6 +52,7 @@ pub struct SnapshotRepo {
}

const STALE_TMP_PACK_AGE: Duration = Duration::from_secs(60 * 60);
const SNAPSHOT_LOCK_FILE: &str = "codewhale-snapshot.lock";

/// Maximum total snapshot storage in megabytes before pruning kicks in at
/// snapshot time. Keeps the side repo from blowing up the user's disk during
Expand Down Expand Up @@ -305,6 +307,10 @@ impl SnapshotRepo {
///
/// Returns the snapshot's commit SHA.
pub fn snapshot(&self, label: &str) -> io::Result<SnapshotId> {
self.with_repo_write_lock(|| self.snapshot_locked(label))
}

fn snapshot_locked(&self, label: &str) -> io::Result<SnapshotId> {
// Guard against disk blowup (#1112): if the snapshot directory has
// grown beyond the limit, prune aggressively before adding more.
if let Ok(current_mb) = dir_size_mb(&self.git_dir)
Expand All @@ -320,7 +326,7 @@ impl SnapshotRepo {
// we're under the target, or until there's nothing left.
let mut age = Duration::from_secs(1);
for _ in 0..10 {
let _ = self.prune_older_than(age);
let _ = self.prune_older_than_locked(age);
if let Ok(new_size) = dir_size_mb(&self.git_dir)
&& new_size <= PRUNE_TARGET_MB
{
Expand All @@ -343,7 +349,7 @@ impl SnapshotRepo {
target: "snapshot",
"snapshot storage still over limit after pruning; wiping history"
);
let _ = self.prune_older_than(Duration::ZERO);
let _ = self.prune_older_than_locked(Duration::ZERO);
let _ = self.prune_unreachable_objects();
}
}
Expand Down Expand Up @@ -550,6 +556,10 @@ impl SnapshotRepo {
/// `git gc --prune=now` to actually reclaim space. Cheap and avoids
/// rewriting history when nothing has aged out.
pub fn prune_older_than(&self, max_age: Duration) -> io::Result<usize> {
self.with_repo_write_lock(|| self.prune_older_than_locked(max_age))
}

fn prune_older_than_locked(&self, max_age: Duration) -> io::Result<usize> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| io_other(format!("clock error: {e}")))?
Expand Down Expand Up @@ -712,6 +722,19 @@ impl SnapshotRepo {
Ok(removed)
}

fn with_repo_write_lock<T>(&self, f: impl FnOnce() -> io::Result<T>) -> io::Result<T> {
let lock_path = self.git_dir.join(SNAPSHOT_LOCK_FILE);
let lock_file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(lock_path)?;
let mut lock = fd_lock::RwLock::new(lock_file);
let _guard = lock.write()?;
f()
}

/// Drop unreachable loose objects left behind by interrupted or
/// orphaned side-repo operations.
pub fn prune_unreachable_objects(&self) -> io::Result<()> {
Expand Down
Loading
Loading