fix(session): start fresh on plain launch

This commit is contained in:
Hunter Bown
2026-05-07 15:16:12 -05:00
parent 9805c1b45e
commit 74b2f2f911
+166 -32
View File
@@ -769,16 +769,18 @@ async fn main() -> Result<()> {
return run_one_shot(&config, &model, &prompt).await;
}
// Handle session resume
// Handle session resume. Plain `deepseek` starts fresh: interrupted
// snapshots are preserved for explicit resume, but never auto-attached.
let resume_session_id = if cli.continue_session {
let workspace = resolve_workspace(&cli);
latest_session_id_for_workspace(&workspace).ok().flatten()
recover_interrupted_checkpoint_for_resume(&workspace)
.or_else(|| latest_session_id_for_workspace(&workspace).ok().flatten())
} else if let Some(id) = cli.resume.clone() {
Some(id)
} else if !cli.fresh {
// Check for crash-recovery checkpoint (unless --fresh was passed).
let workspace = resolve_workspace(&cli);
try_recover_checkpoint(&workspace)
preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
None
} else {
None
};
@@ -3683,27 +3685,12 @@ fn default_mouse_capture_enabled(terminal_emulator: Option<&str>) -> bool {
true
}
/// Check for a crash-recovery checkpoint and return the session ID if
/// recovery is possible *and* the checkpoint belongs to the current
/// workspace.
///
/// The checkpoint must exist and its file mtime must be within 24 hours.
/// **The checkpoint's workspace must also match the resolved launch workspace
/// after canonicalisation.** If the workspace doesn't match, the
/// checkpoint is persisted as a regular session (so the user can find it
/// via `deepseek sessions` / `deepseek resume <id>`) and cleared, and the
/// new launch starts fresh — silently importing a session from another
/// project would leak api_messages, working_set entries, and possibly
/// secrets across directories (see v0.8.12 cross-workspace bleed report).
///
/// On a successful match the checkpoint is persisted as a regular session,
/// cleared, and a notice is printed to stderr. Returns `None` if there is
/// nothing to recover or the workspace doesn't match.
fn try_recover_checkpoint(launch_workspace: &Path) -> Option<String> {
let manager = session_manager::SessionManager::default_location().ok()?;
/// Load a recent crash-recovery checkpoint, pruning stale checkpoints first.
fn load_recent_checkpoint(
manager: &session_manager::SessionManager,
) -> Option<(session_manager::SavedSession, std::time::Duration)> {
let session = manager.load_checkpoint().ok().flatten()?;
// Verify the checkpoint file is recent (within 24 hours).
let home = dirs::home_dir()?;
let checkpoint_path = home
.join(".deepseek")
@@ -3714,11 +3701,36 @@ fn try_recover_checkpoint(launch_workspace: &Path) -> Option<String> {
let mtime = metadata.modified().ok()?;
let age = std::time::SystemTime::now().duration_since(mtime).ok()?;
if age > std::time::Duration::from_secs(24 * 3600) {
// Stale checkpoint — clean it up.
let _ = manager.clear_checkpoint();
return None;
}
Some((session, age))
}
fn checkpoint_age_label(age: std::time::Duration) -> String {
if age.as_secs() < 60 {
format!("{}s ago", age.as_secs())
} else if age.as_secs() < 3600 {
format!("{}m ago", age.as_secs() / 60)
} else {
format!("{}h ago", age.as_secs() / 3600)
}
}
/// Check for a crash-recovery checkpoint and return the session ID if explicit
/// recovery was requested *and* the checkpoint belongs to the current
/// workspace.
///
/// The checkpoint must exist and its file mtime must be within 24 hours.
/// **The checkpoint's workspace must also match the resolved launch workspace
/// after canonicalisation.** If the workspace doesn't match, the checkpoint is
/// persisted as a regular session (so the user can find it via
/// `deepseek sessions` / `deepseek resume <id>`) and cleared, but not loaded.
fn recover_interrupted_checkpoint_for_resume(launch_workspace: &Path) -> Option<String> {
let manager = session_manager::SessionManager::default_location().ok()?;
let (session, age) = load_recent_checkpoint(&manager)?;
// Refuse to silently restore a session from another workspace. Compare
// against the resolved launch workspace, not the shell cwd, so callers
// using `--workspace` cannot accidentally recover a checkpoint from the
@@ -3758,19 +3770,50 @@ fn try_recover_checkpoint(launch_workspace: &Path) -> Option<String> {
// Clear the checkpoint now that it has been recovered.
let _ = manager.clear_checkpoint();
// Format age for the notice.
let age_str = if age.as_secs() < 60 {
format!("{}s ago", age.as_secs())
} else if age.as_secs() < 3600 {
format!("{}m ago", age.as_secs() / 60)
} else {
format!("{}h ago", age.as_secs() / 3600)
};
let age_str = checkpoint_age_label(age);
eprintln!("Recovered interrupted session ({age_str}). Use --fresh to start fresh.",);
Some(session_id)
}
/// Preserve an interrupted checkpoint on a normal fresh launch without
/// attaching it to the new TUI instance. This keeps "open another deepseek in
/// the same folder" from re-entering the previous in-flight session while still
/// leaving an explicit resume path.
fn preserve_interrupted_checkpoint_for_explicit_resume(launch_workspace: &Path) {
let Some(manager) = session_manager::SessionManager::default_location().ok() else {
return;
};
let Some((session, age)) = load_recent_checkpoint(&manager) else {
return;
};
let session_id = session.metadata.id.clone();
let session_workspace = session.metadata.workspace.clone();
let _ = manager.save_session(&session);
let _ = manager.clear_checkpoint();
let age_str = checkpoint_age_label(age);
let short_id = session_id.chars().take(8).collect::<String>();
if session_manager::workspace_scope_matches(&session_workspace, launch_workspace) {
eprintln!(
"Found an in-flight session snapshot ({age_str}, {short_id}…). \
Starting a new session. Run `deepseek resume {session_id}` or \
`deepseek --continue` to resume it."
);
} else {
eprintln!(
"Note: an interrupted session ({short_id}…) from another workspace ({}) \
is available. Run `deepseek resume {}` from there to recover it, or \
use `deepseek sessions` to list all saved sessions. Starting fresh \
in {}.",
session_workspace.display(),
session_id,
launch_workspace.display(),
);
}
}
/// Load project-level config from `$WORKSPACE/.deepseek/config.toml` and
/// apply its fields as overrides on top of the global config (#485).
/// Only explicitly set fields in the project file are applied; everything
@@ -5273,6 +5316,97 @@ mod setup_helper_tests {
assert!(!dir.exists());
}
fn with_home<T>(home: &Path, f: impl FnOnce() -> T) -> T {
let prev_home = std::env::var_os("HOME");
let prev_userprofile = std::env::var_os("USERPROFILE");
unsafe {
std::env::set_var("HOME", home);
std::env::set_var("USERPROFILE", home);
}
let result = f();
unsafe {
match prev_home {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
match prev_userprofile {
Some(value) => std::env::set_var("USERPROFILE", value),
None => std::env::remove_var("USERPROFILE"),
}
}
result
}
#[test]
fn plain_launch_preserves_checkpoint_but_starts_fresh() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
with_home(tmp.path(), || {
let manager = SessionManager::default_location().expect("manager");
let messages = vec![Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "in flight".to_string(),
cache_control: None,
}],
}];
let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
let session_id = session.metadata.id.clone();
manager.save_checkpoint(&session).expect("save checkpoint");
preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
assert!(
manager
.load_checkpoint()
.expect("load checkpoint")
.is_none(),
"normal launch should clear latest checkpoint after preserving it"
);
assert!(
manager.load_session(&session_id).is_ok(),
"normal launch should keep an explicit resume target"
);
});
}
#[test]
fn continue_recovers_same_workspace_checkpoint() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
with_home(tmp.path(), || {
let manager = SessionManager::default_location().expect("manager");
let messages = vec![Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "continue me".to_string(),
cache_control: None,
}],
}];
let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
let session_id = session.metadata.id.clone();
manager.save_checkpoint(&session).expect("save checkpoint");
let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
assert!(
manager
.load_checkpoint()
.expect("load checkpoint")
.is_none(),
"--continue should consume the checkpoint"
);
assert!(manager.load_session(&session_id).is_ok());
});
}
#[test]
fn dotenv_status_points_to_example_when_present() {
let tmp = TempDir::new().unwrap();