Skip to content
Open
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
113 changes: 82 additions & 31 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,13 @@ 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();
// Snapshot pruning rewrites side-repo refs and can run GC, so keep it
// before the TUI exposes snapshot list/restore commands.
if config.snapshots_config().enabled {
session_manager::prune_workspace_snapshots(&workspace, config.snapshots_config().max_age());
}
let protected_session_token = resume_session_id.clone();
spawn_interactive_startup_maintenance(workspace.clone(), protected_session_token);

// 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 +6509,81 @@ async fn run_interactive(
.await
}

fn spawn_interactive_startup_maintenance(
workspace: PathBuf,
protected_session_token: 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, protected_session_token.as_deref());
});

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

fn startup_maintenance_protected_session_id(
resume_session_id: Option<&str>,
workspace: &Path,
) -> Option<String> {
let session_id = resume_session_id?;
if session_id != "latest" {
return Some(session_id.to_string());
}

session_manager::SessionManager::default_location()
.ok()
.and_then(|manager| {
manager
.get_latest_session_for_workspace(workspace)
.ok()
Comment thread
nightt5879 marked this conversation as resolved.
.flatten()
})
.map(|session| session.id)
}

fn run_interactive_startup_maintenance(workspace: &Path, protected_session_token: Option<&str>) {
let started = Instant::now();

// 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.
let protected_session_id =
startup_maintenance_protected_session_id(protected_session_token, workspace);
match session_manager::SessionManager::default_location() {
Ok(manager) => {
if let Err(err) = manager.cleanup_old_sessions_except(protected_session_id.as_deref()) {
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
55 changes: 54 additions & 1 deletion crates/tui/src/session_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -489,11 +489,22 @@ 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.
///
/// Accepts a full id or resume prefix; startup passes the same user-facing
/// resume token that `load_session_by_prefix` accepts.
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| session.id == id || session.id.starts_with(id)) {
continue;
}
let _ = self.delete_session(&session.id);
}
}
Expand Down Expand Up @@ -1108,6 +1119,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 +1158,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 +2212,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}-abcdef"),
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-abcdef"));
assert!(!ids.contains(&"session-50-abcdef"));
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
1 change: 1 addition & 0 deletions crates/tui/src/snapshot/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pub mod repo;

#[allow(unused_imports)]
pub use paths::{snapshot_dir_for, snapshot_git_dir};
#[allow(unused_imports)]
pub use prune::{DEFAULT_MAX_AGE, prune_older_than};

/// Maximum snapshots kept per workspace side-repo. Oldest are pruned
Expand Down
76 changes: 65 additions & 11 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,8 +349,8 @@ impl SnapshotRepo {
target: "snapshot",
"snapshot storage still over limit after pruning; wiping history"
);
let _ = self.prune_older_than(Duration::ZERO);
let _ = self.prune_unreachable_objects();
let _ = self.prune_older_than_locked(Duration::ZERO);
let _ = self.prune_unreachable_objects_locked();
}
}
// Stage every tracked + untracked path the workspace exposes.
Expand Down Expand Up @@ -419,6 +425,10 @@ impl SnapshotRepo {
/// snapshot tree relative to the workspace root. We do NOT touch the
/// user's own `.git` — snapshots only contain working-tree files.
pub fn restore(&self, id: &SnapshotId) -> io::Result<()> {
self.with_repo_write_lock(|| self.restore_locked(id))
}

fn restore_locked(&self, id: &SnapshotId) -> io::Result<()> {
let current_paths = self.tree_paths("HEAD")?;
let target_paths = self.tree_paths(id.as_str())?;
let checkout = run_git(
Expand Down Expand Up @@ -446,12 +456,14 @@ impl SnapshotRepo {
/// again would be a no-op, so the caller should continue scanning
/// older snapshots.
pub fn work_tree_matches_snapshot(&self, id: &SnapshotId) -> io::Result<bool> {
let diff = run_git(
&self.git_dir,
&self.work_tree,
&["diff", "--quiet", id.as_str(), "--", ":/"],
)?;
Ok(diff.status.success())
self.with_repo_read_lock(|| {
let diff = run_git(
&self.git_dir,
&self.work_tree,
&["diff", "--quiet", id.as_str(), "--", ":/"],
)?;
Ok(diff.status.success())
})
}

fn tree_paths(&self, treeish: &str) -> io::Result<HashSet<PathBuf>> {
Expand Down Expand Up @@ -506,6 +518,10 @@ impl SnapshotRepo {

/// List up to `limit` most-recent snapshots, newest first.
pub fn list(&self, limit: usize) -> io::Result<Vec<Snapshot>> {
self.with_repo_read_lock(|| self.list_locked(limit))
}

fn list_locked(&self, limit: usize) -> io::Result<Vec<Snapshot>> {
// `git log -<n>` is the short form of `--max-count=<n>`; if `limit`
// is `usize::MAX` (caller asked for "everything") we pass an empty
// count so git defaults to no upper bound.
Expand Down Expand Up @@ -550,13 +566,17 @@ 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}")))?
.as_secs() as i64;
let cutoff = now - max_age.as_secs() as i64;

let snapshots = self.list(usize::MAX)?;
let snapshots = self.list_locked(usize::MAX)?;
if snapshots.is_empty() {
return Ok(0);
}
Expand Down Expand Up @@ -637,7 +657,11 @@ impl SnapshotRepo {
/// are preserved — only the parent chain to older snapshots is cut.
/// Old objects become unreachable and gc reclaims them.
pub fn prune_keep_last_n(&self, max_count: usize) -> io::Result<usize> {
let snapshots = self.list(usize::MAX)?;
self.with_repo_write_lock(|| self.prune_keep_last_n_locked(max_count))
}

fn prune_keep_last_n_locked(&self, max_count: usize) -> io::Result<usize> {
let snapshots = self.list_locked(usize::MAX)?;
if snapshots.len() <= max_count {
return Ok(0);
}
Expand Down Expand Up @@ -712,9 +736,39 @@ 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()
}

fn with_repo_read_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 lock = fd_lock::RwLock::new(lock_file);
let _guard = lock.read()?;
f()
}

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

fn prune_unreachable_objects_locked(&self) -> io::Result<()> {
let prune = run_git(&self.git_dir, &self.work_tree, &["prune", "--expire=now"])?;
if !prune.status.success() {
return Err(io_other(format!(
Expand Down
Loading
Loading