返回 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 let query = crate::session_projection::SessionQuery::default()
268 .with_filter(crate::session_manager::SessionListFilter::ActiveOnly)
269 .with_sort(crate::session_projection::SessionSortMode::Recent)
270 .scoped_to(workspace)
271 .with_limit(MAX_AUTO_RESUME_CANDIDATES);
272 crate::session_projection::select_sessions(sessions, &query)
273 .into_iter()
274 .filter(|metadata| !is_empty_placeholder(metadata))
275 .map(|metadata| metadata.id.clone())
276 .collect()
277 }
278
279 /// An auto-created, never-used session is not something to resume into.
280 /// Mirrors the filter `get_latest_session_for_workspace` applies.
281 fn is_empty_placeholder(metadata: &crate::session_manager::SessionMetadata) -> bool {
282 metadata.message_count == 0
283 && metadata
284 .title
285 .trim()
286 .eq_ignore_ascii_case(crate::session_manager::DEFAULT_SESSION_TITLE)
287 }
288
289 fn plural_sessions(count: usize) -> &'static str {
290 if count == 1 { "session" } else { "sessions" }
291 }
292
293 fn describe_load_error(err: &std::io::Error) -> String {
294 match err.kind() {
295 std::io::ErrorKind::NotFound => "session file is missing".to_string(),
296 std::io::ErrorKind::InvalidData => "session file is corrupt".to_string(),
297 std::io::ErrorKind::PermissionDenied => "session file is not readable".to_string(),
298 _ => err.to_string(),
299 }
300 }
301
302 #[cfg(test)]
303 mod tests {
304 use super::*;
305 use crate::session_manager::{SavedSession, create_saved_session_with_id_and_mode};
306 use codewhale_models::Role;
307 use codewhale_models::{ContentBlock, Message};
308 use std::path::PathBuf;
309 use tempfile::TempDir;
310
311 struct Fixture {
312 _dir: TempDir,
313 workspace: PathBuf,
314 manager: SessionManager,
315 }
316
317 fn fixture() -> Fixture {
318 let dir = TempDir::new().expect("tempdir");
319 let workspace = dir.path().join("workspace");
320 std::fs::create_dir_all(&workspace).expect("workspace dir");
321 let manager = SessionManager::new(dir.path().join("sessions")).expect("session manager");
322 Fixture {
323 _dir: dir,
324 workspace,
325 manager,
326 }
327 }
328
329 fn saved(id: &str, workspace: &Path, title: &str) -> SavedSession {
330 let messages = vec![Message {
331 role: Role::User,
332 content: vec![ContentBlock::Text {
333 text: "hello".to_string(),
334 cache_control: None,
335 }],
336 }];
337 let mut session = create_saved_session_with_id_and_mode(
338 id.to_string(),
339 &messages,
340 "deepseek-chat",
341 workspace,
342 12,
343 None,
344 Some("agent"),
345 );
346 session.metadata.title = title.to_string();
347 session
348 }
349
350 #[test]
351 fn disabled_by_default_never_looks_at_disk() {
352 let fx = fixture();
353 fx.manager
354 .save_session(&saved("s1", &fx.workspace, "Prior work"))
355 .expect("save");
356
357 let decision =
358 decide_auto_resume(false, &ResumeRequest::default(), &fx.workspace, &fx.manager);
359
360 assert_eq!(decision, AutoResumeDecision::Disabled);
361 assert!(decision.starts_fresh());
362 assert_eq!(decision.status_message(), None);
363 }
364
365 #[test]
366 fn enabled_resumes_the_newest_valid_session_for_this_workspace() {
367 let fx = fixture();
368 fx.manager
369 .save_session(&saved("older", &fx.workspace, "Older work"))
370 .expect("save older");
371 let mut newer = saved("newer", &fx.workspace, "Newer work");
372 newer.metadata.updated_at = chrono::Utc::now() + chrono::Duration::minutes(5);
373 fx.manager.save_session(&newer).expect("save newer");
374
375 let decision =
376 decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager);
377
378 assert_eq!(decision.session_id(), Some("newer"));
379 assert_eq!(
380 decision.status_message().as_deref(),
381 Some("Auto-resumed session: Newer work")
382 );
383 }
384
385 #[test]
386 fn missing_session_starts_fresh_with_a_receipt() {
387 let fx = fixture();
388
389 let decision =
390 decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager);
391
392 assert_eq!(decision, AutoResumeDecision::NoSession);
393 assert!(decision.starts_fresh());
394 assert!(
395 decision
396 .status_message()
397 .expect("receipt")
398 .contains("starting fresh")
399 );
400 }
401
402 #[test]
403 fn corrupt_session_falls_back_to_fresh_instead_of_failing_startup() {
404 let fx = fixture();
405 fx.manager
406 .save_session(&saved("broken", &fx.workspace, "Broken work"))
407 .expect("save");
408 // Drop the closing brace: the metadata block still parses from the
409 // file prefix (so listing still surfaces the session), but the full
410 // load fails. That is exactly the shape auto-resume must survive.
411 let path = fx.manager.sessions_dir().join("broken.json");
412 let content = std::fs::read_to_string(&path).expect("read session");
413 let truncated = content
414 .trim_end()
415 .strip_suffix('}')
416 .expect("session JSON ends with }");
417 std::fs::write(&path, truncated).expect("truncate session");
418
419 let decision =
420 decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager);
421
422 assert!(decision.starts_fresh());
423 assert!(
424 matches!(&decision, AutoResumeDecision::Unreadable { session_id, .. } if session_id == "broken"),
425 "expected an Unreadable decision, got {decision:?}"
426 );
427 assert!(
428 decision
429 .status_message()
430 .expect("receipt")
431 .contains("starting fresh")
432 );
433 }
434
435 #[test]
436 fn a_session_from_another_workspace_is_never_resumed() {
437 let fx = fixture();
438 let other = fx._dir.path().join("other-workspace");
439 std::fs::create_dir_all(&other).expect("other workspace");
440 fx.manager
441 .save_session(&saved("elsewhere", &other, "Someone else's project"))
442 .expect("save");
443
444 let decision =
445 decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager);
446
447 assert!(decision.starts_fresh());
448 assert_eq!(decision, AutoResumeDecision::NoSession);
449 }
450
451 #[test]
452 fn archived_sessions_are_not_auto_resume_candidates() {
453 let fx = fixture();
454 let mut session = saved("put-away", &fx.workspace, "Put away");
455 session.metadata.archived = true;
456 fx.manager.save_session(&session).expect("save");
457
458 let decision =
459 decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager);
460
461 assert_eq!(decision, AutoResumeDecision::NoSession);
462 }
463
464 #[test]
465 fn explicit_resume_wins_over_the_setting_in_both_directions() {
466 let fx = fixture();
467 fx.manager
468 .save_session(&saved("auto", &fx.workspace, "Auto candidate"))
469 .expect("save");
470
471 let explicit = ResumeRequest {
472 explicit_session_id: Some("chosen".to_string()),
473 force_fresh: false,
474 };
475 // Setting off: the explicit id still resumes.
476 assert_eq!(
477 decide_auto_resume(false, &explicit, &fx.workspace, &fx.manager).session_id(),
478 Some("chosen")
479 );
480 // Setting on: the explicit id is not replaced by the auto candidate.
481 assert_eq!(
482 decide_auto_resume(true, &explicit, &fx.workspace, &fx.manager).session_id(),
483 Some("chosen")
484 );
485 }
486
487 #[test]
488 fn fresh_flag_suppresses_auto_resume() {
489 let fx = fixture();
490 fx.manager
491 .save_session(&saved("auto", &fx.workspace, "Auto candidate"))
492 .expect("save");
493
494 let decision = decide_auto_resume(
495 true,
496 &ResumeRequest {
497 explicit_session_id: None,
498 force_fresh: true,
499 },
500 &fx.workspace,
501 &fx.manager,
502 );
503
504 assert_eq!(decision, AutoResumeDecision::ForcedFresh);
505 assert_eq!(decision.status_message(), None);
506 }
507
508 #[test]
509 fn blank_explicit_id_is_treated_as_absent() {
510 let fx = fixture();
511 let decision = decide_auto_resume(
512 false,
513 &ResumeRequest {
514 explicit_session_id: Some(" ".to_string()),
515 force_fresh: false,
516 },
517 &fx.workspace,
518 &fx.manager,
519 );
520 assert_eq!(decision, AutoResumeDecision::Disabled);
521 }
522 }
523
523 lines RUST