返回 CodeWhale
session_resume.rs
根目录 / crates / tui / src / session_resume.rs
1 //! Truthful auto-resume of the last valid session (#2934).
2 //!
3 //! Plain `codewhale` has always started fresh. Issue #2934 asked for the
4 //! previous conversation to come back automatically; the review that followed
5 //! (see the 2026-07-16 comment) landed on a firm constraint: **no surprise
6 //! resume**. So this is an explicit, opt-in setting, and when it is on it
7 //! still refuses to guess.
8 //!
9 //! The rules, in the order they are applied:
10 //!
11 //! 1. An explicit `--resume <id>` or `--continue` always wins. Auto-resume
12 //! never overrides, reorders, or second-guesses what the user typed.
13 //! 2. `--fresh` always wins the other way.
14 //! 3. With the setting off (the default), the decision is
15 //! [`AutoResumeDecision::Disabled`] and startup is unchanged.
16 //! 4. With the setting on, the candidate is the newest non-archived session
17 //! recorded against *this* workspace. If there is none, startup is fresh
18 //! and says so.
19 //! 5. The candidate is then **verified**: it must load, and its recorded
20 //! workspace must still match the workspace we are launching in. A session
21 //! that fails to load (truncated, hand-edited, half-written) yields
22 //! [`AutoResumeDecision::Unreadable`] — fresh start, with the reason
23 //! surfaced rather than swallowed.
24 //!
25 //! Rule 5's workspace re-check is the one that matters most. The candidate
26 //! already came from a workspace-scoped query, but the metadata read there is
27 //! a 64 KB prefix extraction; re-checking against the fully loaded session is
28 //! what makes "we will never silently resume a different workspace" a property
29 //! of the code rather than a claim in a doc.
30 //!
31 //! Nothing here contacts a provider or the network. Deciding what to resume,
32 //! and resuming it, are pure disk operations.
33
34 use std::path::Path;
35
36 use crate::session_manager::{SessionManager, workspace_scope_matches};
37
38 /// How far down the candidate list auto-resume will walk before giving up.
39 ///
40 /// Bounded on purpose: a sessions directory full of damaged files must not
41 /// turn startup into a long scan, and the receipt's skipped count stays a
42 /// small, honest number rather than an unbounded tally.
43 pub const MAX_AUTO_RESUME_CANDIDATES: usize = 10;
44
45 /// Why startup is (or is not) attaching to a previous session.
46 #[derive(Debug, Clone, PartialEq, Eq)]
47 pub enum AutoResumeDecision {
48 /// The user asked for a specific session, or for `--continue`. Auto-resume
49 /// stands down and the explicit request is carried through untouched.
50 ExplicitRequest { session_id: String },
51 /// `--fresh` was passed. Start clean, no lookup performed.
52 ForcedFresh,
53 /// The setting is off. This is the shipped default.
54 Disabled,
55 /// A verified session for this workspace. Safe to resume.
56 ///
57 /// `skipped_unreadable` counts candidates newer than this one that failed
58 /// verification. It is reported rather than swallowed: silently landing on
59 /// an older session while newer ones rot is exactly the kind of quiet
60 /// degradation a user should be told about.
61 Resume {
62 session_id: String,
63 title: String,
64 skipped_unreadable: usize,
65 },
66 /// Auto-resume is on but this workspace has no eligible session yet.
67 /// Start fresh; this is a normal first-run state, not an error.
68 NoSession,
69 /// Every candidate for this workspace failed verification. Start fresh and
70 /// name the newest failure plus how many were tried.
71 Unreadable {
72 session_id: String,
73 reason: String,
74 skipped_unreadable: usize,
75 },
76 /// The newest candidate belongs to a different workspace than the one we
77 /// are launching in. Start fresh — resuming would silently move the user's
78 /// conversation to another project.
79 WorkspaceMismatch { session_id: String },
80 }
81
82 impl AutoResumeDecision {
83 /// The session id startup should actually load, if any.
84 #[must_use]
85 pub fn session_id(&self) -> Option<&str> {
86 match self {
87 Self::ExplicitRequest { session_id } | Self::Resume { session_id, .. } => {
88 Some(session_id.as_str())
89 }
90 Self::ForcedFresh
91 | Self::Disabled
92 | Self::NoSession
93 | Self::Unreadable { .. }
94 | Self::WorkspaceMismatch { .. } => None,
95 }
96 }
97
98 /// A short, user-facing receipt, or `None` when there is nothing worth
99 /// saying. Silence is correct for `Disabled` (the default posture) and for
100 /// `ExplicitRequest` (the existing resume path prints its own receipt).
101 #[must_use]
102 pub fn status_message(&self) -> Option<String> {
103 match self {
104 Self::ExplicitRequest { .. } | Self::ForcedFresh | Self::Disabled => None,
105 Self::Resume {
106 title,
107 skipped_unreadable: 0,
108 ..
109 } => Some(format!("Auto-resumed session: {title}")),
110 Self::Resume {
111 title,
112 skipped_unreadable,
113 ..
114 } => Some(format!(
115 "Auto-resumed session: {title} (skipped {skipped_unreadable} unreadable {} above it)",
116 plural_sessions(*skipped_unreadable)
117 )),
118 Self::NoSession => {
119 Some("Auto-resume: no previous session for this workspace — starting fresh".into())
120 }
121 Self::Unreadable {
122 session_id,
123 reason,
124 skipped_unreadable,
125 } => Some(format!(
126 "Auto-resume found no readable session ({skipped_unreadable} unreadable {}); newest was {}: {reason} — starting fresh",
127 plural_sessions(*skipped_unreadable),
128 crate::session_manager::truncate_id(session_id)
129 )),
130 Self::WorkspaceMismatch { session_id } => Some(format!(
131 "Auto-resume skipped session {}: it belongs to a different workspace — starting fresh",
132 crate::session_manager::truncate_id(session_id)
133 )),
134 }
135 }
136
137 /// True when startup will begin with an empty transcript.
138 ///
139 /// Test-only: startup itself branches on [`Self::session_id`], and this is
140 /// the same fact spelled the way the assertions read. Gating it keeps the
141 /// shipped surface to what the shipped code calls.
142 #[cfg(test)]
143 #[must_use]
144 pub fn starts_fresh(&self) -> bool {
145 self.session_id().is_none()
146 }
147 }
148
149 /// What the CLI asked for, independent of the persisted setting.
150 #[derive(Debug, Clone, Default)]
151 pub struct ResumeRequest {
152 /// `--resume <id>` (or a `--continue`-resolved id).
153 pub explicit_session_id: Option<String>,
154 /// `--fresh`.
155 pub force_fresh: bool,
156 }
157
158 /// Decide what, if anything, to auto-resume.
159 ///
160 /// `manager` is borrowed rather than opened here so tests and the `main`
161 /// startup path share one session directory, and so a caller that already has
162 /// a manager does not pay for a second directory resolution.
163 pub fn decide_auto_resume(
164 enabled: bool,
165 request: &ResumeRequest,
166 workspace: &Path,
167 manager: &SessionManager,
168 ) -> AutoResumeDecision {
169 if let Some(session_id) = request
170 .explicit_session_id
171 .as_deref()
172 .map(str::trim)
173 .filter(|id| !id.is_empty())
174 {
175 return AutoResumeDecision::ExplicitRequest {
176 session_id: session_id.to_string(),
177 };
178 }
179 if request.force_fresh {
180 return AutoResumeDecision::ForcedFresh;
181 }
182 if !enabled {
183 return AutoResumeDecision::Disabled;
184 }
185
186 let listed = match manager.list_sessions() {
187 Ok(listed) => listed,
188 // A sessions directory we cannot enumerate is not a reason to fail
189 // startup; it is a reason not to resume.
190 Err(err) => {
191 return AutoResumeDecision::Unreadable {
192 session_id: String::new(),
193 reason: format!("sessions directory unreadable ({err})"),
194 skipped_unreadable: 0,
195 };
196 }
197 };
198
199 // Candidates newest-first, scoped through the *same* matcher the picker,
200 // the rail, and the API use. `list_sessions` already sorts by `updated_at`
201 // descending.
202 let candidates = candidate_ids(&listed, workspace);
203 if candidates.is_empty() {
204 return AutoResumeDecision::NoSession;
205 }
206
207 let mut skipped_unreadable = 0usize;
208 let mut newest_failure: Option<(String, String)> = None;
209
210 // One corrupt newest session must not cost the user every older one. Walk
211 // down until something verifies, bounded so a directory full of damaged
212 // files cannot turn startup into a long scan.
213 for id in candidates.into_iter().take(MAX_AUTO_RESUME_CANDIDATES) {
214 // Verify against the real file before trusting it: the listing above
215 // only parsed each session's bounded metadata prefix. The probe only
216 // needs durable metadata — repair belongs to the resume that follows,
217 // so read the snapshot without running or logging it here.
218 let saved = match manager.load_session_snapshot(&id) {
219 Ok(saved) => saved,
220 Err(err) => {
221 skipped_unreadable += 1;
222 if newest_failure.is_none() {
223 newest_failure = Some((id, describe_load_error(&err)));
224 }
225 continue;
226 }
227 };
228
229 // A workspace mismatch is a different failure from corruption: it means
230 // the candidate is fine but is not ours. Report it rather than counting
231 // it as damage, and stop — resuming past it would be guessing.
232 if !workspace_scope_matches(&saved.metadata.workspace, workspace) {
233 return AutoResumeDecision::WorkspaceMismatch {
234 session_id: saved.metadata.id,
235 };
236 }
237 if saved.metadata.archived {
238 continue;
239 }
240
241 return AutoResumeDecision::Resume {
242 title: saved.metadata.title.clone(),
243 session_id: saved.metadata.id,
244 skipped_unreadable,
245 };
246 }
247
248 match newest_failure {
249 Some((session_id, reason)) => AutoResumeDecision::Unreadable {
250 session_id,
251 reason,
252 skipped_unreadable,
253 },
254 None => AutoResumeDecision::NoSession,
255 }
256 }
257
258 /// Newest-first eligible session ids for `workspace`.
259 ///
260 /// Uses [`crate::session_projection::select_sessions`] so "which sessions
261 /// belong to this workspace" is answered by the same code the browse surfaces
262 /// use — auto-resume cannot pick something the rail would not have listed.
263 fn candidate_ids(
264 sessions: &[crate::session_manager::SessionMetadata],
265 workspace: &Path,
266 ) -> Vec<String> {
267 // Empty auto-created placeholders are excluded *before* the candidate
268 // cap, the same definition `get_latest_session_for_workspace` applies:
269 // a run of fresh placeholders must not push a real session out of reach.
270 let query = crate::session_projection::SessionQuery::default()
271 .with_filter(crate::session_manager::SessionListFilter::ActiveOnly)
272 .with_sort(crate::session_projection::SessionSortMode::Recent)
273 .scoped_to(workspace)
274 .without_empty_auto_created()
275 .with_limit(MAX_AUTO_RESUME_CANDIDATES);
276 crate::session_projection::select_sessions(sessions, &query)
277 .into_iter()
278 .map(|metadata| metadata.id.clone())
279 .collect()
280 }
281
282 fn plural_sessions(count: usize) -> &'static str {
283 if count == 1 { "session" } else { "sessions" }
284 }
285
286 fn describe_load_error(err: &std::io::Error) -> String {
287 match err.kind() {
288 std::io::ErrorKind::NotFound => "session file is missing".to_string(),
289 std::io::ErrorKind::InvalidData => "session file is corrupt".to_string(),
290 std::io::ErrorKind::PermissionDenied => "session file is not readable".to_string(),
291 _ => err.to_string(),
292 }
293 }
294
295 #[cfg(test)]
296 mod tests {
297 use super::*;
298 use crate::session_manager::{SavedSession, create_saved_session_with_id_and_mode};
299 use codewhale_models::Role;
300 use codewhale_models::{ContentBlock, Message};
301 use std::path::PathBuf;
302 use tempfile::TempDir;
303
304 struct Fixture {
305 _dir: TempDir,
306 workspace: PathBuf,
307 manager: SessionManager,
308 }
309
310 fn fixture() -> Fixture {
311 let dir = TempDir::new().expect("tempdir");
312 let workspace = dir.path().join("workspace");
313 std::fs::create_dir_all(&workspace).expect("workspace dir");
314 let manager = SessionManager::new(dir.path().join("sessions")).expect("session manager");
315 Fixture {
316 _dir: dir,
317 workspace,
318 manager,
319 }
320 }
321
322 fn saved(id: &str, workspace: &Path, title: &str) -> SavedSession {
323 let messages = vec![Message {
324 role: Role::User,
325 content: vec![ContentBlock::Text {
326 text: "hello".to_string(),
327 cache_control: None,
328 }],
329 }];
330 let mut session = create_saved_session_with_id_and_mode(
331 id.to_string(),
332 &messages,
333 "deepseek-chat",
334 workspace,
335 12,
336 None,
337 Some("agent"),
338 );
339 session.metadata.title = title.to_string();
340 session
341 }
342
343 #[test]
344 fn disabled_by_default_never_looks_at_disk() {
345 let fx = fixture();
346 fx.manager
347 .save_session(&saved("s1", &fx.workspace, "Prior work"))
348 .expect("save");
349
350 let decision =
351 decide_auto_resume(false, &ResumeRequest::default(), &fx.workspace, &fx.manager);
352
353 assert_eq!(decision, AutoResumeDecision::Disabled);
354 assert!(decision.starts_fresh());
355 assert_eq!(decision.status_message(), None);
356 }
357
358 #[test]
359 fn fresh_placeholders_do_not_push_a_real_session_past_the_candidate_cap() {
360 let fx = fixture();
361 let mut real = saved("real", &fx.workspace, "Real work");
362 real.metadata.updated_at = chrono::Utc::now() - chrono::Duration::hours(1);
363 fx.manager.save_session(&real).expect("save real session");
364 for index in 0..MAX_AUTO_RESUME_CANDIDATES {
365 let mut placeholder = create_saved_session_with_id_and_mode(
366 format!("placeholder-{index}"),
367 &[],
368 "deepseek-chat",
369 &fx.workspace,
370 0,
371 None,
372 Some("agent"),
373 );
374 placeholder.metadata.title = crate::session_manager::DEFAULT_SESSION_TITLE.to_string();
375 placeholder.metadata.updated_at =
376 chrono::Utc::now() - chrono::Duration::seconds(index as i64);
377 fx.manager
378 .save_session(&placeholder)
379 .expect("save placeholder");
380 }
381 let listed = fx.manager.list_sessions().expect("list");
382 assert_eq!(
383 listed
384 .iter()
385 .filter(|session| crate::session_manager::is_empty_auto_created_session(session))
386 .count(),
387 MAX_AUTO_RESUME_CANDIDATES,
388 "fixture keeps a full cap of newer placeholders"
389 );
390
391 let decision =
392 decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager);
393
394 assert_eq!(decision.session_id(), Some("real"));
395 }
396
397 #[test]
398 fn enabled_resumes_the_newest_valid_session_for_this_workspace() {
399 let fx = fixture();
400 fx.manager
401 .save_session(&saved("older", &fx.workspace, "Older work"))
402 .expect("save older");
403 let mut newer = saved("newer", &fx.workspace, "Newer work");
404 newer.metadata.updated_at = chrono::Utc::now() + chrono::Duration::minutes(5);
405 fx.manager.save_session(&newer).expect("save newer");
406
407 let decision =
408 decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager);
409
410 assert_eq!(decision.session_id(), Some("newer"));
411 assert_eq!(
412 decision.status_message().as_deref(),
413 Some("Auto-resumed session: Newer work")
414 );
415 }
416
417 #[test]
418 fn missing_session_starts_fresh_with_a_receipt() {
419 let fx = fixture();
420
421 let decision =
422 decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager);
423
424 assert_eq!(decision, AutoResumeDecision::NoSession);
425 assert!(decision.starts_fresh());
426 assert!(
427 decision
428 .status_message()
429 .expect("receipt")
430 .contains("starting fresh")
431 );
432 }
433
434 #[test]
435 fn corrupt_session_falls_back_to_fresh_instead_of_failing_startup() {
436 let fx = fixture();
437 fx.manager
438 .save_session(&saved("broken", &fx.workspace, "Broken work"))
439 .expect("save");
440 // Drop the closing brace: the metadata block still parses from the
441 // file prefix (so listing still surfaces the session), but the full
442 // load fails. That is exactly the shape auto-resume must survive.
443 let path = fx.manager.sessions_dir().join("broken.json");
444 let content = std::fs::read_to_string(&path).expect("read session");
445 let truncated = content
446 .trim_end()
447 .strip_suffix('}')
448 .expect("session JSON ends with }");
449 std::fs::write(&path, truncated).expect("truncate session");
450
451 let decision =
452 decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager);
453
454 assert!(decision.starts_fresh());
455 assert!(
456 matches!(&decision, AutoResumeDecision::Unreadable { session_id, .. } if session_id == "broken"),
457 "expected an Unreadable decision, got {decision:?}"
458 );
459 assert!(
460 decision
461 .status_message()
462 .expect("receipt")
463 .contains("starting fresh")
464 );
465 }
466
467 #[test]
468 fn a_session_from_another_workspace_is_never_resumed() {
469 let fx = fixture();
470 let other = fx._dir.path().join("other-workspace");
471 std::fs::create_dir_all(&other).expect("other workspace");
472 fx.manager
473 .save_session(&saved("elsewhere", &other, "Someone else's project"))
474 .expect("save");
475
476 let decision =
477 decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager);
478
479 assert!(decision.starts_fresh());
480 assert_eq!(decision, AutoResumeDecision::NoSession);
481 }
482
483 #[test]
484 fn archived_sessions_are_not_auto_resume_candidates() {
485 let fx = fixture();
486 let mut session = saved("put-away", &fx.workspace, "Put away");
487 session.metadata.archived = true;
488 fx.manager.save_session(&session).expect("save");
489
490 let decision =
491 decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager);
492
493 assert_eq!(decision, AutoResumeDecision::NoSession);
494 }
495
496 #[test]
497 fn explicit_resume_wins_over_the_setting_in_both_directions() {
498 let fx = fixture();
499 fx.manager
500 .save_session(&saved("auto", &fx.workspace, "Auto candidate"))
501 .expect("save");
502
503 let explicit = ResumeRequest {
504 explicit_session_id: Some("chosen".to_string()),
505 force_fresh: false,
506 };
507 // Setting off: the explicit id still resumes.
508 assert_eq!(
509 decide_auto_resume(false, &explicit, &fx.workspace, &fx.manager).session_id(),
510 Some("chosen")
511 );
512 // Setting on: the explicit id is not replaced by the auto candidate.
513 assert_eq!(
514 decide_auto_resume(true, &explicit, &fx.workspace, &fx.manager).session_id(),
515 Some("chosen")
516 );
517 }
518
519 #[test]
520 fn fresh_flag_suppresses_auto_resume() {
521 let fx = fixture();
522 fx.manager
523 .save_session(&saved("auto", &fx.workspace, "Auto candidate"))
524 .expect("save");
525
526 let decision = decide_auto_resume(
527 true,
528 &ResumeRequest {
529 explicit_session_id: None,
530 force_fresh: true,
531 },
532 &fx.workspace,
533 &fx.manager,
534 );
535
536 assert_eq!(decision, AutoResumeDecision::ForcedFresh);
537 assert_eq!(decision.status_message(), None);
538 }
539
540 #[test]
541 fn blank_explicit_id_is_treated_as_absent() {
542 let fx = fixture();
543 let decision = decide_auto_resume(
544 false,
545 &ResumeRequest {
546 explicit_session_id: Some(" ".to_string()),
547 force_fresh: false,
548 },
549 &fx.workspace,
550 &fx.manager,
551 );
552 assert_eq!(decision, AutoResumeDecision::Disabled);
553 }
554 }
555
555 lines RUST