返回 CodeWhale
session_manager.rs
根目录 / crates / tui / src / session_manager.rs
1 //! Session management for resuming conversations.
2 //!
3 //! This module provides functionality for:
4 //! - Saving sessions to disk
5 //! - Listing previous sessions
6 //! - Resuming sessions by ID
7 //! - Managing session lifecycle
8
9 use crate::approval_log::{ApprovalReceipt, ApprovalReceiptStore, ApprovalReplay};
10 use crate::artifacts::ArtifactRecord;
11 use crate::config::ApiProvider;
12 use crate::model_routing::AutoRouteReceipt;
13 use crate::project_context::find_git_root;
14 use crate::session_tree::{SessionEntry, SessionImportContainer, SessionJournal};
15 use crate::tools::goal::{GoalPauseReason, GoalSnapshot};
16 use crate::tools::plan::PlanSnapshot;
17 use crate::tools::todo::TodoListSnapshot;
18 use crate::utils::write_atomic;
19 use crate::work_graph::ReasoningEffortTier;
20 use chrono::{DateTime, Utc};
21 use codewhale_core::ContextReference;
22 #[cfg(test)]
23 use codewhale_core::{ContextReferenceKind, ContextReferenceSource};
24 use codewhale_models::{ContentBlock, Message, SystemPrompt};
25 use serde::{Deserialize, Serialize};
26 use std::collections::{BTreeMap, BTreeSet};
27 use std::fs::{self, OpenOptions};
28 use std::io;
29 use std::path::{Component, Path, PathBuf};
30 use std::sync::atomic::AtomicBool;
31 use uuid::Uuid;
32
33 /// Maximum number of active (non-archived) transcripts to retain.
34 ///
35 /// A transcript that falls out of this window is archived, never unlinked
36 /// (#6136); archived records sit outside the cap until the user prunes them,
37 /// and empty auto-created stubs are capped separately (#6137).
38 const MAX_SESSIONS: usize = 50;
39 /// Maximum empty auto-created stubs ("New Session", zero messages) to keep.
40 ///
41 /// The product writes one per boot; they are junk that must never occupy a
42 /// transcript's slot in the cap (#6137).
43 const MAX_EMPTY_SESSION_STUBS: usize = 10;
44 /// Maximum session title length, in `char`s. Matches the bound the session
45 /// picker's rename prompt has always enforced.
46 pub const MAX_SESSION_TITLE_CHARS: usize = 100;
47 const WORK_GRAPH_IMPORT_ARCHIVE_DIR: &str = ".work-graph-import-archive";
48 const SESSION_GOALS_DIR: &str = ".goals";
49 const CURRENT_SESSION_GOAL_SCHEMA_VERSION: u32 = 2;
50 const MAX_SESSION_GOAL_OBJECTIVE_CHARS: usize = 8_192;
51 const MAX_SESSION_GOAL_FILE_BYTES: u64 = 64 * 1_024;
52 const CURRENT_SESSION_SCHEMA_VERSION: u32 = 1;
53 const CURRENT_QUEUE_SCHEMA_VERSION: u32 = 1;
54 const LATE_USAGE_DIR: &str = ".late-usage";
55 const CURRENT_LATE_USAGE_SCHEMA_VERSION: u32 = 1;
56 const MAX_LATE_USAGE_RECORDS_PER_SESSION: usize = 64;
57 const MAX_LATE_USAGE_LEDGER_BYTES: u64 = 1024 * 1024;
58 const LATE_USAGE_DELETED: &[u8] = b"codewhale-session-deleted-v1\n";
59 const LATE_USAGE_UNAVAILABLE_REASON: &str = "late_usage_ledger_unavailable";
60
61 #[derive(Clone, Copy)]
62 enum SessionRemoval {
63 Explicit,
64 Retention,
65 }
66
67 #[derive(Debug, Clone, Serialize, Deserialize)]
68 struct LateUsageRecord {
69 source_fingerprint: String,
70 turn_fingerprint: String,
71 route: crate::cost_status::EffectiveRouteEnvelope,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 usage: Option<codewhale_models::Usage>,
74 }
75
76 #[derive(Debug, Clone, Serialize, Deserialize)]
77 struct LateUsageLedger {
78 schema_version: u32,
79 #[serde(default)]
80 records: Vec<LateUsageRecord>,
81 #[serde(default)]
82 overflowed: bool,
83 }
84
85 impl Default for LateUsageLedger {
86 fn default() -> Self {
87 Self {
88 schema_version: CURRENT_LATE_USAGE_SCHEMA_VERSION,
89 records: Vec::new(),
90 overflowed: false,
91 }
92 }
93 }
94
95 fn is_sha256_fingerprint(value: &str) -> bool {
96 value.len() == 64
97 && value
98 .bytes()
99 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
100 }
101
102 const fn default_session_schema_version() -> u32 {
103 CURRENT_SESSION_SCHEMA_VERSION
104 }
105
106 const fn default_queue_schema_version() -> u32 {
107 CURRENT_QUEUE_SCHEMA_VERSION
108 }
109
110 fn normalize_managed_dir(path: PathBuf) -> std::io::Result<PathBuf> {
111 if path.as_os_str().is_empty() {
112 return Err(std::io::Error::new(
113 std::io::ErrorKind::InvalidInput,
114 "managed directory path cannot be empty",
115 ));
116 }
117 if path.components().any(|component| {
118 matches!(
119 component,
120 Component::ParentDir | Component::Prefix(_) | Component::RootDir
121 )
122 }) && path.is_relative()
123 {
124 return Err(std::io::Error::new(
125 std::io::ErrorKind::InvalidInput,
126 "managed directory path cannot contain traversal components",
127 ));
128 }
129 if path.is_absolute() {
130 return Ok(path);
131 }
132 std::env::current_dir().map(|cwd| cwd.join(path))
133 }
134
135 fn open_private_lock_file(path: &Path) -> io::Result<fs::File> {
136 let mut options = OpenOptions::new();
137 options.create(true).read(true).write(true);
138 #[cfg(unix)]
139 {
140 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
141 options
142 .mode(0o600)
143 .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK);
144 let file = options.open(path)?;
145 validate_private_regular_file(&file, path)?;
146 file.set_permissions(fs::Permissions::from_mode(0o600))?;
147 Ok(file)
148 }
149 #[cfg(windows)]
150 {
151 use std::os::windows::fs::OpenOptionsExt as _;
152 use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT;
153 options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
154 let file = options.open(path)?;
155 validate_private_regular_file(&file, path)?;
156 Ok(file)
157 }
158 #[cfg(all(not(unix), not(windows)))]
159 {
160 let file = options.open(path)?;
161 validate_private_regular_file(&file, path)?;
162 Ok(file)
163 }
164 }
165
166 fn open_private_read_file(path: &Path) -> io::Result<fs::File> {
167 let mut options = OpenOptions::new();
168 options.read(true);
169 #[cfg(unix)]
170 {
171 use std::os::unix::fs::OpenOptionsExt as _;
172 options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK);
173 }
174 #[cfg(windows)]
175 {
176 use std::os::windows::fs::OpenOptionsExt as _;
177 use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT;
178 options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
179 }
180 let file = options.open(path)?;
181 validate_private_regular_file(&file, path)?;
182 Ok(file)
183 }
184
185 #[cfg(unix)]
186 fn validate_private_regular_file(file: &fs::File, path: &Path) -> io::Result<()> {
187 use std::os::unix::fs::MetadataExt as _;
188
189 let metadata = file.metadata()?;
190 if !metadata.is_file() || metadata.nlink() != 1 {
191 return Err(io::Error::new(
192 io::ErrorKind::InvalidData,
193 format!(
194 "private sidecar file {} must be one regular filesystem link",
195 path.display()
196 ),
197 ));
198 }
199 Ok(())
200 }
201
202 #[cfg(windows)]
203 fn validate_private_regular_file(file: &fs::File, path: &Path) -> io::Result<()> {
204 use std::os::windows::fs::MetadataExt as _;
205 use std::os::windows::io::AsRawHandle as _;
206 use windows_sys::Win32::Storage::FileSystem::{
207 BY_HANDLE_FILE_INFORMATION, FILE_ATTRIBUTE_REPARSE_POINT, GetFileInformationByHandle,
208 };
209
210 let metadata = file.metadata()?;
211 if !metadata.is_file() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
212 return Err(io::Error::new(
213 io::ErrorKind::InvalidData,
214 format!(
215 "private sidecar file {} must be a non-reparse regular file",
216 path.display()
217 ),
218 ));
219 }
220 let mut info = BY_HANDLE_FILE_INFORMATION::default();
221 // SAFETY: `file` keeps the handle valid and `info` is writable for the call.
222 if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 {
223 return Err(io::Error::last_os_error());
224 }
225 if info.nNumberOfLinks != 1 {
226 return Err(io::Error::new(
227 io::ErrorKind::InvalidData,
228 format!(
229 "private sidecar file {} must have exactly one filesystem link",
230 path.display()
231 ),
232 ));
233 }
234 Ok(())
235 }
236
237 #[cfg(all(not(unix), not(windows)))]
238 fn validate_private_regular_file(file: &fs::File, path: &Path) -> io::Result<()> {
239 if !file.metadata()?.is_file() {
240 return Err(io::Error::new(
241 io::ErrorKind::InvalidData,
242 format!("private sidecar file {} must be regular", path.display()),
243 ));
244 }
245 Ok(())
246 }
247
248 /// Persisted queued message for offline/degraded mode.
249 #[derive(Debug, Clone, Serialize, Deserialize)]
250 pub struct QueuedSessionMessage {
251 pub display: String,
252 #[serde(default)]
253 pub skill_instruction: Option<String>,
254 #[serde(default)]
255 pub skill_provenance: Option<crate::plugins::types::PluginAuthority>,
256 }
257
258 /// Persisted queue state for recovery after restart/crash.
259 #[derive(Debug, Clone, Serialize, Deserialize)]
260 pub struct OfflineQueueState {
261 #[serde(default = "default_queue_schema_version")]
262 pub schema_version: u32,
263 /// Session ID this queue belongs to. Redundant with the per-session file
264 /// name it is stored under; the UI's restore path still compares it
265 /// against the live session before adopting the messages.
266 #[serde(default)]
267 pub session_id: Option<String>,
268 #[serde(default)]
269 pub messages: Vec<QueuedSessionMessage>,
270 #[serde(default)]
271 pub draft: Option<QueuedSessionMessage>,
272 }
273
274 /// Result of explicitly repairing a persisted session for process resume.
275 ///
276 /// Normal snapshot reads must not infer that an unmatched tool call crashed:
277 /// an embedding host can persist and inspect a session while that tool is
278 /// still running. Hosts should use [`SessionManager::load_session_snapshot`]
279 /// during normal operation and reserve this recovery path for a known process
280 /// or engine restart.
281 #[derive(Debug, Clone)]
282 pub struct SessionRecovery {
283 pub session: SavedSession,
284 pub changed: bool,
285 #[cfg_attr(not(test), expect(dead_code))]
286 pub repaired_call_count: usize,
287 #[cfg_attr(not(test), expect(dead_code))]
288 pub duplicate_result_count: usize,
289 #[cfg_attr(not(test), expect(dead_code))]
290 pub orphan_result_count: usize,
291 }
292
293 impl Default for OfflineQueueState {
294 fn default() -> Self {
295 Self {
296 schema_version: CURRENT_QUEUE_SCHEMA_VERSION,
297 session_id: None,
298 messages: Vec::new(),
299 draft: None,
300 }
301 }
302 }
303
304 /// Durable context-reference metadata attached to a user message.
305 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306 pub struct SessionContextReference {
307 pub message_index: usize,
308 pub reference: ContextReference,
309 }
310
311 /// Session metadata stored with each saved session
312 #[derive(Debug, Clone, Serialize, Deserialize)]
313 pub struct SessionMetadata {
314 /// Unique session identifier
315 pub id: String,
316 /// Actual host Runtime authority; independent of the conversation id.
317 /// Legacy/imported conversations have no binding until saved by a host.
318 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub runtime_store: Option<crate::runtime_threads::RuntimeStoreBinding>,
320 /// Human-readable title (derived from first message)
321 pub title: String,
322 /// When the session was created
323 pub created_at: DateTime<Utc>,
324 /// When the session was last updated
325 pub updated_at: DateTime<Utc>,
326 /// Number of messages in the session
327 pub message_count: usize,
328 /// Total tokens used
329 pub total_tokens: u64,
330 /// Model used for the session
331 pub model: String,
332 /// Provider used for the session model. Defaults for legacy saved sessions.
333 #[serde(default = "default_model_provider")]
334 pub model_provider: String,
335 /// Exact configured provider key. This is separate from `model_provider`
336 /// so old consumers can keep treating that field as the built-in provider
337 /// kind (`custom` for every named custom route).
338 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub model_provider_id: Option<String>,
340 /// Workspace directory
341 pub workspace: PathBuf,
342 /// Optional mode label (agent/plan/etc.)
343 #[serde(default)]
344 pub mode: Option<String>,
345 /// Accumulated cost data for persisted billing and high-water mark.
346 #[serde(default)]
347 pub cost: SessionCostSnapshot,
348 /// Source session id when this session was created with `deepseek fork`.
349 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub parent_session_id: Option<String>,
351 /// Source message count at fork time. This is intentionally coarse:
352 /// current saved sessions are linear JSON files, not per-entry trees.
353 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub forked_from_message_count: Option<usize>,
355 /// Cumulative turn duration in seconds (sum of completed turn elapsed
356 /// times). Persisted so the footer "worked" chip survives restarts
357 /// (#2038).
358 #[serde(default)]
359 pub cumulative_turn_secs: u64,
360 /// Durable archive flag (#2934 / #4397). Archived sessions stay on disk
361 /// and stay loadable; they are hidden from the default browse surfaces
362 /// and are never chosen by auto-resume.
363 ///
364 /// This mirrors `ThreadRecord::archived` in [`crate::runtime_threads`] so
365 /// the TUI session surfaces and the Runtime API/web dashboard project the
366 /// same lifecycle field instead of two divergent notions of "put away".
367 /// Additive and `skip_serializing_if`-guarded: sessions written before
368 /// v0.9.2 load as `archived = false` and round-trip byte-identically
369 /// until the flag is actually set.
370 #[serde(default, skip_serializing_if = "is_not_archived")]
371 pub archived: bool,
372 #[serde(default)]
373 pub spawn_depth: u32,
374 }
375
376 fn is_not_archived(archived: &bool) -> bool {
377 !*archived
378 }
379
380 /// Sessions currently owned by an in-process interactive surface (the TUI).
381 ///
382 /// A saved session is a file, and a running TUI holds the authoritative copy
383 /// in memory: it autosaves the whole document from `App` state. That makes an
384 /// out-of-band write to the *same* session unsafe — the next autosave would
385 /// silently revert it. Rather than let that happen quietly, the owner claims
386 /// the id here and any external writer is refused.
387 ///
388 /// A static registry rather than a field on `RuntimeApiState` because the
389 /// embedded Runtime API runs inside the TUI process; a standalone
390 /// `codewhale web` has an empty registry and is therefore never blocked, which
391 /// is exactly right — there is no TUI holding anything.
392 static LIVE_SESSIONS: std::sync::OnceLock<std::sync::RwLock<std::collections::HashSet<String>>> =
393 std::sync::OnceLock::new();
394
395 fn live_sessions() -> &'static std::sync::RwLock<std::collections::HashSet<String>> {
396 LIVE_SESSIONS.get_or_init(Default::default)
397 }
398
399 /// Who is asking to mutate a saved session.
400 ///
401 /// This is an authority distinction, not a convenience one: the owner may
402 /// write because it will update its in-memory copy in the same step; anyone
403 /// else may not, because it cannot.
404 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
405 pub enum SessionMutator {
406 /// The in-process surface that currently owns the session (the TUI). It
407 /// is responsible for updating its cached metadata atomically with the
408 /// write — see `App::apply_session_mutation`.
409 Owner,
410 /// Any other writer: the Runtime API, the web dashboard, a second
411 /// process. Refused while the session is claimed.
412 External,
413 }
414
415 /// Set the claimed session to exactly `session_id` (or nothing).
416 ///
417 /// The TUI owns at most one session at a time, so switching sessions must
418 /// release the previous claim in the same step — otherwise a `/new` would
419 /// leave the old id permanently locked against the dashboard.
420 pub fn set_live_session(session_id: Option<&str>) {
421 if let Ok(mut live) = live_sessions().write() {
422 live.clear();
423 if let Some(id) = session_id.map(str::trim).filter(|id| !id.is_empty()) {
424 live.insert(id.to_string());
425 }
426 }
427 }
428
429 /// Is this session currently owned by **this process's** interactive surface?
430 ///
431 /// The registry is process-local. Reclamation must not treat a missing entry
432 /// here as proof that no other Codewhale process still owns the directory.
433 #[must_use]
434 pub fn is_live_session(session_id: &str) -> bool {
435 live_sessions()
436 .read()
437 .is_ok_and(|live| live.contains(session_id))
438 }
439
440 /// The error an external writer gets when the session is live.
441 ///
442 /// `ResourceBusy` so callers can map it to a typed conflict rather than
443 /// pattern-matching on a message.
444 fn live_session_conflict(session_id: &str) -> std::io::Error {
445 std::io::Error::new(
446 std::io::ErrorKind::ResourceBusy,
447 format!(
448 "session '{session_id}' is open in an interactive Codewhale session; \
449 change it there instead — an external write would be reverted by its next autosave"
450 ),
451 )
452 }
453
454 /// File-name stem of the sidecar mapping session ids to the session
455 /// instance (process boot) that created their persisted record. Lives in
456 /// the sessions directory next to the `<id>.json` records it describes.
457 const SESSION_BOOT_OWNERS_STEM: &str = "session_boot_owners";
458
459 static SESSION_BOOT_ID: std::sync::OnceLock<String> = std::sync::OnceLock::new();
460
461 /// Identity of this running session instance (one per process boot).
462 ///
463 /// Mirrors the `SubAgentManager` boot id from #405: persisted records are
464 /// stamped with the instance that created them, so a later Codewhale
465 /// instance in the same workspace can tell restored rows from its own live
466 /// work (#4416).
467 #[must_use]
468 pub fn current_session_boot_id() -> &'static str {
469 SESSION_BOOT_ID.get_or_init(|| format!("boot_{}", &Uuid::new_v4().to_string()[..12]))
470 }
471
472 /// Which archive states a session listing includes.
473 ///
474 /// Deliberately the same three-way shape as
475 /// [`crate::runtime_threads::ThreadListFilter`] so `/v1/sessions` and
476 /// `/v1/threads` answer the same `include_archived` / `archived_only` query
477 /// pair with the same semantics.
478 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
479 pub enum SessionListFilter {
480 /// Only `archived = false` sessions. The browse default.
481 #[default]
482 ActiveOnly,
483 /// Active and archived sessions, newest first.
484 IncludeArchived,
485 /// Only `archived = true` sessions.
486 ArchivedOnly,
487 }
488
489 impl SessionListFilter {
490 /// Resolve the `include_archived` / `archived_only` query pair the same
491 /// way the threads routes do.
492 #[must_use]
493 pub fn from_query(include_archived: Option<bool>, archived_only: Option<bool>) -> Self {
494 if archived_only.unwrap_or(false) {
495 Self::ArchivedOnly
496 } else if include_archived.unwrap_or(false) {
497 Self::IncludeArchived
498 } else {
499 Self::ActiveOnly
500 }
501 }
502
503 #[must_use]
504 pub fn admits(self, archived: bool) -> bool {
505 match self {
506 Self::ActiveOnly => !archived,
507 Self::IncludeArchived => true,
508 Self::ArchivedOnly => archived,
509 }
510 }
511 }
512
513 fn default_model_provider() -> String {
514 "deepseek".to_string()
515 }
516
517 impl SessionMetadata {
518 pub(crate) fn set_model_provider_route(&mut self, kind: &str, identity: Option<&str>) {
519 self.model_provider = kind.to_string();
520 self.model_provider_id = identity.map(str::to_string);
521 }
522 }
523
524 /// Cost and high-water-mark fields persisted with each session.
525 ///
526 /// The coverage fields below are persisted **alongside** the money so a restored
527 /// session can still say what its total covers. Without them a reload produced a
528 /// dollar figure with no completeness information, which then rendered as "0 of 0
529 /// turns priced" — a fabricated claim of a complete total. Sessions written
530 /// before these fields existed deserialize them from `Default`, which is
531 /// indistinguishable from that same false reading, so the load path detects the
532 /// legacy shape explicitly (see [`Self::coverage_is_legacy_unknown`]) rather than
533 /// trusting the defaults (#4318).
534 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
535 pub struct SessionCostSnapshot {
536 /// Accumulated parent-turn session cost in USD.
537 #[serde(default)]
538 pub session_cost_usd: f64,
539 /// Accumulated parent-turn session cost in CNY.
540 #[serde(default)]
541 pub session_cost_cny: f64,
542 /// Accumulated sub-agent/background LLM cost in USD.
543 #[serde(default)]
544 pub subagent_cost_usd: f64,
545 /// Accumulated sub-agent/background LLM cost in CNY.
546 #[serde(default)]
547 pub subagent_cost_cny: f64,
548 /// Max-ever displayed session+subagent cost in USD (preserves #244
549 /// monotonic guarantee across session restarts).
550 #[serde(default)]
551 pub displayed_cost_high_water_usd: f64,
552 /// Max-ever displayed session+subagent cost in CNY.
553 #[serde(default)]
554 pub displayed_cost_high_water_cny: f64,
555 /// Turns whose route was money-metered and produced an authoritative price.
556 /// These are exactly the turns the persisted totals contain.
557 #[serde(default)]
558 pub priced_turns: u32,
559 /// Money-metered (or unknown-basis) turns that produced no authoritative
560 /// price, so their spend is missing from the persisted totals.
561 #[serde(default)]
562 pub unpriced_turns: u32,
563 /// CNY-specific coverage. USD-only routes are unpriced in CNY rather than
564 /// silently contributing a fabricated zero.
565 #[serde(default)]
566 pub cny_priced_turns: u32,
567 #[serde(default)]
568 pub cny_unpriced_turns: u32,
569 /// Stable reason labels for the unpriced turns.
570 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
571 pub unpriced_reasons: BTreeSet<String>,
572 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
573 pub cny_unpriced_reasons: BTreeSet<String>,
574 /// Token classes used on some route that carry no published price.
575 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
576 pub unpriced_classes: BTreeSet<String>,
577 /// Provenance labels of the pricing rows the totals were built from.
578 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
579 pub pricing_provenances: BTreeSet<String>,
580 /// Live-pricing downgrade receipts recorded while building the totals.
581 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
582 pub live_pricing_defects: BTreeSet<String>,
583 /// Live rows that failed validation and had no usable bundled fallback.
584 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
585 pub live_pricing_unusable_defects: BTreeSet<String>,
586 /// Redacted per-route receipts: provider, configured identity, wire model,
587 /// billing surface, endpoint fingerprint, billing mode, currency. Never a URL, a
588 /// credential, or a filesystem path.
589 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
590 pub route_receipts: BTreeSet<String>,
591 /// Redacted provider-response identities already included in the live and
592 /// durable sub-agent totals. Worker records persist the same fingerprints.
593 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
594 pub usage_source_fingerprints: BTreeSet<String>,
595 /// Written by builds that track coverage, so a reader can tell "this session
596 /// genuinely had zero money-metered turns" apart from "this session predates
597 /// coverage tracking". Absent on legacy rows.
598 #[serde(default)]
599 pub coverage_recorded: bool,
600 }
601
602 impl SessionCostSnapshot {
603 fn absorb_late_background_cost(&mut self, pool: &crate::cost_status::PendingBackgroundCost) {
604 let estimate = crate::pricing::CostEstimate {
605 usd: self.subagent_cost_usd,
606 cny: self.subagent_cost_cny,
607 }
608 .saturating_add(pool.estimate);
609 self.subagent_cost_usd = estimate.usd;
610 self.subagent_cost_cny = estimate.cny;
611 self.priced_turns = self.priced_turns.saturating_add(pool.priced_turns);
612 self.unpriced_turns = self.unpriced_turns.saturating_add(pool.unpriced_turns);
613 self.cny_priced_turns = self.cny_priced_turns.saturating_add(pool.cny_priced_turns);
614 self.cny_unpriced_turns = self
615 .cny_unpriced_turns
616 .saturating_add(pool.cny_unpriced_turns);
617 self.unpriced_reasons
618 .extend(pool.unpriced_reasons.iter().map(ToString::to_string));
619 self.cny_unpriced_reasons
620 .extend(pool.cny_unpriced_reasons.iter().map(ToString::to_string));
621 self.unpriced_classes
622 .extend(pool.unpriced_classes.iter().map(ToString::to_string));
623 self.pricing_provenances
624 .extend(pool.pricing_provenances.iter().map(ToString::to_string));
625 self.live_pricing_defects
626 .extend(pool.live_pricing_defects.iter().map(ToString::to_string));
627 self.live_pricing_unusable_defects.extend(
628 pool.live_pricing_unusable_defects
629 .iter()
630 .map(ToString::to_string),
631 );
632 self.route_receipts
633 .extend(pool.route_receipts.iter().cloned());
634 self.usage_source_fingerprints
635 .extend(pool.usage_source_fingerprints.iter().cloned());
636 self.coverage_recorded = true;
637 let total = self.total_estimate();
638 self.displayed_cost_high_water_usd = self.displayed_cost_high_water_usd.max(total.usd);
639 self.displayed_cost_high_water_cny = self.displayed_cost_high_water_cny.max(total.cny);
640 }
641
642 /// Session + subagent spend as **one** dual-currency accumulator.
643 ///
644 /// The persisted USD and CNY columns are projections of per-turn
645 /// [`crate::pricing::CostEstimate`]s that were accumulated jointly; every
646 /// display total is derived from this single fold so the two currencies
647 /// cannot be re-summed by separate code paths that then drift (#4939).
648 /// CNY is *not* an FX multiple of USD: a turn carries CNY only when its
649 /// route published an authoritative CNY row (provider-published
650 /// dual-currency pricing, e.g. DeepSeek's CNY table), and a USD-only turn
651 /// contributes exactly zero CNY while `cny_unpriced_turns` records the gap.
652 #[must_use]
653 pub fn total_estimate(&self) -> crate::pricing::CostEstimate {
654 crate::pricing::CostEstimate {
655 usd: self.session_cost_usd,
656 cny: self.session_cost_cny,
657 }
658 .saturating_add(crate::pricing::CostEstimate {
659 usd: self.subagent_cost_usd,
660 cny: self.subagent_cost_cny,
661 })
662 }
663
664 /// Session + subagent cost in USD.
665 pub fn total_usd(&self) -> f64 {
666 self.total_estimate()
667 .amount(crate::pricing::CostCurrency::Usd)
668 }
669
670 /// Session + subagent cost in CNY.
671 pub fn total_cny(&self) -> f64 {
672 self.total_estimate()
673 .amount(crate::pricing::CostCurrency::Cny)
674 }
675
676 /// Whether this snapshot's coverage state must be shown as unknown.
677 ///
678 /// True when the snapshot has no coverage evidence — the signature of a
679 /// session written before coverage was persisted. Reporting any such
680 /// session as "0 of 0 priced" would claim completeness without evidence,
681 /// including when the saved amount is zero.
682 #[must_use]
683 pub fn coverage_is_legacy_unknown(&self) -> bool {
684 !self.coverage_recorded
685 }
686 }
687
688 impl SessionMetadata {
689 /// Copy cost fields from another metadata (used when forking a session).
690 pub fn copy_cost_from(&mut self, other: &SessionMetadata) {
691 self.cost = other.cost.clone();
692 }
693
694 /// Record additive lineage metadata for a forked saved session.
695 pub fn mark_forked_from(&mut self, parent: &SessionMetadata) {
696 self.parent_session_id = Some(parent.id.clone());
697 self.forked_from_message_count = Some(parent.message_count);
698 }
699 }
700
701 /// Durable Work-panel state. Optional on [`SavedSession`] so every session
702 /// written before v0.8.68 remains loadable without migration.
703 #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
704 pub struct SessionWorkState {
705 /// Authoritative Work Graph. Optional so pre-Work-Graph sessions and old
706 /// binaries continue to exchange fully populated Plan/To-do views.
707 #[serde(default, skip_serializing_if = "Option::is_none")]
708 pub graph: Option<crate::work_graph::WorkGraphSnapshot>,
709 #[serde(default, skip_serializing_if = "TodoListSnapshot::is_empty")]
710 pub todos: TodoListSnapshot,
711 #[serde(default, skip_serializing_if = "PlanSnapshot::is_empty")]
712 pub plan: PlanSnapshot,
713 }
714
715 /// Bounded goal projection persisted beside the owning saved session.
716 ///
717 /// This intentionally excludes completion prose, verifier output, transcripts,
718 /// and filesystem evidence. The saved session already owns conversation
719 /// history; restart only needs the typed control state that makes the next turn
720 /// continue the same objective without trusting text reconstructed from it.
721 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
722 #[serde(deny_unknown_fields)]
723 pub struct SessionGoalState {
724 #[serde(default = "current_session_goal_schema_version")]
725 pub schema_version: u32,
726 pub objective: String,
727 pub status: SessionGoalStatus,
728 #[serde(default, skip_serializing_if = "Option::is_none")]
729 pub token_budget: Option<u32>,
730 #[serde(default)]
731 pub tokens_used: u64,
732 #[serde(default)]
733 pub time_used_seconds: u64,
734 #[serde(default)]
735 pub continuation_count: u32,
736 #[serde(default)]
737 pub elapsed_seconds: u64,
738 #[serde(default, skip_serializing_if = "Option::is_none")]
739 pub pause_reason: Option<GoalPauseReason>,
740 #[serde(default, skip_serializing_if = "Option::is_none")]
741 pub goal_id: Option<String>,
742 #[serde(default, skip_serializing_if = "Option::is_none")]
743 pub last_gap_fingerprint: Option<String>,
744 #[serde(default)]
745 pub repeated_gap_count: u32,
746 #[serde(default, skip_serializing_if = "Option::is_none")]
747 pub last_gap_pass: Option<u32>,
748 }
749
750 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
751 #[serde(rename_all = "snake_case")]
752 pub enum SessionGoalStatus {
753 Active,
754 Paused,
755 Complete,
756 Blocked,
757 }
758
759 const fn current_session_goal_schema_version() -> u32 {
760 CURRENT_SESSION_GOAL_SCHEMA_VERSION
761 }
762
763 impl SessionGoalState {
764 /// Convert a runtime update into the durable, bounded session contract.
765 /// The canonical empty runtime snapshot removes the sidecar.
766 pub fn from_runtime(snapshot: &GoalSnapshot) -> io::Result<Option<Self>> {
767 if snapshot.objective.is_none() && snapshot.status.trim() == "none" {
768 return Ok(None);
769 }
770 let objective = snapshot
771 .objective
772 .as_deref()
773 .map(str::trim)
774 .filter(|objective| !objective.is_empty())
775 .ok_or_else(|| {
776 io::Error::new(io::ErrorKind::InvalidData, "goal snapshot has no objective")
777 })?;
778 let status = match snapshot.status.trim() {
779 "active" => SessionGoalStatus::Active,
780 "paused" => SessionGoalStatus::Paused,
781 "complete" => SessionGoalStatus::Complete,
782 "blocked" => SessionGoalStatus::Blocked,
783 other => {
784 return Err(io::Error::new(
785 io::ErrorKind::InvalidData,
786 format!("goal snapshot has unsupported status '{other}'"),
787 ));
788 }
789 };
790 let state = Self {
791 schema_version: CURRENT_SESSION_GOAL_SCHEMA_VERSION,
792 objective: objective.to_string(),
793 status,
794 token_budget: snapshot.token_budget,
795 tokens_used: snapshot.tokens_used,
796 time_used_seconds: snapshot.time_used_seconds,
797 continuation_count: snapshot.continuation_count,
798 elapsed_seconds: snapshot.elapsed_seconds.unwrap_or_default(),
799 pause_reason: snapshot.pause_reason,
800 goal_id: snapshot.goal_id.clone(),
801 last_gap_fingerprint: snapshot.last_gap_fingerprint.clone(),
802 repeated_gap_count: snapshot.repeated_gap_count,
803 last_gap_pass: snapshot.last_gap_pass,
804 };
805 state.validate()?;
806 Ok(Some(state))
807 }
808
809 pub fn validate(&self) -> io::Result<()> {
810 if self.schema_version > CURRENT_SESSION_GOAL_SCHEMA_VERSION {
811 return Err(io::Error::new(
812 io::ErrorKind::InvalidData,
813 format!(
814 "Session goal schema v{} is newer than supported v{}",
815 self.schema_version, CURRENT_SESSION_GOAL_SCHEMA_VERSION
816 ),
817 ));
818 }
819 let objective = self.objective.trim();
820 if objective.is_empty() || objective.chars().count() > MAX_SESSION_GOAL_OBJECTIVE_CHARS {
821 return Err(io::Error::new(
822 io::ErrorKind::InvalidData,
823 format!(
824 "Session goal objective must contain 1..={MAX_SESSION_GOAL_OBJECTIVE_CHARS} characters"
825 ),
826 ));
827 }
828 if self
829 .goal_id
830 .as_ref()
831 .is_some_and(|id| id.is_empty() || id.len() > 128)
832 {
833 return Err(io::Error::new(
834 io::ErrorKind::InvalidData,
835 "invalid session goal revision",
836 ));
837 }
838 codewhale_protocol::validate_goal_stall_state(
839 self.last_gap_fingerprint.as_deref(),
840 self.repeated_gap_count,
841 self.last_gap_pass,
842 self.continuation_count,
843 )
844 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
845 }
846
847 #[must_use]
848 pub fn to_runtime_snapshot(&self) -> GoalSnapshot {
849 GoalSnapshot {
850 goal_id: self.goal_id.clone(),
851 objective: Some(self.objective.clone()),
852 status: match self.status {
853 SessionGoalStatus::Active => "active",
854 SessionGoalStatus::Paused => "paused",
855 SessionGoalStatus::Complete => "complete",
856 SessionGoalStatus::Blocked => "blocked",
857 }
858 .to_string(),
859 token_budget: self.token_budget,
860 tokens_used: self.tokens_used,
861 time_used_seconds: self.time_used_seconds,
862 continuation_count: self.continuation_count,
863 elapsed_seconds: Some(self.elapsed_seconds),
864 evidence: None,
865 blocker: None,
866 pause_reason: self.pause_reason,
867 completion_verification: None,
868 advisories: Vec::new(),
869 last_gap_fingerprint: self.last_gap_fingerprint.clone(),
870 repeated_gap_count: self.repeated_gap_count,
871 last_gap_pass: self.last_gap_pass,
872 progress: None,
873 }
874 }
875 }
876
877 impl SessionWorkState {
878 #[must_use]
879 pub fn is_empty(&self) -> bool {
880 self.graph
881 .as_ref()
882 .is_none_or(crate::work_graph::WorkGraphSnapshot::is_empty)
883 && self.todos.is_empty()
884 && self.plan.is_empty()
885 }
886 }
887
888 /// Latest concrete Auto route and the decision receipt that produced it.
889 ///
890 /// This is additive, optional session metadata: sessions written before
891 /// v0.9.1 deserialize with no receipt and keep their legacy restore behavior.
892 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
893 pub(crate) struct SavedAutoRouteReceipt {
894 pub(crate) provider: ApiProvider,
895 pub(crate) provider_identity: String,
896 pub(crate) model: String,
897 pub(crate) receipt: AutoRouteReceipt,
898 /// Canonical effective reasoning receipt for the selected route, including
899 /// routes where a concrete tier cannot be proven. Optional so older
900 /// sessions remain loadable.
901 #[serde(default, skip_serializing_if = "Option::is_none")]
902 pub(crate) effective_reasoning_effort: Option<ReasoningEffortTier>,
903 }
904
905 /// A saved session containing full conversation history
906 /// Starting with v0.9.5 (#5262) the canonical history is the append-only entry journal (`journal` / `leaf_id`).
907 #[derive(Debug, Clone, Serialize, Deserialize)]
908 pub struct SavedSession {
909 /// Schema version for migration compatibility
910 #[serde(default = "default_session_schema_version")]
911 pub schema_version: u32,
912 /// Session metadata
913 pub metadata: SessionMetadata,
914 /// Conversation messages — derived from the journal's active branch (kept for compat).
915 pub messages: Vec<Message>,
916 #[serde(default, skip_serializing_if = "Option::is_none")]
917 pub journal: Option<SessionJournal>,
918 #[serde(default, skip_serializing_if = "Option::is_none")]
919 pub leaf_id: Option<String>,
920 /// System prompt if any
921 pub system_prompt: Option<String>,
922 /// Compact linked context references for user-visible `@path` and
923 /// `/attach` mentions. Optional for backward-compatible session loads.
924 #[serde(default, skip_serializing_if = "Vec::is_empty")]
925 pub context_references: Vec<SessionContextReference>,
926 /// Metadata registry of large outputs produced during this session.
927 /// Artifact contents are stored in the session-owned artifact directory.
928 #[serde(default, skip_serializing_if = "Vec::is_empty")]
929 pub artifacts: Vec<ArtifactRecord>,
930 /// Session-owned approval evidence. The append-only sidecar is canonical
931 /// during a live turn; this projection makes saved snapshots self-
932 /// describing without putting receipts in the model transcript.
933 #[serde(default, skip_serializing_if = "Vec::is_empty")]
934 pub(crate) approval_receipts: Vec<ApprovalReceipt>,
935 /// To-do and plan state shown in the Work sidebar.
936 #[serde(default, skip_serializing_if = "Option::is_none")]
937 pub work_state: Option<SessionWorkState>,
938 /// User-configured tab/window title for this session (`/title`), shown as
939 /// `[title] …` in front of the terminal window title. Optional for
940 /// backward-compatible session loads; absent sessions use the `title`
941 /// config default instead.
942 #[serde(default, skip_serializing_if = "Option::is_none")]
943 pub window_title: Option<String>,
944 /// Most recent accepted/completed Auto decision, when the saved model mode
945 /// is `auto`. Optional for backward-compatible session loads.
946 #[serde(default, skip_serializing_if = "Option::is_none")]
947 pub(crate) last_auto_route: Option<SavedAutoRouteReceipt>,
948 }
949 impl SavedSession {
950 /// Drop the journal-derived compatibility projection before an async
951 /// persistence request takes ownership. Disk serialization restores it.
952 pub(crate) fn compact_for_persistence_queue(&mut self) {
953 if self.journal.is_some() {
954 self.messages = Vec::new();
955 }
956 }
957
958 /// Bring `messages` and the journal into the shape the on-disk schema
959 /// expects, in place.
960 ///
961 /// This used to be `storage_compatible_copy`, which cloned the whole
962 /// session to do it. On the debounced persistence path the caller already
963 /// owns the value and `compact_for_persistence_queue` has already emptied
964 /// `messages`, so the clone was pure waste — two full deep copies of the
965 /// history per write (#6214 T3).
966 ///
967 /// The no-op cases are load-bearing and must stay no-ops: with no journal,
968 /// or with `messages` already equal to the journal's active branch, the
969 /// session serializes exactly as it arrived — including a
970 /// `metadata.message_count` that disagrees with `messages.len()`. Rewriting
971 /// that count here would silently edit live data on every save.
972 pub(crate) fn make_storage_compatible(&mut self) {
973 let Some(journal) = self.journal.as_ref() else {
974 return;
975 };
976 if self.messages.is_empty() {
977 self.messages = journal.to_messages();
978 } else {
979 if self.messages == journal.to_messages() {
980 return;
981 }
982 // Split the `journal` / `messages` borrows; the take is returned
983 // before this function ends, so the session is never left short.
984 let messages = std::mem::take(&mut self.messages);
985 if let Some(journal) = self.journal.as_mut() {
986 journal.rebranch_active_messages(&messages);
987 self.leaf_id = journal.leaf_id.clone();
988 }
989 self.messages = messages;
990 }
991 self.metadata.message_count = self.messages.len();
992 }
993
994 pub fn ensure_journal(&mut self) {
995 if self.journal.is_some() {
996 if self.leaf_id.is_none() {
997 self.leaf_id = self.journal.as_ref().and_then(|j| j.leaf_id.clone());
998 }
999 let active = self
1000 .journal
1001 .as_ref()
1002 .map(|j| j.to_messages())
1003 .unwrap_or_default();
1004 if !active.is_empty() {
1005 self.messages = active;
1006 self.metadata.message_count = self.messages.len();
1007 }
1008 return;
1009 }
1010 let journal =
1011 SessionJournal::from_messages(self.messages.clone(), self.metadata.spawn_depth);
1012 self.leaf_id = journal.leaf_id.clone();
1013 self.journal = Some(journal);
1014 }
1015 #[expect(dead_code)]
1016 pub fn journal_append_message(&mut self, message: Message) -> String {
1017 self.ensure_journal();
1018 let journal = self.journal.as_mut().expect("journal ensured");
1019 let id = journal.append_message(message.clone());
1020 self.leaf_id = journal.leaf_id.clone();
1021 self.messages = journal.to_messages();
1022 self.metadata.message_count = self.messages.len();
1023 self.metadata.updated_at = Utc::now();
1024 id
1025 }
1026 pub fn journal_branch_to(&mut self, entry_id: &str) -> Result<(), String> {
1027 self.ensure_journal();
1028 let journal = self.journal.as_mut().expect("journal ensured");
1029 journal.branch_to(entry_id)?;
1030 self.leaf_id = journal.leaf_id.clone();
1031 self.messages = journal.to_messages();
1032 self.metadata.message_count = self.messages.len();
1033 self.metadata.updated_at = Utc::now();
1034 Ok(())
1035 }
1036 #[expect(dead_code)]
1037 pub fn active_entries(&self) -> Vec<SessionEntry> {
1038 self.journal
1039 .as_ref()
1040 .map(|j| j.root_to_leaf().into_iter().cloned().collect())
1041 .unwrap_or_default()
1042 }
1043 /// `created_at` of the active branch's message entries, in order — the
1044 /// stamps a resumed session hands back to the live message log so the
1045 /// next save preserves append times instead of rewriting them to resume
1046 /// time.
1047 pub fn journal_message_stamps(&self) -> Vec<DateTime<Utc>> {
1048 self.journal
1049 .as_ref()
1050 .map(|journal| {
1051 journal
1052 .root_to_leaf()
1053 .iter()
1054 .filter(|entry| {
1055 matches!(
1056 entry.kind,
1057 crate::session_tree::SessionEntryKind::Message { .. }
1058 )
1059 })
1060 .map(|entry| entry.created_at)
1061 .collect()
1062 })
1063 .unwrap_or_default()
1064 }
1065 pub fn export_container(&self, source: &str) -> SessionImportContainer {
1066 let journal = self.journal.clone().unwrap_or_else(|| {
1067 SessionJournal::from_messages(self.messages.clone(), self.metadata.spawn_depth)
1068 });
1069 SessionImportContainer::new(
1070 source.to_string(),
1071 &journal,
1072 serde_json::to_value(&self.metadata).ok(),
1073 )
1074 }
1075 pub fn import_foreign(
1076 container: SessionImportContainer,
1077 workspace: PathBuf,
1078 model: String,
1079 ) -> Result<Self, String> {
1080 let journal = container.into_journal()?;
1081 let leaf_id = journal.leaf_id.clone();
1082 let messages = journal.to_messages();
1083 let now = Utc::now();
1084 let spawn_depth = journal.spawn_depth.saturating_add(1);
1085 // Reuse the conversation-derived title so an imported session that
1086 // opens with runtime-owned control traffic (Operate contract, restore
1087 // checkpoint) is named after the real prompt, not the envelope.
1088 let title = conversation_derived_title(&messages)
1089 .unwrap_or_else(|| crate::session_manager::DEFAULT_SESSION_TITLE.to_string());
1090 let metadata = SessionMetadata {
1091 id: Uuid::new_v4().to_string(),
1092 title,
1093 created_at: now,
1094 updated_at: now,
1095 message_count: messages.len(),
1096 total_tokens: 0,
1097 model,
1098 model_provider: default_model_provider(),
1099 model_provider_id: None,
1100 workspace,
1101 mode: None,
1102 cost: SessionCostSnapshot::default(),
1103 parent_session_id: None,
1104 forked_from_message_count: None,
1105 runtime_store: None,
1106 cumulative_turn_secs: 0,
1107 archived: false,
1108 spawn_depth,
1109 };
1110 let mut journal = journal;
1111 journal.spawn_depth = spawn_depth;
1112 Ok(Self {
1113 schema_version: CURRENT_SESSION_SCHEMA_VERSION,
1114 metadata,
1115 messages,
1116 journal: Some(journal),
1117 leaf_id,
1118 system_prompt: None,
1119 context_references: Vec::new(),
1120 artifacts: Vec::new(),
1121 approval_receipts: Vec::new(),
1122 work_state: None,
1123 window_title: None,
1124 last_auto_route: None,
1125 })
1126 }
1127 }
1128
1129 fn serialize_saved_session(mut session: SavedSession) -> io::Result<String> {
1130 session.make_storage_compatible();
1131 serde_json::to_string_pretty(&session)
1132 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
1133 }
1134
1135 /// Repair dangling tool-call/result pairs in an already-loaded session and
1136 /// rebranch its journal to the repaired messages. Returns the repair receipt;
1137 /// callers decide whether the result gets persisted (`SessionManager::resume_*`
1138 /// does, foreign `/load` files do not).
1139 pub(crate) fn repair_recovered_session(
1140 session: &mut SavedSession,
1141 ) -> crate::tool_history_repair::ToolRepairReceipt {
1142 let repair = crate::tool_history_repair::repair_tool_call_pairs(&mut session.messages);
1143 if !repair.is_empty() {
1144 if let Some(journal) = session.journal.as_mut() {
1145 journal.rebranch_active_messages(&session.messages);
1146 session.leaf_id = journal.leaf_id.clone();
1147 }
1148 session.metadata.message_count = session.messages.len();
1149 tracing::warn!(
1150 session_id = %session.metadata.id,
1151 repaired_call_ids = ?repair.repaired_call_ids,
1152 duplicate_result_ids = ?repair.duplicate_result_ids,
1153 orphan_result_ids = ?repair.orphan_result_ids,
1154 "repaired persisted tool call/result history"
1155 );
1156 }
1157 repair
1158 }
1159
1160 /// Manager for session persistence operations
1161 #[derive(Debug)]
1162 pub struct SessionManager {
1163 /// Directory where sessions are stored
1164 sessions_dir: PathBuf,
1165 /// Re-entrancy guard: archiving a record saves it, and every save runs
1166 /// retention. Without this, a backlog past the cap would nest one
1167 /// cleanup per archived transcript instead of draining in one pass.
1168 retention_in_progress: AtomicBool,
1169 }
1170
1171 /// One interactive editor owns a session's unsent text until its last queued
1172 /// write finishes. The stable lock file is never unlinked: replacing it would
1173 /// let two processes lock different files for the same session.
1174 #[derive(Debug)]
1175 pub struct OfflineQueueLease {
1176 session_id: String,
1177 _file: fs::File,
1178 }
1179
1180 impl OfflineQueueLease {
1181 pub fn session_id(&self) -> &str {
1182 &self.session_id
1183 }
1184 }
1185
1186 impl Drop for OfflineQueueLease {
1187 fn drop(&mut self) {
1188 // A forked child can briefly retain the same open-file description.
1189 // Release the editor's lock now, rather than waiting for every inherited
1190 // descriptor to close, as RuntimeProcessOwnerLock does on shutdown.
1191 #[cfg(all(unix, not(target_os = "solaris")))]
1192 {
1193 use std::os::fd::AsRawFd as _;
1194 // SAFETY: the lease still owns this descriptor throughout Drop.
1195 unsafe {
1196 libc::flock(self._file.as_raw_fd(), libc::LOCK_UN);
1197 }
1198 }
1199 #[cfg(windows)]
1200 {
1201 use std::os::windows::io::AsRawHandle as _;
1202 use windows_sys::Win32::Storage::FileSystem::UnlockFile;
1203 // SAFETY: the lease owns the handle; fd-lock locks byte 0 only.
1204 unsafe {
1205 UnlockFile(self._file.as_raw_handle() as _, 0, 0, 1, 0);
1206 }
1207 }
1208 // fd-lock uses process-associated fcntl locks on Solaris. They are not
1209 // inherited by fork and closing this descriptor releases the lock.
1210 }
1211 }
1212
1213 /// Origin of a crash-recovery checkpoint file.
1214 #[derive(Debug, Clone, PartialEq, Eq)]
1215 pub enum CheckpointSource {
1216 /// Per-session checkpoint file `checkpoints/<session_id>.json`.
1217 Session(String),
1218 /// Legacy single-slot checkpoint file `checkpoints/latest.json`.
1219 Legacy,
1220 }
1221
1222 /// A crash-recovery checkpoint file discovered on disk (metadata only —
1223 /// callers load the session content separately).
1224 #[derive(Debug, Clone)]
1225 pub struct CheckpointRef {
1226 pub source: CheckpointSource,
1227 #[cfg_attr(not(test), expect(dead_code))]
1228 pub path: PathBuf,
1229 pub modified: std::time::SystemTime,
1230 }
1231
1232 /// File names in `checkpoints/` that are never per-session checkpoints.
1233 const LEGACY_CHECKPOINT_FILE: &str = "latest.json";
1234 /// Pre-per-session global offline queue, still read once for migration.
1235 const OFFLINE_QUEUE_FILE: &str = "offline_queue.json";
1236 /// Per-session offline queue file: `checkpoints/<session_id>.offline_queue.json`.
1237 const OFFLINE_QUEUE_SUFFIX: &str = ".offline_queue.json";
1238
1239 pub(crate) fn is_offline_queue_file(name: &str) -> bool {
1240 name == OFFLINE_QUEUE_FILE || name.ends_with(OFFLINE_QUEUE_SUFFIX)
1241 }
1242
1243 impl SessionManager {
1244 fn approval_receipt_store(&self) -> ApprovalReceiptStore {
1245 ApprovalReceiptStore::new(self.sessions_dir.clone())
1246 }
1247
1248 fn hydrate_approval_receipts(&self, session: &mut SavedSession) -> io::Result<()> {
1249 let durable = self.approval_receipt_store().load(&session.metadata.id)?;
1250 if !durable.is_empty() {
1251 session.approval_receipts = durable;
1252 }
1253 ApprovalReplay::from_receipts(&session.approval_receipts)
1254 .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
1255 Ok(())
1256 }
1257
1258 /// Reconstruct completed approvals and interrupted unmatched asks for one
1259 /// session without consulting the model transcript.
1260 #[cfg_attr(not(test), expect(dead_code))]
1261 pub(crate) fn replay_approvals(&self, session_id: &str) -> io::Result<ApprovalReplay> {
1262 self.approval_receipt_store().replay(session_id)
1263 }
1264
1265 fn validated_session_id<'a>(&self, id: &'a str) -> std::io::Result<&'a str> {
1266 let trimmed = id.trim();
1267 if trimmed.is_empty() {
1268 return Err(std::io::Error::new(
1269 std::io::ErrorKind::InvalidInput,
1270 "Session id cannot be empty",
1271 ));
1272 }
1273 if !trimmed
1274 .chars()
1275 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1276 {
1277 return Err(std::io::Error::new(
1278 std::io::ErrorKind::InvalidInput,
1279 format!("Invalid session id '{id}'"),
1280 ));
1281 }
1282 if trimmed == SESSION_BOOT_OWNERS_STEM {
1283 return Err(std::io::Error::new(
1284 std::io::ErrorKind::InvalidInput,
1285 format!("Session id '{trimmed}' collides with a reserved sessions file"),
1286 ));
1287 }
1288 Ok(trimmed)
1289 }
1290
1291 fn validated_session_path(&self, id: &str) -> std::io::Result<PathBuf> {
1292 let trimmed = self.validated_session_id(id)?;
1293 Ok(self.sessions_dir.join(format!("{trimmed}.json")))
1294 }
1295
1296 fn checkpoints_dir(&self) -> PathBuf {
1297 self.sessions_dir.join("checkpoints")
1298 }
1299
1300 fn session_goals_dir(&self) -> PathBuf {
1301 self.sessions_dir.join(SESSION_GOALS_DIR)
1302 }
1303
1304 fn checked_existing_session_goals_dir(&self) -> std::io::Result<Option<PathBuf>> {
1305 let dir = self.session_goals_dir();
1306 let metadata = match fs::symlink_metadata(&dir) {
1307 Ok(metadata) => metadata,
1308 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
1309 Err(error) => return Err(error),
1310 };
1311 if metadata.file_type().is_symlink() || !metadata.is_dir() {
1312 return Err(io::Error::new(
1313 io::ErrorKind::InvalidData,
1314 format!(
1315 "Session goal store {} must be a real directory",
1316 dir.display()
1317 ),
1318 ));
1319 }
1320 Ok(Some(dir))
1321 }
1322
1323 fn ensure_session_goals_dir(&self) -> std::io::Result<PathBuf> {
1324 if let Some(dir) = self.checked_existing_session_goals_dir()? {
1325 return Ok(dir);
1326 }
1327 let dir = self.session_goals_dir();
1328 match fs::create_dir(&dir) {
1329 Ok(()) => {}
1330 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
1331 Err(error) => return Err(error),
1332 }
1333 self.checked_existing_session_goals_dir()?.ok_or_else(|| {
1334 io::Error::new(
1335 io::ErrorKind::NotFound,
1336 format!("Session goal store {} was not created", dir.display()),
1337 )
1338 })
1339 }
1340
1341 fn validated_session_goal_path(&self, session_id: &str) -> std::io::Result<PathBuf> {
1342 let id = self.validated_session_id(session_id)?;
1343 Ok(self.session_goals_dir().join(format!("{id}.json")))
1344 }
1345
1346 fn checked_existing_session_goal_file(path: &Path) -> std::io::Result<bool> {
1347 let metadata = match fs::symlink_metadata(path) {
1348 Ok(metadata) => metadata,
1349 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
1350 Err(error) => return Err(error),
1351 };
1352 if metadata.file_type().is_symlink() || !metadata.is_file() {
1353 return Err(io::Error::new(
1354 io::ErrorKind::InvalidData,
1355 format!("Session goal {} must be a regular file", path.display()),
1356 ));
1357 }
1358 Ok(true)
1359 }
1360
1361 fn validated_checkpoint_path(&self, session_id: &str) -> std::io::Result<PathBuf> {
1362 let trimmed = self.validated_session_id(session_id)?;
1363 // Reserved file names inside `checkpoints/` must never collide with a
1364 // per-session checkpoint file.
1365 if format!("{trimmed}.json") == LEGACY_CHECKPOINT_FILE
1366 || format!("{trimmed}.json") == OFFLINE_QUEUE_FILE
1367 {
1368 return Err(std::io::Error::new(
1369 std::io::ErrorKind::InvalidInput,
1370 format!("Session id '{trimmed}' collides with a reserved checkpoint file"),
1371 ));
1372 }
1373 Ok(self.checkpoints_dir().join(format!("{trimmed}.json")))
1374 }
1375
1376 /// Create a new `SessionManager` with the specified sessions directory
1377 pub fn new(sessions_dir: PathBuf) -> std::io::Result<Self> {
1378 let sessions_dir = normalize_managed_dir(sessions_dir)?;
1379 // Ensure the sessions directory exists
1380 fs::create_dir_all(&sessions_dir)?;
1381 Ok(Self {
1382 sessions_dir,
1383 retention_in_progress: AtomicBool::new(false),
1384 })
1385 }
1386
1387 /// Create a `SessionManager` using the default location.
1388 pub fn default_location() -> std::io::Result<Self> {
1389 Self::new(default_sessions_dir()?)
1390 }
1391
1392 /// Return the resolved sessions directory path.
1393 pub fn sessions_dir(&self) -> &Path {
1394 &self.sessions_dir
1395 }
1396
1397 fn late_usage_paths(&self, session_id: &str) -> io::Result<(PathBuf, PathBuf)> {
1398 let session_id = self.validated_session_id(session_id)?;
1399 let dir = self.sessions_dir.join(LATE_USAGE_DIR);
1400 match fs::symlink_metadata(&dir) {
1401 Ok(metadata) => {
1402 #[cfg(windows)]
1403 let linked = {
1404 use std::os::windows::fs::MetadataExt as _;
1405 metadata.file_attributes()
1406 & windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT
1407 != 0
1408 };
1409 #[cfg(not(windows))]
1410 let linked = metadata.file_type().is_symlink();
1411 if linked || !metadata.is_dir() {
1412 return Err(io::Error::new(
1413 io::ErrorKind::InvalidData,
1414 "late usage store must be a real directory",
1415 ));
1416 }
1417 }
1418 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
1419 Err(error) => return Err(error),
1420 }
1421 Ok((
1422 dir.join(format!("{session_id}.json")),
1423 dir.join(format!("{session_id}.lock")),
1424 ))
1425 }
1426
1427 /// Only mutations create accounting storage. Snapshot/list reads must work
1428 /// for a healthy transcript even when no sidecar has ever been written.
1429 fn ensure_late_usage_paths(&self, session_id: &str) -> io::Result<(PathBuf, PathBuf)> {
1430 self.late_usage_paths(session_id)?;
1431 let dir = self.sessions_dir.join(LATE_USAGE_DIR);
1432 match fs::create_dir(&dir) {
1433 Ok(()) => {}
1434 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
1435 Err(error) => return Err(error),
1436 }
1437 let paths = self.late_usage_paths(session_id)?;
1438 #[cfg(unix)]
1439 {
1440 use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
1441 OpenOptions::new()
1442 .read(true)
1443 .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
1444 .open(&dir)?
1445 .set_permissions(fs::Permissions::from_mode(0o700))?;
1446 }
1447 Ok(paths)
1448 }
1449
1450 /// A deletion marker and its stable lock survive deletion, without any
1451 /// route or usage data. A captured callback must never recreate the ledger.
1452 fn late_usage_is_deleted(path: &Path) -> io::Result<bool> {
1453 use std::io::Read as _;
1454 let tombstone = match open_private_read_file(&path.with_extension("deleted")) {
1455 Ok(file) => file,
1456 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
1457 Err(error) => return Err(error),
1458 };
1459 let mut marker = Vec::with_capacity(LATE_USAGE_DELETED.len());
1460 tombstone
1461 .take(u64::try_from(LATE_USAGE_DELETED.len()).unwrap_or(u64::MAX) + 1)
1462 .read_to_end(&mut marker)?;
1463 if marker != LATE_USAGE_DELETED {
1464 return Err(io::Error::new(
1465 io::ErrorKind::InvalidData,
1466 "invalid late usage deletion marker",
1467 ));
1468 }
1469 Ok(true)
1470 }
1471
1472 fn write_late_usage_ledger(path: &Path, ledger: &LateUsageLedger) -> io::Result<()> {
1473 let bytes = serde_json::to_vec(ledger)
1474 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
1475 if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_LATE_USAGE_LEDGER_BYTES {
1476 return Err(io::Error::new(
1477 io::ErrorKind::InvalidData,
1478 "late usage ledger exceeds its size bound",
1479 ));
1480 }
1481 write_atomic(path, &bytes)
1482 }
1483
1484 fn load_late_usage_unlocked(path: &Path) -> io::Result<LateUsageLedger> {
1485 let file = match open_private_read_file(path) {
1486 Ok(file) => file,
1487 Err(error) if error.kind() == io::ErrorKind::NotFound => {
1488 return Ok(LateUsageLedger::default());
1489 }
1490 Err(error) => return Err(error),
1491 };
1492 let metadata = file.metadata()?;
1493 if metadata.len() > MAX_LATE_USAGE_LEDGER_BYTES {
1494 return Err(io::Error::new(
1495 io::ErrorKind::InvalidData,
1496 format!(
1497 "late usage ledger {} exceeds its size bound",
1498 path.display()
1499 ),
1500 ));
1501 }
1502 use std::io::Read as _;
1503 let mut raw = Vec::with_capacity(
1504 usize::try_from(metadata.len().min(MAX_LATE_USAGE_LEDGER_BYTES)).unwrap_or(0),
1505 );
1506 file.take(MAX_LATE_USAGE_LEDGER_BYTES.saturating_add(1))
1507 .read_to_end(&mut raw)?;
1508 if u64::try_from(raw.len()).unwrap_or(u64::MAX) > MAX_LATE_USAGE_LEDGER_BYTES {
1509 return Err(io::Error::new(
1510 io::ErrorKind::InvalidData,
1511 format!(
1512 "late usage ledger {} exceeds its size bound",
1513 path.display()
1514 ),
1515 ));
1516 }
1517 let ledger: LateUsageLedger = serde_json::from_slice(&raw)
1518 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
1519 if ledger.schema_version != CURRENT_LATE_USAGE_SCHEMA_VERSION
1520 || ledger.records.len() > MAX_LATE_USAGE_RECORDS_PER_SESSION
1521 || ledger.records.iter().any(|record| {
1522 !is_sha256_fingerprint(&record.source_fingerprint)
1523 || !is_sha256_fingerprint(&record.turn_fingerprint)
1524 })
1525 {
1526 return Err(io::Error::new(
1527 io::ErrorKind::InvalidData,
1528 "late usage ledger has an unsupported or unbounded shape",
1529 ));
1530 }
1531 Ok(ledger)
1532 }
1533
1534 fn with_session_write_admission<T>(
1535 &self,
1536 session_id: &str,
1537 write: impl FnOnce() -> io::Result<T>,
1538 ) -> io::Result<Option<T>> {
1539 let (path, lock_path) = self.ensure_late_usage_paths(session_id)?;
1540 let lock_file = open_private_lock_file(&lock_path)?;
1541 let mut lock = fd_lock::RwLock::new(lock_file);
1542 let _guard = lock.write()?;
1543 if Self::late_usage_is_deleted(&path)? {
1544 return Ok(None);
1545 }
1546 write().map(Some)
1547 }
1548
1549 /// Serialize active accounting admission with deletion of its origin.
1550 /// A retired origin is handled without running the callback. Callers must
1551 /// release this boundary before attempting a late-usage append, which
1552 /// independently checks retirement under the same stable lock.
1553 pub(crate) fn with_live_session_origin(
1554 &self,
1555 session_id: &str,
1556 accept: impl FnOnce() -> bool,
1557 ) -> io::Result<Option<bool>> {
1558 self.with_session_write_admission(session_id, || Ok(accept()))
1559 }
1560
1561 fn retired_session_write_error() -> io::Error {
1562 io::Error::new(io::ErrorKind::NotFound, "session was deleted")
1563 }
1564
1565 fn persist_late_usage_record(
1566 &self,
1567 session_id: &str,
1568 turn_id: &str,
1569 source_id: &str,
1570 route: &crate::cost_status::EffectiveRouteEnvelope,
1571 usage: Option<&codewhale_models::Usage>,
1572 ) -> io::Result<bool> {
1573 let (path, lock_path) = self.ensure_late_usage_paths(session_id)?;
1574 let lock_file = open_private_lock_file(&lock_path)?;
1575 let mut lock = fd_lock::RwLock::new(lock_file);
1576 let _guard = lock.write()?;
1577 if Self::late_usage_is_deleted(&path)? {
1578 // Handled, rather than a failed sink that should queue a retry.
1579 return Ok(true);
1580 }
1581 let mut ledger = Self::load_late_usage_unlocked(&path)?;
1582 let source_fingerprint = crate::cost_status::usage_source_fingerprint(source_id);
1583 if ledger
1584 .records
1585 .iter()
1586 .any(|record| record.source_fingerprint == source_fingerprint)
1587 {
1588 return Ok(true);
1589 }
1590 if ledger.records.len() == MAX_LATE_USAGE_RECORDS_PER_SESSION {
1591 if !ledger.overflowed {
1592 ledger.overflowed = true;
1593 Self::write_late_usage_ledger(&path, &ledger)?;
1594 }
1595 return Ok(true);
1596 }
1597 ledger.records.push(LateUsageRecord {
1598 source_fingerprint,
1599 turn_fingerprint: crate::cost_status::usage_source_fingerprint(turn_id),
1600 route: route.sanitized_for_persistence(),
1601 usage: usage.cloned(),
1602 });
1603 Self::write_late_usage_ledger(&path, &ledger)?;
1604 Ok(true)
1605 }
1606
1607 pub(crate) fn persist_late_runtime_usage(
1608 &self,
1609 session_id: &str,
1610 turn_id: &str,
1611 record: &crate::cost_status::RuntimeUsageRecord,
1612 ) -> io::Result<bool> {
1613 self.persist_late_usage_record(
1614 session_id,
1615 turn_id,
1616 &record.source_id,
1617 &record.usage.route,
1618 Some(&record.usage.usage),
1619 )
1620 }
1621
1622 pub(crate) fn persist_late_runtime_drop(
1623 &self,
1624 session_id: &str,
1625 turn_id: &str,
1626 record: &crate::cost_status::RuntimeUsageDropRecord,
1627 ) -> io::Result<bool> {
1628 self.persist_late_usage_record(session_id, turn_id, &record.source_id, &record.route, None)
1629 }
1630
1631 fn with_session_read_lock<T>(
1632 &self,
1633 session_id: &str,
1634 read: impl FnOnce(&Path) -> io::Result<T>,
1635 ) -> io::Result<T> {
1636 let (path, lock_path) = self.late_usage_paths(session_id)?;
1637 let lock_file = match open_private_read_file(&lock_path) {
1638 Ok(file) => file,
1639 Err(error) if error.kind() == io::ErrorKind::NotFound => {
1640 // Atomic replacement makes a copied ledger readable without
1641 // creating a lock. No writer can have published a tombstone
1642 // without first creating the stable lock.
1643 return read(&path);
1644 }
1645 Err(error) => return Err(error),
1646 };
1647 let lock = fd_lock::RwLock::new(lock_file);
1648 let _guard = lock.read()?;
1649 read(&path)
1650 }
1651
1652 fn load_late_usage(&self, session_id: &str) -> io::Result<LateUsageLedger> {
1653 self.with_session_read_lock(session_id, |path| {
1654 if Self::late_usage_is_deleted(path)? {
1655 return Err(io::Error::new(
1656 io::ErrorKind::NotFound,
1657 "session accounting was deleted",
1658 ));
1659 }
1660 Self::load_late_usage_unlocked(path)
1661 })
1662 }
1663
1664 fn apply_late_usage_to_metadata(&self, metadata: &mut SessionMetadata) {
1665 let ledger = match self.load_late_usage(&metadata.id) {
1666 Ok(ledger) => ledger,
1667 Err(_) => {
1668 // The transcript is independent of optional accounting data.
1669 // Keep a stable gap receipt even when this projection is later
1670 // saved; loading it again must not invent another missing call.
1671 let fingerprint = crate::cost_status::usage_source_fingerprint(&format!(
1672 "late-usage-unavailable:{}",
1673 crate::cost_status::usage_source_fingerprint(&metadata.id)
1674 ));
1675 if metadata.cost.usage_source_fingerprints.insert(fingerprint) {
1676 metadata.cost.unpriced_turns = metadata.cost.unpriced_turns.saturating_add(1);
1677 metadata.cost.cny_unpriced_turns =
1678 metadata.cost.cny_unpriced_turns.saturating_add(1);
1679 }
1680 metadata
1681 .cost
1682 .unpriced_reasons
1683 .insert(LATE_USAGE_UNAVAILABLE_REASON.to_string());
1684 metadata
1685 .cost
1686 .cny_unpriced_reasons
1687 .insert(LATE_USAGE_UNAVAILABLE_REASON.to_string());
1688 metadata.cost.coverage_recorded = true;
1689 return;
1690 }
1691 };
1692 for record in ledger.records {
1693 let source_fingerprint = record.source_fingerprint.clone();
1694 let source_id = format!("late:{}", record.source_fingerprint);
1695 let mut pending = if let Some(usage) = record.usage.as_ref() {
1696 crate::cost_status::background_cost_for_runtime_usage(
1697 &crate::cost_status::RuntimeUsageRecord {
1698 source_id,
1699 usage: crate::cost_status::EffectiveRouteUsage {
1700 route: record.route,
1701 usage: usage.clone(),
1702 },
1703 },
1704 )
1705 } else {
1706 crate::cost_status::background_cost_for_runtime_drop(
1707 &crate::cost_status::RuntimeUsageDropRecord {
1708 source_id,
1709 route: record.route,
1710 },
1711 )
1712 };
1713 // The sidecar already stores the canonical SHA-256 identity. Do
1714 // not hash it again while projecting the receipt into the saved
1715 // session, or a concurrent main-snapshot writer that already
1716 // contains the response would not dedupe against this overlay.
1717 pending.usage_source_fingerprints.clear();
1718 pending
1719 .usage_source_fingerprints
1720 .insert(source_fingerprint.clone());
1721 if metadata
1722 .cost
1723 .usage_source_fingerprints
1724 .contains(&source_fingerprint)
1725 {
1726 continue;
1727 }
1728 if let Some(usage) = record.usage {
1729 metadata.total_tokens = metadata
1730 .total_tokens
1731 .saturating_add(u64::from(usage.input_tokens))
1732 .saturating_add(u64::from(usage.output_tokens));
1733 }
1734 metadata.cost.absorb_late_background_cost(&pending);
1735 }
1736 if ledger.overflowed {
1737 let fingerprint = crate::cost_status::usage_source_fingerprint(&format!(
1738 "late-usage-overflow:{}",
1739 crate::cost_status::usage_source_fingerprint(&metadata.id)
1740 ));
1741 if metadata.cost.usage_source_fingerprints.insert(fingerprint) {
1742 metadata.cost.unpriced_turns = metadata.cost.unpriced_turns.saturating_add(1);
1743 metadata.cost.cny_unpriced_turns =
1744 metadata.cost.cny_unpriced_turns.saturating_add(1);
1745 metadata
1746 .cost
1747 .unpriced_reasons
1748 .insert("late_usage_ledger_overflow".to_string());
1749 metadata
1750 .cost
1751 .cny_unpriced_reasons
1752 .insert("late_usage_ledger_overflow".to_string());
1753 metadata.cost.coverage_recorded = true;
1754 }
1755 }
1756 }
1757
1758 /// Persist the bounded goal control state for one saved session.
1759 /// `None` is the canonical clear operation and is idempotent.
1760 pub fn save_session_goal(
1761 &self,
1762 session_id: &str,
1763 goal: Option<&SessionGoalState>,
1764 ) -> std::io::Result<()> {
1765 let path = self.validated_session_goal_path(session_id)?;
1766 let Some(goal) = goal else {
1767 if self.checked_existing_session_goals_dir()?.is_some() && path.exists() {
1768 fs::remove_file(path)?;
1769 }
1770 return Ok(());
1771 };
1772 goal.validate()?;
1773 self.ensure_session_goals_dir()?;
1774 Self::checked_existing_session_goal_file(&path)?;
1775 let content = serde_json::to_string_pretty(goal)
1776 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
1777 write_atomic(&path, content.as_bytes())
1778 }
1779
1780 /// Load a saved session's durable goal, rejecting malformed or future
1781 /// records instead of silently starting a different objective.
1782 pub fn load_session_goal(&self, session_id: &str) -> std::io::Result<Option<SessionGoalState>> {
1783 let path = self.validated_session_goal_path(session_id)?;
1784 if self.checked_existing_session_goals_dir()?.is_none()
1785 || !Self::checked_existing_session_goal_file(&path)?
1786 {
1787 return Ok(None);
1788 }
1789 let file_len = fs::metadata(&path)?.len();
1790 if file_len > MAX_SESSION_GOAL_FILE_BYTES {
1791 return Err(io::Error::new(
1792 io::ErrorKind::InvalidData,
1793 format!(
1794 "Session goal {} is {file_len} bytes; maximum is {MAX_SESSION_GOAL_FILE_BYTES}",
1795 path.display()
1796 ),
1797 ));
1798 }
1799 let raw = fs::read_to_string(path)?;
1800 let goal: SessionGoalState = serde_json::from_str(&raw)
1801 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
1802 goal.validate()?;
1803 Ok(Some(goal))
1804 }
1805
1806 fn hydrate_recovered_runtime_binding(&self, session: &mut SavedSession) -> std::io::Result<()> {
1807 // Compare under the session write lock. A stale process may neither
1808 // resurrect a missing binding nor replace a different recovered owner.
1809 // An adoptable empty store (#6207) counts as abandonable on either
1810 // side, exactly like a missing one: there is no durable work to lose
1811 // in either direction.
1812 if let Some(incoming) = session.metadata.runtime_store.as_ref()
1813 && let Ok(persisted) =
1814 Self::load_session_metadata(&self.validated_session_path(&session.metadata.id)?)
1815 && let Some(binding) = persisted.runtime_store
1816 && incoming != &binding
1817 {
1818 let incoming_abandonable = incoming.is_missing_session_store().unwrap_or(false)
1819 || incoming.is_adoptable_empty_store().unwrap_or(false);
1820 if incoming_abandonable && binding.validate_existing_store().is_ok() {
1821 session.metadata.runtime_store = Some(binding);
1822 } else {
1823 let persisted_abandonable = binding.is_missing_session_store().unwrap_or(false)
1824 || binding.is_adoptable_empty_store().unwrap_or(false);
1825 if !persisted_abandonable {
1826 return Err(io::Error::new(
1827 io::ErrorKind::PermissionDenied,
1828 "Session Runtime ownership changed; reopen the session before saving",
1829 ));
1830 }
1831 }
1832 }
1833 Ok(())
1834 }
1835
1836 /// Save a session to disk using atomic write (temp file + fsync + rename).
1837 ///
1838 /// Borrowing form: clones once so the ~150 existing `&session` call sites
1839 /// keep working. The debounced persistence path already owns its value and
1840 /// calls [`Self::save_session_owned`] instead (#6214 T3).
1841 pub fn save_session(&self, session: &SavedSession) -> std::io::Result<PathBuf> {
1842 self.save_session_owned(session.clone())
1843 }
1844
1845 /// Save a session to disk, consuming it.
1846 pub(crate) fn save_session_owned(&self, session: SavedSession) -> std::io::Result<PathBuf> {
1847 let session_id = session.metadata.id.clone();
1848 let path = self.validated_session_path(&session_id)?;
1849 // Not a `move` closure: `session` is consumed inside, so inference
1850 // captures it by value while `path` and `session_id` stay borrowed for
1851 // the caller to use after the write.
1852 self.with_session_write_admission(&session_id, || {
1853 let already_persisted = path.exists()
1854 || self
1855 .validated_checkpoint_path(&session_id)
1856 .is_ok_and(|checkpoint| checkpoint.exists());
1857
1858 // Still the pre-hydration value, and still before write_atomic.
1859 self.archive_before_first_graph_write(&session, &path)?;
1860
1861 let mut durable_session = session;
1862 self.hydrate_recovered_runtime_binding(&mut durable_session)?;
1863 self.hydrate_approval_receipts(&mut durable_session)?;
1864 let content = serialize_saved_session(durable_session)?;
1865
1866 // Atomic write via write_atomic (NamedTempFile + fsync + persist)
1867 write_atomic(&path, content.as_bytes())?;
1868 self.stamp_session_boot_owner_for_new_record(&session_id, already_persisted);
1869 Ok(())
1870 })?
1871 .ok_or_else(Self::retired_session_write_error)?;
1872
1873 // Cleanup may delete sessions, so release this session's lifecycle
1874 // lock first instead of recursively acquiring it during cleanup.
1875 self.cleanup_old_sessions()?;
1876
1877 Ok(path)
1878 }
1879
1880 /// Save a crash-recovery checkpoint for in-flight turns.
1881 ///
1882 /// Checkpoints are keyed per session (`checkpoints/<session_id>.json`) so
1883 /// concurrent sessions never overwrite each other's crash-recovery state.
1884 pub fn save_checkpoint(&self, session: &SavedSession) -> std::io::Result<PathBuf> {
1885 self.save_checkpoint_owned(session.clone())
1886 }
1887
1888 /// Save a crash-recovery checkpoint, consuming the session.
1889 pub(crate) fn save_checkpoint_owned(&self, session: SavedSession) -> std::io::Result<PathBuf> {
1890 let session_id = session.metadata.id.clone();
1891 let path = self.validated_checkpoint_path(&session_id)?;
1892 self.with_session_write_admission(&session_id, || {
1893 let session_path = self.validated_session_path(&session_id)?;
1894 self.archive_before_first_graph_write(&session, &session_path)?;
1895 fs::create_dir_all(self.checkpoints_dir())?;
1896 let already_persisted = path.exists() || session_path.exists();
1897 let mut durable_session = session;
1898 self.hydrate_recovered_runtime_binding(&mut durable_session)?;
1899 self.hydrate_approval_receipts(&mut durable_session)?;
1900 let content = serialize_saved_session(durable_session)?;
1901 write_atomic(&path, content.as_bytes())?;
1902 self.stamp_session_boot_owner_for_new_record(&session_id, already_persisted);
1903 Ok(())
1904 })?
1905 .ok_or_else(Self::retired_session_write_error)?;
1906 Ok(path)
1907 }
1908
1909 fn session_boot_owners_path(&self) -> PathBuf {
1910 self.sessions_dir
1911 .join(format!("{SESSION_BOOT_OWNERS_STEM}.json"))
1912 }
1913
1914 fn load_session_boot_owners(&self) -> BTreeMap<String, String> {
1915 fs::read_to_string(self.session_boot_owners_path())
1916 .ok()
1917 .and_then(|content| serde_json::from_str(&content).ok())
1918 .unwrap_or_default()
1919 }
1920
1921 /// Does any durable record (session file or crash checkpoint) exist for
1922 /// this session id?
1923 fn session_record_exists(&self, session_id: &str) -> bool {
1924 self.validated_session_path(session_id)
1925 .is_ok_and(|path| path.exists())
1926 || self
1927 .validated_checkpoint_path(session_id)
1928 .is_ok_and(|path| path.exists())
1929 }
1930
1931 /// Record which session instance owns `session_id`'s persisted record.
1932 ///
1933 /// Entries whose durable record no longer exists are pruned on the same
1934 /// write, so the sidecar cannot grow without bound.
1935 pub(crate) fn record_session_boot_owner(
1936 &self,
1937 session_id: &str,
1938 boot_id: &str,
1939 ) -> std::io::Result<()> {
1940 let id = self.validated_session_id(session_id)?.to_string();
1941 let mut owners = self.load_session_boot_owners();
1942 owners.retain(|owned, _| owned == &id || self.session_record_exists(owned));
1943 owners.insert(id, boot_id.to_string());
1944 let content = serde_json::to_string_pretty(&owners)
1945 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
1946 write_atomic(&self.session_boot_owners_path(), content.as_bytes())
1947 }
1948
1949 /// The session-instance boot id stamped on this session's persisted
1950 /// record, when one was recorded.
1951 #[must_use]
1952 pub fn session_boot_owner(&self, session_id: &str) -> Option<String> {
1953 let id = self.validated_session_id(session_id).ok()?;
1954 self.load_session_boot_owners().get(id).cloned()
1955 }
1956
1957 /// Was this session's persisted record created by a different session
1958 /// instance (an earlier or sibling Codewhale process)?
1959 ///
1960 /// Mirrors `SubAgentManager::is_from_prior_session` (#405): a durable
1961 /// record with no stamped owner predates the marker and is classified as
1962 /// prior-instance work, while an id with no durable record at all is
1963 /// this instance's own not-yet-persisted session.
1964 #[must_use]
1965 pub fn session_from_prior_instance(&self, session_id: &str) -> bool {
1966 match self.session_boot_owner(session_id) {
1967 Some(owner) => owner != current_session_boot_id(),
1968 None => self.session_record_exists(session_id),
1969 }
1970 }
1971
1972 /// Stamp this instance as creator when a save writes the first durable
1973 /// record for `session_id`. A record that already existed keeps its
1974 /// original owner: re-serializing another instance's work (crash
1975 /// recovery, external mutation) must not re-badge it as ours.
1976 fn stamp_session_boot_owner_for_new_record(&self, session_id: &str, already_persisted: bool) {
1977 if already_persisted || self.session_boot_owner(session_id).is_some() {
1978 return;
1979 }
1980 if let Err(error) = self.record_session_boot_owner(session_id, current_session_boot_id()) {
1981 tracing::warn!(session_id, %error, "could not stamp session boot owner");
1982 }
1983 }
1984
1985 fn clear_session_boot_owner(&self, session_id: &str) {
1986 let Ok(id) = self.validated_session_id(session_id) else {
1987 return;
1988 };
1989 let mut owners = self.load_session_boot_owners();
1990 if owners.remove(id).is_none() {
1991 return;
1992 }
1993 if let Ok(content) = serde_json::to_string_pretty(&owners) {
1994 let _ = write_atomic(&self.session_boot_owners_path(), content.as_bytes());
1995 }
1996 }
1997
1998 /// Preserve the exact pre-import session once, before the first graph-
1999 /// bearing session or checkpoint write can replace it.
2000 fn archive_before_first_graph_write(
2001 &self,
2002 session: &SavedSession,
2003 source: &Path,
2004 ) -> std::io::Result<()> {
2005 let writes_graph = session
2006 .work_state
2007 .as_ref()
2008 .and_then(|state| state.graph.as_ref())
2009 .is_some_and(|graph| !graph.is_empty());
2010 if !writes_graph || !source.exists() {
2011 return Ok(());
2012 }
2013 let bytes = fs::read(source)?;
2014 let already_graph_backed = serde_json::from_slice::<SavedSession>(&bytes)
2015 .ok()
2016 .and_then(|saved| saved.work_state)
2017 .and_then(|state| state.graph)
2018 .is_some_and(|graph| !graph.is_empty());
2019 if already_graph_backed {
2020 return Ok(());
2021 }
2022 let archive_dir = self.sessions_dir.join(WORK_GRAPH_IMPORT_ARCHIVE_DIR);
2023 fs::create_dir_all(&archive_dir)?;
2024 let archive =
2025 archive_dir.join(source.file_name().ok_or_else(|| {
2026 io::Error::new(io::ErrorKind::InvalidInput, "invalid session path")
2027 })?);
2028 if !archive.exists() {
2029 write_atomic(&archive, &bytes)?;
2030 }
2031 Ok(())
2032 }
2033
2034 fn read_checkpoint_file(&self, path: &Path) -> std::io::Result<Option<SavedSession>> {
2035 if !path.exists() {
2036 return Ok(None);
2037 }
2038 let content = fs::read_to_string(path)?;
2039 let mut session: SavedSession = serde_json::from_str(&content)
2040 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
2041 if session.schema_version > CURRENT_SESSION_SCHEMA_VERSION {
2042 return Err(std::io::Error::new(
2043 std::io::ErrorKind::InvalidData,
2044 format!(
2045 "Checkpoint schema v{} is newer than supported v{}",
2046 session.schema_version, CURRENT_SESSION_SCHEMA_VERSION
2047 ),
2048 ));
2049 }
2050 // A crash after retirement but before checkpoint removal must not
2051 // offer the deleted origin for recovery. Optional accounting damage
2052 // still permits recovery and is projected as incomplete below.
2053 if self
2054 .with_session_read_lock(&session.metadata.id, Self::late_usage_is_deleted)
2055 .unwrap_or(false)
2056 {
2057 return Ok(None);
2058 }
2059 session.system_prompt = strip_legacy_truncation_note(session.system_prompt);
2060 self.hydrate_approval_receipts(&mut session)?;
2061 self.apply_late_usage_to_metadata(&mut session.metadata);
2062 Ok(Some(session))
2063 }
2064
2065 /// Load a specific session's crash-recovery checkpoint if present.
2066 pub fn load_session_checkpoint(
2067 &self,
2068 session_id: &str,
2069 ) -> std::io::Result<Option<SavedSession>> {
2070 let path = self.validated_checkpoint_path(session_id)?;
2071 self.read_checkpoint_file(&path)
2072 }
2073
2074 /// Load the legacy single-slot checkpoint (`checkpoints/latest.json`) if
2075 /// present. Compatibility read only — this release no longer writes it.
2076 pub fn load_legacy_checkpoint(&self) -> std::io::Result<Option<SavedSession>> {
2077 let path = self.checkpoints_dir().join(LEGACY_CHECKPOINT_FILE);
2078 self.read_checkpoint_file(&path)
2079 }
2080
2081 fn legacy_checkpoint_origin(&self) -> io::Result<Option<String>> {
2082 use std::io::Read as _;
2083
2084 let path = self.checkpoints_dir().join(LEGACY_CHECKPOINT_FILE);
2085 let file = match open_private_read_file(&path) {
2086 Ok(file) => file,
2087 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
2088 Err(error) => return Err(error),
2089 };
2090 // Lifecycle cleanup only needs the leading metadata. Never follow
2091 // links or read an unbounded legacy transcript to identify its owner.
2092 let mut prefix = Vec::new();
2093 file.take(1024 * 1024).read_to_end(&mut prefix)?;
2094 extract_top_level_metadata(&prefix)
2095 .map(|metadata| Some(metadata.id))
2096 .ok_or_else(|| {
2097 io::Error::new(
2098 io::ErrorKind::InvalidData,
2099 "unknown legacy checkpoint origin",
2100 )
2101 })
2102 }
2103
2104 /// Clear one session's crash-recovery checkpoint. Scoped: this can never
2105 /// remove another session's checkpoint file or the legacy slot.
2106 pub fn clear_session_checkpoint(&self, session_id: &str) -> std::io::Result<()> {
2107 let path = self.validated_checkpoint_path(session_id)?;
2108 if path.exists() {
2109 fs::remove_file(path)?;
2110 }
2111 Ok(())
2112 }
2113
2114 /// Remove the legacy single-slot checkpoint file.
2115 pub fn clear_legacy_checkpoint(&self) -> std::io::Result<()> {
2116 let path = self.checkpoints_dir().join(LEGACY_CHECKPOINT_FILE);
2117 if path.exists() {
2118 fs::remove_file(path)?;
2119 }
2120 Ok(())
2121 }
2122
2123 /// Enumerate all crash-recovery checkpoint files (per-session files plus
2124 /// the legacy single slot), sorted most recently modified first. Only
2125 /// file metadata is read here; callers load content per candidate.
2126 pub fn list_checkpoints(&self) -> std::io::Result<Vec<CheckpointRef>> {
2127 let dir = self.checkpoints_dir();
2128 let mut refs = Vec::new();
2129 let entries = match fs::read_dir(&dir) {
2130 Ok(entries) => entries,
2131 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(refs),
2132 Err(err) => return Err(err),
2133 };
2134 for entry in entries {
2135 let entry = entry?;
2136 let path = entry.path();
2137 if !path.is_file() || path.extension().is_none_or(|ext| ext != "json") {
2138 continue;
2139 }
2140 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
2141 continue;
2142 };
2143 let source = if name == LEGACY_CHECKPOINT_FILE {
2144 CheckpointSource::Legacy
2145 } else if is_offline_queue_file(name) {
2146 // Parked offline queues live in this directory but are not
2147 // crash-recovery checkpoints.
2148 continue;
2149 } else {
2150 let session_id = name.trim_end_matches(".json").to_string();
2151 if self.validated_checkpoint_path(&session_id).is_err() {
2152 continue;
2153 }
2154 CheckpointSource::Session(session_id)
2155 };
2156 let Ok(modified) = entry.metadata().and_then(|m| m.modified()) else {
2157 continue;
2158 };
2159 refs.push(CheckpointRef {
2160 source,
2161 path,
2162 modified,
2163 });
2164 }
2165 refs.sort_by_key(|r| std::cmp::Reverse(r.modified));
2166 Ok(refs)
2167 }
2168
2169 /// Does `session_id` still hold a crash-recovery checkpoint — the
2170 /// durable sign the session ended mid-turn (#5715)?
2171 #[must_use]
2172 pub fn session_has_checkpoint(&self, session_id: &str) -> bool {
2173 self.validated_checkpoint_path(session_id)
2174 .is_ok_and(|path| path.exists())
2175 }
2176
2177 /// The most recent workspace-scoped session that still holds a
2178 /// crash-recovery checkpoint — durable evidence a prior session in this
2179 /// workspace ended mid-turn (#5715). Metadata only; the transcript is
2180 /// never read. `exclude` is the live session's own id: its in-flight
2181 /// checkpoint is current work, not prior work, and engine respawns
2182 /// inside one session must not report the session's own checkpoint.
2183 /// Sessions this process instance created are likewise excluded.
2184 pub fn interrupted_workspace_session(
2185 &self,
2186 workspace: &Path,
2187 exclude: Option<&str>,
2188 ) -> Option<SessionMetadata> {
2189 // Newest-first already; a checkpoint file only survives a session
2190 // that never reached a settled save.
2191 for checkpoint in self.list_checkpoints().ok()? {
2192 let CheckpointSource::Session(id) = checkpoint.source else {
2193 continue;
2194 };
2195 if Some(id.as_str()) == exclude || !self.session_from_prior_instance(&id) {
2196 continue;
2197 }
2198 // One malformed id or unreadable record must not hide a later
2199 // valid checkpoint — skip and keep scanning.
2200 let Ok(path) = self.validated_session_path(&id) else {
2201 continue;
2202 };
2203 if let Ok(meta) = Self::load_session_metadata(&path)
2204 && workspace_scope_matches(&meta.workspace, workspace)
2205 {
2206 return Some(meta);
2207 }
2208 }
2209 None
2210 }
2211
2212 /// Migrate a session recovered from the legacy single-slot checkpoint to
2213 /// a per-session checkpoint file. Never overwrites an existing
2214 /// per-session file and leaves the legacy file in place (older binaries
2215 /// still read it; the legacy writer is already gone). Returns whether a
2216 /// file was written.
2217 pub fn write_session_checkpoint_if_absent(
2218 &self,
2219 session: &SavedSession,
2220 ) -> std::io::Result<bool> {
2221 let path = self.validated_checkpoint_path(&session.metadata.id)?;
2222 if path.exists() {
2223 return Ok(false);
2224 }
2225 self.save_checkpoint(session)?;
2226 Ok(true)
2227 }
2228
2229 /// Acquire before loading or editing a queue, including on in-process
2230 /// resume. A per-write lock is insufficient: the second editor's stale
2231 /// snapshot would overwrite the first as soon as its write completed.
2232 pub fn acquire_offline_queue_lease(
2233 &self,
2234 session_id: &str,
2235 ) -> io::Result<std::sync::Arc<OfflineQueueLease>> {
2236 let session_id = self.validated_session_id(session_id)?.to_string();
2237 let directory = self.checkpoints_dir();
2238 fs::create_dir_all(&directory)?;
2239 let path = directory.join(format!("{session_id}.offline_queue.lock"));
2240 let file = fs::OpenOptions::new()
2241 .create(true)
2242 .truncate(false)
2243 .read(true)
2244 .write(true)
2245 .open(path)?;
2246 let mut lock = fd_lock::RwLock::new(file);
2247 let guard = lock.try_write().map_err(|error| {
2248 io::Error::new(
2249 error.kind(),
2250 format!("Cannot open session {session_id}: its queued input is already open in another window, or its previous writes are still finishing ({error})"),
2251 )
2252 })?;
2253 // fd-lock's guard borrows its owner. Retain the underlying descriptor
2254 // instead so this lease can travel with asynchronous writes. Forgetting
2255 // this non-owning guard keeps the OS lock held; the final Arc explicitly
2256 // unlocks in Drop. The OS also releases it when the process crashes.
2257 std::mem::forget(guard);
2258 Ok(std::sync::Arc::new(OfflineQueueLease {
2259 session_id,
2260 _file: lock.into_inner(),
2261 }))
2262 }
2263
2264 /// Park this session's offline queue (queued + draft messages).
2265 ///
2266 /// Queues are keyed per session (`checkpoints/<session_id>.offline_queue.json`)
2267 /// for exactly the reason checkpoints are: concurrent Codewhale instances
2268 /// must never overwrite — or delete — each other's unsent user text.
2269 ///
2270 /// A queue with no session id has no owner to restore it to, so parking is
2271 /// refused rather than written to a shared file where the next boot would
2272 /// destroy it.
2273 pub fn save_offline_queue_state(
2274 &self,
2275 state: &OfflineQueueState,
2276 session_id: Option<&str>,
2277 ) -> std::io::Result<PathBuf> {
2278 let session_id = session_id.ok_or_else(|| {
2279 std::io::Error::new(
2280 std::io::ErrorKind::InvalidInput,
2281 "Offline queue cannot be parked without a session id",
2282 )
2283 })?;
2284 let path = self.validated_offline_queue_path(session_id)?;
2285 fs::create_dir_all(self.checkpoints_dir())?;
2286 let mut owned = state.clone();
2287 // The stamp is redundant with the file name; it stays because the UI's
2288 // restore path still compares it against the live session id.
2289 owned.session_id = Some(self.validated_session_id(session_id)?.to_string());
2290 let content = serde_json::to_string_pretty(&owned)
2291 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
2292 write_atomic(&path, content.as_bytes())?;
2293 Ok(path)
2294 }
2295
2296 /// Load one session's parked offline queue if present.
2297 pub fn load_offline_queue_state(
2298 &self,
2299 session_id: &str,
2300 ) -> std::io::Result<Option<OfflineQueueState>> {
2301 let path = self.validated_offline_queue_path(session_id)?;
2302 Ok(match Self::read_offline_queue_file(&path)? {
2303 Some(state) => Some(state),
2304 None => self.adopt_legacy_offline_queue(session_id, &path)?,
2305 })
2306 }
2307
2308 /// Remove one named session's parked offline queue.
2309 pub fn clear_offline_queue_state_for(&self, session_id: &str) -> std::io::Result<()> {
2310 let path = self.validated_offline_queue_path(session_id)?;
2311 match fs::remove_file(&path) {
2312 Ok(()) => {}
2313 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
2314 Err(error) => return Err(error),
2315 }
2316 Ok(())
2317 }
2318
2319 fn validated_offline_queue_path(&self, session_id: &str) -> std::io::Result<PathBuf> {
2320 let trimmed = self.validated_session_id(session_id)?;
2321 Ok(self
2322 .checkpoints_dir()
2323 .join(format!("{trimmed}{OFFLINE_QUEUE_SUFFIX}")))
2324 }
2325
2326 fn read_offline_queue_file(path: &Path) -> std::io::Result<Option<OfflineQueueState>> {
2327 let content = match fs::read_to_string(path) {
2328 Ok(content) => content,
2329 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
2330 Err(error) => return Err(error),
2331 };
2332 let state: OfflineQueueState = serde_json::from_str(&content)
2333 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
2334 if state.schema_version > CURRENT_QUEUE_SCHEMA_VERSION {
2335 return Err(std::io::Error::new(
2336 std::io::ErrorKind::InvalidData,
2337 format!(
2338 "Offline queue schema v{} is newer than supported v{}",
2339 state.schema_version, CURRENT_QUEUE_SCHEMA_VERSION
2340 ),
2341 ));
2342 }
2343 Ok(Some(state))
2344 }
2345
2346 /// Migrate the pre-per-session global queue (`checkpoints/offline_queue.json`).
2347 ///
2348 /// It holds user-authored text, so it is adopted only by the session it was
2349 /// stamped for, and it is removed only once this session's copy is durably
2350 /// written. A queue stamped for someone else — or for nobody — is left
2351 /// exactly where it is, still readable, for its owner to claim.
2352 fn adopt_legacy_offline_queue(
2353 &self,
2354 session_id: &str,
2355 path: &Path,
2356 ) -> std::io::Result<Option<OfflineQueueState>> {
2357 let legacy = self.checkpoints_dir().join(OFFLINE_QUEUE_FILE);
2358 // A corrupt or future-schema legacy file must not fail this session's
2359 // boot: leave it on disk untouched and start with an empty queue.
2360 let Ok(Some(state)) = Self::read_offline_queue_file(&legacy) else {
2361 return Ok(None);
2362 };
2363 if state.session_id.as_deref() != Some(self.validated_session_id(session_id)?) {
2364 return Ok(None);
2365 }
2366 let content = serde_json::to_string_pretty(&state)
2367 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
2368 fs::create_dir_all(self.checkpoints_dir())?;
2369 write_atomic(path, content.as_bytes())?;
2370 match fs::remove_file(&legacy) {
2371 Ok(()) => {}
2372 // A second instance of the same session can win the adoption
2373 // race: both read the legacy file, both write this session's
2374 // per-session copy, and the twin's remove already retired the
2375 // legacy one. The queue is durably adopted either way, so a
2376 // vanished legacy file is success here, not a boot error.
2377 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2378 Err(error) => return Err(error),
2379 }
2380 Ok(Some(state))
2381 }
2382
2383 /// Read a session snapshot without repairing tool call/result pairs.
2384 ///
2385 /// This is the correct API for embedding hosts that inspect or update a
2386 /// durable session while an engine may still be executing a tool call.
2387 /// A dangling `tool_use` is not proof of a crashed process in that state.
2388 pub fn load_session_snapshot(&self, id: &str) -> std::io::Result<SavedSession> {
2389 let path = self.validated_session_path(id)?;
2390
2391 let content = fs::read_to_string(&path)?;
2392 let mut session: SavedSession = serde_json::from_str(&content)
2393 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
2394 if session.schema_version > CURRENT_SESSION_SCHEMA_VERSION {
2395 return Err(std::io::Error::new(
2396 std::io::ErrorKind::InvalidData,
2397 format!(
2398 "Session schema v{} is newer than supported v{}",
2399 session.schema_version, CURRENT_SESSION_SCHEMA_VERSION
2400 ),
2401 ));
2402 }
2403
2404 session.system_prompt = strip_legacy_truncation_note(session.system_prompt);
2405 session.ensure_journal();
2406 self.hydrate_approval_receipts(&mut session)?;
2407 self.apply_late_usage_to_metadata(&mut session.metadata);
2408
2409 Ok(session)
2410 }
2411
2412 /// Load and repair a session after a known process or engine restart.
2413 ///
2414 /// The returned repair remains in memory until the caller persists
2415 /// `recovery.session`. Keeping persistence explicit lets embedding hosts
2416 /// serialize recovery with their own transcript mutation lock.
2417 pub fn recover_session_for_resume(&self, id: &str) -> std::io::Result<SessionRecovery> {
2418 let mut session = self.load_session_snapshot(id)?;
2419 let repair = repair_recovered_session(&mut session);
2420
2421 Ok(SessionRecovery {
2422 session,
2423 changed: !repair.is_empty(),
2424 repaired_call_count: repair.repaired_call_ids.len(),
2425 duplicate_result_count: repair.duplicate_result_ids.len(),
2426 orphan_result_count: repair.orphan_result_ids.len(),
2427 })
2428 }
2429
2430 /// Load, repair, and durably persist a session being resumed.
2431 ///
2432 /// Resume is where a crash-repaired history becomes durable: the repaired
2433 /// record replaces the interrupted one so the same repair does not re-run
2434 /// on every later load. A persist failure is logged and the repaired
2435 /// in-memory session is still returned — a failed write-back must not
2436 /// strand the resume.
2437 pub fn resume_session(&self, id: &str) -> std::io::Result<SessionRecovery> {
2438 let recovery = self.recover_session_for_resume(id)?;
2439 if recovery.changed
2440 && let Err(error) = self.save_session(&recovery.session)
2441 {
2442 tracing::warn!(
2443 session_id = %recovery.session.metadata.id,
2444 %error,
2445 "repaired session history could not be persisted; the repair will re-run on the next load"
2446 );
2447 }
2448 Ok(recovery)
2449 }
2450
2451 /// [`Self::resume_session`] with a partial-ID prefix.
2452 pub fn resume_session_by_prefix(&self, prefix: &str) -> std::io::Result<SessionRecovery> {
2453 self.resume_session(&self.resolve_session_id_prefix(prefix)?)
2454 }
2455
2456 /// True when `path` is this store's durable record for `id`. File-based
2457 /// session loads use it to decide whether a repair may be written back in
2458 /// place or must stay in memory (a foreign file is not ours to rewrite).
2459 pub(crate) fn owns_session_path(&self, id: &str, path: &Path) -> bool {
2460 let Ok(managed) = self.validated_session_path(id) else {
2461 return false;
2462 };
2463 managed == path
2464 || managed
2465 .canonicalize()
2466 .is_ok_and(|managed| path.canonicalize().is_ok_and(|path| managed == path))
2467 }
2468
2469 /// Load a session by ID for the standalone CodeWhale resume flow.
2470 ///
2471 /// This preserves the historical recovery behavior for existing callers.
2472 /// Embedding hosts performing ordinary runtime reads should use
2473 /// [`Self::load_session_snapshot`] instead.
2474 pub fn load_session(&self, id: &str) -> std::io::Result<SavedSession> {
2475 self.recover_session_for_resume(id)
2476 .map(|recovery| recovery.session)
2477 }
2478
2479 /// Load a session by partial ID prefix
2480 pub fn load_session_by_prefix(&self, prefix: &str) -> std::io::Result<SavedSession> {
2481 self.load_session(&self.resolve_session_id_prefix(prefix)?)
2482 }
2483
2484 /// Resolve a unique ID without applying resume-time repair to its record.
2485 pub(crate) fn resolve_session_id_prefix(&self, prefix: &str) -> std::io::Result<String> {
2486 let sessions = self.list_sessions()?;
2487
2488 let matches: Vec<_> = sessions
2489 .into_iter()
2490 .filter(|s| s.id.starts_with(prefix))
2491 .collect();
2492
2493 match matches.len() {
2494 0 => Err(std::io::Error::new(
2495 std::io::ErrorKind::NotFound,
2496 format!("No session found with prefix: {prefix}"),
2497 )),
2498 1 => Ok(matches[0].id.clone()),
2499 _ => Err(std::io::Error::new(
2500 std::io::ErrorKind::InvalidInput,
2501 format!(
2502 "Ambiguous prefix '{}' matches {} sessions",
2503 prefix,
2504 matches.len()
2505 ),
2506 )),
2507 }
2508 }
2509
2510 /// List all saved sessions, sorted by most recently updated
2511 pub fn list_sessions(&self) -> std::io::Result<Vec<SessionMetadata>> {
2512 let mut sessions = Vec::new();
2513
2514 for entry in fs::read_dir(&self.sessions_dir)? {
2515 let entry = entry?;
2516 let path = entry.path();
2517
2518 if path.extension().is_some_and(|ext| ext == "json")
2519 && let Ok(mut session) = Self::load_session_metadata(&path)
2520 {
2521 self.apply_late_usage_to_metadata(&mut session);
2522 sessions.push(session);
2523 }
2524 }
2525
2526 // Sort by updated_at descending (most recent first)
2527 sessions.sort_by_key(|s| std::cmp::Reverse(s.updated_at));
2528
2529 Ok(sessions)
2530 }
2531
2532 /// Set the durable archive flag on a saved session and return the
2533 /// resulting metadata.
2534 ///
2535 /// This is the single writer for the flag: the picker, the `/sessions`
2536 /// command, and `PATCH /v1/sessions/{id}` all route through it so the TUI
2537 /// and the web dashboard cannot drift into two archive notions. A no-op
2538 /// call (already in the requested state) still returns the metadata and
2539 /// does not rewrite the file.
2540 pub fn set_session_archived(
2541 &self,
2542 id: &str,
2543 archived: bool,
2544 mutator: SessionMutator,
2545 ) -> std::io::Result<SessionMetadata> {
2546 if mutator == SessionMutator::External && is_live_session(id) {
2547 return Err(live_session_conflict(id));
2548 }
2549 let mut session = self.load_session(id)?;
2550 if session.metadata.archived == archived {
2551 return Ok(session.metadata);
2552 }
2553 session.metadata.archived = archived;
2554 self.save_session(&session)?;
2555 Ok(session.metadata)
2556 }
2557
2558 /// Re-read the durable lifecycle fields for `metadata` from disk.
2559 ///
2560 /// This is the autosave-survival guard. A TUI autosave rebuilds the whole
2561 /// session document from in-memory `App` state; any lifecycle field it
2562 /// carries from a stale cache would silently revert a rename or archive
2563 /// that landed in between — including one applied by the picker earlier in
2564 /// the same event loop, or by `/rename` while a snapshot was already
2565 /// queued.
2566 ///
2567 /// So rather than trusting any cache, the writer re-reads the persisted
2568 /// values immediately before writing. `title`, `archived`, `created_at`,
2569 /// and fork lineage are *lifecycle* state owned by the file, not
2570 /// conversation state owned by the running turn. Reading them back costs
2571 /// one bounded metadata-prefix read.
2572 ///
2573 /// Returns `true` when an existing record was found and merged. A missing
2574 /// record is not an error: the first save of a new session has nothing to
2575 /// merge from.
2576 pub fn merge_persisted_lifecycle(&self, metadata: &mut SessionMetadata) -> bool {
2577 let Ok(path) = self.validated_session_path(&metadata.id) else {
2578 return false;
2579 };
2580 let Ok(persisted) = Self::load_session_metadata(&path) else {
2581 return false;
2582 };
2583 metadata.title = persisted.title;
2584 metadata.archived = persisted.archived;
2585 metadata.created_at = persisted.created_at;
2586 metadata.parent_session_id = persisted.parent_session_id;
2587 metadata.forked_from_message_count = persisted.forked_from_message_count;
2588 metadata.runtime_store = persisted.runtime_store;
2589 true
2590 }
2591
2592 /// Rename a saved session and return the resulting metadata.
2593 ///
2594 /// Titles are trimmed and bounded to [`MAX_SESSION_TITLE_CHARS`]
2595 /// characters (counted in `char`s, not bytes, so a CJK or emoji title is
2596 /// not truncated mid-scalar). Created-at and fork lineage are untouched.
2597 pub fn rename_session(
2598 &self,
2599 id: &str,
2600 title: &str,
2601 mutator: SessionMutator,
2602 ) -> std::io::Result<SessionMetadata> {
2603 let title = normalize_session_title(title)?;
2604 if mutator == SessionMutator::External && is_live_session(id) {
2605 return Err(live_session_conflict(id));
2606 }
2607 let mut session = self.load_session(id)?;
2608 if session.metadata.title == title {
2609 return Ok(session.metadata);
2610 }
2611 session.metadata.title = title;
2612 self.save_session(&session)?;
2613 Ok(session.metadata)
2614 }
2615
2616 /// Load only the metadata from a session file.
2617 ///
2618 /// Optimization for #337: previously this called
2619 /// `serde_json::from_reader` which forces serde to scan every token in
2620 /// the file just to validate JSON structure — including the
2621 /// (potentially many MB of) `messages` and `tool_log` arrays we're
2622 /// going to discard. For a user with hundreds of long sessions, a
2623 /// single `list_sessions()` call could chew through tens of MB of
2624 /// JSON per startup.
2625 ///
2626 /// We now read at most 64 KB up front and string-extract the
2627 /// top-level `metadata` object, which is invariably tiny (~500 B)
2628 /// and appears before any large `messages`/`tool_log` payload. We
2629 /// fall back to a full-file read only if the prefix doesn't yield a
2630 /// parseable metadata block (e.g. an oddly-formatted legacy file).
2631 fn load_session_metadata(path: &Path) -> std::io::Result<SessionMetadata> {
2632 use std::io::Read;
2633
2634 const PREFIX_BYTES: usize = 64 * 1024;
2635 let mut file = fs::File::open(path)?;
2636 let mut buf = Vec::with_capacity(PREFIX_BYTES);
2637 file.by_ref()
2638 .take(PREFIX_BYTES as u64)
2639 .read_to_end(&mut buf)?;
2640
2641 if let Some(mut metadata) = extract_top_level_metadata(&buf) {
2642 apply_legacy_title_recovery(&mut metadata, &buf);
2643 return Ok(metadata);
2644 }
2645
2646 // Metadata wasn't extractable from the prefix (truncated mid-block,
2647 // unusual key ordering, etc.). Read the rest and try again with the
2648 // full buffer before giving up.
2649 let mut rest = Vec::new();
2650 file.read_to_end(&mut rest)?;
2651 buf.extend_from_slice(&rest);
2652 let mut metadata = extract_top_level_metadata(&buf).ok_or_else(|| {
2653 std::io::Error::new(
2654 std::io::ErrorKind::InvalidData,
2655 "session file missing parseable `metadata` block",
2656 )
2657 })?;
2658 apply_legacy_title_recovery(&mut metadata, &buf);
2659 Ok(metadata)
2660 }
2661
2662 /// Delete a session and its recovery checkpoints, retiring its origin.
2663 pub fn delete_session(&self, id: &str) -> std::io::Result<()> {
2664 self.remove_session(id, SessionRemoval::Explicit)
2665 }
2666
2667 fn remove_session(&self, id: &str, removal: SessionRemoval) -> std::io::Result<()> {
2668 let path = self.validated_session_path(id)?;
2669 // Older ordinary snapshots may use a name reserved by the checkpoint
2670 // directory. Such a name must never address its shared legacy files.
2671 let checkpoint = self.validated_checkpoint_path(id).ok();
2672 let legacy_checkpoint = self.checkpoints_dir().join(LEGACY_CHECKPOINT_FILE);
2673 let (late_path, lock_path) = self.ensure_late_usage_paths(id)?;
2674 let lock_file = open_private_lock_file(&lock_path)?;
2675 let mut lock = fd_lock::RwLock::new(lock_file);
2676 let _guard = lock.write()?;
2677 let already_deleted = Self::late_usage_is_deleted(&late_path)?;
2678 let legacy_origin = self.legacy_checkpoint_origin();
2679 let owns_legacy_checkpoint =
2680 matches!(&legacy_origin, Ok(Some(origin)) if origin == id.trim());
2681 let has_recovery = match checkpoint.as_ref() {
2682 Some(path) => path.try_exists()?,
2683 None => false,
2684 } || owns_legacy_checkpoint;
2685 if !already_deleted {
2686 // An unknown id must not acquire a deletion marker. A prior
2687 // tombstone, however, lets a retry finish interrupted cleanup.
2688 match fs::symlink_metadata(&path) {
2689 Ok(_) => {}
2690 Err(error) if error.kind() == io::ErrorKind::NotFound && has_recovery => {}
2691 Err(error) => return Err(error),
2692 }
2693 }
2694 if matches!(removal, SessionRemoval::Retention)
2695 && (has_recovery || legacy_origin.is_err())
2696 && !already_deleted
2697 {
2698 // Retention owns the ordinary snapshot, not crash recovery. Keep
2699 // the origin and its accounting/evidence writable for resume.
2700 // An unreadable legacy origin cannot justify retiring any id.
2701 return match fs::remove_file(&path) {
2702 Ok(()) => Ok(()),
2703 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
2704 Err(error) => Err(error),
2705 };
2706 }
2707 self.save_session_goal(id, None)?;
2708 // Publish the tombstone before removing data. A crash or a delayed
2709 // callback can no longer re-create this session's accounting. The
2710 // stable lock inode must never be removed or atomically replaced.
2711 if !already_deleted {
2712 write_atomic(&late_path.with_extension("deleted"), LATE_USAGE_DELETED)?;
2713 }
2714 match fs::remove_file(&path) {
2715 Ok(()) => {}
2716 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
2717 Err(error) => return Err(error),
2718 }
2719 match fs::remove_file(&late_path) {
2720 Ok(()) => {}
2721 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
2722 Err(error) => return Err(error),
2723 }
2724 if let Some(checkpoint) = checkpoint {
2725 match fs::remove_file(checkpoint) {
2726 Ok(()) => {}
2727 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
2728 Err(error) => return Err(error),
2729 }
2730 }
2731 if owns_legacy_checkpoint {
2732 match fs::remove_file(&legacy_checkpoint) {
2733 Ok(()) => {}
2734 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
2735 Err(error) => return Err(error),
2736 }
2737 }
2738 self.clear_session_boot_owner(id);
2739 let session_dir = self.sessions_dir.join(id.trim());
2740 if session_dir.exists() {
2741 if crate::plugins::metadata_is_link_or_reparse(&fs::symlink_metadata(&session_dir)?) {
2742 // Preserve remove_dir_all's existing no-follow behavior.
2743 fs::remove_dir_all(session_dir)?;
2744 return Ok(());
2745 }
2746 // Other conversations and automations can share this host's Runtime
2747 // authority. Deleting a transcript must never delete that store.
2748 for entry in fs::read_dir(&session_dir)? {
2749 let entry = entry?;
2750 if entry.file_name() == "runtime" {
2751 continue;
2752 }
2753 if entry.file_type()?.is_dir() {
2754 fs::remove_dir_all(entry.path())?;
2755 } else {
2756 fs::remove_file(entry.path())?;
2757 }
2758 }
2759 if fs::read_dir(&session_dir)?.next().is_none() {
2760 fs::remove_dir(session_dir)?;
2761 }
2762 }
2763 Ok(())
2764 }
2765
2766 /// Clean up old sessions to stay within the active cap.
2767 pub fn cleanup_old_sessions(&self) -> std::io::Result<()> {
2768 self.cleanup_old_sessions_keeping(None)
2769 }
2770
2771 /// As [`Self::cleanup_old_sessions`], but never touches `keep` — the
2772 /// session being resumed at boot. Without this, a background cleanup that
2773 /// races session restore can retire the just-resumed session when 50+
2774 /// newer records exist (its `updated_at` is not bumped until first save).
2775 ///
2776 /// The cap counts *active* transcripts: archived records sit outside it
2777 /// until the user prunes them, and empty auto-created stubs get their own
2778 /// small cap so they can never push a real transcript out (#6136, #6137).
2779 /// A transcript past the cap is archived, never unlinked; the store's
2780 /// destructive paths stay the explicit user actions.
2781 pub fn cleanup_old_sessions_keeping(&self, keep: Option<&str>) -> std::io::Result<()> {
2782 // Archiving saves the record, and every save runs retention again.
2783 // Drain a backlog in one pass here instead of nesting one cleanup per
2784 // archived transcript.
2785 if self
2786 .retention_in_progress
2787 .swap(true, std::sync::atomic::Ordering::SeqCst)
2788 {
2789 return Ok(());
2790 }
2791 let result = self.cleanup_old_sessions_inner(keep);
2792 self.retention_in_progress
2793 .store(false, std::sync::atomic::Ordering::SeqCst);
2794 result
2795 }
2796
2797 fn cleanup_old_sessions_inner(&self, keep: Option<&str>) -> std::io::Result<()> {
2798 let sessions = self.list_sessions()?;
2799
2800 // What retention owes each class (#6136/#6137): archived records are
2801 // already outside the cap; empty auto-created stubs are junk the
2802 // product writes on every boot and are capped apart so they can never
2803 // occupy a transcript's slot; everything else carries the
2804 // MAX_SESSIONS window.
2805 let mut active: Vec<&SessionMetadata> = Vec::new();
2806 let mut stubs: Vec<&SessionMetadata> = Vec::new();
2807 for session in &sessions {
2808 if session.archived {
2809 continue;
2810 }
2811 if is_empty_auto_created_session(session) {
2812 stubs.push(session);
2813 } else {
2814 active.push(session);
2815 }
2816 }
2817
2818 for session in active.iter().skip(MAX_SESSIONS) {
2819 if keep.is_some_and(|id| id == session.id) {
2820 continue;
2821 }
2822 // `External` keeps the live-session guard honest: a record
2823 // another process is driving is not ours to retire.
2824 if let Err(err) = self.set_session_archived(&session.id, true, SessionMutator::External)
2825 {
2826 tracing::warn!(
2827 target: "session",
2828 session = session.id,
2829 ?err,
2830 "retention could not archive a transcript past the cap; it stays active"
2831 );
2832 }
2833 }
2834
2835 for session in stubs.iter().skip(MAX_EMPTY_SESSION_STUBS) {
2836 if keep.is_some_and(|id| id == session.id) {
2837 continue;
2838 }
2839 if let Err(err) = self.remove_session(&session.id, SessionRemoval::Retention) {
2840 tracing::warn!(
2841 target: "session",
2842 session = session.id,
2843 ?err,
2844 "retention could not remove an empty session stub"
2845 );
2846 }
2847 }
2848
2849 // A directory without a top-level session snapshot is not proof of an
2850 // orphan: runtime threads and automations own independent durable stores,
2851 // including in other processes and before their first snapshot. Retention
2852 // only retires records it listed above; never infer authority to delete
2853 // other directories from an absent transcript or process-local claim.
2854
2855 Ok(())
2856 }
2857
2858 /// Remove session files whose `updated_at` is older than `max_age`
2859 /// from the persisted-sessions directory. Returns the number of
2860 /// records pruned. Building block for #406's phase-2 auto-archive
2861 /// on boot; today the user-facing entry point is the
2862 /// `/sessions prune <days>` slash command.
2863 ///
2864 /// Crash-recovery safety: skips the per-session checkpoint files
2865 /// (`checkpoints/<session_id>.json`), the legacy single-slot
2866 /// checkpoint (`checkpoints/latest.json`), and any file under `checkpoints/`
2867 /// — those are owned by the checkpoint subsystem and live with
2868 /// stricter durability rules. Only top-level `<session_id>.json`
2869 /// files are candidates.
2870 ///
2871 /// `max_age` is checked against the metadata's `updated_at`
2872 /// timestamp embedded in the JSON, not the filesystem mtime — the
2873 /// user may have rsynced their `~/.deepseek` between machines and
2874 /// fs mtimes can lie.
2875 #[cfg_attr(not(test), expect(dead_code))]
2876 pub fn prune_sessions_older_than(
2877 &self,
2878 max_age: std::time::Duration,
2879 ) -> std::io::Result<usize> {
2880 self.prune_sessions_older_than_keeping(max_age, None)
2881 }
2882
2883 /// As [`Self::prune_sessions_older_than`], but never deletes `keep` — the
2884 /// active session. A just-resumed session's `updated_at` is stale until
2885 /// its first post-resume save, so an age prune could otherwise delete the
2886 /// live session out from under the TUI.
2887 pub fn prune_sessions_older_than_keeping(
2888 &self,
2889 max_age: std::time::Duration,
2890 keep: Option<&str>,
2891 ) -> std::io::Result<usize> {
2892 let cutoff = Utc::now()
2893 - chrono::Duration::from_std(max_age).unwrap_or(chrono::Duration::days(365 * 10));
2894 let sessions = self.list_sessions()?;
2895 let mut pruned = 0usize;
2896 for session in sessions {
2897 if keep.is_some_and(|id| id == session.id) {
2898 continue;
2899 }
2900 if session.updated_at < cutoff {
2901 if let Err(err) = self.remove_session(&session.id, SessionRemoval::Retention) {
2902 tracing::warn!(
2903 target: "session",
2904 session = session.id,
2905 ?err,
2906 "session prune skipped a record",
2907 );
2908 continue;
2909 }
2910 pruned += 1;
2911 }
2912 }
2913 Ok(pruned)
2914 }
2915
2916 /// Get the most recent session scoped to the current workspace.
2917 ///
2918 /// Archived sessions are skipped: archiving is the user saying "not this
2919 /// one", and `--continue` / auto-resume must honour that rather than
2920 /// dragging a put-away session back.
2921 pub fn get_latest_session_for_workspace(
2922 &self,
2923 workspace: &Path,
2924 ) -> std::io::Result<Option<SessionMetadata>> {
2925 let sessions = self.list_sessions()?;
2926 Ok(sessions.into_iter().find(|session| {
2927 !session.archived
2928 && workspace_scope_matches(&session.workspace, workspace)
2929 && !is_empty_auto_created_session(session)
2930 }))
2931 }
2932
2933 /// Search sessions by title
2934 pub fn search_sessions(&self, query: &str) -> std::io::Result<Vec<SessionMetadata>> {
2935 let query_lower = query.to_lowercase();
2936 let sessions = self.list_sessions()?;
2937
2938 Ok(sessions
2939 .into_iter()
2940 .filter(|s| s.title.to_lowercase().contains(&query_lower))
2941 .collect())
2942 }
2943 }
2944
2945 /// Unicode format characters that never belong in a session title: bidi
2946 /// embeddings/overrides/isolates and marks, zero-width joiners/spaces, the
2947 /// soft hyphen, BOM, and line/paragraph separators. Together with
2948 /// `char::is_control` (C0, DEL, C1 — so ESC, BEL, ST, and OSC introducers)
2949 /// this is the one character policy for the persisted title, the terminal
2950 /// tab title, and every plain-text listing that echoes a title.
2951 pub(crate) fn is_title_format_char(ch: char) -> bool {
2952 matches!(
2953 ch,
2954 '\u{00ad}'
2955 | '\u{061c}'
2956 | '\u{200b}'..='\u{200f}'
2957 | '\u{2028}'..='\u{202e}'
2958 | '\u{2060}'..='\u{2064}'
2959 | '\u{2066}'..='\u{2069}'
2960 | '\u{feff}'
2961 )
2962 }
2963
2964 /// One-line notice that a prior session in `workspace` ended mid-turn
2965 /// (#5715), for the session-pinned prompt prefix. `current_session_id` is
2966 /// excluded: an in-flight checkpoint of the live session is current work,
2967 /// not prior work. Returns `None` when no interrupted session exists.
2968 pub(crate) fn session_recovery_hint(
2969 workspace: &Path,
2970 current_session_id: Option<&str>,
2971 ) -> Option<String> {
2972 let manager = SessionManager::default_location().ok()?;
2973 let meta = manager.interrupted_workspace_session(workspace, current_session_id)?;
2974 Some(format!(
2975 "A previous Codewhale session in this workspace (\"{}\", id {}, last active {}) has a recovery checkpoint — it likely ended mid-task. Use session_search/session_get to inspect it and offer to summarize or continue the work; resuming is the user's decision (e.g. /resume).",
2976 meta.title,
2977 truncate_id(&meta.id),
2978 meta.updated_at.format("%Y-%m-%d %H:%M UTC"),
2979 ))
2980 }
2981
2982 /// Drop control and bidi/zero-width format characters from a title.
2983 ///
2984 /// A session title is user- or content-derived text that later reaches an
2985 /// OSC 0 terminal title, `codewhale sessions` stdout, and the picker, so the
2986 /// persisted value must not be able to carry a raw escape sequence. Ordinary
2987 /// text, punctuation, CJK, and emoji pass through untouched.
2988 pub fn sanitize_session_title(raw: &str) -> String {
2989 raw.chars()
2990 .filter(|ch| !ch.is_control() && !is_title_format_char(*ch))
2991 .collect()
2992 }
2993
2994 /// Sanitize, trim, and bound a user-supplied session title.
2995 ///
2996 /// Returns `InvalidInput` for an empty title or one longer than
2997 /// [`MAX_SESSION_TITLE_CHARS`] so every rename surface (picker, `/rename`,
2998 /// `PATCH /v1/sessions/{id}`) rejects the same inputs with the same reason.
2999 pub fn normalize_session_title(title: &str) -> std::io::Result<String> {
3000 let sanitized = sanitize_session_title(title);
3001 let trimmed = sanitized.trim();
3002 if trimmed.is_empty() {
3003 return Err(std::io::Error::new(
3004 std::io::ErrorKind::InvalidInput,
3005 "Session title cannot be empty",
3006 ));
3007 }
3008 if trimmed.chars().count() > MAX_SESSION_TITLE_CHARS {
3009 return Err(std::io::Error::new(
3010 std::io::ErrorKind::InvalidInput,
3011 format!("Session title cannot exceed {MAX_SESSION_TITLE_CHARS} characters"),
3012 ));
3013 }
3014 Ok(trimmed.to_string())
3015 }
3016
3017 pub(crate) fn workspace_scope_matches(saved_workspace: &Path, current_workspace: &Path) -> bool {
3018 if paths_equivalent(saved_workspace, current_workspace) {
3019 return true;
3020 }
3021
3022 // Repository identity comes from the containing checkout itself (Git
3023 // dir/worktree traversal shared with project-context scope resolution),
3024 // never from branch names or paths mentioned in conversation.
3025 let canonical = |path: &Path| fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
3026 match (
3027 find_git_root(&canonical(saved_workspace)),
3028 find_git_root(&canonical(current_workspace)),
3029 ) {
3030 (Some(saved_root), Some(current_root)) => paths_equivalent(&saved_root, &current_root),
3031 _ => false,
3032 }
3033 }
3034
3035 pub(crate) fn is_empty_auto_created_session(session: &SessionMetadata) -> bool {
3036 session.message_count == 0
3037 && session
3038 .title
3039 .trim()
3040 .eq_ignore_ascii_case(DEFAULT_SESSION_TITLE)
3041 }
3042
3043 pub(crate) fn paths_equivalent(lhs: &Path, rhs: &Path) -> bool {
3044 let lhs_canonical = fs::canonicalize(lhs).ok();
3045 let rhs_canonical = fs::canonicalize(rhs).ok();
3046 match (lhs_canonical, rhs_canonical) {
3047 (Some(lhs), Some(rhs)) => lhs == rhs,
3048 _ => lhs == rhs,
3049 }
3050 }
3051
3052 /// Resolve the default session directory path.
3053 ///
3054 /// v0.8.44: prefers `~/.codewhale/sessions`, falls back to
3055 /// `~/.deepseek/sessions` for existing installs. Uses the write-path resolver
3056 /// so the first access relocates any legacy `~/.deepseek/sessions` into
3057 /// `~/.codewhale/sessions` when the primary directory is missing (#3240).
3058 /// If an older build already created an empty primary sessions directory, copy
3059 /// missing legacy entries into it without overwriting newer CodeWhale data.
3060 pub fn default_sessions_dir() -> std::io::Result<PathBuf> {
3061 let dir = codewhale_config::ensure_state_dir("sessions")
3062 .map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, e.to_string()))?;
3063 match merge_missing_legacy_session_entries(&dir) {
3064 Ok(0) => {}
3065 Ok(count) => {
3066 tracing::info!(
3067 target: "session::migration",
3068 "Copied {count} missing legacy session entries into {}",
3069 dir.display()
3070 );
3071 }
3072 Err(err) => {
3073 tracing::warn!(
3074 target: "session::migration",
3075 "Could not copy legacy sessions into {}: {err}",
3076 dir.display()
3077 );
3078 }
3079 }
3080 Ok(dir)
3081 }
3082
3083 fn merge_missing_legacy_session_entries(primary: &Path) -> io::Result<usize> {
3084 if codewhale_paths::codewhale_home_is_explicit() {
3085 return Ok(0);
3086 }
3087
3088 let legacy = codewhale_config::legacy_deepseek_home()
3089 .map_err(|e| io::Error::new(io::ErrorKind::NotFound, e.to_string()))?
3090 .join("sessions");
3091 if !legacy.is_dir() || paths_equivalent(primary, &legacy) {
3092 return Ok(0);
3093 }
3094
3095 copy_missing_dir_entries(&legacy, primary)
3096 }
3097
3098 fn copy_missing_dir_entries(src: &Path, dst: &Path) -> io::Result<usize> {
3099 fs::create_dir_all(dst)?;
3100 let mut copied = 0;
3101 for entry in fs::read_dir(src)? {
3102 let entry = entry?;
3103 let source = entry.path();
3104 let target = dst.join(entry.file_name());
3105
3106 let file_type = entry.file_type()?;
3107 if file_type.is_dir() {
3108 if entry.file_name() == std::ffi::OsStr::new("checkpoints") || target.exists() {
3109 continue;
3110 }
3111 copied += copy_missing_dir_entries(&source, &target)?;
3112 } else if file_type.is_file() {
3113 copied += usize::from(copy_file_create_new(&source, &target)?);
3114 }
3115 }
3116 Ok(copied)
3117 }
3118
3119 fn copy_file_create_new(src: &Path, dst: &Path) -> io::Result<bool> {
3120 let mut source = fs::File::open(src)?;
3121 let mut target = match fs::OpenOptions::new()
3122 .write(true)
3123 .create_new(true)
3124 .open(dst)
3125 {
3126 Ok(file) => file,
3127 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => return Ok(false),
3128 Err(err) => return Err(err),
3129 };
3130 if let Err(err) = io::copy(&mut source, &mut target) {
3131 let _ = fs::remove_file(dst);
3132 return Err(err);
3133 }
3134 Ok(true)
3135 }
3136
3137 /// Prune snapshots older than `max_age` for `workspace`.
3138 ///
3139 /// Always non-fatal. Returns silently — callers don't need the count
3140 /// (the underlying repo logs at WARN if anything blew up).
3141 pub fn prune_workspace_snapshots(workspace: &Path, max_age: std::time::Duration) {
3142 match crate::snapshot::prune_older_than(workspace, max_age) {
3143 Ok(0) => {}
3144 Ok(n) => {
3145 tracing::debug!(target: "snapshot", "boot prune removed {n} snapshot(s)");
3146 }
3147 Err(e) => {
3148 tracing::warn!(target: "snapshot", "boot prune failed: {e}");
3149 }
3150 }
3151 }
3152
3153 /// Create a new `SavedSession` from conversation state
3154 pub fn create_saved_session(
3155 messages: &[Message],
3156 model: &str,
3157 workspace: &Path,
3158 total_tokens: u64,
3159 system_prompt: Option<&SystemPrompt>,
3160 ) -> SavedSession {
3161 create_saved_session_with_mode(
3162 messages,
3163 model,
3164 workspace,
3165 total_tokens,
3166 system_prompt,
3167 None,
3168 )
3169 }
3170
3171 /// Placeholder title used for a session that has no first user message yet.
3172 /// `build_session_snapshot` (tui/ui/frame.rs) treats a title equal to this
3173 /// constant as an auto-generated placeholder and lets the conversation-derived
3174 /// title win once a user message exists. Keep this string stable on purpose.
3175 pub(crate) const DEFAULT_SESSION_TITLE: &str = "New Session";
3176
3177 /// Create a new `SavedSession` from conversation state with optional mode label
3178 pub fn create_saved_session_with_mode(
3179 messages: &[Message],
3180 model: &str,
3181 workspace: &Path,
3182 total_tokens: u64,
3183 system_prompt: Option<&SystemPrompt>,
3184 mode: Option<&str>,
3185 ) -> SavedSession {
3186 create_saved_session_with_id_and_mode(
3187 Uuid::new_v4().to_string(),
3188 messages,
3189 model,
3190 workspace,
3191 total_tokens,
3192 system_prompt,
3193 mode,
3194 )
3195 }
3196
3197 /// Create a new `SavedSession` using a caller-owned session id.
3198 pub fn create_saved_session_with_id_and_mode(
3199 id: String,
3200 messages: &[Message],
3201 model: &str,
3202 workspace: &Path,
3203 total_tokens: u64,
3204 system_prompt: Option<&SystemPrompt>,
3205 mode: Option<&str>,
3206 ) -> SavedSession {
3207 create_saved_session_with_id_mode_and_stamps(
3208 id,
3209 messages,
3210 &[],
3211 model,
3212 workspace,
3213 total_tokens,
3214 system_prompt,
3215 mode,
3216 )
3217 }
3218
3219 /// Create a new `SavedSession` whose journal entries keep the time each
3220 /// message actually landed. `message_stamps[i]` is the append time of
3221 /// `messages[i]`; a missing stamp falls back to now. Callers without a
3222 /// live stamp log pass `&[]` and get the historical save-time behavior.
3223 pub fn create_saved_session_with_id_mode_and_stamps(
3224 id: String,
3225 messages: &[Message],
3226 message_stamps: &[DateTime<Utc>],
3227 model: &str,
3228 workspace: &Path,
3229 total_tokens: u64,
3230 system_prompt: Option<&SystemPrompt>,
3231 mode: Option<&str>,
3232 ) -> SavedSession {
3233 create_saved_session_inner(
3234 id,
3235 messages,
3236 SessionJournal::from_messages_stamped(messages.to_vec(), message_stamps, 0),
3237 model,
3238 workspace,
3239 total_tokens,
3240 system_prompt,
3241 mode,
3242 true,
3243 )
3244 }
3245
3246 /// Create a snapshot whose `messages` projection is left empty (#6214 T3).
3247 ///
3248 /// The journal still carries every message, and every serialization path
3249 /// rehydrates the projection (`serialize_saved_session` runs
3250 /// `make_storage_compatible`), so the on-disk bytes are identical to the
3251 /// filled form. The persistence queue drops the projection anyway
3252 /// (`compact_for_persistence_queue`), so building it first is one full
3253 /// history copy per debounced flush for nothing. Callers that serialize a
3254 /// journal-only snapshot directly must run `make_storage_compatible` first.
3255 pub fn create_saved_session_journal_only(
3256 id: String,
3257 messages: &[Message],
3258 journal: SessionJournal,
3259 model: &str,
3260 workspace: &Path,
3261 total_tokens: u64,
3262 system_prompt: Option<&SystemPrompt>,
3263 mode: Option<&str>,
3264 ) -> SavedSession {
3265 create_saved_session_inner(
3266 id,
3267 messages,
3268 journal,
3269 model,
3270 workspace,
3271 total_tokens,
3272 system_prompt,
3273 mode,
3274 false,
3275 )
3276 }
3277
3278 fn create_saved_session_inner(
3279 id: String,
3280 messages: &[Message],
3281 journal: SessionJournal,
3282 model: &str,
3283 workspace: &Path,
3284 total_tokens: u64,
3285 system_prompt: Option<&SystemPrompt>,
3286 mode: Option<&str>,
3287 fill_messages: bool,
3288 ) -> SavedSession {
3289 let now = Utc::now();
3290
3291 // Generate title from the first real user message (runtime-owned control
3292 // traffic is skipped by `conversation_derived_title`). Fall back to the
3293 // placeholder when no user-authored prompt exists yet.
3294 let title =
3295 conversation_derived_title(messages).unwrap_or_else(|| DEFAULT_SESSION_TITLE.to_string());
3296
3297 let leaf_id = journal.leaf_id.clone();
3298 SavedSession {
3299 schema_version: CURRENT_SESSION_SCHEMA_VERSION,
3300 metadata: SessionMetadata {
3301 id,
3302 title,
3303 created_at: now,
3304 updated_at: now,
3305 message_count: messages.len(),
3306 total_tokens,
3307 model: model.to_string(),
3308 model_provider: default_model_provider(),
3309 model_provider_id: None,
3310 workspace: workspace.to_path_buf(),
3311 mode: mode.map(str::to_string),
3312 cost: SessionCostSnapshot::default(),
3313 parent_session_id: None,
3314 forked_from_message_count: None,
3315 runtime_store: None,
3316 cumulative_turn_secs: 0,
3317 archived: false,
3318 spawn_depth: journal.spawn_depth,
3319 },
3320 messages: if fill_messages {
3321 messages.to_vec()
3322 } else {
3323 Vec::new()
3324 },
3325 journal: Some(journal),
3326 leaf_id,
3327 system_prompt: system_prompt_to_string(system_prompt),
3328 context_references: Vec::new(),
3329 artifacts: Vec::new(),
3330 approval_receipts: Vec::new(),
3331 work_state: None,
3332 window_title: None,
3333 last_auto_route: None,
3334 }
3335 }
3336
3337 /// Update an existing session with new messages
3338 pub fn update_session(
3339 mut session: SavedSession,
3340 messages: &[Message],
3341 total_tokens: u64,
3342 system_prompt: Option<&SystemPrompt>,
3343 ) -> SavedSession {
3344 session.schema_version = CURRENT_SESSION_SCHEMA_VERSION;
3345 session.ensure_journal();
3346 let old_len = session.messages.len();
3347 let new_len = messages.len();
3348 if new_len >= old_len && messages[..old_len] == session.messages[..] {
3349 if let Some(journal) = session.journal.as_mut() {
3350 for msg in &messages[old_len..] {
3351 journal.append_message(msg.clone());
3352 }
3353 session.leaf_id = journal.leaf_id.clone();
3354 }
3355 } else if (new_len != old_len || messages != session.messages.as_slice())
3356 && let Some(journal) = session.journal.as_mut()
3357 {
3358 let common = messages
3359 .iter()
3360 .zip(session.messages.iter())
3361 .take_while(|(a, b)| a == b)
3362 .count();
3363 if common > 0 && common <= journal.entries.len() {
3364 let target_id = journal
3365 .root_to_leaf()
3366 .get(common - 1)
3367 .map(|entry| entry.id.clone());
3368 if let Some(target_id) = target_id {
3369 let _ = journal.branch_to(&target_id);
3370 } else {
3371 journal.leaf_id = None;
3372 }
3373 } else if common == 0 {
3374 journal.leaf_id = journal.entries.first().and_then(|e| e.parent_id.clone());
3375 if journal.leaf_id.is_none() && !journal.entries.is_empty() {
3376 journal.leaf_id = None;
3377 }
3378 }
3379 for msg in messages.iter().skip(common) {
3380 journal.append_message(msg.clone());
3381 }
3382 session.leaf_id = journal.leaf_id.clone();
3383 }
3384 session.messages.clear();
3385 session.messages.extend_from_slice(messages);
3386 session.metadata.updated_at = Utc::now();
3387 session.metadata.message_count = messages.len();
3388 session.metadata.total_tokens = total_tokens;
3389 session.system_prompt = system_prompt_to_string(system_prompt);
3390 session
3391 }
3392
3393 /// Strip a stale `[Session note]` block that was written by the old
3394 /// 500-message cap. Only removes notes that contain the specific
3395 /// "older messages were dropped" phrase — ordinary user-added
3396 /// `[Session note]` prompts are left untouched.
3397 fn strip_legacy_truncation_note(system_prompt: Option<String>) -> Option<String> {
3398 let sp = system_prompt?;
3399 let Some(trimmed) = sp.strip_prefix("[Session note]\n") else {
3400 return Some(sp);
3401 };
3402 // Only strip if this is the known cap_messages note.
3403 if !trimmed.contains("older messages were dropped") {
3404 return Some(sp);
3405 }
3406 // The note block ends with "\n\n---\n\n" (7 chars) followed by the real prompt.
3407 trimmed
3408 .find("\n\n---\n\n")
3409 .map(|pos| trimmed[pos + 7..].to_string())
3410 }
3411
3412 /// Byte offset of `key` (a quoted JSON key such as `"metadata"`) outside any
3413 /// string literal. Brace/string-aware so a key name quoted inside an earlier
3414 /// message body is never matched.
3415 fn find_json_key(bytes: &[u8], key: &[u8]) -> Option<usize> {
3416 let mut idx = 0usize;
3417 let mut in_string = false;
3418 let mut escape = false;
3419 while idx < bytes.len() {
3420 let c = bytes[idx];
3421 if escape {
3422 escape = false;
3423 } else if c == b'\\' {
3424 escape = true;
3425 } else if c == b'"' {
3426 if !in_string && bytes[idx..].starts_with(key) {
3427 return Some(idx);
3428 }
3429 in_string = !in_string;
3430 }
3431 idx += 1;
3432 }
3433 None
3434 }
3435
3436 /// Offset of the value opening with `open` that follows the key at
3437 /// `key_offset`.
3438 fn json_value_start(bytes: &[u8], key_offset: usize, key_len: usize, open: u8) -> Option<usize> {
3439 let mut idx = key_offset + key_len;
3440 while idx < bytes.len() && (bytes[idx] as char).is_whitespace() {
3441 idx += 1;
3442 }
3443 if idx >= bytes.len() || bytes[idx] != b':' {
3444 return None;
3445 }
3446 idx += 1;
3447 while idx < bytes.len() && (bytes[idx] as char).is_whitespace() {
3448 idx += 1;
3449 }
3450 (idx < bytes.len() && bytes[idx] == open).then_some(idx)
3451 }
3452
3453 /// Exclusive end of the balanced `{...}` starting at `start`, or `None` when
3454 /// the buffer is truncated before it closes.
3455 fn json_object_end(bytes: &[u8], start: usize) -> Option<usize> {
3456 let mut depth = 0i32;
3457 let mut in_string = false;
3458 let mut escape = false;
3459 for (offset, &c) in bytes[start..].iter().enumerate() {
3460 if escape {
3461 escape = false;
3462 continue;
3463 }
3464 match c {
3465 b'\\' => escape = true,
3466 b'"' => in_string = !in_string,
3467 b'{' if !in_string => depth += 1,
3468 b'}' if !in_string => {
3469 depth -= 1;
3470 if depth == 0 {
3471 return Some(start + offset + 1);
3472 }
3473 }
3474 _ => {}
3475 }
3476 }
3477 None
3478 }
3479
3480 /// String-scan a JSON byte buffer for the top-level `"metadata":{...}`
3481 /// block and return it parsed. Returns `None` if no balanced metadata
3482 /// object is present in the buffer.
3483 ///
3484 /// Supports the optimisation in `SessionManager::load_session_metadata`
3485 /// (#337). The scanner is brace-balanced and string-aware so a `{` or
3486 /// `}` appearing inside a string literal doesn't perturb the depth
3487 /// count.
3488 fn extract_top_level_metadata(buf: &[u8]) -> Option<SessionMetadata> {
3489 let s = std::str::from_utf8(buf).ok()?;
3490 let bytes = s.as_bytes();
3491 const KEY: &[u8] = b"\"metadata\"";
3492 let start = json_value_start(bytes, find_json_key(bytes, KEY)?, KEY.len(), b'{')?;
3493 let end = json_object_end(bytes, start)?;
3494 serde_json::from_str::<SessionMetadata>(&s[start..end]).ok()
3495 }
3496
3497 /// Complete message objects from the front of the `messages` array, plus
3498 /// whether the array was seen to end. A message the prefix cut in half is
3499 /// simply absent; nothing is reconstructed.
3500 fn extract_leading_messages(buf: &[u8], max: usize) -> (Vec<Message>, bool) {
3501 let Ok(s) = std::str::from_utf8(buf) else {
3502 return (Vec::new(), false);
3503 };
3504 let bytes = s.as_bytes();
3505 const KEY: &[u8] = b"\"messages\"";
3506 let Some(key_offset) = find_json_key(bytes, KEY) else {
3507 return (Vec::new(), false);
3508 };
3509 let Some(array_start) = json_value_start(bytes, key_offset, KEY.len(), b'[') else {
3510 return (Vec::new(), false);
3511 };
3512 let mut cursor = array_start + 1;
3513 let mut out = Vec::new();
3514 loop {
3515 while cursor < bytes.len() && matches!(bytes[cursor], b' ' | b'\t' | b'\r' | b'\n' | b',') {
3516 cursor += 1;
3517 }
3518 if cursor < bytes.len() && bytes[cursor] == b']' {
3519 return (out, true);
3520 }
3521 if out.len() >= max || cursor >= bytes.len() || bytes[cursor] != b'{' {
3522 return (out, false);
3523 }
3524 let Some(end) = json_object_end(bytes, cursor) else {
3525 return (out, false);
3526 };
3527 let Ok(message) = serde_json::from_str::<Message>(&s[cursor..end]) else {
3528 return (out, false);
3529 };
3530 out.push(message);
3531 cursor = end;
3532 }
3533 }
3534
3535 /// How many leading messages the legacy-title recovery will parse. The
3536 /// enclosing read is already bounded to a 64 KB prefix (#337); this bounds
3537 /// the parse inside it.
3538 const LEGACY_TITLE_SCAN_MESSAGES: usize = 24;
3539
3540 /// Recover a title that a superseded derivation took from runtime control
3541 /// traffic, using the session's own first real user prompt.
3542 ///
3543 /// Provenance is proven, never guessed: the stored title has to be exactly
3544 /// what the old rule produced — [`truncate_title`] of a message the current
3545 /// classifier rejects as not a user turn. A renamed session, and a person who
3546 /// literally typed an envelope as their first message, never match, so their
3547 /// text is kept. Returns `None` when there is nothing proven to recover.
3548 fn recovered_legacy_title(
3549 stored: &str,
3550 messages: &[Message],
3551 array_complete: bool,
3552 ) -> Option<String> {
3553 let stale = messages.iter().any(|message| {
3554 crate::runtime_handoff::classify_user_turn_prompt(message)
3555 == crate::runtime_handoff::UserTurnPromptKind::NotPrompt
3556 && message.content.iter().any(|block| match block {
3557 ContentBlock::Text { text, .. } => {
3558 truncate_title(text, 50) == stored
3559 || truncate_title(extract_user_prompt(text), 50) == stored
3560 }
3561 _ => false,
3562 })
3563 });
3564 if !stale {
3565 return None;
3566 }
3567 match conversation_derived_title(messages) {
3568 Some(title) => Some(title),
3569 // No user turn in what we read. Only claim the conversation has none
3570 // when the array actually ended inside the prefix; a truncated read
3571 // keeps the stored title rather than inventing a neutral one.
3572 None if array_complete => Some(DEFAULT_SESSION_TITLE.to_string()),
3573 None => None,
3574 }
3575 }
3576
3577 /// Apply [`recovered_legacy_title`] to freshly loaded metadata. In memory
3578 /// only — the session file is never rewritten, so the stored title (and any
3579 /// rename) survives on disk.
3580 fn apply_legacy_title_recovery(metadata: &mut SessionMetadata, buf: &[u8]) {
3581 // Cost gate, never the rename decision: an envelope title always opens
3582 // with `<`, so this keeps #337's bounded-parse win for ordinary titles.
3583 // Whether to rewrite is `recovered_legacy_title`'s proven provenance.
3584 if !metadata.title.starts_with('<') {
3585 return;
3586 }
3587 let (messages, complete) = extract_leading_messages(buf, LEGACY_TITLE_SCAN_MESSAGES);
3588 if messages.is_empty() {
3589 return;
3590 }
3591 if let Some(title) = recovered_legacy_title(&metadata.title, &messages, complete) {
3592 metadata.title = title;
3593 }
3594 }
3595
3596 fn system_prompt_to_string(system_prompt: Option<&SystemPrompt>) -> Option<String> {
3597 match system_prompt {
3598 Some(SystemPrompt::Text(text)) => Some(text.clone()),
3599 Some(SystemPrompt::Blocks(blocks)) => Some(
3600 blocks
3601 .iter()
3602 .map(|b| b.text.clone())
3603 .collect::<Vec<_>>()
3604 .join("\n\n---\n\n"),
3605 ),
3606 None => None,
3607 }
3608 }
3609
3610 /// Truncate a session ID to 8 characters for compact display.
3611 /// Returns a `&str` borrowing from the input — no allocation.
3612 pub fn truncate_id(id: &str) -> &str {
3613 id.get(..8).unwrap_or(id)
3614 }
3615
3616 /// Strip a leading `<turn_meta>...</turn_meta>` block from saved user text.
3617 ///
3618 /// Older sessions can have turn metadata prefixed to the first user message.
3619 /// The session picker and generated session titles should show the user's
3620 /// prompt, not the cache/debug envelope.
3621 pub(crate) fn extract_user_prompt(raw: &str) -> &str {
3622 let trimmed = raw.trim_start();
3623 let Some(after_open) = trimmed.strip_prefix("<turn_meta>") else {
3624 return trimmed;
3625 };
3626 if let Some(close_pos) = after_open.find("</turn_meta>") {
3627 return after_open[close_pos + "</turn_meta>".len()..].trim_start();
3628 }
3629 after_open.trim_start()
3630 }
3631
3632 /// Clean a stored title for display, falling back to a neutral label.
3633 pub(crate) fn extract_title(raw: &str) -> &str {
3634 let title = extract_user_prompt(raw);
3635 if title.is_empty() { "Session" } else { title }
3636 }
3637
3638 /// Strip common inline thinking/reasoning XML sections from saved assistant
3639 /// text before it is shown in session previews.
3640 pub(crate) fn strip_thinking_tags(text: &str) -> String {
3641 if !text.contains("<think") && !text.contains("<thinking") && !text.contains("<reasoning") {
3642 return text.to_string();
3643 }
3644
3645 let tags = ["think", "thinking", "reasoning"];
3646 let mut result = text.to_string();
3647 for tag in tags {
3648 let open = format!("<{tag}>");
3649 let close = format!("</{tag}>");
3650 while let Some(start) = result.find(&open) {
3651 let Some(end) = result[start..].find(&close) else {
3652 break;
3653 };
3654 let end_abs = start + end + close.len();
3655 result.replace_range(start..end_abs, "");
3656 }
3657 }
3658 result
3659 }
3660
3661 /// Truncate a string to create a title (character-safe for UTF-8)
3662 fn truncate_title(s: &str, max_len: usize) -> String {
3663 let s = s.trim();
3664 // Older sessions may carry a title saved before sanitization existed;
3665 // never echo raw controls into stdout or the picker. Take the first
3666 // line before sanitizing so a legacy multi-line title still shows only
3667 // its first line.
3668 let first_line = sanitize_session_title(s.lines().next().unwrap_or(s));
3669 let first_line = first_line.trim();
3670
3671 let char_count = first_line.chars().count();
3672 if char_count <= max_len {
3673 first_line.to_string()
3674 } else {
3675 let truncated: String = first_line.chars().take(max_len - 3).collect();
3676 format!("{truncated}...")
3677 }
3678 }
3679
3680 /// Derive the auto-title from the first real user message of a conversation.
3681 ///
3682 /// Returns `None` when no user-authored message exists to name the session
3683 /// after (an empty transcript, or one holding only runtime-owned control
3684 /// traffic); callers fall back to [`DEFAULT_SESSION_TITLE`].
3685 ///
3686 /// Chat-template compatibility forces runtime-owned control traffic
3687 /// (sub-agent handoffs, the Operate contract, restore checkpoints) through
3688 /// `role = "user"`, but an internal envelope is not what the person typed.
3689 /// Prompt eligibility comes from the existing user-turn classifier; the live
3690 /// title fallback shares the same selection through `conversation_title_prompt`.
3691 fn conversation_derived_title(messages: &[Message]) -> Option<String> {
3692 conversation_title_prompt(messages).map(|prompt| truncate_title(prompt, 50))
3693 }
3694
3695 /// Select the first real user turn's text for persisted and live titles.
3696 /// Keep an image-only turn as the first user boundary, and strip historical
3697 /// leading turn metadata without introducing another provenance classifier.
3698 pub(crate) fn conversation_title_prompt(messages: &[Message]) -> Option<&str> {
3699 messages
3700 .iter()
3701 .find(|message| {
3702 crate::runtime_handoff::classify_user_turn_prompt(message)
3703 != crate::runtime_handoff::UserTurnPromptKind::NotPrompt
3704 })
3705 .and_then(|m| {
3706 m.content.iter().find_map(|block| match block {
3707 ContentBlock::Text { text, .. } => {
3708 let prompt = extract_user_prompt(text);
3709 if prompt.is_empty() {
3710 None
3711 } else {
3712 Some(prompt)
3713 }
3714 }
3715 _ => None,
3716 })
3717 })
3718 }
3719
3720 /// Format a session for display in a picker
3721 pub fn format_session_line(meta: &SessionMetadata) -> String {
3722 let age = format_age(&meta.updated_at);
3723 let updated = format_session_updated_at(&meta.updated_at, &age);
3724 let truncated_title = truncate_title(extract_title(&meta.title), 40);
3725 let fork_label = if meta.parent_session_id.is_some() {
3726 " | fork"
3727 } else {
3728 ""
3729 };
3730
3731 format!(
3732 "{} | {} | {} msgs{} | {}",
3733 truncate_id(&meta.id),
3734 truncated_title,
3735 meta.message_count,
3736 fork_label,
3737 updated
3738 )
3739 }
3740
3741 pub(crate) fn format_session_updated_at(dt: &DateTime<Utc>, age: &str) -> String {
3742 format!("{} ({age})", dt.format("%Y-%m-%d %H:%M UTC"))
3743 }
3744
3745 /// Format a datetime as relative age
3746 fn format_age(dt: &DateTime<Utc>) -> String {
3747 let now = Utc::now();
3748 let duration = now.signed_duration_since(*dt);
3749
3750 if duration.num_minutes() < 1 {
3751 "just now".to_string()
3752 } else if duration.num_hours() < 1 {
3753 format!("{}m ago", duration.num_minutes())
3754 } else if duration.num_days() < 1 {
3755 format!("{}h ago", duration.num_hours())
3756 } else if duration.num_weeks() < 1 {
3757 format!("{}d ago", duration.num_days())
3758 } else {
3759 format!("{}w ago", duration.num_weeks())
3760 }
3761 }
3762
3763 // === Unit Tests ===
3764
3765 #[cfg(test)]
3766 mod tests {
3767 use super::*;
3768 use crate::approval_log::ApprovalOutcome;
3769 use crate::tools::plan::StepStatus;
3770 use crate::tui::history::{HistoryCell, ToolCell, history_cells_from_message};
3771 use codewhale_models::ContentBlock;
3772 use codewhale_models::Role;
3773 use std::fs;
3774 use tempfile::tempdir;
3775
3776 fn make_test_message(role: &str, text: &str) -> Message {
3777 Message {
3778 role: Role::from(role),
3779 content: vec![codewhale_models::ContentBlock::Text {
3780 text: text.to_string(),
3781 cache_control: None,
3782 }],
3783 }
3784 }
3785
3786 /// The journal is the session's timeline: an entry's `created_at` is when
3787 /// the message landed, not when a save ran. A rebuilt journal must not
3788 /// collapse 90 minutes of appends into the save instant — an inspector
3789 /// reading the file needs "this loop is 12 seconds" to be true.
3790 #[test]
3791 fn journal_entries_keep_append_stamps_across_saves() {
3792 let tmp = tempdir().expect("tempdir");
3793 let messages = vec![
3794 make_test_message("user", "first"),
3795 make_test_message("assistant", "answer"),
3796 ];
3797 let t0 = Utc::now() - chrono::Duration::minutes(90);
3798 let t1 = t0 + chrono::Duration::seconds(12);
3799 let session = create_saved_session_with_id_mode_and_stamps(
3800 "stamped".to_string(),
3801 &messages,
3802 &[t0, t1],
3803 "deepseek-v4-flash",
3804 tmp.path(),
3805 0,
3806 None,
3807 None,
3808 );
3809 let journal = session.journal.as_ref().expect("journal");
3810 assert_eq!(journal.entries[0].created_at, t0);
3811 assert_eq!(journal.entries[1].created_at, t1);
3812 assert_ne!(
3813 journal.entries[0].created_at, session.metadata.updated_at,
3814 "an append 90 minutes before save must not read as save time"
3815 );
3816 // Resume hands the same stamps back to the live log.
3817 assert_eq!(session.journal_message_stamps(), vec![t0, t1]);
3818 // A save with no stamps keeps the old behavior: entries collapse to
3819 // save time rather than inventing times.
3820 let before_save = Utc::now();
3821 let unstamped = create_saved_session_with_id_and_mode(
3822 "unstamped".to_string(),
3823 &messages,
3824 "deepseek-v4-flash",
3825 tmp.path(),
3826 0,
3827 None,
3828 None,
3829 );
3830 let journal = unstamped.journal.as_ref().expect("journal");
3831 assert!(
3832 journal.entries.iter().all(|entry| {
3833 entry.created_at >= before_save && entry.created_at <= unstamped.metadata.created_at
3834 }),
3835 "unstamped entries are created during save, before snapshot metadata"
3836 );
3837 }
3838
3839 fn save_late_usage_test_session(manager: &SessionManager, id: &str) -> SavedSession {
3840 let session = create_saved_session_with_id_and_mode(
3841 id.to_string(),
3842 &[make_test_message("user", "recoverable transcript")],
3843 "deepseek-v4-flash",
3844 manager.sessions_dir(),
3845 0,
3846 None,
3847 Some("agent"),
3848 );
3849 manager.save_session(&session).expect("save session");
3850 session
3851 }
3852
3853 fn late_usage_test_record(source_id: &str) -> crate::cost_status::RuntimeUsageRecord {
3854 crate::cost_status::RuntimeUsageRecord {
3855 source_id: source_id.to_string(),
3856 usage: crate::cost_status::EffectiveRouteUsage {
3857 route: crate::cost_status::EffectiveRouteEnvelope::capture(
3858 None,
3859 ApiProvider::Deepseek,
3860 "deepseek",
3861 "deepseek-v4-flash",
3862 Some(crate::config::DEFAULT_DEEPSEEK_BASE_URL),
3863 Utc::now(),
3864 ),
3865 usage: codewhale_models::Usage {
3866 input_tokens: 1,
3867 ..Default::default()
3868 },
3869 },
3870 }
3871 }
3872
3873 #[test]
3874 fn late_usage_reads_do_not_create_accounting_storage() {
3875 let tmp = tempdir().expect("tempdir");
3876 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
3877 let saved = save_late_usage_test_session(&manager, "no-late-usage");
3878 let directory = manager.sessions_dir().join(LATE_USAGE_DIR);
3879 let inventory = || {
3880 fs::read_dir(&directory)
3881 .expect("accounting directory")
3882 .map(|entry| entry.expect("entry").file_name())
3883 .collect::<BTreeSet<_>>()
3884 };
3885 let before = inventory();
3886 assert_eq!(before.len(), 1, "save creates the lifecycle lock only");
3887 assert_eq!(manager.list_sessions().expect("list").len(), 1);
3888 manager.load_session_by_prefix("no-late").expect("resume");
3889 manager
3890 .load_session_snapshot("no-late-usage")
3891 .expect("snapshot");
3892 assert_eq!(inventory(), before, "reads must not create sidecar files");
3893
3894 // Imported snapshots predate lifecycle locks. Reading one must not
3895 // create either its missing accounting directory or a lock leaf.
3896 let imported = SessionManager::new(tmp.path().join("imported")).expect("imported store");
3897 write_atomic(
3898 &imported
3899 .validated_session_path(&saved.metadata.id)
3900 .expect("imported path"),
3901 serialize_saved_session(saved.clone())
3902 .expect("snapshot bytes")
3903 .as_bytes(),
3904 )
3905 .expect("import snapshot");
3906 let imported_directory = imported.sessions_dir().join(LATE_USAGE_DIR);
3907 imported.list_sessions().expect("imported list");
3908 imported
3909 .load_session_by_prefix("no-late")
3910 .expect("imported resume");
3911 assert!(
3912 !imported_directory.exists(),
3913 "reads must not create the sidecar directory"
3914 );
3915
3916 fs::create_dir(&imported_directory).expect("empty accounting directory");
3917 imported
3918 .load_session_snapshot("no-late-usage")
3919 .expect("imported snapshot");
3920 assert_eq!(
3921 fs::read_dir(imported_directory).expect("directory").count(),
3922 0
3923 );
3924 }
3925
3926 #[test]
3927 fn late_usage_projection_failure_preserves_recovery_and_is_idempotent() {
3928 for malformed in ["json", "oversized", "directory", "tombstone"] {
3929 let tmp = tempdir().expect("tempdir");
3930 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
3931 let affected = save_late_usage_test_session(&manager, "affected-session");
3932 save_late_usage_test_session(&manager, "healthy-session");
3933 let (ledger, _) = manager
3934 .ensure_late_usage_paths("affected-session")
3935 .expect("paths");
3936 match malformed {
3937 "json" => fs::write(&ledger, b"{invalid accounting").expect("malformed ledger"),
3938 "oversized" => fs::File::create(&ledger)
3939 .expect("file")
3940 .set_len(MAX_LATE_USAGE_LEDGER_BYTES + 1)
3941 .expect("oversized ledger"),
3942 "directory" => fs::create_dir(&ledger).expect("special ledger"),
3943 "tombstone" => fs::write(ledger.with_extension("deleted"), b"invalid marker")
3944 .expect("malformed tombstone"),
3945 _ => unreachable!(),
3946 }
3947
3948 let listed = manager
3949 .list_sessions()
3950 .expect("list survives sidecar failure");
3951 assert_eq!(listed.len(), 2);
3952 let bad = listed
3953 .iter()
3954 .find(|session| session.id == affected.metadata.id)
3955 .expect("affected");
3956 assert!(
3957 bad.cost
3958 .unpriced_reasons
3959 .contains(LATE_USAGE_UNAVAILABLE_REASON)
3960 );
3961 assert_eq!(bad.cost.unpriced_turns, 1);
3962 let good = manager
3963 .load_session_by_prefix("healthy")
3964 .expect("unaffected resume");
3965 assert_eq!(good.metadata.cost.unpriced_turns, 0);
3966
3967 let mut restored = manager
3968 .load_session_by_prefix("affected")
3969 .expect("affected recovery");
3970 assert_eq!(restored.messages, affected.messages);
3971 manager.apply_late_usage_to_metadata(&mut restored.metadata);
3972 assert_eq!(restored.metadata.cost.unpriced_turns, 1);
3973 assert_eq!(restored.metadata.cost.cny_unpriced_turns, 1);
3974 assert_eq!(restored.metadata.cost.usage_source_fingerprints.len(), 1);
3975 if malformed == "tombstone" {
3976 assert!(
3977 manager.save_session(&restored).is_err(),
3978 "an invalid deletion marker must fail closed for writes"
3979 );
3980 fs::remove_file(ledger.with_extension("deleted"))
3981 .expect("repair malformed deletion marker");
3982 }
3983 manager
3984 .save_session(&restored)
3985 .expect("save recovered transcript");
3986 let again = manager
3987 .load_session_snapshot("affected-session")
3988 .expect("repeat recovery");
3989 assert_eq!(again.metadata.cost.unpriced_turns, 1);
3990 assert_eq!(again.metadata.cost.cny_unpriced_turns, 1);
3991 assert_eq!(
3992 again.metadata.total_tokens, 0,
3993 "unsafe accounting must not be used"
3994 );
3995 }
3996 }
3997
3998 #[cfg(unix)]
3999 #[test]
4000 fn linked_late_usage_directory_does_not_block_transcripts_or_touch_target() {
4001 use std::os::unix::fs::{PermissionsExt as _, symlink};
4002 let tmp = tempdir().expect("tempdir");
4003 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
4004 save_late_usage_test_session(&manager, "linked-directory");
4005 let outside = tmp.path().join("outside");
4006 fs::create_dir(&outside).expect("outside directory");
4007 fs::set_permissions(&outside, fs::Permissions::from_mode(0o755))
4008 .expect("outside permissions");
4009 fs::rename(
4010 manager.sessions_dir().join(LATE_USAGE_DIR),
4011 tmp.path().join("original-accounting"),
4012 )
4013 .expect("park original accounting directory");
4014 symlink(&outside, manager.sessions_dir().join(LATE_USAGE_DIR)).expect("linked store");
4015 let recovered = manager
4016 .load_session_snapshot("linked-directory")
4017 .expect("transcript");
4018 assert!(
4019 recovered
4020 .metadata
4021 .cost
4022 .unpriced_reasons
4023 .contains(LATE_USAGE_UNAVAILABLE_REASON)
4024 );
4025 assert_eq!(manager.list_sessions().expect("listing").len(), 1);
4026 assert!(
4027 manager
4028 .persist_late_runtime_usage(
4029 "linked-directory",
4030 "turn",
4031 &late_usage_test_record("source")
4032 )
4033 .is_err()
4034 );
4035 assert_eq!(fs::read_dir(&outside).expect("outside contents").count(), 0);
4036 assert_eq!(
4037 fs::metadata(outside)
4038 .expect("outside metadata")
4039 .permissions()
4040 .mode()
4041 & 0o777,
4042 0o755
4043 );
4044 }
4045
4046 #[test]
4047 fn deleting_session_retires_late_usage_and_keeps_one_lock_inode() {
4048 let tmp = tempdir().expect("tempdir");
4049 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
4050 save_late_usage_test_session(&manager, "deleted-session");
4051 let record = late_usage_test_record("before-deletion");
4052 manager
4053 .persist_late_runtime_usage("deleted-session", "turn", &record)
4054 .expect("append");
4055 let (ledger, lock_path) = manager.late_usage_paths("deleted-session").expect("paths");
4056 let mut old_lock =
4057 fd_lock::RwLock::new(open_private_lock_file(&lock_path).expect("captured lock"));
4058
4059 manager.delete_session("deleted-session").expect("delete");
4060 assert!(!ledger.exists());
4061 assert!(
4062 !manager
4063 .validated_session_path("deleted-session")
4064 .expect("session path")
4065 .exists()
4066 );
4067 assert!(SessionManager::late_usage_is_deleted(&ledger).expect("tombstone"));
4068 assert!(manager.load_late_usage("deleted-session").is_err());
4069 assert!(
4070 manager
4071 .persist_late_runtime_usage("deleted-session", "turn", &record)
4072 .expect("retired replay")
4073 );
4074 assert!(
4075 !ledger.exists(),
4076 "late callback must not resurrect accounting"
4077 );
4078 manager
4079 .delete_session("deleted-session")
4080 .expect("idempotent cleanup retry");
4081
4082 let mut new_lock =
4083 fd_lock::RwLock::new(open_private_lock_file(&lock_path).expect("current lock"));
4084 let _held = old_lock.write().expect("old handle still owns the lock");
4085 assert!(
4086 matches!(new_lock.try_write(), Err(error) if error.kind() == io::ErrorKind::WouldBlock),
4087 "deletion must not replace or unlink a held lock inode"
4088 );
4089 }
4090
4091 #[test]
4092 fn lifecycle_admission_holds_delete_lock_and_rejects_retired_origin() {
4093 let tmp = tempdir().expect("tempdir");
4094 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
4095 save_late_usage_test_session(&manager, "active-origin");
4096 let (_, lock_path) = manager.late_usage_paths("active-origin").expect("paths");
4097 let mut competing_lock =
4098 fd_lock::RwLock::new(open_private_lock_file(&lock_path).expect("competing lock"));
4099 assert_eq!(
4100 manager
4101 .with_live_session_origin("active-origin", || {
4102 assert!(
4103 matches!(competing_lock.try_write(), Err(error) if error.kind() == io::ErrorKind::WouldBlock),
4104 "active acceptance must hold the deletion lock"
4105 );
4106 true
4107 })
4108 .expect("active acceptance"),
4109 Some(true)
4110 );
4111 assert_eq!(
4112 manager
4113 .with_live_session_origin("active-origin", || false)
4114 .expect("stale scope falls through"),
4115 Some(false)
4116 );
4117 drop(competing_lock.write().expect("admission releases the lock"));
4118
4119 manager.delete_session("active-origin").expect("delete");
4120 let mut ran_after_delete = false;
4121 assert_eq!(
4122 manager
4123 .with_live_session_origin("active-origin", || {
4124 ran_after_delete = true;
4125 true
4126 })
4127 .expect("retired origin"),
4128 None
4129 );
4130 assert!(!ran_after_delete, "retired scopes cannot accept new usage");
4131 }
4132
4133 #[test]
4134 fn deleted_session_rejects_stale_snapshot_and_checkpoint_saves() {
4135 let tmp = tempdir().expect("tempdir");
4136 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
4137 let stale = save_late_usage_test_session(&manager, "stale-writer");
4138 manager.delete_session("stale-writer").expect("delete");
4139 for error in [
4140 manager.save_session(&stale).expect_err("reject stale save"),
4141 manager
4142 .save_checkpoint(&stale)
4143 .expect_err("reject stale checkpoint"),
4144 ] {
4145 assert_eq!(error.kind(), io::ErrorKind::NotFound);
4146 }
4147 assert!(manager.list_sessions().expect("list").is_empty());
4148 assert!(
4149 !manager
4150 .validated_checkpoint_path("stale-writer")
4151 .expect("checkpoint path")
4152 .exists(),
4153 "a retired writer must not recreate crash-recovery data"
4154 );
4155 }
4156
4157 #[test]
4158 fn explicit_delete_removes_owned_recovery_checkpoints_only() {
4159 let tmp = tempdir().expect("tempdir");
4160 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
4161 let retired = save_late_usage_test_session(&manager, "retired-recovery");
4162 let retained = save_late_usage_test_session(&manager, "retained-recovery");
4163 manager.save_checkpoint(&retired).expect("owned checkpoint");
4164 manager
4165 .save_checkpoint(&retained)
4166 .expect("other checkpoint");
4167 let legacy_path = manager.checkpoints_dir().join(LEGACY_CHECKPOINT_FILE);
4168 write_atomic(
4169 &legacy_path,
4170 serialize_saved_session(retired.clone())
4171 .expect("legacy bytes")
4172 .as_bytes(),
4173 )
4174 .expect("owned legacy checkpoint");
4175
4176 manager.delete_session("retired-recovery").expect("delete");
4177 assert!(manager.load_legacy_checkpoint().expect("legacy").is_none());
4178 assert!(
4179 manager
4180 .load_session_checkpoint("retired-recovery")
4181 .expect("owned checkpoint")
4182 .is_none()
4183 );
4184 let checkpoints = manager.list_checkpoints().expect("checkpoint picker");
4185 assert_eq!(checkpoints.len(), 1);
4186 assert!(matches!(
4187 &checkpoints[0].source,
4188 CheckpointSource::Session(id) if id == "retained-recovery"
4189 ));
4190 assert!(
4191 manager
4192 .load_session_checkpoint("retained-recovery")
4193 .expect("other recovery")
4194 .is_some()
4195 );
4196
4197 // An origin can exist only as crash recovery, with no ordinary
4198 // snapshot. Explicit deletion must still be able to retire it.
4199 fs::remove_file(
4200 manager
4201 .validated_session_path("retained-recovery")
4202 .expect("ordinary snapshot path"),
4203 )
4204 .expect("simulate checkpoint-only origin");
4205 manager
4206 .delete_session("retained-recovery")
4207 .expect("delete recovery-only origin");
4208 assert!(
4209 manager
4210 .list_checkpoints()
4211 .expect("checkpoint picker")
4212 .is_empty()
4213 );
4214 assert!(manager.save_checkpoint(&retained).is_err());
4215 }
4216
4217 #[test]
4218 fn retention_preserves_checkpoint_origin_receipts_and_evidence() {
4219 for retention in ["age", "size"] {
4220 for checkpoint_kind in ["session", "legacy"] {
4221 let tmp = tempdir().expect("tempdir");
4222 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
4223 let id = "55555555-5555-4555-8555-555555555555";
4224 let mut old = save_late_usage_test_session(&manager, id);
4225 old.metadata.updated_at = Utc::now() - chrono::Duration::days(60);
4226 manager.save_session(&old).expect("old snapshot");
4227 let evidence = manager.sessions_dir().join(id).join("artifacts");
4228 fs::create_dir_all(&evidence).expect("recovery evidence");
4229 fs::write(evidence.join("receipt.txt"), b"recoverable evidence").expect("receipt");
4230 if checkpoint_kind == "session" {
4231 manager.save_checkpoint(&old).expect("recovery checkpoint");
4232 } else {
4233 fs::create_dir_all(manager.checkpoints_dir()).expect("checkpoints");
4234 write_atomic(
4235 &manager.checkpoints_dir().join(LEGACY_CHECKPOINT_FILE),
4236 serialize_saved_session(old.clone())
4237 .expect("legacy bytes")
4238 .as_bytes(),
4239 )
4240 .expect("legacy recovery checkpoint");
4241 }
4242 manager
4243 .persist_late_runtime_usage(id, "turn", &late_usage_test_record("before-prune"))
4244 .expect("origin accounting");
4245 if retention == "age" {
4246 assert_eq!(
4247 manager
4248 .prune_sessions_older_than(std::time::Duration::from_secs(24 * 3600))
4249 .expect("age prune"),
4250 1
4251 );
4252 assert!(
4253 !manager
4254 .validated_session_path(id)
4255 .expect("snapshot path")
4256 .exists(),
4257 "an explicit age prune still unlinks"
4258 );
4259 } else {
4260 for index in 0..MAX_SESSIONS {
4261 write_session_with_updated_at(
4262 &manager,
4263 &format!("fresh-{index}"),
4264 Utc::now(),
4265 );
4266 }
4267 manager.cleanup_old_sessions().expect("size cleanup");
4268 let listed = manager.list_sessions().expect("sessions");
4269 assert_eq!(
4270 listed.len(),
4271 MAX_SESSIONS + 1,
4272 "the archived record stays listed outside the active cap"
4273 );
4274 let retained = listed
4275 .iter()
4276 .find(|session| session.id == id)
4277 .expect("archived record remains on disk");
4278 assert!(
4279 retained.archived,
4280 "a transcript past the cap is archived, never unlinked (#6136)"
4281 );
4282 assert!(
4283 manager
4284 .validated_session_path(id)
4285 .expect("snapshot path")
4286 .exists(),
4287 "the transcript file survives retention"
4288 );
4289 }
4290 let (ledger, _) = manager.late_usage_paths(id).expect("ledger paths");
4291 assert!(!SessionManager::late_usage_is_deleted(&ledger).expect("origin retained"));
4292 assert!(ledger.exists(), "recovery must retain accounting");
4293 assert!(
4294 evidence.join("receipt.txt").exists(),
4295 "recovery must retain evidence"
4296 );
4297 assert_eq!(
4298 manager
4299 .with_live_session_origin(id, || true)
4300 .expect("resume admission"),
4301 Some(true)
4302 );
4303 let mut recovered = if checkpoint_kind == "session" {
4304 manager
4305 .load_session_checkpoint(id)
4306 .expect("checkpoint read")
4307 } else {
4308 manager.load_legacy_checkpoint().expect("legacy read")
4309 }
4310 .expect("retained recovery");
4311 assert_eq!(
4312 recovered.metadata.total_tokens, 1,
4313 "checkpoint overlays exact origin usage"
4314 );
4315 recovered.metadata.updated_at = Utc::now();
4316 manager
4317 .save_session(&recovered)
4318 .expect("save resumed origin");
4319 assert_eq!(
4320 manager
4321 .load_session_snapshot(id)
4322 .expect("resumed snapshot")
4323 .metadata
4324 .total_tokens,
4325 1,
4326 "replayed recovery accounting remains idempotent"
4327 );
4328 }
4329 }
4330 }
4331
4332 #[test]
4333 fn interrupted_session_deletion_keeps_recovery_incomplete_and_can_finish() {
4334 let tmp = tempdir().expect("tempdir");
4335 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
4336 let saved = save_late_usage_test_session(&manager, "interrupted-delete");
4337 manager
4338 .save_checkpoint(&saved)
4339 .expect("checkpoint before deletion");
4340 write_atomic(
4341 &manager.checkpoints_dir().join(LEGACY_CHECKPOINT_FILE),
4342 serialize_saved_session(saved.clone())
4343 .expect("legacy bytes")
4344 .as_bytes(),
4345 )
4346 .expect("legacy checkpoint before deletion");
4347 let (ledger, _) = manager
4348 .ensure_late_usage_paths("interrupted-delete")
4349 .expect("paths");
4350 write_atomic(&ledger.with_extension("deleted"), LATE_USAGE_DELETED)
4351 .expect("crash after tombstone");
4352 let recovered = manager
4353 .load_session_snapshot("interrupted-delete")
4354 .expect("transcript remains recoverable");
4355 assert!(
4356 recovered
4357 .metadata
4358 .cost
4359 .unpriced_reasons
4360 .contains(LATE_USAGE_UNAVAILABLE_REASON)
4361 );
4362 assert!(
4363 manager
4364 .load_session_checkpoint("interrupted-delete")
4365 .expect("checkpoint read")
4366 .is_none(),
4367 "a checkpoint retired before a crash must not be offered for recovery"
4368 );
4369 assert!(
4370 manager
4371 .load_legacy_checkpoint()
4372 .expect("legacy read")
4373 .is_none()
4374 );
4375 manager
4376 .delete_session("interrupted-delete")
4377 .expect("finish deletion");
4378 assert!(manager.list_sessions().expect("list").is_empty());
4379 assert!(manager.list_checkpoints().expect("checkpoints").is_empty());
4380 }
4381
4382 #[test]
4383 #[ignore = "subprocess helper for the late usage deletion regression"]
4384 fn late_usage_callback_subprocess() {
4385 let directory = PathBuf::from(
4386 std::env::var_os("CODEWHALE_LATE_USAGE_TEST_DIR").expect("fixture directory"),
4387 );
4388 let manager = SessionManager::new(directory.join("sessions")).expect("manager");
4389 manager
4390 .persist_late_runtime_usage(
4391 "process-delete-race",
4392 "turn",
4393 &late_usage_test_record("first-callback"),
4394 )
4395 .expect("first callback");
4396 fs::write(directory.join("ready"), b"ready").expect("signal ready");
4397 let started = std::time::Instant::now();
4398 while !directory.join("continue").exists() {
4399 assert!(
4400 started.elapsed() < std::time::Duration::from_secs(10),
4401 "callback gate timed out"
4402 );
4403 std::thread::sleep(std::time::Duration::from_millis(5));
4404 }
4405 assert!(
4406 manager
4407 .persist_late_runtime_usage(
4408 "process-delete-race",
4409 "turn",
4410 &late_usage_test_record("late-callback")
4411 )
4412 .expect("retired callback")
4413 );
4414 }
4415
4416 #[test]
4417 fn late_usage_callback_in_another_process_cannot_resurrect_deleted_session() {
4418 let tmp = tempdir().expect("tempdir");
4419 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
4420 save_late_usage_test_session(&manager, "process-delete-race");
4421 let mut child =
4422 std::process::Command::new(std::env::current_exe().expect("test executable"))
4423 .args([
4424 "--exact",
4425 "session_manager::tests::late_usage_callback_subprocess",
4426 "--ignored",
4427 ])
4428 .env("CODEWHALE_LATE_USAGE_TEST_DIR", tmp.path())
4429 .stdout(std::process::Stdio::null())
4430 .stderr(std::process::Stdio::null())
4431 .spawn()
4432 .expect("callback process");
4433 let started = std::time::Instant::now();
4434 while !tmp.path().join("ready").exists() {
4435 if started.elapsed() >= std::time::Duration::from_secs(10)
4436 || child.try_wait().expect("child status").is_some()
4437 {
4438 let _ = child.kill();
4439 let _ = child.wait();
4440 panic!("callback process did not reach the deletion gate");
4441 }
4442 std::thread::sleep(std::time::Duration::from_millis(5));
4443 }
4444 assert_eq!(
4445 manager
4446 .load_session_snapshot("process-delete-race")
4447 .expect("first callback persisted")
4448 .metadata
4449 .total_tokens,
4450 1
4451 );
4452 let deleted = manager.delete_session("process-delete-race");
4453 fs::write(tmp.path().join("continue"), b"continue").expect("release callback");
4454 let status = loop {
4455 if let Some(status) = child.try_wait().expect("child status") {
4456 break status;
4457 }
4458 if started.elapsed() >= std::time::Duration::from_secs(10) {
4459 let _ = child.kill();
4460 let _ = child.wait();
4461 panic!("late callback process did not finish");
4462 }
4463 std::thread::sleep(std::time::Duration::from_millis(5));
4464 };
4465 deleted.expect("delete while callback process was pending");
4466 assert!(status.success(), "callback process failed");
4467 let (ledger, _) = manager
4468 .late_usage_paths("process-delete-race")
4469 .expect("paths");
4470 assert!(!ledger.exists());
4471 assert!(manager.list_sessions().expect("list").is_empty());
4472 }
4473
4474 #[test]
4475 fn late_usage_sidecar_survives_stale_session_save_and_replays_once() {
4476 let tmp = tempdir().expect("tempdir");
4477 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
4478 let old_id = "old-session";
4479 let new_id = "new-session";
4480 let old = create_saved_session_with_id_and_mode(
4481 old_id.to_string(),
4482 &[make_test_message("user", "old session")],
4483 "deepseek-v4-flash",
4484 tmp.path(),
4485 0,
4486 None,
4487 Some("agent"),
4488 );
4489 let new = create_saved_session_with_id_and_mode(
4490 new_id.to_string(),
4491 &[make_test_message("user", "new session")],
4492 "deepseek-v4-flash",
4493 tmp.path(),
4494 0,
4495 None,
4496 Some("agent"),
4497 );
4498 manager.save_session(&old).expect("save old");
4499 manager.save_session(&new).expect("save new");
4500
4501 let priced_route = crate::cost_status::EffectiveRouteEnvelope::capture(
4502 None,
4503 ApiProvider::Deepseek,
4504 "deepseek",
4505 "deepseek-v4-flash",
4506 Some(crate::config::DEFAULT_DEEPSEEK_BASE_URL),
4507 Utc::now(),
4508 );
4509 let usage = codewhale_models::Usage {
4510 input_tokens: 17,
4511 output_tokens: 5,
4512 ..codewhale_models::Usage::default()
4513 };
4514 let usage_record = crate::cost_status::RuntimeUsageRecord {
4515 source_id: "translation:old-turn:assistant:1".to_string(),
4516 usage: crate::cost_status::EffectiveRouteUsage {
4517 route: priced_route.clone(),
4518 usage: usage.clone(),
4519 },
4520 };
4521 let missing_record = crate::cost_status::RuntimeUsageDropRecord {
4522 source_id: "advisor:old-turn:provider-response:0".to_string(),
4523 route: priced_route,
4524 };
4525 let mut subscription_route = missing_record.route.clone();
4526 subscription_route.billing_mode = crate::cost_status::RouteBillingMode::Subscription;
4527 let subscription_missing = crate::cost_status::RuntimeUsageDropRecord {
4528 source_id: "translation:old-turn:thinking:2".to_string(),
4529 route: subscription_route,
4530 };
4531
4532 for _ in 0..2 {
4533 assert!(
4534 manager
4535 .persist_late_runtime_usage(old_id, "old-turn", &usage_record)
4536 .expect("persist late usage")
4537 );
4538 assert!(
4539 manager
4540 .persist_late_runtime_drop(old_id, "old-turn", &missing_record)
4541 .expect("persist missing usage")
4542 );
4543 assert!(
4544 manager
4545 .persist_late_runtime_drop(old_id, "old-turn", &subscription_missing)
4546 .expect("persist subscription missing usage")
4547 );
4548 }
4549
4550 // A concurrent stale whole-session writer cannot erase the independent
4551 // origin ledger. Loading overlays it once by stable response identity.
4552 manager.save_session(&old).expect("stale old-session save");
4553 let first = manager.load_session_snapshot(old_id).expect("load old");
4554 let second = manager.load_session_snapshot(old_id).expect("replay old");
4555 for loaded in [&first, &second] {
4556 assert_eq!(loaded.metadata.total_tokens, 22);
4557 assert_eq!(loaded.metadata.cost.unpriced_turns, 1);
4558 assert_eq!(loaded.metadata.cost.cny_unpriced_turns, 1);
4559 assert_eq!(loaded.metadata.cost.usage_source_fingerprints.len(), 3);
4560 assert!(
4561 loaded
4562 .metadata
4563 .cost
4564 .unpriced_reasons
4565 .contains("provider_success_missing_usage")
4566 );
4567 }
4568 assert_eq!(first.metadata.cost.priced_turns, 1);
4569
4570 let clean = manager.load_session_snapshot(new_id).expect("load new");
4571 assert_eq!(clean.metadata.total_tokens, 0);
4572 assert_eq!(clean.metadata.cost.priced_turns, 0);
4573 assert_eq!(clean.metadata.cost.unpriced_turns, 0);
4574 assert!(clean.metadata.cost.usage_source_fingerprints.is_empty());
4575
4576 let ledger = fs::read_to_string(
4577 manager
4578 .sessions_dir()
4579 .join(LATE_USAGE_DIR)
4580 .join(format!("{old_id}.json")),
4581 )
4582 .expect("late ledger");
4583 assert!(!ledger.contains("translation:old-turn"));
4584 assert!(!ledger.contains(crate::config::DEFAULT_DEEPSEEK_BASE_URL));
4585 #[cfg(unix)]
4586 {
4587 use std::os::unix::fs::PermissionsExt;
4588 let ledger_dir = manager.sessions_dir().join(LATE_USAGE_DIR);
4589 assert_eq!(
4590 fs::metadata(&ledger_dir)
4591 .expect("private sidecar directory")
4592 .permissions()
4593 .mode()
4594 & 0o777,
4595 0o700
4596 );
4597 for path in [
4598 ledger_dir.join(format!("{old_id}.json")),
4599 ledger_dir.join(format!("{old_id}.lock")),
4600 ] {
4601 assert_eq!(
4602 fs::metadata(path)
4603 .expect("private sidecar metadata")
4604 .permissions()
4605 .mode()
4606 & 0o777,
4607 0o600
4608 );
4609 }
4610 }
4611 }
4612
4613 #[test]
4614 fn late_usage_sidecar_has_a_bounded_fail_closed_overflow() {
4615 let tmp = tempdir().expect("tempdir");
4616 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
4617 let session_id = "bounded-session";
4618 let session = create_saved_session_with_id_and_mode(
4619 session_id.to_string(),
4620 &[make_test_message("user", "bounded session")],
4621 "local-model",
4622 tmp.path(),
4623 0,
4624 None,
4625 Some("agent"),
4626 );
4627 manager.save_session(&session).expect("save bounded");
4628 let mut route = crate::cost_status::EffectiveRouteEnvelope::capture(
4629 None,
4630 ApiProvider::Custom,
4631 "local-provider",
4632 "local-model",
4633 Some("http://127.0.0.1:11434/v1"),
4634 Utc::now(),
4635 );
4636 route.billing_mode = crate::cost_status::RouteBillingMode::Local;
4637 for index in 0..=MAX_LATE_USAGE_RECORDS_PER_SESSION {
4638 manager
4639 .persist_late_runtime_usage(
4640 session_id,
4641 "bounded-turn",
4642 &crate::cost_status::RuntimeUsageRecord {
4643 source_id: format!("late-bounded:{index}"),
4644 usage: crate::cost_status::EffectiveRouteUsage {
4645 route: route.clone(),
4646 usage: codewhale_models::Usage {
4647 input_tokens: 1,
4648 ..codewhale_models::Usage::default()
4649 },
4650 },
4651 },
4652 )
4653 .expect("bounded append");
4654 }
4655
4656 let loaded = manager
4657 .load_session_snapshot(session_id)
4658 .expect("load bounded");
4659 assert_eq!(
4660 loaded.metadata.total_tokens,
4661 u64::try_from(MAX_LATE_USAGE_RECORDS_PER_SESSION).unwrap_or(u64::MAX)
4662 );
4663 assert_eq!(loaded.metadata.cost.unpriced_turns, 1);
4664 assert!(
4665 loaded
4666 .metadata
4667 .cost
4668 .unpriced_reasons
4669 .contains("late_usage_ledger_overflow")
4670 );
4671 let ledger = manager.load_late_usage(session_id).expect("bounded ledger");
4672 assert_eq!(ledger.records.len(), MAX_LATE_USAGE_RECORDS_PER_SESSION);
4673 assert!(ledger.overflowed);
4674 }
4675
4676 #[cfg(unix)]
4677 #[test]
4678 fn late_usage_sidecar_rejects_linked_lock_and_ledger_leaves() {
4679 use std::os::unix::fs::symlink;
4680
4681 let tmp = tempdir().expect("tempdir");
4682 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
4683 let session_id = "linked-sidecar-session";
4684 save_late_usage_test_session(&manager, session_id);
4685 save_late_usage_test_session(&manager, "unaffected-sidecar-session");
4686 let (ledger_path, lock_path) = manager.ensure_late_usage_paths(session_id).expect("paths");
4687 let route = crate::cost_status::EffectiveRouteEnvelope::capture(
4688 None,
4689 ApiProvider::Deepseek,
4690 "deepseek",
4691 "deepseek-v4-flash",
4692 Some(crate::config::DEFAULT_DEEPSEEK_BASE_URL),
4693 Utc::now(),
4694 );
4695 let record = crate::cost_status::RuntimeUsageRecord {
4696 source_id: "linked-sidecar-response".to_string(),
4697 usage: crate::cost_status::EffectiveRouteUsage {
4698 route,
4699 usage: codewhale_models::Usage {
4700 input_tokens: 1,
4701 ..codewhale_models::Usage::default()
4702 },
4703 },
4704 };
4705
4706 let outside_lock = tmp.path().join("outside.lock");
4707 fs::write(&outside_lock, b"outside-lock").expect("outside lock");
4708 fs::remove_file(&lock_path).expect("replace fixture lifecycle lock");
4709 symlink(&outside_lock, &lock_path).expect("symlink lock");
4710 assert!(
4711 manager
4712 .persist_late_runtime_usage(session_id, "turn", &record)
4713 .is_err(),
4714 "a symlink lock leaf must fail closed"
4715 );
4716 assert_eq!(
4717 fs::read(&outside_lock).expect("outside lock unchanged"),
4718 b"outside-lock"
4719 );
4720 fs::remove_file(&lock_path).expect("remove lock symlink");
4721
4722 fs::hard_link(&outside_lock, &lock_path).expect("hard-linked lock");
4723 assert!(
4724 manager
4725 .persist_late_runtime_usage(session_id, "turn", &record)
4726 .is_err(),
4727 "a multiply linked lock leaf must fail closed"
4728 );
4729 fs::remove_file(&lock_path).expect("remove hard-linked lock");
4730
4731 let outside_ledger = tmp.path().join("outside.json");
4732 fs::write(
4733 &outside_ledger,
4734 br#"{"schema_version":1,"records":[],"overflowed":false}"#,
4735 )
4736 .expect("outside ledger");
4737 symlink(&outside_ledger, &ledger_path).expect("symlink ledger");
4738 assert!(
4739 manager.load_late_usage(session_id).is_err(),
4740 "a symlink ledger leaf must fail closed"
4741 );
4742 assert!(
4743 manager
4744 .load_session_snapshot(session_id)
4745 .expect("recover linked ledger transcript")
4746 .metadata
4747 .cost
4748 .unpriced_reasons
4749 .contains(LATE_USAGE_UNAVAILABLE_REASON)
4750 );
4751 fs::remove_file(&ledger_path).expect("remove ledger symlink");
4752
4753 fs::hard_link(&outside_ledger, &ledger_path).expect("hard-linked ledger");
4754 assert!(
4755 manager.load_late_usage(session_id).is_err(),
4756 "a multiply linked ledger leaf must fail closed"
4757 );
4758 assert_eq!(
4759 manager
4760 .list_sessions()
4761 .expect("list linked ledger transcript")
4762 .len(),
4763 2
4764 );
4765 assert_eq!(
4766 manager
4767 .load_session_by_prefix("unaffected")
4768 .expect("unaffected resume")
4769 .metadata
4770 .cost
4771 .unpriced_turns,
4772 0
4773 );
4774 assert_eq!(
4775 fs::read(&outside_ledger).expect("outside ledger unchanged"),
4776 br#"{"schema_version":1,"records":[],"overflowed":false}"#
4777 );
4778 }
4779
4780 fn container_with(messages: Vec<Message>, dir: &std::path::Path) -> SessionImportContainer {
4781 let session = create_saved_session(&messages, "test-model", dir, 100, None);
4782 session.export_container("test-session.json")
4783 }
4784
4785 #[test]
4786 fn session_goal_sidecar_round_trips_control_state_without_model_output() {
4787 let tmp = tempdir().expect("tempdir");
4788 let sessions_dir = tmp.path().join("sessions");
4789 let manager = SessionManager::new(sessions_dir.clone()).expect("manager");
4790 let session_id = "11111111-2222-4333-8444-555555555555";
4791 let runtime = GoalSnapshot {
4792 goal_id: None,
4793 objective: Some("finish the provider migration".to_string()),
4794 status: "paused".to_string(),
4795 token_budget: Some(50_000),
4796 tokens_used: 12_345,
4797 time_used_seconds: 67,
4798 continuation_count: 4,
4799 elapsed_seconds: Some(91),
4800 evidence: Some("Bearer credential-shaped-model-output".to_string()),
4801 blocker: Some("/arbitrary/private/path".to_string()),
4802 pause_reason: Some(GoalPauseReason::User),
4803 completion_verification: None,
4804 advisories: Vec::new(),
4805 last_gap_fingerprint: None,
4806 repeated_gap_count: 0,
4807 last_gap_pass: None,
4808 progress: None,
4809 };
4810 let durable = SessionGoalState::from_runtime(&runtime)
4811 .expect("valid runtime goal")
4812 .expect("non-empty durable goal");
4813
4814 manager
4815 .save_session_goal(session_id, Some(&durable))
4816 .expect("save goal");
4817 let raw = fs::read_to_string(
4818 sessions_dir
4819 .join(SESSION_GOALS_DIR)
4820 .join(format!("{session_id}.json")),
4821 )
4822 .expect("read goal sidecar");
4823 assert!(!raw.contains("credential-shaped-model-output"));
4824 assert!(!raw.contains("/arbitrary/private/path"));
4825
4826 let reopened = SessionManager::new(sessions_dir).expect("reopen manager");
4827 let restored = reopened
4828 .load_session_goal(session_id)
4829 .expect("load goal")
4830 .expect("persisted goal");
4831 assert_eq!(restored, durable);
4832 assert_eq!(restored.to_runtime_snapshot().objective, runtime.objective);
4833 assert_eq!(restored.to_runtime_snapshot().status, "paused");
4834
4835 reopened
4836 .save_session_goal(session_id, None)
4837 .expect("clear goal");
4838 assert_eq!(
4839 reopened.load_session_goal(session_id).expect("load clear"),
4840 None
4841 );
4842 }
4843
4844 /// Coverage state round-trips with the money it qualifies, and a session
4845 /// written before coverage existed is detected as *unknown* rather than being
4846 /// read as a complete total covering zero turns (#4318).
4847 #[test]
4848 fn cost_snapshot_round_trips_coverage_and_detects_legacy_unknown() {
4849 // A pre-coverage row: real money, no coverage fields at all.
4850 let legacy: SessionCostSnapshot = serde_json::from_value(serde_json::json!({
4851 "session_cost_usd": 1.25,
4852 "session_cost_cny": 0.0,
4853 "subagent_cost_usd": 0.0,
4854 "subagent_cost_cny": 0.0,
4855 "displayed_cost_high_water_usd": 1.25,
4856 "displayed_cost_high_water_cny": 0.0
4857 }))
4858 .expect("legacy cost snapshot stays readable");
4859 assert_eq!(legacy.priced_turns, 0);
4860 assert_eq!(legacy.unpriced_turns, 0);
4861 assert!(!legacy.coverage_recorded);
4862 assert!(
4863 legacy.coverage_is_legacy_unknown(),
4864 "a non-zero total with no coverage evidence must not read as complete"
4865 );
4866
4867 // An all-zero pre-coverage session is still unknown: zero may mean no
4868 // turns, all unpriced turns, or exact zero usage. Absence of evidence is
4869 // never rewritten into a complete 0/0 claim.
4870 let empty = SessionCostSnapshot::default();
4871 assert!(empty.coverage_is_legacy_unknown());
4872
4873 // A coverage-aware writer that recorded zero money-metered turns is also
4874 // not unknown — it positively knows the answer is zero.
4875 let recorded_zero = SessionCostSnapshot {
4876 session_cost_usd: 1.25,
4877 coverage_recorded: true,
4878 ..SessionCostSnapshot::default()
4879 };
4880 assert!(!recorded_zero.coverage_is_legacy_unknown());
4881
4882 // Full round-trip of every coverage field.
4883 let full = SessionCostSnapshot {
4884 session_cost_usd: 2.5,
4885 session_cost_cny: 3.0,
4886 subagent_cost_usd: 0.5,
4887 subagent_cost_cny: 0.25,
4888 displayed_cost_high_water_usd: 3.0,
4889 displayed_cost_high_water_cny: 3.25,
4890 priced_turns: 7,
4891 unpriced_turns: 2,
4892 cny_priced_turns: 1,
4893 cny_unpriced_turns: 8,
4894 unpriced_reasons: ["missing_class_price".to_string()].into(),
4895 cny_unpriced_reasons: ["currency_not_published".to_string()].into(),
4896 unpriced_classes: ["cache_write".to_string()].into(),
4897 pricing_provenances: ["models_dev_bundled".to_string()].into(),
4898 live_pricing_defects: ["live_pricing_stale".to_string()].into(),
4899 live_pricing_unusable_defects: ["live_pricing_scope_mismatch".to_string()].into(),
4900 route_receipts: ["provider=anthropic identity=- model=claude-haiku-4-5 \
4901 surface=first-party-payg endpoint_fp=abc123 currency=usd"
4902 .to_string()]
4903 .into(),
4904 usage_source_fingerprints: ["response-fingerprint".to_string()].into(),
4905 coverage_recorded: true,
4906 };
4907 let json = serde_json::to_string(&full).expect("serialize");
4908 let back: SessionCostSnapshot = serde_json::from_str(&json).expect("round-trip");
4909 assert_eq!(back.priced_turns, 7);
4910 assert_eq!(back.unpriced_turns, 2);
4911 assert_eq!(back.cny_priced_turns, 1);
4912 assert_eq!(back.cny_unpriced_turns, 8);
4913 assert_eq!(back.unpriced_reasons, full.unpriced_reasons);
4914 assert_eq!(back.cny_unpriced_reasons, full.cny_unpriced_reasons);
4915 assert_eq!(back.unpriced_classes, full.unpriced_classes);
4916 assert_eq!(back.pricing_provenances, full.pricing_provenances);
4917 assert_eq!(back.live_pricing_defects, full.live_pricing_defects);
4918 assert_eq!(
4919 back.usage_source_fingerprints,
4920 full.usage_source_fingerprints
4921 );
4922 assert_eq!(
4923 back.live_pricing_unusable_defects,
4924 full.live_pricing_unusable_defects
4925 );
4926 assert_eq!(back.route_receipts, full.route_receipts);
4927 assert!(back.coverage_recorded);
4928 assert!(!back.coverage_is_legacy_unknown());
4929
4930 // The persisted receipts carry no endpoint URL or credential.
4931 let lower = json.to_lowercase();
4932 for needle in ["http", "api_key", "authorization", "bearer", "sk-"] {
4933 assert!(!lower.contains(needle), "{needle} leaked into {json}");
4934 }
4935 }
4936
4937 /// The USD and CNY totals a snapshot reports are projections of one
4938 /// dual-currency accumulation, never two independent sums that could
4939 /// disagree (#4939).
4940 ///
4941 /// For any turn sequence — dual-priced, USD-only, CNY-only, or garbage
4942 /// estimates — folding the turns jointly and projecting each currency must
4943 /// equal accumulating that currency on its own. This is the invariant that
4944 /// makes the persisted per-currency columns safe: they are written from the
4945 /// same joint fold, so a code path can no longer update one and forget the
4946 /// other. CNY is derived from provider-published CNY rows, not from an FX
4947 /// multiple of USD, so a USD-only turn must contribute exactly zero CNY.
4948 #[test]
4949 fn cost_snapshot_currency_totals_are_projections_of_one_accumulator() {
4950 use crate::pricing::CostEstimate;
4951
4952 let turn_sequences: &[&[CostEstimate]] = &[
4953 // Dual-priced turns (DeepSeek-style routes with a published CNY row).
4954 &[
4955 CostEstimate {
4956 usd: 0.01,
4957 cny: 0.07,
4958 },
4959 CostEstimate {
4960 usd: 0.02,
4961 cny: 0.14,
4962 },
4963 ],
4964 // USD-only turns: CNY unpublished, so the CNY projection stays zero.
4965 &[
4966 CostEstimate {
4967 usd: 0.25,
4968 cny: 0.0,
4969 },
4970 CostEstimate { usd: 1.5, cny: 0.0 },
4971 ],
4972 // Mixed: one currency priced per turn, alternating.
4973 &[
4974 CostEstimate { usd: 0.5, cny: 0.0 },
4975 CostEstimate { usd: 0.0, cny: 3.5 },
4976 CostEstimate {
4977 usd: 0.125,
4978 cny: 0.875,
4979 },
4980 ],
4981 // Hostile values: sanitization must apply identically per currency.
4982 &[
4983 CostEstimate {
4984 usd: f64::NAN,
4985 cny: 0.25,
4986 },
4987 CostEstimate {
4988 usd: 0.75,
4989 cny: -1.0,
4990 },
4991 CostEstimate {
4992 usd: f64::INFINITY,
4993 cny: 0.25,
4994 },
4995 ],
4996 ];
4997
4998 for turns in turn_sequences {
4999 // Joint fold: how the app accumulates (one accumulator, both
5000 // currencies advance together through the same saturating_add).
5001 let joint = turns.iter().fold(CostEstimate::default(), |acc, turn| {
5002 acc.saturating_add(*turn)
5003 });
5004
5005 // Independent per-currency folds: what a drifted parallel
5006 // accumulator would compute if it only saw one currency.
5007 let usd_alone = turns.iter().fold(CostEstimate::default(), |acc, turn| {
5008 acc.saturating_add(CostEstimate {
5009 usd: turn.usd,
5010 cny: 0.0,
5011 })
5012 });
5013 let cny_alone = turns.iter().fold(CostEstimate::default(), |acc, turn| {
5014 acc.saturating_add(CostEstimate {
5015 usd: 0.0,
5016 cny: turn.cny,
5017 })
5018 });
5019
5020 let snapshot = SessionCostSnapshot {
5021 session_cost_usd: joint.usd,
5022 session_cost_cny: joint.cny,
5023 ..SessionCostSnapshot::default()
5024 };
5025 assert_eq!(
5026 snapshot.total_usd(),
5027 usd_alone.usd,
5028 "USD projection drifted from independent accumulation for {turns:?}"
5029 );
5030 assert_eq!(
5031 snapshot.total_cny(),
5032 cny_alone.cny,
5033 "CNY projection drifted from independent accumulation for {turns:?}"
5034 );
5035 assert_eq!(snapshot.total_estimate().usd, snapshot.total_usd());
5036 assert_eq!(snapshot.total_estimate().cny, snapshot.total_cny());
5037 }
5038
5039 // A USD-only session projects zero CNY — no fabricated FX conversion —
5040 // and the subagent column joins the same fold.
5041 let usd_only = SessionCostSnapshot {
5042 session_cost_usd: 2.5,
5043 subagent_cost_usd: 0.5,
5044 ..SessionCostSnapshot::default()
5045 };
5046 assert_eq!(usd_only.total_usd(), 3.0);
5047 assert_eq!(usd_only.total_cny(), 0.0);
5048 }
5049
5050 fn write_session_record(
5051 manager: &SessionManager,
5052 id: &str,
5053 workspace: &Path,
5054 updated_at: DateTime<Utc>,
5055 ) {
5056 let session = SavedSession {
5057 schema_version: CURRENT_SESSION_SCHEMA_VERSION,
5058 messages: vec![make_test_message("user", "hi")],
5059 metadata: SessionMetadata {
5060 id: id.to_string(),
5061 title: format!("session-{id}"),
5062 created_at: updated_at,
5063 updated_at,
5064 message_count: 1,
5065 total_tokens: 0,
5066 model: "deepseek-v4-flash".to_string(),
5067 model_provider: "deepseek".to_string(),
5068 model_provider_id: None,
5069 workspace: workspace.to_path_buf(),
5070 mode: None,
5071 cost: SessionCostSnapshot::default(),
5072 parent_session_id: None,
5073 forked_from_message_count: None,
5074 runtime_store: None,
5075 cumulative_turn_secs: 0,
5076 archived: false,
5077 spawn_depth: 0,
5078 },
5079 journal: None,
5080 leaf_id: None,
5081 system_prompt: None,
5082 context_references: Vec::new(),
5083 artifacts: Vec::new(),
5084 approval_receipts: Vec::new(),
5085 work_state: None,
5086 window_title: None,
5087 last_auto_route: None,
5088 };
5089 manager.save_session(&session).expect("save");
5090 }
5091
5092 fn write_empty_session_record(
5093 manager: &SessionManager,
5094 id: &str,
5095 workspace: &Path,
5096 updated_at: DateTime<Utc>,
5097 ) {
5098 let session = SavedSession {
5099 schema_version: CURRENT_SESSION_SCHEMA_VERSION,
5100 messages: Vec::new(),
5101 metadata: SessionMetadata {
5102 id: id.to_string(),
5103 title: DEFAULT_SESSION_TITLE.to_string(),
5104 created_at: updated_at,
5105 updated_at,
5106 message_count: 0,
5107 total_tokens: 0,
5108 model: "deepseek-v4-pro".to_string(),
5109 model_provider: "deepseek".to_string(),
5110 model_provider_id: None,
5111 workspace: workspace.to_path_buf(),
5112 mode: Some("yolo".to_string()),
5113 cost: SessionCostSnapshot::default(),
5114 parent_session_id: None,
5115 forked_from_message_count: None,
5116 runtime_store: None,
5117 cumulative_turn_secs: 0,
5118 archived: false,
5119 spawn_depth: 0,
5120 },
5121 journal: None,
5122 leaf_id: None,
5123 system_prompt: None,
5124 context_references: Vec::new(),
5125 artifacts: Vec::new(),
5126 approval_receipts: Vec::new(),
5127 work_state: None,
5128 window_title: None,
5129 last_auto_route: None,
5130 };
5131 manager.save_session(&session).expect("save empty");
5132 }
5133
5134 // === session retention and independent runtime data ===
5135
5136 #[test]
5137 fn cleanup_preserves_artifacts_without_a_session_snapshot() {
5138 let tmp = tempdir().expect("tempdir");
5139 let manager = SessionManager::new(tmp.path().to_path_buf()).expect("manager");
5140 let workspace = tmp.path().join("ws");
5141
5142 let orphan = "11111111-1111-4111-8111-111111111111";
5143 let live = "22222222-2222-4222-8222-222222222222";
5144 for id in [orphan, live] {
5145 let artifacts = tmp.path().join(id).join("artifacts");
5146 fs::create_dir_all(&artifacts).expect("artifact dir");
5147 fs::write(artifacts.join("art_evidence.txt"), b"stdout").expect("artifact");
5148 }
5149 // Only `live` still has a session document.
5150 write_session_record(&manager, live, &workspace, Utc::now());
5151
5152 manager.cleanup_old_sessions().expect("cleanup");
5153
5154 assert!(
5155 tmp.path()
5156 .join(orphan)
5157 .join("artifacts/art_evidence.txt")
5158 .exists(),
5159 "an absent snapshot does not authorize deleting independent evidence"
5160 );
5161 assert!(
5162 tmp.path().join(live).join("artifacts").exists(),
5163 "a directory whose session still exists must be left alone"
5164 );
5165 }
5166
5167 #[tokio::test]
5168 async fn cleanup_in_another_process_preserves_runtime_without_a_snapshot() {
5169 const PROBE: &str = "CODEWHALE_RUNTIME_RETENTION_PROBE";
5170 if let Some(directory) = std::env::var_os(PROBE) {
5171 let manager = SessionManager::new(PathBuf::from(directory)).expect("child manager");
5172 manager.cleanup_old_sessions().expect("child retention");
5173 return;
5174 }
5175 let tmp = tempdir().expect("tempdir");
5176 let sessions = tmp.path().join("sessions");
5177 let manager = SessionManager::new(sessions.clone()).expect("manager");
5178 let runtime = sessions.join("44444444-4444-4444-8444-444444444444/runtime");
5179 let store = crate::runtime_threads::RuntimeThreadStore::open(runtime.clone())
5180 .expect("live automation store");
5181 let first = store
5182 .append_event(
5183 "thread_probe",
5184 None,
5185 None,
5186 "probe",
5187 serde_json::json!({"step": 1}),
5188 )
5189 .await
5190 .expect("first durable event");
5191 let run_cleanup = || {
5192 let output = std::process::Command::new(std::env::current_exe().expect("test executable"))
5193 .args([
5194 "--exact",
5195 "session_manager::tests::cleanup_in_another_process_preserves_runtime_without_a_snapshot",
5196 "--nocapture",
5197 ])
5198 .env(PROBE, &sessions)
5199 .output()
5200 .expect("independent cleanup process");
5201 assert!(
5202 output.status.success(),
5203 "{}\n{}",
5204 String::from_utf8_lossy(&output.stdout),
5205 String::from_utf8_lossy(&output.stderr)
5206 );
5207 assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"));
5208 };
5209 assert!(
5210 manager
5211 .list_sessions()
5212 .expect("no interactive snapshot")
5213 .is_empty()
5214 );
5215 run_cleanup();
5216 assert_eq!(
5217 store.current_seq().await.expect("live cursor survives"),
5218 first.seq
5219 );
5220 drop(store);
5221 // A closed store can still own resumable events. It is not garbage
5222 // simply because no process or interactive transcript claims it.
5223 run_cleanup();
5224 let reopened = crate::runtime_threads::RuntimeThreadStore::open(runtime)
5225 .expect("reopen preserved automation store");
5226 assert_eq!(
5227 reopened.current_seq().await.expect("recovered cursor"),
5228 first.seq
5229 );
5230 let next = reopened
5231 .append_event(
5232 "thread_probe",
5233 None,
5234 None,
5235 "probe",
5236 serde_json::json!({"step": 2}),
5237 )
5238 .await
5239 .expect("continue recovered event sequence");
5240 assert_eq!(next.seq, first.seq + 1);
5241 }
5242
5243 #[test]
5244 fn a_crashed_sessions_evidence_survives_even_without_its_document() {
5245 // Recovery reads exactly this: a checkpoint with no session document.
5246 // Reclaiming its evidence would delete what recovery needs.
5247 let tmp = tempdir().expect("tempdir");
5248 let manager = SessionManager::new(tmp.path().to_path_buf()).expect("manager");
5249 let crashed = "33333333-3333-4333-8333-333333333333";
5250
5251 fs::create_dir_all(tmp.path().join(crashed).join("artifacts")).expect("artifacts");
5252 let checkpoints = tmp.path().join("checkpoints");
5253 fs::create_dir_all(&checkpoints).expect("checkpoints dir");
5254 fs::write(checkpoints.join(format!("{crashed}.json")), b"{}").expect("checkpoint");
5255
5256 manager.cleanup_old_sessions().expect("cleanup");
5257
5258 assert!(
5259 tmp.path().join(crashed).exists(),
5260 "a crashed session's evidence must outlive its missing document"
5261 );
5262 }
5263
5264 #[test]
5265 fn reclamation_never_touches_bookkeeping_directories() {
5266 let tmp = tempdir().expect("tempdir");
5267 let manager = SessionManager::new(tmp.path().to_path_buf()).expect("manager");
5268 // `checkpoints` is not a session id and must survive being empty.
5269 let checkpoints = tmp.path().join("checkpoints");
5270 fs::create_dir_all(&checkpoints).expect("checkpoints dir");
5271 let not_a_session = tmp.path().join("some-user-folder");
5272 fs::create_dir_all(&not_a_session).expect("user dir");
5273
5274 manager.cleanup_old_sessions().expect("cleanup");
5275
5276 assert!(checkpoints.exists(), "checkpoints/ is not a session dir");
5277 assert!(
5278 not_a_session.exists(),
5279 "a name that is not a valid session id is not ours to remove"
5280 );
5281 }
5282
5283 #[test]
5284 fn save_and_resume_reconstructs_closed_and_interrupted_approvals() {
5285 let tmp = tempdir().expect("tempdir");
5286 let sessions_dir = tmp.path().join("sessions");
5287 let manager = SessionManager::new(sessions_dir.clone()).expect("manager");
5288 let session = create_saved_session(
5289 &[make_test_message("user", "approval recovery")],
5290 "test-model",
5291 tmp.path(),
5292 0,
5293 None,
5294 );
5295 let session_id = session.metadata.id.clone();
5296 let store = ApprovalReceiptStore::new(sessions_dir);
5297 store
5298 .append(
5299 &session_id,
5300 &ApprovalReceipt::asked("tool-complete", "exec_shell"),
5301 )
5302 .expect("persist completed ask");
5303 store
5304 .append(
5305 &session_id,
5306 &ApprovalReceipt::decided("tool-complete", ApprovalOutcome::Denied),
5307 )
5308 .expect("persist completed decision");
5309 store
5310 .append(
5311 &session_id,
5312 &ApprovalReceipt::asked("tool-interrupted", "write_file"),
5313 )
5314 .expect("persist interrupted ask");
5315
5316 manager.save_session(&session).expect("save session");
5317 let resumed = manager
5318 .load_session_snapshot(&session_id)
5319 .expect("resume session");
5320 let replay = ApprovalReplay::from_receipts(&resumed.approval_receipts)
5321 .expect("replay resumed approval evidence");
5322
5323 assert_eq!(resumed.messages, session.messages);
5324 assert_eq!(replay.completed.len(), 1);
5325 assert_eq!(replay.completed[0].outcome, ApprovalOutcome::Denied);
5326 assert_eq!(replay.unmatched_asks.len(), 1);
5327 assert!(matches!(
5328 &replay.unmatched_asks[0],
5329 ApprovalReceipt::Asked { tool_call_id, .. } if tool_call_id == "tool-interrupted"
5330 ));
5331 assert_eq!(
5332 manager
5333 .replay_approvals(&session_id)
5334 .expect("replay canonical sidecar"),
5335 replay
5336 );
5337 }
5338
5339 #[test]
5340 fn session_boot_owner_stamps_only_the_creating_instance() {
5341 let tmp = tempdir().expect("tempdir");
5342 let manager = SessionManager::new(tmp.path().to_path_buf()).expect("manager");
5343 let workspace = tmp.path().join("ws");
5344
5345 // A record this instance creates is stamped with this boot id and is
5346 // therefore not prior-instance work.
5347 write_session_record(&manager, "mine", &workspace, Utc::now());
5348 assert_eq!(
5349 manager.session_boot_owner("mine").as_deref(),
5350 Some(current_session_boot_id())
5351 );
5352 assert!(!manager.session_from_prior_instance("mine"));
5353
5354 // An id with no durable record at all is this instance's own
5355 // not-yet-persisted session.
5356 assert!(!manager.session_from_prior_instance("unsaved"));
5357
5358 // A record stamped by another boot id stays owned by that instance,
5359 // even after this instance re-serializes it (crash recovery must not
5360 // re-badge restored work as ours).
5361 manager
5362 .record_session_boot_owner("theirs", "boot_other_instance")
5363 .expect("stamp");
5364 write_session_record(&manager, "theirs", &workspace, Utc::now());
5365 assert_eq!(
5366 manager.session_boot_owner("theirs").as_deref(),
5367 Some("boot_other_instance")
5368 );
5369 assert!(manager.session_from_prior_instance("theirs"));
5370
5371 // A legacy record with no marker is classified as prior-instance
5372 // work, and a later re-save keeps it unclaimed.
5373 write_session_record(&manager, "legacy", &workspace, Utc::now());
5374 manager.clear_session_boot_owner("legacy");
5375 assert!(manager.session_from_prior_instance("legacy"));
5376 write_session_record(&manager, "legacy", &workspace, Utc::now());
5377 assert!(manager.session_from_prior_instance("legacy"));
5378
5379 // Deleting the record drops its marker.
5380 manager.delete_session("theirs").expect("delete");
5381 assert_eq!(manager.session_boot_owner("theirs"), None);
5382 }
5383
5384 #[test]
5385 fn session_boot_owner_sidecar_never_lists_as_a_session() {
5386 let tmp = tempdir().expect("tempdir");
5387 let manager = SessionManager::new(tmp.path().to_path_buf()).expect("manager");
5388 write_session_record(&manager, "real", &tmp.path().join("ws"), Utc::now());
5389 assert!(manager.session_boot_owners_path().exists());
5390 let listed = manager.list_sessions().expect("list");
5391 assert_eq!(listed.len(), 1);
5392 assert_eq!(listed[0].id, "real");
5393 // The reserved stem cannot be claimed as a session id either.
5394 assert!(manager.load_session("session_boot_owners").is_err());
5395 }
5396
5397 #[test]
5398 fn test_session_manager_new() {
5399 let tmp = tempdir().expect("tempdir");
5400 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
5401 assert!(tmp.path().join("sessions").exists());
5402 let _ = manager;
5403 }
5404
5405 #[test]
5406 fn test_save_and_load_session() {
5407 let tmp = tempdir().expect("tempdir");
5408 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
5409
5410 let messages = vec![
5411 make_test_message("user", "Hello!"),
5412 make_test_message("assistant", "Hi there!"),
5413 ];
5414
5415 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
5416 let session_id = session.metadata.id.clone();
5417
5418 manager.save_session(&session).expect("save");
5419
5420 let loaded = manager.load_session(&session_id).expect("load");
5421 assert_eq!(loaded.metadata.id, session_id);
5422 assert_eq!(loaded.messages.len(), 2);
5423 }
5424
5425 /// #4681: reopening a session must not surface `<turn_meta>` machine
5426 /// blocks in the transcript. Covers the current trailing shape and the
5427 /// legacy leading shape (sessions saved before the turn-meta tail move),
5428 /// while the loaded API history keeps both envelopes intact for replay.
5429 #[test]
5430 fn rehydrated_turn_meta_blocks_never_render_in_history_cells() {
5431 let tmp = tempdir().expect("tempdir");
5432 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
5433
5434 let turn_meta = "<turn_meta>\nCurrent local date: 2026-08-01\n</turn_meta>";
5435 let trailing_shape = Message {
5436 role: Role::User,
5437 content: vec![
5438 ContentBlock::Text {
5439 text: "Fix the flaky test".to_string(),
5440 cache_control: None,
5441 },
5442 ContentBlock::Text {
5443 text: turn_meta.to_string(),
5444 cache_control: None,
5445 },
5446 ],
5447 };
5448 let legacy_leading_shape = Message {
5449 role: Role::User,
5450 content: vec![
5451 ContentBlock::Text {
5452 text: turn_meta.to_string(),
5453 cache_control: None,
5454 },
5455 ContentBlock::Text {
5456 text: "Now add the docs".to_string(),
5457 cache_control: None,
5458 },
5459 ],
5460 };
5461 let messages = vec![
5462 trailing_shape,
5463 make_test_message("assistant", "Done."),
5464 legacy_leading_shape,
5465 ];
5466 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
5467 let session_id = session.metadata.id.clone();
5468 manager.save_session(&session).expect("save");
5469
5470 let loaded = manager.load_session(&session_id).expect("load");
5471
5472 // Display path: no rendered cell may carry turn_meta markup.
5473 let rendered: Vec<HistoryCell> = loaded
5474 .messages
5475 .iter()
5476 .flat_map(history_cells_from_message)
5477 .collect();
5478 let user_texts: Vec<&str> = rendered
5479 .iter()
5480 .filter_map(|cell| match cell {
5481 HistoryCell::User { content } => Some(content.as_str()),
5482 _ => None,
5483 })
5484 .collect();
5485 assert_eq!(user_texts, vec!["Fix the flaky test", "Now add the docs"]);
5486 assert!(
5487 !user_texts.iter().any(|text| text.contains("<turn_meta")),
5488 "rendered cells must not contain turn_meta markup: {user_texts:?}"
5489 );
5490
5491 // Model-facing replay: the persisted envelopes survive the round trip.
5492 let replayed_envelopes = loaded
5493 .messages
5494 .iter()
5495 .flat_map(|message| &message.content)
5496 .filter(|block| {
5497 matches!(block, ContentBlock::Text { text, .. } if text.contains("<turn_meta>"))
5498 })
5499 .count();
5500 assert_eq!(replayed_envelopes, 2);
5501 }
5502
5503 #[test]
5504 fn runtime_snapshot_load_preserves_in_flight_tool_call() {
5505 let tmp = tempdir().expect("tempdir");
5506 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
5507 let messages = vec![Message {
5508 role: Role::Assistant,
5509 content: vec![ContentBlock::ToolUse {
5510 id: "call-in-flight".to_string(),
5511 name: "read_file".to_string(),
5512 input: serde_json::json!({"path": "README.md"}),
5513 caller: None,
5514 thought_signature: None,
5515 }],
5516 }];
5517 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
5518 let session_id = session.metadata.id.clone();
5519 manager.save_session(&session).expect("save");
5520
5521 let loaded = manager
5522 .load_session_snapshot(&session_id)
5523 .expect("snapshot load");
5524
5525 assert_eq!(loaded.messages, messages);
5526 assert_eq!(loaded.metadata.message_count, 1);
5527 assert!(!loaded.messages.iter().any(|message| {
5528 message.content.iter().any(|block| {
5529 matches!(
5530 block,
5531 ContentBlock::ToolResult { content, .. }
5532 if content.contains("crashed_and_repaired")
5533 )
5534 })
5535 }));
5536 }
5537
5538 #[test]
5539 fn explicit_session_recovery_is_reported_and_idempotent_after_save() {
5540 let tmp = tempdir().expect("tempdir");
5541 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
5542 let messages = vec![Message {
5543 role: Role::Assistant,
5544 content: vec![ContentBlock::ToolUse {
5545 id: "call-crashed".to_string(),
5546 name: "read_file".to_string(),
5547 input: serde_json::json!({"path": "README.md"}),
5548 caller: None,
5549 thought_signature: None,
5550 }],
5551 }];
5552 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
5553 let session_id = session.metadata.id.clone();
5554 manager.save_session(&session).expect("save");
5555
5556 let recovered = manager
5557 .recover_session_for_resume(&session_id)
5558 .expect("recover");
5559 assert!(recovered.changed);
5560 assert_eq!(recovered.repaired_call_count, 1);
5561 assert_eq!(recovered.duplicate_result_count, 0);
5562 assert_eq!(recovered.orphan_result_count, 0);
5563 manager
5564 .save_session(&recovered.session)
5565 .expect("persist recovery");
5566
5567 let second = manager
5568 .recover_session_for_resume(&session_id)
5569 .expect("recover twice");
5570 assert!(!second.changed);
5571 assert_eq!(second.repaired_call_count, 0);
5572 assert_eq!(second.session.messages, recovered.session.messages);
5573 }
5574
5575 #[test]
5576 fn resume_session_persists_repair_once() {
5577 let tmp = tempdir().expect("tempdir");
5578 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
5579 let messages = vec![Message {
5580 role: Role::Assistant,
5581 content: vec![ContentBlock::ToolUse {
5582 id: "call-crashed".to_string(),
5583 name: "read_file".to_string(),
5584 input: serde_json::json!({"path": "README.md"}),
5585 caller: None,
5586 thought_signature: None,
5587 }],
5588 }];
5589 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
5590 let session_id = session.metadata.id.clone();
5591 manager.save_session(&session).expect("save");
5592
5593 let first = manager.resume_session(&session_id).expect("first resume");
5594 assert!(first.changed);
5595 assert_eq!(first.repaired_call_count, 1);
5596
5597 // The repair is already durable: a second resume finds a clean record
5598 // instead of re-running and re-logging the same repair on every load.
5599 let second = manager.resume_session(&session_id).expect("second resume");
5600 assert!(!second.changed);
5601 assert_eq!(second.repaired_call_count, 0);
5602 assert_eq!(second.session.messages, first.session.messages);
5603 }
5604
5605 #[test]
5606 fn load_session_repairs_dangling_tool_call_with_visible_receipt() {
5607 let tmp = tempdir().expect("tempdir");
5608 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
5609 let messages = vec![Message {
5610 role: Role::Assistant,
5611 content: vec![ContentBlock::ToolUse {
5612 id: "call-crashed".to_string(),
5613 name: "read_file".to_string(),
5614 input: serde_json::json!({"path": "README.md"}),
5615 caller: None,
5616 thought_signature: None,
5617 }],
5618 }];
5619 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
5620 let session_id = session.metadata.id.clone();
5621 manager.save_session(&session).expect("save");
5622
5623 let loaded = manager.load_session(&session_id).expect("load");
5624
5625 assert_eq!(loaded.metadata.message_count, loaded.messages.len());
5626 assert!(loaded.messages.iter().any(|message| {
5627 message.content.iter().any(|block| {
5628 matches!(
5629 block,
5630 ContentBlock::ToolResult {
5631 tool_use_id,
5632 content,
5633 is_error: Some(true),
5634 ..
5635 } if tool_use_id == "call-crashed" && content.contains("crashed_and_repaired")
5636 )
5637 })
5638 }));
5639 assert_eq!(
5640 loaded.journal.as_ref().map(SessionJournal::to_messages),
5641 Some(loaded.messages.clone()),
5642 "the append-only journal must follow the repaired active branch"
5643 );
5644 assert!(loaded.messages.iter().any(|message| {
5645 (message.role == "assistant"
5646 || message.role == codewhale_models::INTERRUPTED_ASSISTANT_ROLE)
5647 && message.content.iter().any(|block| {
5648 matches!(
5649 block,
5650 ContentBlock::Text { text, .. }
5651 if text.contains("[tool_history_repair]")
5652 )
5653 })
5654 }));
5655 }
5656
5657 #[test]
5658 fn save_and_load_session_preserves_rich_update_plan_tool_payload() {
5659 let tmp = tempdir().expect("tempdir");
5660 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
5661 let messages = vec![
5662 make_test_message("user", "plan this carefully"),
5663 Message {
5664 role: Role::Assistant,
5665 content: vec![ContentBlock::ToolUse {
5666 id: "plan-1".to_string(),
5667 name: "update_plan".to_string(),
5668 input: serde_json::json!({
5669 "objective": "Make Plan mode reviewable",
5670 "sources_used": ["gh issue view 2691"],
5671 "critical_files": ["crates/tui/src/tools/plan.rs"],
5672 "constraints": ["Preserve legacy update_plan payloads"],
5673 "verification_plan": "Run focused plan tests",
5674 "handoff_packet": "Next agent should inspect replay",
5675 "plan": [
5676 { "step": "render replay card", "status": "completed" }
5677 ]
5678 }),
5679 caller: None,
5680 thought_signature: None,
5681 }],
5682 },
5683 Message {
5684 role: Role::User,
5685 content: vec![ContentBlock::ToolResult {
5686 tool_use_id: "plan-1".to_string(),
5687 content: "Plan updated".to_string(),
5688 is_error: None,
5689 content_blocks: None,
5690 }],
5691 },
5692 ];
5693 let session = create_saved_session(&messages, "deepseek-v4-flash", tmp.path(), 42, None);
5694 let session_id = session.metadata.id.clone();
5695
5696 manager.save_session(&session).expect("save");
5697 let loaded = manager.load_session(&session_id).expect("load");
5698
5699 assert_eq!(loaded.messages.len(), 3);
5700 let cells = history_cells_from_message(&loaded.messages[1]);
5701 let Some(HistoryCell::Tool(ToolCell::PlanUpdate(cell))) = cells.first() else {
5702 panic!("expected loaded update_plan to replay as a PlanUpdate cell");
5703 };
5704 assert_eq!(
5705 cell.snapshot.objective.as_deref(),
5706 Some("Make Plan mode reviewable")
5707 );
5708 assert_eq!(
5709 cell.snapshot.critical_files,
5710 vec!["crates/tui/src/tools/plan.rs"]
5711 );
5712 assert_eq!(cell.snapshot.items[0].status, StepStatus::Completed);
5713 }
5714
5715 #[test]
5716 fn save_session_preserves_large_tool_outputs_for_cache_fidelity() {
5717 let tmp = tempdir().expect("tempdir");
5718 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
5719 let raw = "RAW_SESSION_SENTINEL\n".repeat(2_000);
5720 let messages = vec![
5721 Message {
5722 role: Role::Assistant,
5723 content: vec![ContentBlock::ToolUse {
5724 id: "call-big".to_string(),
5725 name: "exec_shell".to_string(),
5726 input: serde_json::json!({"command": "cargo test -p codewhale-tui"}),
5727 caller: None,
5728 thought_signature: None,
5729 }],
5730 },
5731 Message {
5732 role: Role::User,
5733 content: vec![ContentBlock::ToolResult {
5734 tool_use_id: "call-big".to_string(),
5735 content: raw.clone(),
5736 is_error: None,
5737 content_blocks: None,
5738 }],
5739 },
5740 ];
5741 let mut session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
5742 session.artifacts.push(crate::artifacts::ArtifactRecord {
5743 id: "art_call-big".to_string(),
5744 kind: crate::artifacts::ArtifactKind::ToolOutput,
5745 session_id: session.metadata.id.clone(),
5746 tool_call_id: "call-big".to_string(),
5747 tool_name: "exec_shell".to_string(),
5748 created_at: Utc::now(),
5749 byte_size: raw.len() as u64,
5750 preview: "checking crate ... error[E0425]".to_string(),
5751 storage_path: PathBuf::from("artifacts/art_call-big.txt"),
5752 });
5753
5754 let path = manager.save_session(&session).expect("save");
5755 let persisted_json = fs::read_to_string(path).expect("read persisted session");
5756 // Raw output is preserved in-session so resume can hit the LLM cache.
5757 assert!(persisted_json.contains("RAW_SESSION_SENTINEL"));
5758
5759 let loaded = manager.load_session(&session.metadata.id).expect("load");
5760 let ContentBlock::ToolResult { content, .. } = &loaded.messages[1].content[0] else {
5761 panic!("expected loaded tool result");
5762 };
5763 // Loaded session retains the original output for cache fidelity.
5764 assert!(content.contains("RAW_SESSION_SENTINEL"));
5765 assert!(!content.contains("[TOOL_OUTPUT_RECEIPT]"));
5766 }
5767
5768 #[test]
5769 fn load_session_preserves_legacy_large_tool_outputs_for_cache_fidelity() {
5770 let tmp = tempdir().expect("tempdir");
5771 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
5772 let raw = "RAW_LEGACY_RESUME_SENTINEL\n".repeat(2_000);
5773 let messages = vec![
5774 Message {
5775 role: Role::Assistant,
5776 content: vec![ContentBlock::ToolUse {
5777 id: "call-legacy".to_string(),
5778 name: "exec_shell".to_string(),
5779 input: serde_json::json!({"command": "cargo check"}),
5780 caller: None,
5781 thought_signature: None,
5782 }],
5783 },
5784 Message {
5785 role: Role::User,
5786 content: vec![ContentBlock::ToolResult {
5787 tool_use_id: "call-legacy".to_string(),
5788 content: raw.clone(),
5789 is_error: None,
5790 content_blocks: None,
5791 }],
5792 },
5793 ];
5794 let mut session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
5795 session.artifacts.push(crate::artifacts::ArtifactRecord {
5796 id: "art_call-legacy".to_string(),
5797 kind: crate::artifacts::ArtifactKind::ToolOutput,
5798 session_id: session.metadata.id.clone(),
5799 tool_call_id: "call-legacy".to_string(),
5800 tool_name: "exec_shell".to_string(),
5801 created_at: Utc::now(),
5802 byte_size: raw.len() as u64,
5803 preview: "cargo check output".to_string(),
5804 storage_path: PathBuf::from("artifacts/art_call-legacy.txt"),
5805 });
5806 let path = manager
5807 .validated_session_path(&session.metadata.id)
5808 .expect("path");
5809 fs::write(
5810 &path,
5811 serde_json::to_string_pretty(&session).expect("serialize legacy session"),
5812 )
5813 .expect("write legacy raw session");
5814 assert!(
5815 fs::read_to_string(&path)
5816 .expect("read legacy raw")
5817 .contains("RAW_LEGACY_RESUME_SENTINEL")
5818 );
5819
5820 let loaded = manager.load_session(&session.metadata.id).expect("load");
5821 let ContentBlock::ToolResult { content, .. } = &loaded.messages[1].content[0] else {
5822 panic!("expected loaded tool result");
5823 };
5824 // Loaded session preserves original output so resume can hit the LLM cache.
5825 assert!(content.contains("RAW_LEGACY_RESUME_SENTINEL"));
5826 assert!(!content.contains("[TOOL_OUTPUT_RECEIPT]"));
5827 }
5828
5829 #[test]
5830 fn test_list_sessions() {
5831 let tmp = tempdir().expect("tempdir");
5832 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
5833
5834 // Create a few sessions
5835 for i in 0..3 {
5836 let messages = vec![make_test_message("user", &format!("Session {i}"))];
5837 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
5838 manager.save_session(&session).expect("save");
5839 }
5840
5841 let sessions = manager.list_sessions().expect("list");
5842 assert_eq!(sessions.len(), 3);
5843 }
5844
5845 #[test]
5846 fn default_manager_copies_legacy_sessions_when_primary_already_exists() {
5847 let _lock = crate::test_support::lock_test_env();
5848 let tmp = tempdir().expect("tempdir");
5849 let home = tmp.path().join("home");
5850 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
5851 let _codewhale_home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
5852
5853 let primary_sessions = home.join(".codewhale").join("sessions");
5854 let legacy_sessions = home.join(".deepseek").join("sessions");
5855 fs::create_dir_all(&primary_sessions).expect("primary sessions");
5856 fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
5857 fs::create_dir_all(legacy_sessions.join("checkpoints")).expect("legacy checkpoints");
5858 fs::write(
5859 legacy_sessions.join("checkpoints").join("latest.json"),
5860 "{}",
5861 )
5862 .expect("legacy checkpoint");
5863
5864 let mut legacy_session = create_saved_session(
5865 &[make_test_message("user", "find my old session")],
5866 "test-model",
5867 tmp.path(),
5868 100,
5869 None,
5870 );
5871 legacy_session.metadata.id = "legacy-visible".to_string();
5872 legacy_session.metadata.title = "session from legacy home".to_string();
5873 fs::write(
5874 legacy_sessions.join("legacy-visible.json"),
5875 serde_json::to_string_pretty(&legacy_session).expect("serialize legacy session"),
5876 )
5877 .expect("write legacy session");
5878
5879 let manager = SessionManager::default_location().expect("default manager");
5880 assert_eq!(manager.sessions_dir(), primary_sessions.as_path());
5881 assert!(primary_sessions.join("legacy-visible.json").exists());
5882 assert!(!primary_sessions.join("checkpoints").exists());
5883 assert!(legacy_sessions.join("legacy-visible.json").exists());
5884
5885 let sessions = manager.list_sessions().expect("list");
5886 assert_eq!(sessions.len(), 1);
5887 assert_eq!(sessions[0].id, "legacy-visible");
5888 }
5889
5890 #[test]
5891 fn legacy_session_copy_never_overwrites_primary_session() {
5892 let _lock = crate::test_support::lock_test_env();
5893 let tmp = tempdir().expect("tempdir");
5894 let home = tmp.path().join("home");
5895 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
5896 let _codewhale_home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
5897
5898 let primary_sessions = home.join(".codewhale").join("sessions");
5899 let legacy_sessions = home.join(".deepseek").join("sessions");
5900 fs::create_dir_all(&primary_sessions).expect("primary sessions");
5901 fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
5902
5903 let primary_path = primary_sessions.join("same-id.json");
5904 fs::write(&primary_path, "primary data wins").expect("write primary session");
5905 fs::write(
5906 legacy_sessions.join("same-id.json"),
5907 "legacy data must not overwrite",
5908 )
5909 .expect("write legacy session");
5910
5911 let dir = default_sessions_dir().expect("default session dir");
5912 assert_eq!(dir, primary_sessions);
5913 assert_eq!(
5914 fs::read_to_string(primary_path).expect("read primary session"),
5915 "primary data wins"
5916 );
5917 }
5918
5919 #[test]
5920 fn explicit_codewhale_home_disables_legacy_session_copy() {
5921 let _lock = crate::test_support::lock_test_env();
5922 let tmp = tempdir().expect("tempdir");
5923 let home = tmp.path().join("home");
5924 let explicit_home = tmp.path().join("explicit-codewhale");
5925 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
5926 let _codewhale_home =
5927 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &explicit_home);
5928
5929 let legacy_sessions = home.join(".deepseek").join("sessions");
5930 fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
5931 fs::write(legacy_sessions.join("legacy-visible.json"), "{}").expect("write legacy session");
5932
5933 let dir = default_sessions_dir().expect("default session dir");
5934 assert_eq!(dir, explicit_home.join("sessions"));
5935 assert!(!dir.join("legacy-visible.json").exists());
5936 }
5937
5938 #[cfg(unix)]
5939 #[test]
5940 fn non_unicode_codewhale_home_is_still_an_explicit_session_boundary() {
5941 use std::os::unix::ffi::OsStringExt;
5942
5943 let _lock = crate::test_support::lock_test_env();
5944 let tmp = tempdir().expect("tempdir");
5945 let home = tmp.path().join("home");
5946 let explicit_home = tmp.path().join(std::ffi::OsString::from_vec(
5947 b"codewhale-\xff-home".to_vec(),
5948 ));
5949 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
5950 let _codewhale_home =
5951 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &explicit_home);
5952
5953 let legacy_sessions = home.join(".deepseek").join("sessions");
5954 fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
5955 fs::write(legacy_sessions.join("ambient.json"), "ambient").expect("ambient legacy session");
5956 let safe_primary = tmp.path().join("safe-primary");
5957 fs::create_dir_all(&safe_primary).expect("safe primary");
5958
5959 assert_eq!(
5960 merge_missing_legacy_session_entries(&safe_primary).expect("merge decision"),
5961 0
5962 );
5963 assert!(!safe_primary.join("ambient.json").exists());
5964 }
5965
5966 #[test]
5967 fn latest_session_for_workspace_ignores_newer_other_directory() {
5968 let tmp = tempdir().expect("tempdir");
5969 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
5970 let workspace_a = tmp.path().join("aa").join("aaa");
5971 let workspace_b = tmp.path().join("bb").join("bbb");
5972 fs::create_dir_all(&workspace_a).expect("mkdir workspace a");
5973 fs::create_dir_all(&workspace_b).expect("mkdir workspace b");
5974 fs::create_dir_all(tmp.path().join(".git")).expect("mkdir invalid git boundary");
5975
5976 write_session_record(
5977 &manager,
5978 "current-workspace",
5979 &workspace_a,
5980 Utc::now() - chrono::Duration::minutes(10),
5981 );
5982 write_session_record(&manager, "other-workspace", &workspace_b, Utc::now());
5983
5984 let global = manager
5985 .list_sessions()
5986 .expect("list")
5987 .into_iter()
5988 .next()
5989 .expect("global latest");
5990 assert_eq!(global.id, "other-workspace");
5991
5992 let scoped = manager
5993 .get_latest_session_for_workspace(&workspace_a)
5994 .expect("latest for workspace")
5995 .expect("scoped latest");
5996 assert_eq!(scoped.id, "current-workspace");
5997 }
5998
5999 #[test]
6000 fn latest_session_for_workspace_ignores_invalid_parent_git_marker() {
6001 let tmp = tempdir().expect("tempdir");
6002 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
6003 let workspace_a = tmp.path().join("aa").join("aaa");
6004 let workspace_b = tmp.path().join("bb").join("bbb");
6005 fs::create_dir_all(&workspace_a).expect("mkdir workspace a");
6006 fs::create_dir_all(&workspace_b).expect("mkdir workspace b");
6007 fs::create_dir_all(tmp.path().join(".git")).expect("mkdir invalid git marker");
6008
6009 write_session_record(
6010 &manager,
6011 "current-workspace",
6012 &workspace_a,
6013 Utc::now() - chrono::Duration::minutes(10),
6014 );
6015 write_session_record(&manager, "other-workspace", &workspace_b, Utc::now());
6016
6017 let scoped = manager
6018 .get_latest_session_for_workspace(&workspace_a)
6019 .expect("latest for workspace")
6020 .expect("scoped latest");
6021 assert_eq!(scoped.id, "current-workspace");
6022 }
6023
6024 #[test]
6025 fn latest_session_for_workspace_matches_same_git_repository() {
6026 let tmp = tempdir().expect("tempdir");
6027 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
6028 let repo = tmp.path().join("repo");
6029 let repo_app = repo.join("apps").join("client");
6030 let repo_crate = repo.join("crates").join("server");
6031 let other_repo = tmp.path().join("other").join("project");
6032 fs::create_dir_all(repo.join(".git")).expect("mkdir .git");
6033 fs::write(repo.join(".git").join("HEAD"), "ref: refs/heads/main\n").expect("write HEAD");
6034 fs::create_dir_all(&repo_app).expect("mkdir repo app");
6035 fs::create_dir_all(&repo_crate).expect("mkdir repo crate");
6036 fs::create_dir_all(&other_repo).expect("mkdir other repo");
6037
6038 write_session_record(
6039 &manager,
6040 "same-repo",
6041 &repo_app,
6042 Utc::now() - chrono::Duration::minutes(5),
6043 );
6044 write_session_record(&manager, "other-repo", &other_repo, Utc::now());
6045
6046 let scoped = manager
6047 .get_latest_session_for_workspace(&repo_crate)
6048 .expect("latest for workspace")
6049 .expect("same repo latest");
6050 assert_eq!(scoped.id, "same-repo");
6051 }
6052
6053 #[test]
6054 fn latest_session_for_workspace_skips_empty_auto_created_session() {
6055 let tmp = tempdir().expect("tempdir");
6056 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
6057 let workspace = tmp.path().join("repo");
6058 fs::create_dir_all(&workspace).expect("mkdir workspace");
6059
6060 write_session_record(
6061 &manager,
6062 "interrupted-user-turn",
6063 &workspace,
6064 Utc::now() - chrono::Duration::minutes(5),
6065 );
6066 write_empty_session_record(&manager, "empty-auto-shell", &workspace, Utc::now());
6067
6068 let global = manager
6069 .list_sessions()
6070 .expect("list")
6071 .into_iter()
6072 .next()
6073 .expect("global latest");
6074 assert_eq!(global.id, "empty-auto-shell");
6075
6076 let scoped = manager
6077 .get_latest_session_for_workspace(&workspace)
6078 .expect("latest for workspace")
6079 .expect("scoped latest");
6080 assert_eq!(scoped.id, "interrupted-user-turn");
6081 }
6082
6083 #[test]
6084 fn test_load_by_prefix() {
6085 let tmp = tempdir().expect("tempdir");
6086 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
6087
6088 let messages = vec![make_test_message("user", "Test session")];
6089 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
6090 let prefix = truncate_id(&session.metadata.id).to_string();
6091 manager.save_session(&session).expect("save");
6092
6093 let loaded = manager.load_session_by_prefix(&prefix).expect("load");
6094 assert_eq!(loaded.messages.len(), 1);
6095 }
6096
6097 #[test]
6098 fn test_delete_session() {
6099 let tmp = tempdir().expect("tempdir");
6100 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
6101
6102 let messages = vec![make_test_message("user", "To be deleted")];
6103 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
6104 let session_id = session.metadata.id.clone();
6105
6106 manager.save_session(&session).expect("save");
6107 assert!(manager.load_session(&session_id).is_ok());
6108
6109 manager.delete_session(&session_id).expect("delete");
6110 assert!(manager.load_session(&session_id).is_err());
6111 }
6112
6113 #[test]
6114 fn delete_session_removes_artifact_directory() {
6115 let tmp = tempdir().expect("tempdir");
6116 let sessions_dir = tmp.path().join("sessions");
6117 let manager = SessionManager::new(sessions_dir.clone()).expect("new");
6118
6119 let session = create_saved_session(
6120 &[make_test_message("user", "artifact session")],
6121 "test-model",
6122 tmp.path(),
6123 100,
6124 None,
6125 );
6126 let session_id = session.metadata.id.clone();
6127 let artifact_dir = sessions_dir.join(&session_id).join("artifacts");
6128 fs::create_dir_all(&artifact_dir).expect("artifact dir");
6129 fs::write(artifact_dir.join("art_call.txt"), "raw output").expect("artifact file");
6130
6131 manager.save_session(&session).expect("save");
6132 manager.delete_session(&session_id).expect("delete");
6133
6134 assert!(!sessions_dir.join(format!("{session_id}.json")).exists());
6135 assert!(!sessions_dir.join(&session_id).exists());
6136 }
6137
6138 #[test]
6139 fn test_session_id_rejects_invalid_characters() {
6140 let tmp = tempdir().expect("tempdir");
6141 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
6142
6143 let err = manager
6144 .load_session("../outside")
6145 .expect_err("invalid id should fail");
6146 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
6147
6148 let err = manager
6149 .delete_session("sess bad")
6150 .expect_err("invalid id should fail");
6151 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
6152 }
6153
6154 #[test]
6155 fn test_session_manager_rejects_relative_traversal_dir() {
6156 let err = SessionManager::new(PathBuf::from("../sessions"))
6157 .expect_err("relative traversal directory should fail");
6158 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
6159 }
6160
6161 #[test]
6162 fn test_truncate_title() {
6163 assert_eq!(truncate_title("Short", 50), "Short");
6164 assert_eq!(
6165 truncate_title("This is a very long title that should be truncated", 20),
6166 "This is a very lo..."
6167 );
6168 assert_eq!(truncate_title("Line 1\nLine 2", 50), "Line 1");
6169 }
6170
6171 #[test]
6172 fn extract_user_prompt_strips_turn_meta_prefix() {
6173 assert_eq!(
6174 extract_user_prompt("<turn_meta>{\"cache\":\"x\"}</turn_meta>\nReal prompt"),
6175 "Real prompt"
6176 );
6177 assert_eq!(extract_user_prompt(" Real prompt"), "Real prompt");
6178 assert_eq!(
6179 extract_user_prompt("<turn_meta>{\"unterminated\":true}\nReal prompt"),
6180 "{\"unterminated\":true}\nReal prompt"
6181 );
6182 }
6183
6184 #[test]
6185 fn create_saved_session_uses_prompt_after_turn_meta_for_title() {
6186 let tmp = tempdir().expect("tempdir");
6187 let messages = vec![make_test_message(
6188 "user",
6189 "<turn_meta>{\"cache\":\"x\"}</turn_meta>\nFix the session picker history pane",
6190 )];
6191 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
6192 assert_eq!(
6193 session.metadata.title,
6194 "Fix the session picker history pane"
6195 );
6196 }
6197
6198 #[test]
6199 fn create_saved_session_skips_runtime_handoffs_when_deriving_title() {
6200 let tmp = tempdir().expect("tempdir");
6201 // Operate/automation sessions start with runtime-owned control traffic
6202 // as the first `user` message. The auto-title must come from the real
6203 // prompt that follows, never from the internal envelope.
6204 let messages = vec![
6205 crate::runtime_handoff::operate_contract_runtime_message(),
6206 make_test_message("user", "Ship the session-title fix"),
6207 ];
6208 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
6209 assert_eq!(session.metadata.title, "Ship the session-title fix");
6210 assert!(
6211 !session.metadata.title.contains("codewhale:runtime"),
6212 "internal envelope leaked into the session title: {}",
6213 session.metadata.title
6214 );
6215 }
6216
6217 #[test]
6218 fn create_saved_session_with_only_runtime_traffic_keeps_placeholder_title() {
6219 let tmp = tempdir().expect("tempdir");
6220 let waiting = crate::runtime_handoff::waiting_for_subagents_runtime_message(2);
6221 let restored =
6222 crate::runtime_handoff::project_messages_for_restore(std::slice::from_ref(&waiting))
6223 .into_iter()
6224 .next()
6225 .expect("restore projection yields one message");
6226 // Runtime handoffs must stay out of the auto-title. Exercise the
6227 // Operate contract, a waiting/restored
6228 // sub-agent checkpoint, and a background shell completion.
6229 let messages = vec![
6230 crate::runtime_handoff::operate_contract_runtime_message(),
6231 waiting,
6232 restored,
6233 crate::runtime_handoff::shell_completion_runtime_message(&[]),
6234 ];
6235 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
6236 assert_eq!(session.metadata.title, DEFAULT_SESSION_TITLE);
6237 assert!(
6238 !session.metadata.title.contains("codewhale:runtime"),
6239 "internal envelope leaked into the session title: {}",
6240 session.metadata.title
6241 );
6242 }
6243
6244 #[test]
6245 fn import_foreign_derives_title_from_the_first_real_user_message() {
6246 let tmp = tempdir().expect("tempdir");
6247 // Importing a session whose transcript opens with the Operate contract
6248 // (the shape this bug produced on export) must not re-derive the
6249 // envelope as the imported title.
6250 let container = container_with(
6251 vec![
6252 crate::runtime_handoff::operate_contract_runtime_message(),
6253 make_test_message("user", "Fix the session picker"),
6254 ],
6255 tmp.path(),
6256 );
6257 let imported = crate::session_manager::SavedSession::import_foreign(
6258 container,
6259 tmp.path().to_path_buf(),
6260 "test-model".to_string(),
6261 )
6262 .expect("import succeeds");
6263 assert_eq!(imported.metadata.title, "Fix the session picker");
6264 assert!(
6265 !imported.metadata.title.contains("codewhale:runtime"),
6266 "internal envelope leaked into the imported session title: {}",
6267 imported.metadata.title
6268 );
6269 }
6270
6271 #[test]
6272 fn import_foreign_keeps_placeholder_when_only_runtime_traffic() {
6273 let tmp = tempdir().expect("tempdir");
6274 let container = container_with(
6275 vec![crate::runtime_handoff::operate_contract_runtime_message()],
6276 tmp.path(),
6277 );
6278 let imported = crate::session_manager::SavedSession::import_foreign(
6279 container,
6280 tmp.path().to_path_buf(),
6281 "test-model".to_string(),
6282 )
6283 .expect("import succeeds");
6284 assert_eq!(imported.metadata.title, DEFAULT_SESSION_TITLE);
6285 }
6286
6287 #[test]
6288 fn title_derivation_skips_current_and_legacy_runtime_provenance() {
6289 let tmp = tempdir().expect("tempdir");
6290 for leading_metadata in [false, true] {
6291 let mut runtime = make_test_message("user", "Internal diagnostic update");
6292 let metadata = ContentBlock::Text {
6293 text: "<turn_meta>\nInput provenance: runtime (non-authoritative)\n</turn_meta>"
6294 .to_string(),
6295 cache_control: None,
6296 };
6297 if leading_metadata {
6298 runtime.content.insert(0, metadata);
6299 } else {
6300 runtime.content.push(metadata);
6301 }
6302 let messages = vec![
6303 runtime,
6304 make_test_message("user", "Fix the diagnostic display"),
6305 ];
6306 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
6307 assert_eq!(session.metadata.title, "Fix the diagnostic display");
6308 let imported = SavedSession::import_foreign(
6309 container_with(messages, tmp.path()),
6310 tmp.path().to_path_buf(),
6311 "test-model".to_string(),
6312 )
6313 .expect("import succeeds");
6314 assert_eq!(imported.metadata.title, "Fix the diagnostic display");
6315 }
6316 }
6317
6318 #[test]
6319 fn title_derivation_keeps_user_authored_runtime_example() {
6320 let tmp = tempdir().expect("tempdir");
6321 let messages = vec![make_test_message(
6322 "user",
6323 "<codewhale:runtime_event> example",
6324 )];
6325 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
6326 assert_eq!(session.metadata.title, "<codewhale:runtime_event> example");
6327 }
6328
6329 /// The exact bytes `SessionManager::load_session_metadata` reads.
6330 fn session_bytes(session: &SavedSession, stored_title: &str) -> Vec<u8> {
6331 let mut stale = session.clone();
6332 stale.metadata.title = stored_title.to_string();
6333 serde_json::to_vec(&stale).expect("serialize session")
6334 }
6335
6336 fn loaded_title(session: &SavedSession, stored_title: &str) -> String {
6337 let buf = session_bytes(session, stored_title);
6338 let mut metadata = extract_top_level_metadata(&buf).expect("metadata extractable");
6339 assert_eq!(metadata.title, stored_title);
6340 apply_legacy_title_recovery(&mut metadata, &buf);
6341 metadata.title
6342 }
6343
6344 /// What the superseded derivation stored for an Operate-contract session:
6345 /// the first line of the engine envelope, cut at 50 characters.
6346 fn legacy_operate_title() -> String {
6347 let message = crate::runtime_handoff::operate_contract_runtime_message();
6348 let ContentBlock::Text { text, .. } = &message.content[0] else {
6349 panic!("operate contract opens with text");
6350 };
6351 truncate_title(text, 50)
6352 }
6353
6354 #[test]
6355 fn legacy_runtime_titles_recover_the_real_user_prompt() {
6356 let tmp = tempdir().expect("tempdir");
6357 let messages = vec![
6358 crate::runtime_handoff::operate_contract_runtime_message(),
6359 make_test_message("user", "Fix the diagnostic display"),
6360 ];
6361 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
6362 let stored = legacy_operate_title();
6363 assert!(
6364 stored.starts_with("<codewhale:runtime_event kind="),
6365 "{stored:?}",
6366 );
6367 assert_eq!(
6368 loaded_title(&session, &stored),
6369 "Fix the diagnostic display"
6370 );
6371 }
6372
6373 #[test]
6374 fn legacy_recovery_leaves_renames_and_user_authored_titles_alone() {
6375 let tmp = tempdir().expect("tempdir");
6376 let messages = vec![
6377 crate::runtime_handoff::operate_contract_runtime_message(),
6378 make_test_message("user", "Fix the diagnostic display"),
6379 ];
6380 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
6381 // Renames win, including ones that open with `<` and so pay for the
6382 // message scan: provenance is proven, never guessed from the shape.
6383 for rename in [
6384 "Operate contract",
6385 "<my own angle-bracket title>",
6386 "<codewhale:runtime_event kind=\"operate_contract\" but renamed by me",
6387 ] {
6388 assert_eq!(loaded_title(&session, rename), rename);
6389 }
6390 }
6391
6392 #[test]
6393 fn a_user_who_types_an_attributed_envelope_keeps_their_title() {
6394 // The one case the earlier substring rule got wrong. The engine's
6395 // envelope carries a runtime provenance line; a person's message does
6396 // not, and the existing classifier is what tells them apart — so this
6397 // title is theirs and survives.
6398 let tmp = tempdir().expect("tempdir");
6399 let typed = "<codewhale:runtime_event kind=\"operate_contract\" visibility=\"internal\">";
6400 let messages = vec![make_test_message("user", typed)];
6401 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
6402 let stored = truncate_title(typed, 50);
6403 assert_eq!(session.metadata.title, stored);
6404 assert_eq!(loaded_title(&session, &stored), stored);
6405 }
6406
6407 #[test]
6408 fn legacy_recovery_names_a_runtime_only_session_by_the_default() {
6409 // Nothing but runtime traffic: there is no user prompt to recover, and
6410 // the array ended inside the read, so the neutral default is provable.
6411 let tmp = tempdir().expect("tempdir");
6412 let messages = vec![crate::runtime_handoff::operate_contract_runtime_message()];
6413 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
6414 assert_eq!(
6415 loaded_title(&session, &legacy_operate_title()),
6416 DEFAULT_SESSION_TITLE
6417 );
6418 }
6419
6420 #[test]
6421 fn legacy_recovery_keeps_the_stored_title_when_the_read_was_truncated() {
6422 // A prefix cut before the user's turn must not be read as "this
6423 // conversation has no prompt".
6424 let tmp = tempdir().expect("tempdir");
6425 let messages = vec![
6426 crate::runtime_handoff::operate_contract_runtime_message(),
6427 make_test_message("user", "Fix the diagnostic display"),
6428 ];
6429 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
6430 let stored = legacy_operate_title();
6431 let full = session_bytes(&session, &stored);
6432 let messages_at = full
6433 .windows(10)
6434 .position(|w| w == b"\"messages\"")
6435 .expect("messages key present");
6436 let cut = &full[..messages_at + 40];
6437 let mut metadata = extract_top_level_metadata(cut).expect("metadata precedes messages");
6438 apply_legacy_title_recovery(&mut metadata, cut);
6439 assert_eq!(metadata.title, stored, "a truncated read must not rename");
6440 }
6441
6442 #[test]
6443 fn ordinary_titles_never_pay_for_the_message_scan() {
6444 // #337's bounded read is the reason `list_sessions` is cheap. The `<`
6445 // gate is a cost filter only; the rename decision is the provenance
6446 // check above.
6447 let tmp = tempdir().expect("tempdir");
6448 let messages = vec![make_test_message("user", "Fix the diagnostic display")];
6449 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
6450 let mut metadata = session.metadata.clone();
6451 assert!(!metadata.title.starts_with('<'));
6452 apply_legacy_title_recovery(&mut metadata, &[]);
6453 assert_eq!(metadata.title, "Fix the diagnostic display");
6454 }
6455
6456 #[test]
6457 fn leading_messages_stop_at_the_edge_of_a_truncated_prefix() {
6458 let tmp = tempdir().expect("tempdir");
6459 let messages = vec![
6460 make_test_message("user", "first"),
6461 make_test_message("assistant", "second"),
6462 make_test_message("user", "third"),
6463 ];
6464 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
6465 let buf = serde_json::to_vec(&session).expect("serialize");
6466 let (all, complete) = extract_leading_messages(&buf, 24);
6467 assert!(complete, "a whole file ends its messages array");
6468 assert_eq!(all.len(), 3);
6469
6470 let (capped, complete) = extract_leading_messages(&buf, 2);
6471 assert_eq!(capped.len(), 2);
6472 assert!(!complete, "a capped scan has not seen the array end");
6473
6474 // The file midpoint depends on metadata path lengths and can already
6475 // follow the messages array. Cut inside the third message instead.
6476 let marker = b"\"third\"";
6477 let cut = buf
6478 .windows(marker.len())
6479 .position(|window| window == marker)
6480 .expect("third message is serialized")
6481 + marker.len() / 2;
6482 let (partial, complete) = extract_leading_messages(&buf[..cut], 24);
6483 assert!(!complete);
6484 assert_eq!(partial.len(), 2, "the cut message must remain absent");
6485 }
6486
6487 #[test]
6488 fn title_derivation_keeps_the_first_image_only_user_boundary() {
6489 let tmp = tempdir().expect("tempdir");
6490 for with_metadata in [false, true] {
6491 let mut first = Message {
6492 role: Role::User,
6493 content: vec![ContentBlock::ImageUrl {
6494 image_url: codewhale_models::ImageUrlContent {
6495 url: "data:image/png;base64,AAAA".to_string(),
6496 },
6497 }],
6498 };
6499 if with_metadata {
6500 first.content.push(ContentBlock::Text {
6501 text: "<turn_meta>\nSession mode: Work\n</turn_meta>".to_string(),
6502 cache_control: None,
6503 });
6504 }
6505 let messages = vec![first, make_test_message("user", "A later request")];
6506 assert_eq!(conversation_title_prompt(&messages), None);
6507 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
6508 assert_eq!(session.metadata.title, DEFAULT_SESSION_TITLE);
6509 let imported = SavedSession::import_foreign(
6510 container_with(messages, tmp.path()),
6511 tmp.path().to_path_buf(),
6512 "test-model".to_string(),
6513 )
6514 .expect("import succeeds");
6515 assert_eq!(imported.metadata.title, DEFAULT_SESSION_TITLE);
6516 }
6517 }
6518
6519 #[test]
6520 fn strip_thinking_tags_removes_common_inline_blocks() {
6521 let text = "Before <think>private</think> middle <reasoning>hidden</reasoning> after";
6522 let cleaned = strip_thinking_tags(text);
6523 assert_eq!(cleaned, "Before middle after");
6524 assert_eq!(strip_thinking_tags("plain answer"), "plain answer");
6525 }
6526
6527 #[test]
6528 fn test_format_age() {
6529 let now = Utc::now();
6530 assert_eq!(format_age(&now), "just now");
6531
6532 let hour_ago = now - chrono::Duration::hours(2);
6533 assert_eq!(format_age(&hour_ago), "2h ago");
6534
6535 let day_ago = now - chrono::Duration::days(3);
6536 assert_eq!(format_age(&day_ago), "3d ago");
6537 }
6538
6539 #[test]
6540 fn session_titles_never_keep_terminal_controls_or_bidi_format_chars() {
6541 let raw = "Ev\u{1b}]0;PWNED\u{7}il\u{202e}R\u{200b}Z\u{9d}0;X\u{9c}After\u{2066}B\u{2069} 会議 🐳";
6542 assert_eq!(
6543 sanitize_session_title(raw),
6544 "Ev]0;PWNEDilRZ0;XAfterB 会議 🐳"
6545 );
6546 // Every rename surface goes through normalize_session_title.
6547 assert_eq!(
6548 normalize_session_title(raw).unwrap(),
6549 "Ev]0;PWNEDilRZ0;XAfterB 会議 🐳"
6550 );
6551 // A title that is nothing but controls is an empty title.
6552 assert!(normalize_session_title("\u{1b}\u{7}\u{200b}").is_err());
6553 // The listing line re-sanitizes titles saved before this policy.
6554 assert_eq!(truncate_title(raw, 40), "Ev]0;PWNEDilRZ0;XAfterB 会議 🐳");
6555 }
6556
6557 #[test]
6558 fn format_session_line_includes_absolute_updated_timestamp() {
6559 let mut session = create_saved_session(
6560 &[make_test_message("user", "Find Friday work")],
6561 "test-model",
6562 Path::new("/tmp/project"),
6563 100,
6564 None,
6565 );
6566 session.metadata.updated_at = DateTime::parse_from_rfc3339("2026-06-01T12:34:00Z")
6567 .expect("timestamp")
6568 .with_timezone(&Utc);
6569
6570 let line = format_session_line(&session.metadata);
6571
6572 assert!(
6573 line.contains("2026-06-01 12:34 UTC"),
6574 "session list should include an absolute timestamp, got {line:?}"
6575 );
6576 }
6577
6578 #[test]
6579 fn test_update_session() {
6580 let tmp = tempdir().expect("tempdir");
6581
6582 let messages = vec![make_test_message("user", "Hello")];
6583 let session = create_saved_session(&messages, "test-model", tmp.path(), 50, None);
6584
6585 let new_messages = vec![
6586 make_test_message("user", "Hello"),
6587 make_test_message("assistant", "Hi!"),
6588 ];
6589
6590 let updated = update_session(session, &new_messages, 100, None);
6591 assert_eq!(updated.messages.len(), 2);
6592 assert_eq!(updated.metadata.total_tokens, 100);
6593 }
6594
6595 #[test]
6596 fn save_load_round_trip_preserves_all_messages_for_cache_fidelity() {
6597 #[derive(serde::Deserialize)]
6598 struct LegacySession {
6599 messages: Vec<Message>,
6600 }
6601
6602 let tmp = tempdir().expect("tempdir");
6603 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
6604 // Covers the old 500-message cap boundary and well beyond.
6605 for count in [0, 1, 500, 501, 600, 1000] {
6606 let original: Vec<_> = (0..count)
6607 .map(|i| {
6608 make_test_message(
6609 if i % 2 == 0 { "user" } else { "assistant" },
6610 &format!("round-trip message {i}"),
6611 )
6612 })
6613 .collect();
6614
6615 let mut session = create_saved_session(&original, "test-model", tmp.path(), 0, None);
6616 let expected_journal = session.journal.clone();
6617 session.compact_for_persistence_queue();
6618 let path = manager.save_session(&session).expect("save");
6619 let legacy: LegacySession =
6620 serde_json::from_slice(&fs::read(path).expect("read")).expect("legacy reader");
6621 let loaded = manager.load_session(&session.metadata.id).expect("load");
6622
6623 assert_eq!(
6624 legacy.messages, original,
6625 "legacy messages for count={count}"
6626 );
6627 assert_eq!(
6628 loaded.journal, expected_journal,
6629 "journal for count={count}"
6630 );
6631 assert_eq!(
6632 loaded.messages.len(),
6633 count,
6634 "count preserved for count={count}"
6635 );
6636 assert_eq!(
6637 loaded.messages, original,
6638 "every message byte-identical after round-trip for count={count}"
6639 );
6640 }
6641 }
6642
6643 #[test]
6644 fn test_checkpoint_round_trip_and_clear() {
6645 let tmp = tempdir().expect("tempdir");
6646 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
6647 let messages = vec![make_test_message("user", "checkpoint me")];
6648 let mut session = create_saved_session(&messages, "test-model", tmp.path(), 12, None);
6649 session.work_state = Some(SessionWorkState {
6650 todos: crate::tools::todo::TodoListSnapshot {
6651 items: vec![crate::tools::todo::TodoItem {
6652 id: 1,
6653 content: "verify checkpoint durability".to_string(),
6654 status: crate::tools::todo::TodoStatus::InProgress,
6655 }],
6656 completion_pct: 0,
6657 in_progress_id: Some(1),
6658 },
6659 ..SessionWorkState::default()
6660 });
6661 let expected_messages = session.messages.clone();
6662 let expected_journal = session.journal.clone();
6663 session.compact_for_persistence_queue();
6664
6665 let path = manager.save_checkpoint(&session).expect("save checkpoint");
6666 assert_eq!(
6667 path.file_name().and_then(|n| n.to_str()),
6668 Some(format!("{}.json", session.metadata.id).as_str()),
6669 "checkpoint file must be keyed by session id"
6670 );
6671 let loaded = manager
6672 .load_session_checkpoint(&session.metadata.id)
6673 .expect("load checkpoint")
6674 .expect("checkpoint exists");
6675 assert_eq!(loaded.metadata.id, session.metadata.id);
6676 assert_eq!(loaded.messages, expected_messages);
6677 assert_eq!(loaded.journal, expected_journal);
6678 assert_eq!(
6679 loaded.work_state, session.work_state,
6680 "work state must survive the checkpoint round trip"
6681 );
6682
6683 manager
6684 .clear_session_checkpoint(&session.metadata.id)
6685 .expect("clear checkpoint");
6686 assert!(
6687 manager
6688 .load_session_checkpoint(&session.metadata.id)
6689 .expect("load checkpoint")
6690 .is_none()
6691 );
6692 }
6693
6694 #[test]
6695 fn graph_backed_work_state_remains_readable_by_legacy_shape() {
6696 #[derive(serde::Deserialize)]
6697 struct LegacyWorkState {
6698 #[serde(default)]
6699 todos: crate::tools::todo::TodoListSnapshot,
6700 #[serde(default)]
6701 plan: crate::tools::plan::PlanSnapshot,
6702 }
6703
6704 let fixture = include_bytes!("../tests/fixtures/work_graph_session_v1_reader.json");
6705 let current: SavedSession = serde_json::from_slice(fixture).expect("current reader");
6706 let state = current.work_state.expect("fixture Work state");
6707 let legacy: LegacyWorkState = serde_json::from_value(
6708 serde_json::from_slice::<serde_json::Value>(fixture)
6709 .expect("fixture JSON")["work_state"]
6710 .clone(),
6711 )
6712 .expect("v1 reader ignores graph");
6713 assert_eq!(legacy.todos, state.todos);
6714 assert_eq!(legacy.plan, state.plan);
6715 let graph = state.graph.expect("fixture graph");
6716 crate::work_graph::validate(&graph).expect("valid fixture graph");
6717 assert_eq!(crate::work_graph::project_todos(&graph), state.todos);
6718 assert_eq!(crate::work_graph::project_plan(&graph), state.plan);
6719 }
6720
6721 #[test]
6722 fn first_graph_write_archives_exact_legacy_session_once() {
6723 let tmp = tempdir().expect("tempdir");
6724 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
6725 let mut session = create_saved_session(
6726 &[make_test_message("user", "archive before import")],
6727 "test-model",
6728 tmp.path(),
6729 0,
6730 None,
6731 );
6732 let plan = crate::tools::plan::PlanSnapshot {
6733 items: vec![crate::tools::plan::PlanItemArg {
6734 step: "Import".to_string(),
6735 status: crate::tools::plan::StepStatus::Pending,
6736 }],
6737 ..crate::tools::plan::PlanSnapshot::default()
6738 };
6739 let todos = crate::tools::todo::TodoListSnapshot::default();
6740 session.work_state = Some(SessionWorkState {
6741 graph: None,
6742 todos: todos.clone(),
6743 plan: plan.clone(),
6744 });
6745 let path = manager.save_session(&session).expect("save legacy session");
6746 let legacy_bytes = fs::read(&path).expect("read legacy bytes");
6747
6748 let graph = crate::work_graph::import_legacy(&session.metadata.id, &plan, &todos)
6749 .expect("import graph");
6750 session.work_state = Some(SessionWorkState {
6751 graph: Some(graph),
6752 todos,
6753 plan,
6754 });
6755 manager.save_session(&session).expect("first graph write");
6756 let archive = manager
6757 .sessions_dir
6758 .join(WORK_GRAPH_IMPORT_ARCHIVE_DIR)
6759 .join(path.file_name().expect("session filename"));
6760 assert_eq!(fs::read(&archive).expect("archive exists"), legacy_bytes);
6761
6762 session.metadata.title = "later graph write".to_string();
6763 manager.save_session(&session).expect("second graph write");
6764 assert_eq!(
6765 fs::read(&archive).expect("archive still exists"),
6766 legacy_bytes,
6767 "later graph writes must not replace the pre-import receipt"
6768 );
6769 }
6770
6771 #[test]
6772 fn checkpoints_are_independent_per_session() {
6773 let tmp = tempdir().expect("tempdir");
6774 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
6775 let first = create_saved_session(
6776 &[make_test_message("user", "session one")],
6777 "test-model",
6778 tmp.path(),
6779 0,
6780 None,
6781 );
6782 let second = create_saved_session(
6783 &[make_test_message("user", "session two")],
6784 "test-model",
6785 tmp.path(),
6786 0,
6787 None,
6788 );
6789
6790 manager.save_checkpoint(&first).expect("save first");
6791 manager.save_checkpoint(&second).expect("save second");
6792 manager
6793 .clear_session_checkpoint(&first.metadata.id)
6794 .expect("clear first");
6795
6796 assert!(
6797 manager
6798 .load_session_checkpoint(&first.metadata.id)
6799 .expect("load first")
6800 .is_none(),
6801 "clearing one session must remove only that session's file"
6802 );
6803 let survivor = manager
6804 .load_session_checkpoint(&second.metadata.id)
6805 .expect("load second")
6806 .expect("second checkpoint survives");
6807 assert_eq!(survivor.metadata.id, second.metadata.id);
6808 }
6809
6810 #[test]
6811 fn list_checkpoints_includes_legacy_slot_and_skips_offline_queue() {
6812 let tmp = tempdir().expect("tempdir");
6813 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
6814 let session = create_saved_session(
6815 &[make_test_message("user", "list me")],
6816 "test-model",
6817 tmp.path(),
6818 0,
6819 None,
6820 );
6821 manager.save_checkpoint(&session).expect("save checkpoint");
6822 let checkpoints = tmp.path().join("sessions").join("checkpoints");
6823 fs::write(checkpoints.join("latest.json"), "{}").expect("write legacy slot");
6824 fs::write(checkpoints.join("offline_queue.json"), "{}").expect("write legacy queue");
6825 fs::write(
6826 checkpoints.join(format!("{}.offline_queue.json", session.metadata.id)),
6827 "{}",
6828 )
6829 .expect("write per-session queue");
6830
6831 let refs = manager.list_checkpoints().expect("list checkpoints");
6832 assert_eq!(refs.len(), 2, "offline queue must not be a candidate");
6833 assert!(
6834 refs.iter()
6835 .any(|r| r.source == CheckpointSource::Session(session.metadata.id.clone()))
6836 );
6837 assert!(refs.iter().any(|r| r.source == CheckpointSource::Legacy));
6838 }
6839
6840 /// A session owned by a *prior* process instance with a crash-recovery
6841 /// checkpoint on disk: the foreign boot-owner stamp keeps
6842 /// `session_from_prior_instance` true (the save keeps the original
6843 /// owner), and the checkpoint is the durable interrupted sign.
6844 fn write_prior_interrupted_session(
6845 manager: &SessionManager,
6846 id: &str,
6847 workspace: &Path,
6848 ) -> SavedSession {
6849 let mut session = create_saved_session(
6850 &[make_test_message("user", "still working")],
6851 "test-model",
6852 workspace,
6853 0,
6854 None,
6855 );
6856 session.metadata.id = id.to_string();
6857 session.metadata.title = format!("prior-{id}");
6858 manager
6859 .record_session_boot_owner(id, "boot_other_instance")
6860 .expect("stamp foreign owner");
6861 manager.save_session(&session).expect("save session");
6862 manager.save_checkpoint(&session).expect("save checkpoint");
6863 session
6864 }
6865
6866 #[test]
6867 fn interrupted_workspace_session_returns_newest_prior_checkpoint() {
6868 let tmp = tempdir().expect("tempdir");
6869 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
6870 let workspace = tmp.path().join("ws");
6871 fs::create_dir_all(&workspace).expect("workspace");
6872
6873 write_prior_interrupted_session(&manager, "sess-old", &workspace);
6874 // Distinct checkpoint mtimes make newest-first deterministic.
6875 std::thread::sleep(std::time::Duration::from_millis(20));
6876 write_prior_interrupted_session(&manager, "sess-new", &workspace);
6877
6878 // A checkpointed session this instance created is current work, not
6879 // prior work: `save_session` stamps the current boot id, and the
6880 // checkpoint write keeps it because the record already exists.
6881 let own = create_saved_session(
6882 &[make_test_message("user", "mine")],
6883 "test-model",
6884 &workspace,
6885 0,
6886 None,
6887 );
6888 manager.save_session(&own).expect("save own");
6889 manager.save_checkpoint(&own).expect("checkpoint own");
6890
6891 // A checkpointed session in another workspace stays invisible.
6892 let other_workspace = tmp.path().join("other-ws");
6893 fs::create_dir_all(&other_workspace).expect("other workspace");
6894 write_prior_interrupted_session(&manager, "sess-elsewhere", &other_workspace);
6895
6896 assert_eq!(
6897 manager
6898 .interrupted_workspace_session(&workspace, Some(own.metadata.id.as_str()))
6899 .map(|meta| meta.id),
6900 Some("sess-new".to_string())
6901 );
6902 // Excluding the newest surfaces the next interrupted session.
6903 assert_eq!(
6904 manager
6905 .interrupted_workspace_session(&workspace, Some("sess-new"))
6906 .map(|meta| meta.id),
6907 Some("sess-old".to_string())
6908 );
6909 assert_eq!(
6910 manager
6911 .interrupted_workspace_session(&other_workspace, None)
6912 .map(|meta| meta.id),
6913 Some("sess-elsewhere".to_string())
6914 );
6915 }
6916
6917 #[test]
6918 fn interrupted_workspace_session_ignores_settled_sessions() {
6919 let tmp = tempdir().expect("tempdir");
6920 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
6921 let workspace = tmp.path().join("ws");
6922 fs::create_dir_all(&workspace).expect("workspace");
6923
6924 // Prior-instance session that settled cleanly: no checkpoint, so it
6925 // is not interrupted even though it is prior work.
6926 manager
6927 .record_session_boot_owner("sess-done", "boot_other_instance")
6928 .expect("stamp foreign owner");
6929 write_session_record(&manager, "sess-done", &workspace, Utc::now());
6930
6931 assert!(
6932 manager
6933 .interrupted_workspace_session(&workspace, None)
6934 .is_none()
6935 );
6936
6937 // Excluding the only interrupted session leaves nothing to report.
6938 write_prior_interrupted_session(&manager, "sess-prior", &workspace);
6939 assert!(
6940 manager
6941 .interrupted_workspace_session(&workspace, Some("sess-prior"))
6942 .is_none()
6943 );
6944 }
6945
6946 #[test]
6947 fn session_recovery_hint_names_the_interrupted_prior_session() {
6948 let _lock = crate::test_support::lock_test_env();
6949 let tmp = tempdir().expect("tempdir");
6950 let home = tmp.path().join("home");
6951 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
6952 let _codewhale_home =
6953 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.join("codewhale"));
6954 let workspace = tmp.path().join("ws");
6955 fs::create_dir_all(&workspace).expect("workspace");
6956
6957 let manager = SessionManager::default_location().expect("default manager");
6958 assert!(
6959 session_recovery_hint(&workspace, None).is_none(),
6960 "clean store must leave the prompt untouched"
6961 );
6962
6963 write_prior_interrupted_session(&manager, "sess-prior", &workspace);
6964 let hint = session_recovery_hint(&workspace, Some("sess-live"))
6965 .expect("hint for interrupted prior session");
6966 assert!(hint.contains("prior-sess-prior"), "{hint}");
6967 assert!(hint.contains("session_search"), "{hint}");
6968 assert!(hint.contains("/resume"), "{hint}");
6969
6970 // The live session's own checkpoint is never reported as prior work.
6971 assert!(session_recovery_hint(&workspace, Some("sess-prior")).is_none());
6972 }
6973
6974 #[test]
6975 fn legacy_migration_never_overwrites_existing_per_session_checkpoint() {
6976 let tmp = tempdir().expect("tempdir");
6977 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
6978 let mut session = create_saved_session(
6979 &[make_test_message("user", "original")],
6980 "test-model",
6981 tmp.path(),
6982 0,
6983 None,
6984 );
6985 manager.save_checkpoint(&session).expect("save checkpoint");
6986
6987 session.messages = vec![make_test_message("user", "stale legacy copy")];
6988 let written = manager
6989 .write_session_checkpoint_if_absent(&session)
6990 .expect("migration attempt");
6991 assert!(!written, "migration must not overwrite an existing file");
6992 let loaded = manager
6993 .load_session_checkpoint(&session.metadata.id)
6994 .expect("load")
6995 .expect("checkpoint exists");
6996 assert_eq!(
6997 loaded.messages,
6998 vec![make_test_message("user", "original")],
6999 "existing per-session checkpoint content must be preserved"
7000 );
7001 }
7002
7003 #[test]
7004 fn workspace_scope_matches_subdirectories_in_same_git_checkout() {
7005 let tmp = tempdir().expect("tempdir");
7006 let repo = tmp.path().join("repo");
7007 let nested = repo.join("crates").join("tui");
7008 fs::create_dir_all(&nested).expect("mkdir nested");
7009 fs::write(repo.join(".git"), "gitdir: .git/worktrees/repo").expect("write git marker");
7010
7011 assert!(workspace_scope_matches(&repo, &nested));
7012 }
7013
7014 #[test]
7015 fn workspace_scope_rejects_sibling_git_checkouts() {
7016 let tmp = tempdir().expect("tempdir");
7017 let first = tmp.path().join("repo-a");
7018 let second = tmp.path().join("repo-b");
7019 fs::create_dir_all(&first).expect("mkdir first");
7020 fs::create_dir_all(&second).expect("mkdir second");
7021 fs::write(first.join(".git"), "gitdir: .git/worktrees/a").expect("write first marker");
7022 fs::write(second.join(".git"), "gitdir: .git/worktrees/b").expect("write second marker");
7023
7024 assert!(!workspace_scope_matches(&first, &second));
7025 }
7026
7027 #[test]
7028 fn test_offline_queue_round_trip_and_clear() {
7029 let tmp = tempdir().expect("tempdir");
7030 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
7031
7032 let state = OfflineQueueState {
7033 messages: vec![QueuedSessionMessage {
7034 display: "queued message".to_string(),
7035 skill_instruction: Some("Use skill".to_string()),
7036 skill_provenance: None,
7037 }],
7038 draft: Some(QueuedSessionMessage {
7039 display: "draft message".to_string(),
7040 skill_instruction: None,
7041 skill_provenance: None,
7042 }),
7043 ..OfflineQueueState::default()
7044 };
7045
7046 manager
7047 .save_offline_queue_state(&state, Some("test-session"))
7048 .expect("save queue state");
7049 let loaded = manager
7050 .load_offline_queue_state("test-session")
7051 .expect("load queue state")
7052 .expect("queue state exists");
7053 assert_eq!(loaded.messages.len(), 1);
7054 assert_eq!(loaded.messages[0].display, "queued message");
7055 assert!(loaded.draft.is_some());
7056
7057 manager
7058 .clear_offline_queue_state_for("test-session")
7059 .expect("clear queue state");
7060 assert!(
7061 manager
7062 .load_offline_queue_state("test-session")
7063 .expect("load queue state")
7064 .is_none()
7065 );
7066
7067 // A queue with no owning session has nowhere to be restored to, so it
7068 // is refused rather than written where another session would find it.
7069 let unowned = manager.save_offline_queue_state(&state, None);
7070 assert!(unowned.is_err(), "unowned queue must not be parked");
7071 }
7072
7073 fn parked(text: &str) -> OfflineQueueState {
7074 OfflineQueueState {
7075 messages: vec![QueuedSessionMessage {
7076 display: text.to_string(),
7077 skill_instruction: None,
7078 skill_provenance: None,
7079 }],
7080 ..OfflineQueueState::default()
7081 }
7082 }
7083
7084 #[test]
7085 fn offline_queues_are_keyed_per_session() {
7086 // Replaces the #487 single-slot test, which pinned the shared
7087 // `checkpoints/offline_queue.json`: two concurrent Codewhale
7088 // instances raced on it and the loser's unsent text was destroyed.
7089 // Queues are keyed per session for the same reason checkpoints are.
7090 let tmp = tempdir().expect("tempdir");
7091 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
7092
7093 manager
7094 .save_offline_queue_state(&parked("A text"), Some("session-A"))
7095 .expect("park A");
7096 manager
7097 .save_offline_queue_state(&parked("B text"), Some("session-B"))
7098 .expect("park B");
7099
7100 let a = manager
7101 .load_offline_queue_state("session-A")
7102 .expect("load A")
7103 .expect("A still parked");
7104 assert_eq!(a.messages[0].display, "A text");
7105 assert_eq!(a.session_id.as_deref(), Some("session-A"));
7106 let b = manager
7107 .load_offline_queue_state("session-B")
7108 .expect("load B")
7109 .expect("B still parked");
7110 assert_eq!(b.messages[0].display, "B text");
7111
7112 // Clearing one session's queue leaves the other's alone.
7113 manager
7114 .clear_offline_queue_state_for("session-A")
7115 .expect("clear A");
7116 assert!(
7117 manager
7118 .load_offline_queue_state("session-A")
7119 .expect("load A")
7120 .is_none()
7121 );
7122 assert!(
7123 manager
7124 .load_offline_queue_state("session-B")
7125 .expect("load B")
7126 .is_some(),
7127 "clearing one session must never delete another's unsent text"
7128 );
7129
7130 // A session with nothing parked reads back nothing — it can never
7131 // inherit, or destroy, a sibling's queue.
7132 assert!(
7133 manager
7134 .load_offline_queue_state("session-C")
7135 .expect("load C")
7136 .is_none()
7137 );
7138 }
7139
7140 #[test]
7141 fn legacy_global_queue_is_adopted_only_by_its_own_session() {
7142 let tmp = tempdir().expect("tempdir");
7143 let sessions_dir = tmp.path().join("sessions");
7144 let manager = SessionManager::new(sessions_dir.clone()).expect("new");
7145 let checkpoints = sessions_dir.join("checkpoints");
7146 fs::create_dir_all(&checkpoints).expect("create checkpoints dir");
7147 let legacy = checkpoints.join("offline_queue.json");
7148 let mut state = parked("text from the old global queue");
7149 state.session_id = Some("session-A".to_string());
7150 fs::write(
7151 &legacy,
7152 serde_json::to_string_pretty(&state).expect("serialize"),
7153 )
7154 .expect("write legacy queue");
7155
7156 // A different session must not inherit it, and must not delete it.
7157 assert!(
7158 manager
7159 .load_offline_queue_state("session-B")
7160 .expect("load B")
7161 .is_none()
7162 );
7163 assert!(legacy.exists(), "another session's text must survive");
7164
7165 // Its own session adopts it, and the global file is retired only
7166 // after the per-session copy is durably written.
7167 let adopted = manager
7168 .load_offline_queue_state("session-A")
7169 .expect("load A")
7170 .expect("adopted");
7171 assert_eq!(
7172 adopted.messages[0].display,
7173 "text from the old global queue"
7174 );
7175 assert!(!legacy.exists(), "adopted legacy queue is retired");
7176 assert!(
7177 checkpoints.join("session-A.offline_queue.json").exists(),
7178 "adoption writes the per-session file"
7179 );
7180 let again = manager
7181 .load_offline_queue_state("session-A")
7182 .expect("reload A")
7183 .expect("still parked");
7184 assert_eq!(again.messages[0].display, "text from the old global queue");
7185 }
7186
7187 #[test]
7188 fn test_session_context_references_round_trip() {
7189 let tmp = tempdir().expect("tempdir");
7190 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
7191 let mut session = create_saved_session(
7192 &[make_test_message("user", "read @src/main.rs")],
7193 "deepseek-v4-pro",
7194 tmp.path(),
7195 0,
7196 None,
7197 );
7198 session.context_references.push(SessionContextReference {
7199 message_index: 0,
7200 reference: ContextReference {
7201 kind: ContextReferenceKind::File,
7202 source: ContextReferenceSource::AtMention,
7203 badge: "file".to_string(),
7204 label: "src/main.rs".to_string(),
7205 target: tmp.path().join("src/main.rs").display().to_string(),
7206 included: true,
7207 expanded: true,
7208 detail: Some("included".to_string()),
7209 },
7210 });
7211
7212 let path = manager.save_session(&session).expect("save session");
7213 let loaded = manager
7214 .load_session(&session.metadata.id)
7215 .expect("load session");
7216 assert!(path.exists());
7217 assert_eq!(loaded.context_references, session.context_references);
7218 }
7219
7220 #[test]
7221 fn test_checkpoint_rejects_newer_schema() {
7222 let tmp = tempdir().expect("tempdir");
7223 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
7224 let checkpoints = tmp.path().join("sessions").join("checkpoints");
7225 fs::create_dir_all(&checkpoints).expect("create checkpoints dir");
7226 let path = checkpoints.join("latest.json");
7227 fs::write(
7228 &path,
7229 r#"{
7230 "schema_version": 999,
7231 "metadata": {
7232 "id": "sid",
7233 "title": "bad",
7234 "created_at": "2026-01-01T00:00:00Z",
7235 "updated_at": "2026-01-01T00:00:00Z",
7236 "message_count": 0,
7237 "total_tokens": 0,
7238 "model": "m",
7239 "workspace": "/tmp",
7240 "mode": null
7241 },
7242 "messages": [],
7243 "system_prompt": null
7244 }"#,
7245 )
7246 .expect("write checkpoint");
7247
7248 let err = manager
7249 .load_legacy_checkpoint()
7250 .expect_err("should reject schema");
7251 assert!(err.to_string().contains("newer than supported"));
7252
7253 // The same guard applies to per-session checkpoint files.
7254 fs::rename(&path, checkpoints.join("sid.json")).expect("rename to per-session file");
7255 let err = manager
7256 .load_session_checkpoint("sid")
7257 .expect_err("should reject schema");
7258 assert!(err.to_string().contains("newer than supported"));
7259 }
7260
7261 #[test]
7262 fn test_load_session_rejects_newer_schema() {
7263 let tmp = tempdir().expect("tempdir");
7264 let sessions_dir = tmp.path().join("sessions");
7265 let manager = SessionManager::new(sessions_dir.clone()).expect("new");
7266
7267 let id = "future-session";
7268 let path = sessions_dir.join(format!("{id}.json"));
7269 fs::write(
7270 &path,
7271 r#"{
7272 "schema_version": 999,
7273 "metadata": {
7274 "id": "future-session",
7275 "title": "future",
7276 "created_at": "2026-01-01T00:00:00Z",
7277 "updated_at": "2026-01-01T00:00:00Z",
7278 "message_count": 0,
7279 "total_tokens": 0,
7280 "model": "m",
7281 "workspace": "/tmp",
7282 "mode": null
7283 },
7284 "messages": [],
7285 "system_prompt": null
7286 }"#,
7287 )
7288 .expect("write session");
7289
7290 let err = manager.load_session(id).expect_err("should reject schema");
7291 assert!(
7292 err.to_string().contains("newer than supported"),
7293 "unexpected error: {err}"
7294 );
7295 }
7296
7297 /// Regression for #337: metadata extraction skips the (potentially
7298 /// huge) `messages` array — it must succeed even when the messages
7299 /// array is megabytes long, and it must NOT confuse a `"metadata"`
7300 /// substring inside a message body for the real top-level key.
7301 #[test]
7302 fn extract_top_level_metadata_skips_huge_messages_array() {
7303 // Build a session JSON with a large `messages` payload that
7304 // contains the literal string `"metadata"` in a user message —
7305 // a naive `find("\"metadata\"")` would mis-target this.
7306 let big_text = format!(
7307 r#"this message references "metadata" inside it, repeated:{}"#,
7308 "x".repeat(20_000)
7309 );
7310 let json = format!(
7311 r#"{{
7312 "schema_version": 1,
7313 "metadata": {{
7314 "id": "abc-123",
7315 "title": "Real Session",
7316 "created_at": "2026-01-01T00:00:00Z",
7317 "updated_at": "2026-01-02T00:00:00Z",
7318 "message_count": 12,
7319 "total_tokens": 4096,
7320 "model": "deepseek-v4-flash",
7321 "workspace": "/tmp"
7322 }},
7323 "messages": [
7324 {{ "role": "user", "content": [ {{ "Text": {{ "text": {big_text:?} }} }} ] }}
7325 ]
7326 }}"#
7327 );
7328
7329 let extracted =
7330 extract_top_level_metadata(json.as_bytes()).expect("metadata extractable from prefix");
7331 assert_eq!(extracted.id, "abc-123");
7332 assert_eq!(extracted.title, "Real Session");
7333 assert_eq!(extracted.message_count, 12);
7334 assert_eq!(extracted.total_tokens, 4096);
7335 }
7336
7337 #[test]
7338 fn extract_top_level_metadata_handles_braces_inside_strings() {
7339 // A title containing `{` and `}` inside the metadata block must
7340 // not throw off the brace counter.
7341 let json = r#"{
7342 "metadata": {
7343 "id": "x",
7344 "title": "weird { title } with braces",
7345 "created_at": "2026-01-01T00:00:00Z",
7346 "updated_at": "2026-01-01T00:00:00Z",
7347 "message_count": 0,
7348 "total_tokens": 0,
7349 "model": "m",
7350 "workspace": "/tmp"
7351 },
7352 "messages": []
7353 }"#;
7354 let extracted = extract_top_level_metadata(json.as_bytes())
7355 .expect("brace-in-string survives the scanner");
7356 assert_eq!(extracted.title, "weird { title } with braces");
7357 }
7358
7359 #[test]
7360 fn saved_session_deserializes_without_artifacts_as_empty_registry() {
7361 let json = r#"{
7362 "schema_version": 1,
7363 "metadata": {
7364 "id": "legacy-session",
7365 "title": "legacy",
7366 "created_at": "2026-05-08T00:00:00Z",
7367 "updated_at": "2026-05-08T00:00:00Z",
7368 "message_count": 0,
7369 "total_tokens": 0,
7370 "model": "deepseek-v4-pro",
7371 "workspace": "/tmp"
7372 },
7373 "messages": [],
7374 "system_prompt": null
7375 }"#;
7376
7377 let session: SavedSession = serde_json::from_str(json).expect("legacy session loads");
7378 assert!(session.artifacts.is_empty());
7379 assert!(session.last_auto_route.is_none());
7380 assert!(session.metadata.parent_session_id.is_none());
7381 assert!(session.metadata.forked_from_message_count.is_none());
7382 }
7383
7384 #[test]
7385 fn fork_lineage_metadata_round_trips_and_formats() {
7386 let tmp = tempdir().expect("tempdir");
7387 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
7388 let parent = create_saved_session(
7389 &[
7390 make_test_message("user", "try approach A"),
7391 make_test_message("assistant", "A looks viable"),
7392 ],
7393 "deepseek-v4-pro",
7394 Path::new("/tmp"),
7395 42,
7396 None,
7397 );
7398 let mut forked = create_saved_session(
7399 &parent.messages,
7400 &parent.metadata.model,
7401 &parent.metadata.workspace,
7402 parent.metadata.total_tokens,
7403 None,
7404 );
7405 forked.metadata.mark_forked_from(&parent.metadata);
7406
7407 manager.save_session(&forked).expect("save fork");
7408 let loaded = manager
7409 .load_session(&forked.metadata.id)
7410 .expect("load fork");
7411
7412 assert_eq!(
7413 loaded.metadata.parent_session_id.as_deref(),
7414 Some(parent.metadata.id.as_str())
7415 );
7416 assert_eq!(loaded.metadata.forked_from_message_count, Some(2));
7417 let line = format_session_line(&loaded.metadata);
7418 assert!(line.contains("fork"));
7419 assert!(!line.contains(parent.metadata.id.as_str()));
7420 }
7421
7422 #[test]
7423 fn save_and_load_session_preserves_artifact_metadata() {
7424 let tmp = tempdir().expect("tempdir");
7425 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
7426 let mut session = create_saved_session(
7427 &[make_test_message("user", "run tests")],
7428 "deepseek-v4-pro",
7429 Path::new("/tmp"),
7430 0,
7431 None,
7432 );
7433 session.artifacts.push(crate::artifacts::ArtifactRecord {
7434 id: "art_call_big".to_string(),
7435 kind: crate::artifacts::ArtifactKind::ToolOutput,
7436 session_id: session.metadata.id.clone(),
7437 tool_call_id: "call-big".to_string(),
7438 tool_name: "exec_shell".to_string(),
7439 created_at: Utc::now(),
7440 byte_size: 512_000,
7441 preview: "cargo test output".to_string(),
7442 storage_path: PathBuf::from("/tmp/tool_outputs/call-big.txt"),
7443 });
7444
7445 manager.save_session(&session).expect("save");
7446 let loaded = manager.load_session(&session.metadata.id).expect("load");
7447
7448 assert_eq!(loaded.artifacts, session.artifacts);
7449 }
7450
7451 // ---- #406 prune_sessions_older_than ----
7452 //
7453 // The helper is a building block for the auto-archive design: it
7454 // removes session files older than a threshold while leaving fresh
7455 // ones (and the checkpoint directory) alone. Tests cover the empty
7456 // case, the all-fresh case, the all-stale case, and the mixed case.
7457
7458 fn write_session_with_updated_at(
7459 manager: &SessionManager,
7460 id: &str,
7461 updated_at: DateTime<Utc>,
7462 ) {
7463 // Build a minimal SavedSession by hand so the test isn't tied
7464 // to whatever the helper functions emit; we just need a
7465 // metadata block whose `updated_at` matches the requested
7466 // value.
7467 write_session_record(manager, id, Path::new("/tmp"), updated_at);
7468 }
7469
7470 #[test]
7471 fn retention_archives_past_the_cap_and_never_unlinks_transcripts() {
7472 // #6136: the cap retires transcripts into the archive; it must not
7473 // delete what the user never asked to delete.
7474 let tmp = tempdir().expect("tempdir");
7475 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
7476 let mut ids = Vec::new();
7477 for index in 0..(MAX_SESSIONS + 3) {
7478 let id = Uuid::new_v4().to_string();
7479 write_session_with_updated_at(
7480 &manager,
7481 &id,
7482 Utc::now() - chrono::Duration::minutes((MAX_SESSIONS + 3 - index) as i64),
7483 );
7484 ids.push(id);
7485 }
7486 manager.cleanup_old_sessions().expect("retention");
7487
7488 let listed = manager.list_sessions().expect("sessions");
7489 assert_eq!(listed.len(), MAX_SESSIONS + 3, "nothing is unlinked");
7490 let archived: Vec<&str> = listed
7491 .iter()
7492 .filter(|session| session.archived)
7493 .map(|session| session.id.as_str())
7494 .collect();
7495 assert_eq!(
7496 archived.len(),
7497 3,
7498 "exactly the overflow is archived: {archived:?}"
7499 );
7500 for id in &ids[..3] {
7501 assert!(archived.contains(&id.as_str()), "{id} must be archived");
7502 assert!(
7503 manager.validated_session_path(id).expect("path").exists(),
7504 "the transcript file survives retention"
7505 );
7506 }
7507 for id in &ids[3..] {
7508 assert!(
7509 !archived.contains(&id.as_str()),
7510 "{id} is inside the cap and must stay active"
7511 );
7512 }
7513 }
7514
7515 #[test]
7516 fn empty_stubs_are_capped_apart_and_never_evict_transcripts() {
7517 // #6137: auto-created "New Session" stubs must not occupy (or evict
7518 // from) the transcript cap.
7519 let tmp = tempdir().expect("tempdir");
7520 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
7521 for index in 0..MAX_SESSIONS {
7522 write_session_with_updated_at(
7523 &manager,
7524 &Uuid::new_v4().to_string(),
7525 Utc::now() - chrono::Duration::minutes((MAX_SESSIONS + 20 - index) as i64),
7526 );
7527 }
7528 let mut stub_ids = Vec::new();
7529 for index in 0..(MAX_EMPTY_SESSION_STUBS + 4) {
7530 let id = Uuid::new_v4().to_string();
7531 write_empty_session_record(
7532 &manager,
7533 &id,
7534 Path::new("/tmp"),
7535 Utc::now()
7536 - chrono::Duration::minutes((MAX_EMPTY_SESSION_STUBS + 4 - index) as i64),
7537 );
7538 stub_ids.push(id);
7539 }
7540 manager.cleanup_old_sessions().expect("retention");
7541
7542 let listed = manager.list_sessions().expect("sessions");
7543 assert_eq!(
7544 listed
7545 .iter()
7546 .filter(|session| !is_empty_auto_created_session(session) && !session.archived)
7547 .count(),
7548 MAX_SESSIONS,
7549 "stubs never push a transcript out of the cap"
7550 );
7551 assert!(
7552 listed
7553 .iter()
7554 .filter(|session| !is_empty_auto_created_session(session))
7555 .all(|session| !session.archived),
7556 "no transcript is archived while only stubs are over their cap"
7557 );
7558 assert_eq!(
7559 listed
7560 .iter()
7561 .filter(|session| is_empty_auto_created_session(session))
7562 .count(),
7563 MAX_EMPTY_SESSION_STUBS,
7564 "stub retention keeps only the newest stubs"
7565 );
7566 for id in &stub_ids[..4] {
7567 assert!(
7568 !manager.validated_session_path(id).expect("path").exists(),
7569 "{id} is an old stub and must be removed"
7570 );
7571 }
7572 for id in &stub_ids[4..] {
7573 assert!(
7574 manager.validated_session_path(id).expect("path").exists(),
7575 "{id} is among the newest stubs and must stay"
7576 );
7577 }
7578 }
7579
7580 #[test]
7581 fn prune_sessions_older_than_returns_zero_for_empty_dir() {
7582 let tmp = tempdir().expect("tempdir");
7583 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
7584 let pruned = manager
7585 .prune_sessions_older_than(std::time::Duration::from_secs(3600))
7586 .expect("prune");
7587 assert_eq!(pruned, 0);
7588 }
7589
7590 #[test]
7591 fn prune_sessions_older_than_keeps_fresh_records() {
7592 let tmp = tempdir().expect("tempdir");
7593 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
7594 // All updated within the last hour.
7595 write_session_with_updated_at(
7596 &manager,
7597 "fresh-1",
7598 Utc::now() - chrono::Duration::minutes(30),
7599 );
7600 write_session_with_updated_at(
7601 &manager,
7602 "fresh-2",
7603 Utc::now() - chrono::Duration::minutes(5),
7604 );
7605 let pruned = manager
7606 .prune_sessions_older_than(std::time::Duration::from_secs(3600))
7607 .expect("prune");
7608 assert_eq!(pruned, 0);
7609 // Both files still on disk.
7610 assert_eq!(manager.list_sessions().expect("list").len(), 2);
7611 }
7612
7613 #[test]
7614 fn prune_sessions_older_than_removes_stale_records() {
7615 let tmp = tempdir().expect("tempdir");
7616 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
7617 // Two stale records ≥7 days old.
7618 write_session_with_updated_at(&manager, "stale-1", Utc::now() - chrono::Duration::days(8));
7619 write_session_with_updated_at(&manager, "stale-2", Utc::now() - chrono::Duration::days(30));
7620 let pruned = manager
7621 .prune_sessions_older_than(std::time::Duration::from_secs(7 * 24 * 3600))
7622 .expect("prune");
7623 assert_eq!(pruned, 2);
7624 assert_eq!(manager.list_sessions().expect("list").len(), 0);
7625 }
7626
7627 #[test]
7628 fn prune_sessions_older_than_only_removes_stale_records_in_mixed_dir() {
7629 let tmp = tempdir().expect("tempdir");
7630 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
7631 write_session_with_updated_at(&manager, "fresh", Utc::now() - chrono::Duration::hours(1));
7632 write_session_with_updated_at(&manager, "stale", Utc::now() - chrono::Duration::days(60));
7633 let pruned = manager
7634 .prune_sessions_older_than(std::time::Duration::from_secs(7 * 24 * 3600))
7635 .expect("prune");
7636 assert_eq!(pruned, 1);
7637 let remaining = manager.list_sessions().expect("list");
7638 assert_eq!(remaining.len(), 1);
7639 assert_eq!(remaining[0].id, "fresh");
7640 }
7641
7642 #[test]
7643 fn prune_sessions_older_than_skips_checkpoint_directory() {
7644 // The checkpoint subsystem owns `<sessions>/checkpoints/` —
7645 // prune must not walk into it. The list_sessions iterator
7646 // already filters to top-level `*.json` files (skipping
7647 // sub-directories), so this test pins that behaviour.
7648 let tmp = tempdir().expect("tempdir");
7649 let sessions_dir = tmp.path().join("sessions");
7650 let manager = SessionManager::new(sessions_dir.clone()).expect("new");
7651 let checkpoint_dir = sessions_dir.join("checkpoints");
7652 fs::create_dir_all(&checkpoint_dir).expect("mkdir checkpoints");
7653 // Drop a stale-looking JSON inside the checkpoint dir; prune
7654 // should leave it alone.
7655 let checkpoint_file = checkpoint_dir.join("latest.json");
7656 fs::write(&checkpoint_file, "{}").expect("write checkpoint");
7657
7658 write_session_with_updated_at(&manager, "stale", Utc::now() - chrono::Duration::days(60));
7659 let pruned = manager
7660 .prune_sessions_older_than(std::time::Duration::from_secs(7 * 24 * 3600))
7661 .expect("prune");
7662 assert_eq!(pruned, 1, "the top-level stale session should be removed");
7663 assert!(
7664 checkpoint_file.exists(),
7665 "checkpoint file should be untouched"
7666 );
7667 }
7668
7669 #[test]
7670 fn test_load_offline_queue_rejects_newer_schema() {
7671 let tmp = tempdir().expect("tempdir");
7672 let sessions_dir = tmp.path().join("sessions");
7673 let manager = SessionManager::new(sessions_dir.clone()).expect("new");
7674 let checkpoints = sessions_dir.join("checkpoints");
7675 fs::create_dir_all(&checkpoints).expect("create checkpoints dir");
7676 let path = checkpoints.join("session-A.offline_queue.json");
7677 fs::write(
7678 &path,
7679 r#"{
7680 "schema_version": 999,
7681 "messages": [],
7682 "draft": null
7683 }"#,
7684 )
7685 .expect("write queue");
7686
7687 let err = manager
7688 .load_offline_queue_state("session-A")
7689 .expect_err("should reject schema");
7690 assert!(
7691 err.to_string().contains("newer than supported"),
7692 "unexpected error: {err}"
7693 );
7694
7695 // An unreadable *legacy* global queue is somebody else's problem to
7696 // recover: it must not fail this session's boot, and must survive.
7697 let legacy = checkpoints.join("offline_queue.json");
7698 fs::write(&legacy, r#"{"schema_version": 999}"#).expect("write legacy queue");
7699 assert!(
7700 manager
7701 .load_offline_queue_state("session-B")
7702 .expect("legacy corruption must not fail the boot")
7703 .is_none()
7704 );
7705 assert!(legacy.exists(), "unreadable legacy queue is left in place");
7706 }
7707 #[cfg(all(unix, not(target_os = "solaris")))]
7708 #[test]
7709 fn offline_queue_lease_releases_while_an_inherited_descriptor_remains_open() {
7710 let directory = tempfile::tempdir().expect("queue fixture");
7711 let manager = SessionManager::new(directory.path().join("sessions")).expect("manager");
7712 let editor = manager
7713 .acquire_offline_queue_lease("shared-session")
7714 .expect("first editor");
7715 // dup and fork share the same open-file description. Keep it alive
7716 // without a timing race or forking the multithreaded test process.
7717 let inherited = editor._file.try_clone().expect("inherited descriptor");
7718 let pending_write = std::sync::Arc::clone(&editor);
7719 drop(editor);
7720 assert_eq!(
7721 manager
7722 .acquire_offline_queue_lease("shared-session")
7723 .unwrap_err()
7724 .kind(),
7725 io::ErrorKind::WouldBlock,
7726 "pending writes retain the exclusive editor lease"
7727 );
7728 drop(pending_write);
7729 let next_editor = manager
7730 .acquire_offline_queue_lease("shared-session")
7731 .expect("completed editor releases even while a child retains its descriptor");
7732 drop(inherited);
7733 assert_eq!(
7734 manager
7735 .acquire_offline_queue_lease("shared-session")
7736 .unwrap_err()
7737 .kind(),
7738 io::ErrorKind::WouldBlock,
7739 "closing the old descriptor must not release the next editor's lock"
7740 );
7741 drop(next_editor);
7742 assert!(
7743 manager
7744 .acquire_offline_queue_lease("shared-session")
7745 .is_ok()
7746 );
7747 }
7748
7749 #[test]
7750 fn offline_queue_lease_excludes_another_process_and_releases() {
7751 const PROBE: &str = "CODEWHALE_QUEUE_LEASE_PROBE_DIR";
7752 const HELD: &str = "CODEWHALE_QUEUE_LEASE_PROBE_HELD";
7753 if let Some(directory) = std::env::var_os(PROBE) {
7754 let manager = SessionManager::new(PathBuf::from(directory)).expect("child store");
7755 let result = manager.acquire_offline_queue_lease("shared-session");
7756 if std::env::var(HELD).as_deref() == Ok("1") {
7757 assert_eq!(result.unwrap_err().kind(), io::ErrorKind::WouldBlock);
7758 } else {
7759 assert!(result.is_ok(), "closed owner must release its kernel lock");
7760 }
7761 return;
7762 }
7763 let directory = tempfile::tempdir().expect("queue fixture");
7764 let sessions = directory.path().join("sessions");
7765 let manager = SessionManager::new(sessions.clone()).expect("parent store");
7766 let lease = manager
7767 .acquire_offline_queue_lease("shared-session")
7768 .expect("first editor");
7769 let probe = |held: bool| {
7770 let output = std::process::Command::new(
7771 std::env::current_exe().expect("test executable"),
7772 )
7773 .args([
7774 "--exact",
7775 "session_manager::tests::offline_queue_lease_excludes_another_process_and_releases",
7776 "--nocapture",
7777 "--test-threads=1",
7778 ])
7779 .env(PROBE, &sessions)
7780 .env(HELD, if held { "1" } else { "0" })
7781 .output()
7782 .expect("second editor process");
7783 assert!(
7784 output.status.success(),
7785 "{}\n{}",
7786 String::from_utf8_lossy(&output.stdout),
7787 String::from_utf8_lossy(&output.stderr)
7788 );
7789 assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"));
7790 };
7791 probe(true);
7792 let _different_session = manager
7793 .acquire_offline_queue_lease("different-session")
7794 .expect("unrelated queue is available");
7795 drop(lease);
7796 probe(false);
7797 for invalid in ["", "../session", "nested/session"] {
7798 assert_eq!(
7799 manager
7800 .acquire_offline_queue_lease(invalid)
7801 .unwrap_err()
7802 .kind(),
7803 io::ErrorKind::InvalidInput
7804 );
7805 }
7806 }
7807 }
7808
7809 #[cfg(test)]
7810 mod storage_compatible_tests {
7811 use super::*;
7812
7813 fn user(text: &str) -> Message {
7814 Message {
7815 role: codewhale_models::Role::from("user"),
7816 content: vec![codewhale_models::ContentBlock::Text {
7817 text: text.to_string(),
7818 cache_control: None,
7819 }],
7820 }
7821 }
7822
7823 /// Journal-only snapshots (#6214 T3) skip building the `messages`
7824 /// projection, but a save still lands full history: serialization
7825 /// rehydrates the projection from the journal, so a reload is whole.
7826 #[test]
7827 fn journal_only_snapshot_saves_and_reloads_full_history() {
7828 let tmp = tempfile::tempdir().expect("tempdir");
7829 let messages = vec![user("first"), user("answer")];
7830 let sparse = create_saved_session_journal_only(
7831 "roundtrip".to_string(),
7832 &messages,
7833 SessionJournal::from_messages(messages.clone(), 0),
7834 "test-model",
7835 tmp.path(),
7836 7,
7837 None,
7838 None,
7839 );
7840 assert!(
7841 sparse.messages.is_empty(),
7842 "journal-only snapshots carry no messages projection"
7843 );
7844 assert_eq!(
7845 sparse.journal.as_ref().expect("journal").to_messages(),
7846 messages,
7847 "the journal still carries every message"
7848 );
7849 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
7850 manager.save_session_owned(sparse).expect("save sparse");
7851 let reloaded = manager.load_session("roundtrip").expect("reload");
7852 assert_eq!(reloaded.messages, messages);
7853 }
7854
7855 /// The no-op cases must stay no-ops, byte for byte.
7856 ///
7857 /// `make_storage_compatible` replaced a clone-and-return-`Option` helper
7858 /// (#6214 T3). Two of that helper's paths returned `None`, and the caller
7859 /// then serialized the *original* — so a `metadata.message_count` that
7860 /// disagrees with `messages.len()` survived untouched. Rewriting it in
7861 /// place would silently edit live data on every save, and nothing else in
7862 /// the suite catches that.
7863 #[test]
7864 fn make_storage_compatible_leaves_the_no_op_cases_byte_identical() {
7865 let workspace = std::env::temp_dir();
7866 let messages = vec![user("one"), user("two")];
7867
7868 // 1. No journal at all (legacy, pre-journal files): untouched.
7869 let mut legacy = create_saved_session(&messages, "test-model", &workspace, 0, None);
7870 legacy.journal = None;
7871 legacy.metadata.message_count = 99; // deliberately disagrees
7872 let before = serde_json::to_string_pretty(&legacy).expect("legacy json");
7873 let mut after_session = legacy.clone();
7874 after_session.make_storage_compatible();
7875 assert_eq!(
7876 serde_json::to_string_pretty(&after_session).expect("legacy json"),
7877 before,
7878 "a session with no journal must serialize exactly as it arrived"
7879 );
7880 assert_eq!(after_session.metadata.message_count, 99);
7881
7882 // 2. Journal present and `messages` already equals its active branch:
7883 // still untouched, including the disagreeing count.
7884 let mut settled = create_saved_session(&messages, "test-model", &workspace, 0, None);
7885 assert!(settled.journal.is_some(), "fixture must carry a journal");
7886 settled.messages = settled
7887 .journal
7888 .as_ref()
7889 .expect("journal present")
7890 .to_messages();
7891 settled.metadata.message_count = 99;
7892 let before = serde_json::to_string_pretty(&settled).expect("settled json");
7893 let mut after_session = settled.clone();
7894 after_session.make_storage_compatible();
7895 assert_eq!(
7896 serde_json::to_string_pretty(&after_session).expect("settled json"),
7897 before,
7898 "an already-consistent session must not be rewritten"
7899 );
7900 assert_eq!(
7901 after_session.metadata.message_count, 99,
7902 "the early return must happen before message_count is recomputed"
7903 );
7904 }
7905
7906 /// The queued (journal-only) path is what the debounced flush actually
7907 /// writes: `compact_for_persistence_queue` empties `messages` first.
7908 #[test]
7909 fn make_storage_compatible_rehydrates_a_compacted_queue_snapshot() {
7910 let workspace = std::env::temp_dir();
7911 let messages = vec![user("one"), user("two"), user("three")];
7912 let mut session = create_saved_session(&messages, "test-model", &workspace, 0, None);
7913 let expected = session
7914 .journal
7915 .as_ref()
7916 .expect("journal present")
7917 .to_messages();
7918
7919 session.compact_for_persistence_queue();
7920 assert!(
7921 session.messages.is_empty(),
7922 "the queued snapshot is journal-only"
7923 );
7924
7925 session.make_storage_compatible();
7926 assert_eq!(
7927 session.messages, expected,
7928 "the compat projection is rebuilt from the journal"
7929 );
7930 assert_eq!(session.metadata.message_count, expected.len());
7931 }
7932 }
7933
7933 lines RUST