返回 CodeWhale
test_cases_01.rs
根目录 / crates / tui / src / session_manager / test_cases_01.rs
1
2 use super::*;
3 use crate::approval_log::ApprovalOutcome;
4 use crate::tools::plan::StepStatus;
5 use crate::tui::history::{HistoryCell, ToolCell, history_cells_from_message};
6 use codewhale_models::ContentBlock;
7 use codewhale_models::Role;
8 use std::fs;
9 use tempfile::tempdir;
10
11 fn make_test_message(role: &str, text: &str) -> Message {
12 Message {
13 role: Role::from(role),
14 content: vec![codewhale_models::ContentBlock::Text {
15 text: text.to_string(),
16 cache_control: None,
17 }],
18 }
19 }
20
21 /// The journal is the session's timeline: an entry's `created_at` is when
22 /// the message landed, not when a save ran. A rebuilt journal must not
23 /// collapse 90 minutes of appends into the save instant — an inspector
24 /// reading the file needs "this loop is 12 seconds" to be true.
25 #[test]
26 fn journal_entries_keep_append_stamps_across_saves() {
27 let tmp = tempdir().expect("tempdir");
28 let messages = vec![
29 make_test_message("user", "first"),
30 make_test_message("assistant", "answer"),
31 ];
32 let t0 = Utc::now() - chrono::Duration::minutes(90);
33 let t1 = t0 + chrono::Duration::seconds(12);
34 let session = create_saved_session_with_id_mode_and_stamps(
35 "stamped".to_string(),
36 &messages,
37 &[t0, t1],
38 "deepseek-v4-flash",
39 tmp.path(),
40 0,
41 None,
42 None,
43 );
44 let journal = session.journal.as_ref().expect("journal");
45 assert_eq!(journal.entries[0].created_at, t0);
46 assert_eq!(journal.entries[1].created_at, t1);
47 assert_ne!(
48 journal.entries[0].created_at, session.metadata.updated_at,
49 "an append 90 minutes before save must not read as save time"
50 );
51 // Resume hands the same stamps back to the live log.
52 assert_eq!(session.journal_message_stamps(), vec![t0, t1]);
53 // A save with no stamps keeps the old behavior: entries collapse to
54 // save time rather than inventing times.
55 let before_save = Utc::now();
56 let unstamped = create_saved_session_with_id_and_mode(
57 "unstamped".to_string(),
58 &messages,
59 "deepseek-v4-flash",
60 tmp.path(),
61 0,
62 None,
63 None,
64 );
65 let journal = unstamped.journal.as_ref().expect("journal");
66 assert!(
67 journal.entries.iter().all(|entry| {
68 entry.created_at >= before_save && entry.created_at <= unstamped.metadata.created_at
69 }),
70 "unstamped entries are created during save, before snapshot metadata"
71 );
72 }
73
74 fn save_late_usage_test_session(manager: &SessionManager, id: &str) -> SavedSession {
75 let session = create_saved_session_with_id_and_mode(
76 id.to_string(),
77 &[make_test_message("user", "recoverable transcript")],
78 "deepseek-v4-flash",
79 manager.sessions_dir(),
80 0,
81 None,
82 Some("agent"),
83 );
84 manager.save_session(&session).expect("save session");
85 session
86 }
87
88 fn late_usage_test_record(source_id: &str) -> crate::cost_status::RuntimeUsageRecord {
89 crate::cost_status::RuntimeUsageRecord {
90 source_id: source_id.to_string(),
91 usage: crate::cost_status::EffectiveRouteUsage {
92 route: crate::cost_status::EffectiveRouteEnvelope::capture(
93 None,
94 ProviderKind::Deepseek,
95 "deepseek",
96 "deepseek-v4-flash",
97 Some(crate::config::DEFAULT_DEEPSEEK_BASE_URL),
98 Utc::now(),
99 ),
100 usage: codewhale_models::Usage {
101 input_tokens: 1,
102 ..Default::default()
103 },
104 },
105 }
106 }
107
108 #[test]
109 fn decision_receipt_survives_restart_replay_and_session_deletion() {
110 let tmp = tempdir().expect("tempdir");
111 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
112 save_late_usage_test_session(&manager, "decision-origin");
113 let receipt = crate::cost_status::decision_receipt_fixture("raw-decision-response-id");
114 manager
115 .persist_late_decision_receipt("decision-origin", "origin-turn", &receipt)
116 .expect("decision append");
117 manager
118 .persist_late_runtime_usage(
119 "decision-origin",
120 "origin-turn",
121 &crate::cost_status::RuntimeUsageRecord {
122 source_id: receipt.source_id.clone(),
123 usage: crate::cost_status::EffectiveRouteUsage {
124 route: receipt.route.clone(),
125 usage: receipt.usage.clone().expect("usage"),
126 },
127 },
128 )
129 .expect("token append dedupes");
130 drop(manager);
131 let manager = SessionManager::new(tmp.path().join("sessions")).expect("restart");
132 let retained = manager
133 .decision_receipts_for_session("decision-origin")
134 .expect("receipts");
135 assert_eq!(retained, vec![receipt.sanitized()]);
136 let mut restored = manager
137 .load_session_snapshot("decision-origin")
138 .expect("restore");
139 assert_eq!(restored.metadata.total_tokens, 13);
140 assert_eq!(
141 restored.metadata.cost.unpriced_turns, 1,
142 "unknown TypeSafe billing remains explicit"
143 );
144 assert!(
145 restored
146 .metadata
147 .cost
148 .route_receipts
149 .iter()
150 .any(|r| r.contains("0.000012054"))
151 );
152 manager.apply_late_usage_to_metadata(&mut restored.metadata);
153 assert_eq!(
154 restored.metadata.total_tokens, 13,
155 "replay must not count tokens twice"
156 );
157 assert_eq!(restored.metadata.cost.unpriced_turns, 1);
158 manager.save_session(&restored).expect("save overlay");
159 assert_eq!(
160 manager
161 .load_session_snapshot("decision-origin")
162 .expect("resume again")
163 .metadata
164 .total_tokens,
165 13
166 );
167 let (ledger, _) = manager.late_usage_paths("decision-origin").expect("paths");
168 assert!(
169 !fs::read_to_string(&ledger)
170 .expect("ledger")
171 .contains("raw-decision-response-id")
172 );
173 save_late_usage_test_session(&manager, "decision-partial-origin");
174 let mut partial = receipt.clone();
175 partial.source_id = "partial-provider-response-id".into();
176 partial.usage_complete = false;
177 partial.usage.as_mut().expect("partial usage").output_tokens = 0;
178 manager
179 .persist_late_decision_receipt("decision-partial-origin", "partial-turn", &partial)
180 .expect("partial diagnostic append");
181 let partial_session = manager
182 .load_session_snapshot("decision-partial-origin")
183 .expect("partial replay");
184 assert_eq!(
185 partial_session.metadata.total_tokens, 0,
186 "partial provider counters remain diagnostic rather than an authoritative subtotal"
187 );
188 assert_eq!(partial_session.metadata.cost.unpriced_turns, 1);
189 assert!(
190 partial_session
191 .metadata
192 .cost
193 .route_receipts
194 .iter()
195 .any(|r| r.contains("0.000012054"))
196 );
197 manager.delete_session("decision-origin").expect("delete");
198 assert!(
199 manager
200 .persist_late_decision_receipt("decision-origin", "origin-turn", &receipt)
201 .expect("retired replay")
202 );
203 assert!(
204 !ledger.exists(),
205 "late evidence must never recreate a deleted origin"
206 );
207 let mut oversized = receipt;
208 oversized.evidence.response_model = Some("x".repeat(129));
209 assert!(
210 manager
211 .persist_late_decision_receipt("decision-origin", "origin-turn", &oversized)
212 .is_err()
213 );
214 }
215
216 #[test]
217 fn late_usage_reads_do_not_create_accounting_storage() {
218 let tmp = tempdir().expect("tempdir");
219 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
220 let saved = save_late_usage_test_session(&manager, "no-late-usage");
221 let directory = manager.sessions_dir().join(LATE_USAGE_DIR);
222 let inventory = || {
223 fs::read_dir(&directory)
224 .expect("accounting directory")
225 .map(|entry| entry.expect("entry").file_name())
226 .collect::<BTreeSet<_>>()
227 };
228 let before = inventory();
229 assert_eq!(before.len(), 1, "save creates the lifecycle lock only");
230 assert_eq!(manager.list_sessions().expect("list").len(), 1);
231 manager.load_session_by_prefix("no-late").expect("resume");
232 manager
233 .load_session_snapshot("no-late-usage")
234 .expect("snapshot");
235 assert_eq!(inventory(), before, "reads must not create sidecar files");
236
237 // Imported snapshots predate lifecycle locks. Reading one must not
238 // create either its missing accounting directory or a lock leaf.
239 let imported = SessionManager::new(tmp.path().join("imported")).expect("imported store");
240 write_atomic(
241 &imported
242 .validated_session_path(&saved.metadata.id)
243 .expect("imported path"),
244 serialize_saved_session(saved.clone())
245 .expect("snapshot bytes")
246 .as_bytes(),
247 )
248 .expect("import snapshot");
249 let imported_directory = imported.sessions_dir().join(LATE_USAGE_DIR);
250 imported.list_sessions().expect("imported list");
251 imported
252 .load_session_by_prefix("no-late")
253 .expect("imported resume");
254 assert!(
255 !imported_directory.exists(),
256 "reads must not create the sidecar directory"
257 );
258
259 fs::create_dir(&imported_directory).expect("empty accounting directory");
260 imported
261 .load_session_snapshot("no-late-usage")
262 .expect("imported snapshot");
263 assert_eq!(
264 fs::read_dir(imported_directory).expect("directory").count(),
265 0
266 );
267 }
268
269 #[test]
270 fn late_usage_projection_failure_preserves_recovery_and_is_idempotent() {
271 for malformed in ["json", "oversized", "directory", "tombstone"] {
272 let tmp = tempdir().expect("tempdir");
273 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
274 let affected = save_late_usage_test_session(&manager, "affected-session");
275 save_late_usage_test_session(&manager, "healthy-session");
276 let (ledger, _) = manager
277 .ensure_late_usage_paths("affected-session")
278 .expect("paths");
279 match malformed {
280 "json" => fs::write(&ledger, b"{invalid accounting").expect("malformed ledger"),
281 "oversized" => fs::File::create(&ledger)
282 .expect("file")
283 .set_len(MAX_LATE_USAGE_LEDGER_BYTES + 1)
284 .expect("oversized ledger"),
285 "directory" => fs::create_dir(&ledger).expect("special ledger"),
286 "tombstone" => fs::write(ledger.with_extension("deleted"), b"invalid marker")
287 .expect("malformed tombstone"),
288 _ => unreachable!(),
289 }
290
291 let listed = manager
292 .list_sessions()
293 .expect("list survives sidecar failure");
294 assert_eq!(listed.len(), 2);
295 let bad = listed
296 .iter()
297 .find(|session| session.id == affected.metadata.id)
298 .expect("affected");
299 assert!(
300 bad.cost
301 .unpriced_reasons
302 .contains(LATE_USAGE_UNAVAILABLE_REASON)
303 );
304 assert_eq!(bad.cost.unpriced_turns, 1);
305 let good = manager
306 .load_session_by_prefix("healthy")
307 .expect("unaffected resume");
308 assert_eq!(good.metadata.cost.unpriced_turns, 0);
309
310 let mut restored = manager
311 .load_session_by_prefix("affected")
312 .expect("affected recovery");
313 assert_eq!(restored.messages, affected.messages);
314 manager.apply_late_usage_to_metadata(&mut restored.metadata);
315 assert_eq!(restored.metadata.cost.unpriced_turns, 1);
316 assert_eq!(restored.metadata.cost.cny_unpriced_turns, 1);
317 assert_eq!(restored.metadata.cost.usage_source_fingerprints.len(), 1);
318 if malformed == "tombstone" {
319 assert!(
320 manager.save_session(&restored).is_err(),
321 "an invalid deletion marker must fail closed for writes"
322 );
323 fs::remove_file(ledger.with_extension("deleted"))
324 .expect("repair malformed deletion marker");
325 }
326 manager
327 .save_session(&restored)
328 .expect("save recovered transcript");
329 let again = manager
330 .load_session_snapshot("affected-session")
331 .expect("repeat recovery");
332 assert_eq!(again.metadata.cost.unpriced_turns, 1);
333 assert_eq!(again.metadata.cost.cny_unpriced_turns, 1);
334 assert_eq!(
335 again.metadata.total_tokens, 0,
336 "unsafe accounting must not be used"
337 );
338 }
339 }
340
341 #[cfg(unix)]
342 #[test]
343 fn linked_late_usage_directory_does_not_block_transcripts_or_touch_target() {
344 use std::os::unix::fs::{PermissionsExt as _, symlink};
345 let tmp = tempdir().expect("tempdir");
346 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
347 save_late_usage_test_session(&manager, "linked-directory");
348 let outside = tmp.path().join("outside");
349 fs::create_dir(&outside).expect("outside directory");
350 fs::set_permissions(&outside, fs::Permissions::from_mode(0o755))
351 .expect("outside permissions");
352 fs::rename(
353 manager.sessions_dir().join(LATE_USAGE_DIR),
354 tmp.path().join("original-accounting"),
355 )
356 .expect("park original accounting directory");
357 symlink(&outside, manager.sessions_dir().join(LATE_USAGE_DIR)).expect("linked store");
358 let recovered = manager
359 .load_session_snapshot("linked-directory")
360 .expect("transcript");
361 assert!(
362 recovered
363 .metadata
364 .cost
365 .unpriced_reasons
366 .contains(LATE_USAGE_UNAVAILABLE_REASON)
367 );
368 assert_eq!(manager.list_sessions().expect("listing").len(), 1);
369 assert!(
370 manager
371 .persist_late_runtime_usage(
372 "linked-directory",
373 "turn",
374 &late_usage_test_record("source")
375 )
376 .is_err()
377 );
378 assert_eq!(fs::read_dir(&outside).expect("outside contents").count(), 0);
379 assert_eq!(
380 fs::metadata(outside)
381 .expect("outside metadata")
382 .permissions()
383 .mode()
384 & 0o777,
385 0o755
386 );
387 }
388
389 #[test]
390 fn deleting_session_retires_late_usage_and_keeps_one_lock_inode() {
391 let tmp = tempdir().expect("tempdir");
392 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
393 save_late_usage_test_session(&manager, "deleted-session");
394 let record = late_usage_test_record("before-deletion");
395 manager
396 .persist_late_runtime_usage("deleted-session", "turn", &record)
397 .expect("append");
398 let (ledger, lock_path) = manager.late_usage_paths("deleted-session").expect("paths");
399 let mut old_lock =
400 fd_lock::RwLock::new(open_private_lock_file(&lock_path).expect("captured lock"));
401
402 manager.delete_session("deleted-session").expect("delete");
403 assert!(!ledger.exists());
404 assert!(
405 !manager
406 .validated_session_path("deleted-session")
407 .expect("session path")
408 .exists()
409 );
410 assert!(SessionManager::late_usage_is_deleted(&ledger).expect("tombstone"));
411 assert!(manager.load_late_usage("deleted-session").is_err());
412 assert!(
413 manager
414 .persist_late_runtime_usage("deleted-session", "turn", &record)
415 .expect("retired replay")
416 );
417 assert!(
418 !ledger.exists(),
419 "late callback must not resurrect accounting"
420 );
421 manager
422 .delete_session("deleted-session")
423 .expect("idempotent cleanup retry");
424
425 let mut new_lock =
426 fd_lock::RwLock::new(open_private_lock_file(&lock_path).expect("current lock"));
427 let _held = old_lock.write().expect("old handle still owns the lock");
428 assert!(
429 matches!(new_lock.try_write(), Err(error) if error.kind() == io::ErrorKind::WouldBlock),
430 "deletion must not replace or unlink a held lock inode"
431 );
432 }
433
434 #[test]
435 fn lifecycle_admission_holds_delete_lock_and_rejects_retired_origin() {
436 let tmp = tempdir().expect("tempdir");
437 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
438 save_late_usage_test_session(&manager, "active-origin");
439 let (_, lock_path) = manager.late_usage_paths("active-origin").expect("paths");
440 let mut competing_lock =
441 fd_lock::RwLock::new(open_private_lock_file(&lock_path).expect("competing lock"));
442 assert_eq!(
443 manager
444 .with_live_session_origin("active-origin", || {
445 assert!(
446 matches!(competing_lock.try_write(), Err(error) if error.kind() == io::ErrorKind::WouldBlock),
447 "active acceptance must hold the deletion lock"
448 );
449 true
450 })
451 .expect("active acceptance"),
452 Some(true)
453 );
454 assert_eq!(
455 manager
456 .with_live_session_origin("active-origin", || false)
457 .expect("stale scope falls through"),
458 Some(false)
459 );
460 drop(competing_lock.write().expect("admission releases the lock"));
461
462 manager.delete_session("active-origin").expect("delete");
463 let mut ran_after_delete = false;
464 assert_eq!(
465 manager
466 .with_live_session_origin("active-origin", || {
467 ran_after_delete = true;
468 true
469 })
470 .expect("retired origin"),
471 None
472 );
473 assert!(!ran_after_delete, "retired scopes cannot accept new usage");
474 }
475
476 #[test]
477 fn deleted_session_rejects_stale_snapshot_and_checkpoint_saves() {
478 let tmp = tempdir().expect("tempdir");
479 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
480 let stale = save_late_usage_test_session(&manager, "stale-writer");
481 manager.delete_session("stale-writer").expect("delete");
482 for error in [
483 manager.save_session(&stale).expect_err("reject stale save"),
484 manager
485 .save_checkpoint(&stale)
486 .expect_err("reject stale checkpoint"),
487 ] {
488 assert_eq!(error.kind(), io::ErrorKind::NotFound);
489 }
490 assert!(manager.list_sessions().expect("list").is_empty());
491 assert!(
492 !manager
493 .validated_checkpoint_path("stale-writer")
494 .expect("checkpoint path")
495 .exists(),
496 "a retired writer must not recreate crash-recovery data"
497 );
498 }
499
500 #[test]
501 fn explicit_delete_removes_owned_recovery_checkpoints_only() {
502 let tmp = tempdir().expect("tempdir");
503 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
504 let retired = save_late_usage_test_session(&manager, "retired-recovery");
505 let retained = save_late_usage_test_session(&manager, "retained-recovery");
506 manager.save_checkpoint(&retired).expect("owned checkpoint");
507 manager
508 .save_checkpoint(&retained)
509 .expect("other checkpoint");
510 let legacy_path = manager.checkpoints_dir().join(LEGACY_CHECKPOINT_FILE);
511 write_atomic(
512 &legacy_path,
513 serialize_saved_session(retired.clone())
514 .expect("legacy bytes")
515 .as_bytes(),
516 )
517 .expect("owned legacy checkpoint");
518
519 manager.delete_session("retired-recovery").expect("delete");
520 assert!(manager.load_legacy_checkpoint().expect("legacy").is_none());
521 assert!(
522 manager
523 .load_session_checkpoint("retired-recovery")
524 .expect("owned checkpoint")
525 .is_none()
526 );
527 let checkpoints = manager.list_checkpoints().expect("checkpoint picker");
528 assert_eq!(checkpoints.len(), 1);
529 assert!(matches!(
530 &checkpoints[0].source,
531 CheckpointSource::Session(id) if id == "retained-recovery"
532 ));
533 assert!(
534 manager
535 .load_session_checkpoint("retained-recovery")
536 .expect("other recovery")
537 .is_some()
538 );
539
540 // An origin can exist only as crash recovery, with no ordinary
541 // snapshot. Explicit deletion must still be able to retire it.
542 fs::remove_file(
543 manager
544 .validated_session_path("retained-recovery")
545 .expect("ordinary snapshot path"),
546 )
547 .expect("simulate checkpoint-only origin");
548 manager
549 .delete_session("retained-recovery")
550 .expect("delete recovery-only origin");
551 assert!(
552 manager
553 .list_checkpoints()
554 .expect("checkpoint picker")
555 .is_empty()
556 );
557 assert!(manager.save_checkpoint(&retained).is_err());
558 }
559
560 #[test]
561 fn retention_preserves_checkpoint_origin_receipts_and_evidence() {
562 for retention in ["age", "size"] {
563 for checkpoint_kind in ["session", "legacy"] {
564 let tmp = tempdir().expect("tempdir");
565 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
566 let id = "55555555-5555-4555-8555-555555555555";
567 let mut old = save_late_usage_test_session(&manager, id);
568 old.metadata.updated_at = Utc::now() - chrono::Duration::days(60);
569 manager.save_session(&old).expect("old snapshot");
570 let evidence = manager.sessions_dir().join(id).join("artifacts");
571 fs::create_dir_all(&evidence).expect("recovery evidence");
572 fs::write(evidence.join("receipt.txt"), b"recoverable evidence").expect("receipt");
573 if checkpoint_kind == "session" {
574 manager.save_checkpoint(&old).expect("recovery checkpoint");
575 } else {
576 fs::create_dir_all(manager.checkpoints_dir()).expect("checkpoints");
577 write_atomic(
578 &manager.checkpoints_dir().join(LEGACY_CHECKPOINT_FILE),
579 serialize_saved_session(old.clone())
580 .expect("legacy bytes")
581 .as_bytes(),
582 )
583 .expect("legacy recovery checkpoint");
584 }
585 manager
586 .persist_late_runtime_usage(id, "turn", &late_usage_test_record("before-prune"))
587 .expect("origin accounting");
588 if retention == "age" {
589 assert_eq!(
590 manager
591 .prune_sessions_older_than(std::time::Duration::from_secs(24 * 3600))
592 .expect("age prune"),
593 1
594 );
595 assert!(
596 !manager
597 .validated_session_path(id)
598 .expect("snapshot path")
599 .exists(),
600 "an explicit age prune still unlinks"
601 );
602 } else {
603 for index in 0..MAX_SESSIONS {
604 write_session_with_updated_at(
605 &manager,
606 &format!("fresh-{index}"),
607 Utc::now(),
608 );
609 }
610 manager.cleanup_old_sessions().expect("size cleanup");
611 let listed = manager.list_sessions().expect("sessions");
612 assert_eq!(
613 listed.len(),
614 MAX_SESSIONS + 1,
615 "the archived record stays listed outside the active cap"
616 );
617 let retained = listed
618 .iter()
619 .find(|session| session.id == id)
620 .expect("archived record remains on disk");
621 assert!(
622 retained.archived,
623 "a transcript past the cap is archived, never unlinked (#6136)"
624 );
625 assert!(
626 manager
627 .validated_session_path(id)
628 .expect("snapshot path")
629 .exists(),
630 "the transcript file survives retention"
631 );
632 }
633 let (ledger, _) = manager.late_usage_paths(id).expect("ledger paths");
634 assert!(!SessionManager::late_usage_is_deleted(&ledger).expect("origin retained"));
635 assert!(ledger.exists(), "recovery must retain accounting");
636 assert!(
637 evidence.join("receipt.txt").exists(),
638 "recovery must retain evidence"
639 );
640 assert_eq!(
641 manager
642 .with_live_session_origin(id, || true)
643 .expect("resume admission"),
644 Some(true)
645 );
646 let mut recovered = if checkpoint_kind == "session" {
647 manager
648 .load_session_checkpoint(id)
649 .expect("checkpoint read")
650 } else {
651 manager.load_legacy_checkpoint().expect("legacy read")
652 }
653 .expect("retained recovery");
654 assert_eq!(
655 recovered.metadata.total_tokens, 1,
656 "checkpoint overlays exact origin usage"
657 );
658 recovered.metadata.updated_at = Utc::now();
659 manager
660 .save_session(&recovered)
661 .expect("save resumed origin");
662 assert_eq!(
663 manager
664 .load_session_snapshot(id)
665 .expect("resumed snapshot")
666 .metadata
667 .total_tokens,
668 1,
669 "replayed recovery accounting remains idempotent"
670 );
671 }
672 }
673 }
674
675 #[test]
676 fn external_writers_are_refused_while_another_process_holds_the_live_lease() {
677 let tmp = tempdir().expect("tempdir");
678 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
679 let id = "77777777-7777-4777-8777-777777777777";
680 save_late_usage_test_session(&manager, id);
681 let snapshot = manager.validated_session_path(id).expect("snapshot path");
682 let original = fs::read(&snapshot).expect("original bytes");
683 // Another process's TUI holds the lease; this process's in-memory
684 // registry knows nothing about it.
685 let lease = manager.hold_live_lease_elsewhere(id);
686 assert!(!is_live_session(id));
687
688 let rename = manager
689 .rename_session(id, "Renamed elsewhere", SessionMutator::External)
690 .expect_err("a rename would be reverted by the owner's next autosave");
691 assert_eq!(rename.kind(), io::ErrorKind::ResourceBusy);
692 let archive = manager
693 .set_session_archived(id, true, SessionMutator::External)
694 .expect_err("an archive would be reverted by the owner's next autosave");
695 assert_eq!(archive.kind(), io::ErrorKind::ResourceBusy);
696 assert_eq!(fs::read(&snapshot).expect("bytes"), original);
697
698 drop(lease);
699 manager
700 .rename_session(id, "Renamed after close", SessionMutator::External)
701 .expect("an unowned session accepts external writes");
702 }
703
704 #[test]
705 fn external_mutations_keep_the_live_lease_until_the_blocked_write_finishes() {
706 let _env = crate::test_support::lock_test_env();
707 let mut failures = Vec::new();
708 for operation in ["rename", "archive", "delete"] {
709 let tmp = tempdir().expect("tempdir");
710 let manager = std::sync::Arc::new(
711 SessionManager::new(tmp.path().join("sessions")).expect("manager"),
712 );
713 let id = "88888888-8888-4888-8888-888888888888";
714 save_late_usage_test_session(&manager, id);
715 // Hold the actual persistence lock so the shipping mutation stays
716 // between admission and commit while another surface tries attach.
717 let (_, lock_path) = manager.ensure_late_usage_paths(id).expect("paths");
718 let mut lock = fd_lock::RwLock::new(open_private_lock_file(&lock_path).unwrap());
719 let write_guard = lock.write().expect("hold persistence lock");
720 let (entered_tx, entered_rx) = std::sync::mpsc::channel();
721 let worker_manager = manager.clone();
722 let ticket = crate::test_support::env_scope_ticket();
723 let worker = std::thread::spawn(move || {
724 let _membership = crate::test_support::join_env_scope(ticket);
725 entered_tx.send(()).unwrap();
726 match operation {
727 "rename" => worker_manager
728 .rename_session(id, "Atomic rename", SessionMutator::External)
729 .map(|_| ()),
730 "archive" => worker_manager
731 .set_session_archived(id, true, SessionMutator::External)
732 .map(|_| ()),
733 "delete" => worker_manager.delete_session(id),
734 _ => unreachable!(),
735 }
736 });
737 entered_rx
738 .recv_timeout(std::time::Duration::from_secs(2))
739 .unwrap();
740 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
741 while !manager.is_session_live_anywhere(id)
742 && !worker.is_finished()
743 && std::time::Instant::now() < deadline
744 {
745 std::thread::sleep(std::time::Duration::from_millis(5));
746 }
747 let holds_lease = manager.is_session_live_anywhere(id);
748 let attach_refused = manager
749 .reserve_session_for_attach(id)
750 .is_err_and(|error| error.kind() == io::ErrorKind::ResourceBusy);
751 // Release and join before asserting, even in the fixes-off case;
752 // no failed assertion may leave a blocked mutation behind.
753 drop(write_guard);
754 worker
755 .join()
756 .expect("mutation worker")
757 .expect("mutation succeeds");
758 if !holds_lease {
759 failures.push(format!("{operation}: released its lease before commit"));
760 }
761 if !attach_refused {
762 failures.push(format!("{operation}: admitted a competing session owner"));
763 }
764 assert!(
765 !manager.is_session_live_anywhere(id),
766 "{operation}: leaked its lease"
767 );
768 }
769 assert!(failures.is_empty(), "{failures:?}");
770 }
771
772 #[cfg(unix)]
773 #[test]
774 fn uncertain_live_lease_refuses_external_mutations_without_changing_history() {
775 let tmp = tempdir().expect("tempdir");
776 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
777 let id = "99999999-9999-4999-8999-999999999999";
778 save_late_usage_test_session(&manager, id);
779 let snapshot = manager.validated_session_path(id).unwrap();
780 let original = fs::read(&snapshot).unwrap();
781 let target = tmp.path().join("untouched");
782 fs::write(&target, b"unrelated bytes").unwrap();
783 let live_path = manager.live_lease_path(id, true).unwrap();
784 std::os::unix::fs::symlink(&target, &live_path).unwrap();
785 assert!(
786 manager.is_session_live_anywhere(id),
787 "uncertain ownership is not free"
788 );
789 assert!(
790 manager
791 .rename_session(id, "Unsafe", SessionMutator::External)
792 .is_err()
793 );
794 assert!(
795 manager
796 .set_session_archived(id, true, SessionMutator::External)
797 .is_err()
798 );
799 assert!(manager.delete_session(id).is_err());
800 assert_eq!(fs::read(snapshot).unwrap(), original);
801 assert_eq!(fs::read(target).unwrap(), b"unrelated bytes");
802 }
803
804 #[test]
805 fn retention_fails_closed_on_an_unreadable_legacy_checkpoint_origin() {
806 let tmp = tempdir().expect("tempdir");
807 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
808 let id = "66666666-6666-4666-8666-666666666666";
809 let mut old = save_late_usage_test_session(&manager, id);
810 old.metadata.updated_at = Utc::now() - chrono::Duration::days(60);
811 manager.save_session(&old).expect("old snapshot");
812 let snapshot = manager.validated_session_path(id).expect("snapshot path");
813 let original = fs::read(&snapshot).expect("original snapshot bytes");
814 // Fault fixture: a legacy checkpoint whose owner cannot be read. It
815 // may be this session's (damaged) recovery.
816 fs::create_dir_all(manager.checkpoints_dir()).expect("checkpoints");
817 let legacy = manager.checkpoints_dir().join(LEGACY_CHECKPOINT_FILE);
818 let corrupt: &[u8] = b"{\"messages\": [truncated";
819 write_atomic(&legacy, corrupt).expect("corrupt legacy checkpoint");
820 assert!(manager.legacy_checkpoint_origin().is_err());
821
822 assert_eq!(
823 manager
824 .prune_sessions_older_than(std::time::Duration::from_secs(24 * 3600))
825 .expect("age prune"),
826 0,
827 "an uncertain origin must not be pruned"
828 );
829 assert_eq!(
830 fs::read(&snapshot).expect("snapshot survives retention"),
831 original,
832 "the ordinary snapshot keeps its original bytes"
833 );
834 assert_eq!(fs::read(&legacy).expect("legacy survives"), corrupt);
835 let (ledger, _) = manager.late_usage_paths(id).expect("ledger paths");
836 assert!(!SessionManager::late_usage_is_deleted(&ledger).expect("not retired"));
837
838 // Explicit deletion remains the user's decision.
839 manager.delete_session(id).expect("explicit delete");
840 assert!(!snapshot.exists());
841 }
842
843 #[test]
844 fn interrupted_session_deletion_keeps_recovery_incomplete_and_can_finish() {
845 let tmp = tempdir().expect("tempdir");
846 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
847 let saved = save_late_usage_test_session(&manager, "interrupted-delete");
848 manager
849 .save_checkpoint(&saved)
850 .expect("checkpoint before deletion");
851 write_atomic(
852 &manager.checkpoints_dir().join(LEGACY_CHECKPOINT_FILE),
853 serialize_saved_session(saved.clone())
854 .expect("legacy bytes")
855 .as_bytes(),
856 )
857 .expect("legacy checkpoint before deletion");
858 let (ledger, _) = manager
859 .ensure_late_usage_paths("interrupted-delete")
860 .expect("paths");
861 write_atomic(&ledger.with_extension("deleted"), LATE_USAGE_DELETED)
862 .expect("crash after tombstone");
863 let recovered = manager
864 .load_session_snapshot("interrupted-delete")
865 .expect("transcript remains recoverable");
866 assert!(
867 recovered
868 .metadata
869 .cost
870 .unpriced_reasons
871 .contains(LATE_USAGE_UNAVAILABLE_REASON)
872 );
873 assert!(
874 manager
875 .load_session_checkpoint("interrupted-delete")
876 .expect("checkpoint read")
877 .is_none(),
878 "a checkpoint retired before a crash must not be offered for recovery"
879 );
880 assert!(
881 manager
882 .load_legacy_checkpoint()
883 .expect("legacy read")
884 .is_none()
885 );
886 manager
887 .delete_session("interrupted-delete")
888 .expect("finish deletion");
889 assert!(manager.list_sessions().expect("list").is_empty());
890 assert!(manager.list_checkpoints().expect("checkpoints").is_empty());
891 }
892
893 #[test]
894 #[ignore = "subprocess helper for the late usage deletion regression"]
895 fn late_usage_callback_subprocess() {
896 let directory = PathBuf::from(
897 std::env::var_os("CODEWHALE_LATE_USAGE_TEST_DIR").expect("fixture directory"),
898 );
899 let manager = SessionManager::new(directory.join("sessions")).expect("manager");
900 manager
901 .persist_late_runtime_usage(
902 "process-delete-race",
903 "turn",
904 &late_usage_test_record("first-callback"),
905 )
906 .expect("first callback");
907 fs::write(directory.join("ready"), b"ready").expect("signal ready");
908 let started = std::time::Instant::now();
909 while !directory.join("continue").exists() {
910 assert!(
911 started.elapsed() < std::time::Duration::from_secs(10),
912 "callback gate timed out"
913 );
914 std::thread::sleep(std::time::Duration::from_millis(5));
915 }
916 assert!(
917 manager
918 .persist_late_runtime_usage(
919 "process-delete-race",
920 "turn",
921 &late_usage_test_record("late-callback")
922 )
923 .expect("retired callback")
924 );
925 }
926
927 #[test]
928 fn late_usage_callback_in_another_process_cannot_resurrect_deleted_session() {
929 let tmp = tempdir().expect("tempdir");
930 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
931 save_late_usage_test_session(&manager, "process-delete-race");
932 let mut child =
933 std::process::Command::new(std::env::current_exe().expect("test executable"))
934 .args([
935 "--exact",
936 "session_manager::tests::late_usage_callback_subprocess",
937 "--ignored",
938 ])
939 .env("CODEWHALE_LATE_USAGE_TEST_DIR", tmp.path())
940 .stdout(std::process::Stdio::null())
941 .stderr(std::process::Stdio::null())
942 .spawn()
943 .expect("callback process");
944 let started = std::time::Instant::now();
945 while !tmp.path().join("ready").exists() {
946 if started.elapsed() >= std::time::Duration::from_secs(10)
947 || child.try_wait().expect("child status").is_some()
948 {
949 let _ = child.kill();
950 let _ = child.wait();
951 panic!("callback process did not reach the deletion gate");
952 }
953 std::thread::sleep(std::time::Duration::from_millis(5));
954 }
955 assert_eq!(
956 manager
957 .load_session_snapshot("process-delete-race")
958 .expect("first callback persisted")
959 .metadata
960 .total_tokens,
961 1
962 );
963 let deleted = manager.delete_session("process-delete-race");
964 fs::write(tmp.path().join("continue"), b"continue").expect("release callback");
965 let status = loop {
966 if let Some(status) = child.try_wait().expect("child status") {
967 break status;
968 }
969 if started.elapsed() >= std::time::Duration::from_secs(10) {
970 let _ = child.kill();
971 let _ = child.wait();
972 panic!("late callback process did not finish");
973 }
974 std::thread::sleep(std::time::Duration::from_millis(5));
975 };
976 deleted.expect("delete while callback process was pending");
977 assert!(status.success(), "callback process failed");
978 let (ledger, _) = manager
979 .late_usage_paths("process-delete-race")
980 .expect("paths");
981 assert!(!ledger.exists());
982 assert!(manager.list_sessions().expect("list").is_empty());
983 }
984
985 #[test]
986 fn late_usage_sidecar_survives_stale_session_save_and_replays_once() {
987 let tmp = tempdir().expect("tempdir");
988 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
989 let old_id = "old-session";
990 let new_id = "new-session";
991 let old = create_saved_session_with_id_and_mode(
992 old_id.to_string(),
993 &[make_test_message("user", "old session")],
994 "deepseek-v4-flash",
995 tmp.path(),
996 0,
997 None,
998 Some("agent"),
999 );
1000 let new = create_saved_session_with_id_and_mode(
1001 new_id.to_string(),
1002 &[make_test_message("user", "new session")],
1003 "deepseek-v4-flash",
1004 tmp.path(),
1005 0,
1006 None,
1007 Some("agent"),
1008 );
1009 manager.save_session(&old).expect("save old");
1010 manager.save_session(&new).expect("save new");
1011
1012 let priced_route = crate::cost_status::EffectiveRouteEnvelope::capture(
1013 None,
1014 ProviderKind::Deepseek,
1015 "deepseek",
1016 "deepseek-v4-flash",
1017 Some(crate::config::DEFAULT_DEEPSEEK_BASE_URL),
1018 Utc::now(),
1019 );
1020 let usage = codewhale_models::Usage {
1021 input_tokens: 17,
1022 output_tokens: 5,
1023 ..codewhale_models::Usage::default()
1024 };
1025 let usage_record = crate::cost_status::RuntimeUsageRecord {
1026 source_id: "translation:old-turn:assistant:1".to_string(),
1027 usage: crate::cost_status::EffectiveRouteUsage {
1028 route: priced_route.clone(),
1029 usage: usage.clone(),
1030 },
1031 };
1032 let missing_record = crate::cost_status::RuntimeUsageDropRecord {
1033 reason: crate::cost_status::RuntimeUsageMissingReason::default(),
1034 source_id: "advisor:old-turn:provider-response:0".to_string(),
1035 route: priced_route,
1036 };
1037 let mut subscription_route = missing_record.route.clone();
1038 subscription_route.billing_mode = crate::cost_status::RouteBillingMode::Subscription;
1039 let subscription_missing = crate::cost_status::RuntimeUsageDropRecord {
1040 reason: crate::cost_status::RuntimeUsageMissingReason::default(),
1041 source_id: "translation:old-turn:thinking:2".to_string(),
1042 route: subscription_route,
1043 };
1044
1045 for _ in 0..2 {
1046 assert!(
1047 manager
1048 .persist_late_runtime_usage(old_id, "old-turn", &usage_record)
1049 .expect("persist late usage")
1050 );
1051 assert!(
1052 manager
1053 .persist_late_runtime_drop(old_id, "old-turn", &missing_record)
1054 .expect("persist missing usage")
1055 );
1056 assert!(
1057 manager
1058 .persist_late_runtime_drop(old_id, "old-turn", &subscription_missing)
1059 .expect("persist subscription missing usage")
1060 );
1061 }
1062
1063 // A concurrent stale whole-session writer cannot erase the independent
1064 // origin ledger. Loading overlays it once by stable response identity.
1065 manager.save_session(&old).expect("stale old-session save");
1066 let first = manager.load_session_snapshot(old_id).expect("load old");
1067 let second = manager.load_session_snapshot(old_id).expect("replay old");
1068 for loaded in [&first, &second] {
1069 assert_eq!(loaded.metadata.total_tokens, 22);
1070 assert_eq!(loaded.metadata.cost.unpriced_turns, 1);
1071 assert_eq!(loaded.metadata.cost.cny_unpriced_turns, 1);
1072 assert_eq!(loaded.metadata.cost.usage_source_fingerprints.len(), 3);
1073 assert!(
1074 loaded
1075 .metadata
1076 .cost
1077 .unpriced_reasons
1078 .contains("provider_success_missing_usage")
1079 );
1080 }
1081 assert_eq!(first.metadata.cost.priced_turns, 1);
1082
1083 let clean = manager.load_session_snapshot(new_id).expect("load new");
1084 assert_eq!(clean.metadata.total_tokens, 0);
1085 assert_eq!(clean.metadata.cost.priced_turns, 0);
1086 assert_eq!(clean.metadata.cost.unpriced_turns, 0);
1087 assert!(clean.metadata.cost.usage_source_fingerprints.is_empty());
1088
1089 let ledger = fs::read_to_string(
1090 manager
1091 .sessions_dir()
1092 .join(LATE_USAGE_DIR)
1093 .join(format!("{old_id}.json")),
1094 )
1095 .expect("late ledger");
1096 assert!(!ledger.contains("translation:old-turn"));
1097 assert!(!ledger.contains(crate::config::DEFAULT_DEEPSEEK_BASE_URL));
1098 #[cfg(unix)]
1099 {
1100 use std::os::unix::fs::PermissionsExt;
1101 let ledger_dir = manager.sessions_dir().join(LATE_USAGE_DIR);
1102 assert_eq!(
1103 fs::metadata(&ledger_dir)
1104 .expect("private sidecar directory")
1105 .permissions()
1106 .mode()
1107 & 0o777,
1108 0o700
1109 );
1110 for path in [
1111 ledger_dir.join(format!("{old_id}.json")),
1112 ledger_dir.join(format!("{old_id}.lock")),
1113 ] {
1114 assert_eq!(
1115 fs::metadata(path)
1116 .expect("private sidecar metadata")
1117 .permissions()
1118 .mode()
1119 & 0o777,
1120 0o600
1121 );
1122 }
1123 }
1124 }
1125
1126 #[test]
1127 fn late_usage_sidecar_has_a_bounded_fail_closed_overflow() {
1128 let tmp = tempdir().expect("tempdir");
1129 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
1130 let session_id = "bounded-session";
1131 let session = create_saved_session_with_id_and_mode(
1132 session_id.to_string(),
1133 &[make_test_message("user", "bounded session")],
1134 "local-model",
1135 tmp.path(),
1136 0,
1137 None,
1138 Some("agent"),
1139 );
1140 manager.save_session(&session).expect("save bounded");
1141 let mut route = crate::cost_status::EffectiveRouteEnvelope::capture(
1142 None,
1143 ProviderKind::Custom,
1144 "local-provider",
1145 "local-model",
1146 Some("http://127.0.0.1:11434/v1"),
1147 Utc::now(),
1148 );
1149 route.billing_mode = crate::cost_status::RouteBillingMode::Local;
1150 for index in 0..=MAX_LATE_USAGE_UNRESOLVED_RECORDS_PER_SESSION {
1151 manager
1152 .persist_late_runtime_drop(
1153 session_id,
1154 "bounded-turn",
1155 &crate::cost_status::RuntimeUsageDropRecord {
1156 reason: crate::cost_status::RuntimeUsageMissingReason::RequestOutcomeUnknown,
1157 source_id: format!("late-bounded:{index}"),
1158 route: route.clone(),
1159 },
1160 )
1161 .expect("bounded append");
1162 }
1163
1164 let loaded = manager
1165 .load_session_snapshot(session_id)
1166 .expect("load bounded");
1167 assert_eq!(
1168 loaded.metadata.total_tokens,
1169 0
1170 );
1171 assert_eq!(loaded.metadata.cost.unpriced_turns, 1);
1172 assert!(
1173 loaded
1174 .metadata
1175 .cost
1176 .unpriced_reasons
1177 .contains("late_usage_ledger_overflow")
1178 );
1179 let ledger = manager.load_late_usage(session_id).expect("bounded ledger");
1180 assert_eq!(ledger.records.len(), MAX_LATE_USAGE_UNRESOLVED_RECORDS_PER_SESSION);
1181 assert!(ledger.overflowed);
1182 }
1183
1184 #[cfg(unix)]
1185 #[test]
1186 fn late_usage_sidecar_rejects_linked_lock_and_ledger_leaves() {
1187 use std::os::unix::fs::symlink;
1188
1189 let tmp = tempdir().expect("tempdir");
1190 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
1191 let session_id = "linked-sidecar-session";
1192 save_late_usage_test_session(&manager, session_id);
1193 save_late_usage_test_session(&manager, "unaffected-sidecar-session");
1194 let (ledger_path, lock_path) = manager.ensure_late_usage_paths(session_id).expect("paths");
1195 let route = crate::cost_status::EffectiveRouteEnvelope::capture(
1196 None,
1197 ProviderKind::Deepseek,
1198 "deepseek",
1199 "deepseek-v4-flash",
1200 Some(crate::config::DEFAULT_DEEPSEEK_BASE_URL),
1201 Utc::now(),
1202 );
1203 let record = crate::cost_status::RuntimeUsageRecord {
1204 source_id: "linked-sidecar-response".to_string(),
1205 usage: crate::cost_status::EffectiveRouteUsage {
1206 route,
1207 usage: codewhale_models::Usage {
1208 input_tokens: 1,
1209 ..codewhale_models::Usage::default()
1210 },
1211 },
1212 };
1213
1214 let outside_lock = tmp.path().join("outside.lock");
1215 fs::write(&outside_lock, b"outside-lock").expect("outside lock");
1216 fs::remove_file(&lock_path).expect("replace fixture lifecycle lock");
1217 symlink(&outside_lock, &lock_path).expect("symlink lock");
1218 assert!(
1219 manager
1220 .persist_late_runtime_usage(session_id, "turn", &record)
1221 .is_err(),
1222 "a symlink lock leaf must fail closed"
1223 );
1224 assert_eq!(
1225 fs::read(&outside_lock).expect("outside lock unchanged"),
1226 b"outside-lock"
1227 );
1228 fs::remove_file(&lock_path).expect("remove lock symlink");
1229
1230 fs::hard_link(&outside_lock, &lock_path).expect("hard-linked lock");
1231 assert!(
1232 manager
1233 .persist_late_runtime_usage(session_id, "turn", &record)
1234 .is_err(),
1235 "a multiply linked lock leaf must fail closed"
1236 );
1237 fs::remove_file(&lock_path).expect("remove hard-linked lock");
1238
1239 let outside_ledger = tmp.path().join("outside.json");
1240 fs::write(
1241 &outside_ledger,
1242 br#"{"schema_version":1,"records":[],"overflowed":false}"#,
1243 )
1244 .expect("outside ledger");
1245 symlink(&outside_ledger, &ledger_path).expect("symlink ledger");
1246 assert!(
1247 manager.load_late_usage(session_id).is_err(),
1248 "a symlink ledger leaf must fail closed"
1249 );
1250 assert!(
1251 manager
1252 .load_session_snapshot(session_id)
1253 .expect("recover linked ledger transcript")
1254 .metadata
1255 .cost
1256 .unpriced_reasons
1257 .contains(LATE_USAGE_UNAVAILABLE_REASON)
1258 );
1259 fs::remove_file(&ledger_path).expect("remove ledger symlink");
1260
1261 fs::hard_link(&outside_ledger, &ledger_path).expect("hard-linked ledger");
1262 assert!(
1263 manager.load_late_usage(session_id).is_err(),
1264 "a multiply linked ledger leaf must fail closed"
1265 );
1266 assert_eq!(
1267 manager
1268 .list_sessions()
1269 .expect("list linked ledger transcript")
1270 .len(),
1271 2
1272 );
1273 assert_eq!(
1274 manager
1275 .load_session_by_prefix("unaffected")
1276 .expect("unaffected resume")
1277 .metadata
1278 .cost
1279 .unpriced_turns,
1280 0
1281 );
1282 assert_eq!(
1283 fs::read(&outside_ledger).expect("outside ledger unchanged"),
1284 br#"{"schema_version":1,"records":[],"overflowed":false}"#
1285 );
1286 }
1287
1288 fn container_with(messages: Vec<Message>, dir: &std::path::Path) -> SessionImportContainer {
1289 let session = create_saved_session(&messages, "test-model", dir, 100, None);
1290 session.export_container("test-session.json")
1291 }
1292
1293 #[test]
1294 fn session_goal_sidecar_round_trips_control_state_without_model_output() {
1295 let tmp = tempdir().expect("tempdir");
1296 let sessions_dir = tmp.path().join("sessions");
1297 let manager = SessionManager::new(sessions_dir.clone()).expect("manager");
1298 let session_id = "11111111-2222-4333-8444-555555555555";
1299 let runtime = GoalSnapshot {
1300 goal_id: None,
1301 objective: Some("finish the provider migration".to_string()),
1302 status: "paused".to_string(),
1303 token_budget: Some(50_000),
1304 tokens_used: 12_345,
1305 time_used_seconds: 67,
1306 continuation_count: 4,
1307 elapsed_seconds: Some(91),
1308 evidence: Some("Bearer credential-shaped-model-output".to_string()),
1309 blocker: Some("/arbitrary/private/path".to_string()),
1310 pause_reason: Some(GoalPauseReason::User),
1311 completion_verification: None,
1312 advisories: Vec::new(),
1313 last_gap_fingerprint: None,
1314 repeated_gap_count: 0,
1315 last_gap_pass: None,
1316 progress: None,
1317 };
1318 let durable = SessionGoalState::from_runtime(&runtime)
1319 .expect("valid runtime goal")
1320 .expect("non-empty durable goal");
1321
1322 manager
1323 .save_session_goal(session_id, Some(&durable))
1324 .expect("save goal");
1325 let raw = fs::read_to_string(
1326 sessions_dir
1327 .join(SESSION_GOALS_DIR)
1328 .join(format!("{session_id}.json")),
1329 )
1330 .expect("read goal sidecar");
1331 assert!(!raw.contains("credential-shaped-model-output"));
1332 assert!(!raw.contains("/arbitrary/private/path"));
1333
1334 let reopened = SessionManager::new(sessions_dir).expect("reopen manager");
1335 let restored = reopened
1336 .load_session_goal(session_id)
1337 .expect("load goal")
1338 .expect("persisted goal");
1339 assert_eq!(restored, durable);
1340 assert_eq!(restored.to_runtime_snapshot().objective, runtime.objective);
1341 assert_eq!(restored.to_runtime_snapshot().status, "paused");
1342
1343 reopened
1344 .save_session_goal(session_id, None)
1345 .expect("clear goal");
1346 assert_eq!(
1347 reopened.load_session_goal(session_id).expect("load clear"),
1348 None
1349 );
1350 }
1351
1352 /// Coverage state round-trips with the money it qualifies, and a session
1353 /// written before coverage existed is detected as *unknown* rather than being
1354 /// read as a complete total covering zero turns (#4318).
1355 #[test]
1356 fn cost_snapshot_round_trips_coverage_and_detects_legacy_unknown() {
1357 // A pre-coverage row: real money, no coverage fields at all.
1358 let legacy: SessionCostSnapshot = serde_json::from_value(serde_json::json!({
1359 "session_cost_usd": 1.25,
1360 "session_cost_cny": 0.0,
1361 "subagent_cost_usd": 0.0,
1362 "subagent_cost_cny": 0.0,
1363 "displayed_cost_high_water_usd": 1.25,
1364 "displayed_cost_high_water_cny": 0.0
1365 }))
1366 .expect("legacy cost snapshot stays readable");
1367 assert_eq!(legacy.priced_turns, 0);
1368 assert_eq!(legacy.unpriced_turns, 0);
1369 assert!(!legacy.coverage_recorded);
1370 assert!(
1371 legacy.coverage_is_legacy_unknown(),
1372 "a non-zero total with no coverage evidence must not read as complete"
1373 );
1374
1375 // An all-zero pre-coverage session is still unknown: zero may mean no
1376 // turns, all unpriced turns, or exact zero usage. Absence of evidence is
1377 // never rewritten into a complete 0/0 claim.
1378 let empty = SessionCostSnapshot::default();
1379 assert!(empty.coverage_is_legacy_unknown());
1380
1381 // A coverage-aware writer that recorded zero money-metered turns is also
1382 // not unknown — it positively knows the answer is zero.
1383 let recorded_zero = SessionCostSnapshot {
1384 session_cost_usd: 1.25,
1385 coverage_recorded: true,
1386 ..SessionCostSnapshot::default()
1387 };
1388 assert!(!recorded_zero.coverage_is_legacy_unknown());
1389
1390 // Full round-trip of every coverage field.
1391 let full = SessionCostSnapshot {
1392 session_cost_usd: 2.5,
1393 session_cost_cny: 3.0,
1394 subagent_cost_usd: 0.5,
1395 subagent_cost_cny: 0.25,
1396 displayed_cost_high_water_usd: 3.0,
1397 displayed_cost_high_water_cny: 3.25,
1398 priced_turns: 7,
1399 unpriced_turns: 2,
1400 cny_priced_turns: 1,
1401 cny_unpriced_turns: 8,
1402 unpriced_reasons: ["missing_class_price".to_string()].into(),
1403 cny_unpriced_reasons: ["currency_not_published".to_string()].into(),
1404 unpriced_classes: ["cache_write".to_string()].into(),
1405 pricing_provenances: ["models_dev_bundled".to_string()].into(),
1406 live_pricing_defects: ["live_pricing_stale".to_string()].into(),
1407 live_pricing_unusable_defects: ["live_pricing_scope_mismatch".to_string()].into(),
1408 route_receipts: ["provider=anthropic identity=- model=claude-haiku-4-5 \
1409 surface=first-party-payg endpoint_fp=abc123 currency=usd"
1410 .to_string()]
1411 .into(),
1412 usage_source_fingerprints: ["response-fingerprint".to_string()].into(),
1413 missing_usage_sources: Default::default(),
1414 missing_usage_overflowed: false,
1415 coverage_recorded: true,
1416 };
1417 let json = serde_json::to_string(&full).expect("serialize");
1418 let back: SessionCostSnapshot = serde_json::from_str(&json).expect("round-trip");
1419 assert_eq!(back.priced_turns, 7);
1420 assert_eq!(back.unpriced_turns, 2);
1421 assert_eq!(back.cny_priced_turns, 1);
1422 assert_eq!(back.cny_unpriced_turns, 8);
1423 assert_eq!(back.unpriced_reasons, full.unpriced_reasons);
1424 assert_eq!(back.cny_unpriced_reasons, full.cny_unpriced_reasons);
1425 assert_eq!(back.unpriced_classes, full.unpriced_classes);
1426 assert_eq!(back.pricing_provenances, full.pricing_provenances);
1427 assert_eq!(back.live_pricing_defects, full.live_pricing_defects);
1428 assert_eq!(
1429 back.usage_source_fingerprints,
1430 full.usage_source_fingerprints
1431 );
1432 assert_eq!(
1433 back.live_pricing_unusable_defects,
1434 full.live_pricing_unusable_defects
1435 );
1436 assert_eq!(back.route_receipts, full.route_receipts);
1437 assert!(back.coverage_recorded);
1438 assert!(!back.coverage_is_legacy_unknown());
1439
1440 // The persisted receipts carry no endpoint URL or credential.
1441 let lower = json.to_lowercase();
1442 for needle in ["http", "api_key", "authorization", "bearer", "sk-"] {
1443 assert!(!lower.contains(needle), "{needle} leaked into {json}");
1444 }
1445 }
1446
1447 /// The USD and CNY totals a snapshot reports are projections of one
1448 /// dual-currency accumulation, never two independent sums that could
1449 /// disagree (#4939).
1450 ///
1451 /// For any turn sequence — dual-priced, USD-only, CNY-only, or garbage
1452 /// estimates — folding the turns jointly and projecting each currency must
1453 /// equal accumulating that currency on its own. This is the invariant that
1454 /// makes the persisted per-currency columns safe: they are written from the
1455 /// same joint fold, so a code path can no longer update one and forget the
1456 /// other. CNY is derived from provider-published CNY rows, not from an FX
1457 /// multiple of USD, so a USD-only turn must contribute exactly zero CNY.
1458 #[test]
1459 fn cost_snapshot_currency_totals_are_projections_of_one_accumulator() {
1460 use crate::pricing::CostEstimate;
1461
1462 let turn_sequences: &[&[CostEstimate]] = &[
1463 // Dual-priced turns (DeepSeek-style routes with a published CNY row).
1464 &[
1465 CostEstimate {
1466 usd: 0.01,
1467 cny: 0.07,
1468 },
1469 CostEstimate {
1470 usd: 0.02,
1471 cny: 0.14,
1472 },
1473 ],
1474 // USD-only turns: CNY unpublished, so the CNY projection stays zero.
1475 &[
1476 CostEstimate {
1477 usd: 0.25,
1478 cny: 0.0,
1479 },
1480 CostEstimate { usd: 1.5, cny: 0.0 },
1481 ],
1482 // Mixed: one currency priced per turn, alternating.
1483 &[
1484 CostEstimate { usd: 0.5, cny: 0.0 },
1485 CostEstimate { usd: 0.0, cny: 3.5 },
1486 CostEstimate {
1487 usd: 0.125,
1488 cny: 0.875,
1489 },
1490 ],
1491 // Hostile values: sanitization must apply identically per currency.
1492 &[
1493 CostEstimate {
1494 usd: f64::NAN,
1495 cny: 0.25,
1496 },
1497 CostEstimate {
1498 usd: 0.75,
1499 cny: -1.0,
1500 },
1501 CostEstimate {
1502 usd: f64::INFINITY,
1503 cny: 0.25,
1504 },
1505 ],
1506 ];
1507
1508 for turns in turn_sequences {
1509 // Joint fold: how the app accumulates (one accumulator, both
1510 // currencies advance together through the same saturating_add).
1511 let joint = turns.iter().fold(CostEstimate::default(), |acc, turn| {
1512 acc.saturating_add(*turn)
1513 });
1514
1515 // Independent per-currency folds: what a drifted parallel
1516 // accumulator would compute if it only saw one currency.
1517 let usd_alone = turns.iter().fold(CostEstimate::default(), |acc, turn| {
1518 acc.saturating_add(CostEstimate {
1519 usd: turn.usd,
1520 cny: 0.0,
1521 })
1522 });
1523 let cny_alone = turns.iter().fold(CostEstimate::default(), |acc, turn| {
1524 acc.saturating_add(CostEstimate {
1525 usd: 0.0,
1526 cny: turn.cny,
1527 })
1528 });
1529
1530 let snapshot = SessionCostSnapshot {
1531 session_cost_usd: joint.usd,
1532 session_cost_cny: joint.cny,
1533 ..SessionCostSnapshot::default()
1534 };
1535 assert_eq!(
1536 snapshot.total_usd(),
1537 usd_alone.usd,
1538 "USD projection drifted from independent accumulation for {turns:?}"
1539 );
1540 assert_eq!(
1541 snapshot.total_cny(),
1542 cny_alone.cny,
1543 "CNY projection drifted from independent accumulation for {turns:?}"
1544 );
1545 assert_eq!(snapshot.total_estimate().usd, snapshot.total_usd());
1546 assert_eq!(snapshot.total_estimate().cny, snapshot.total_cny());
1547 }
1548
1549 // A USD-only session projects zero CNY — no fabricated FX conversion —
1550 // and the subagent column joins the same fold.
1551 let usd_only = SessionCostSnapshot {
1552 session_cost_usd: 2.5,
1553 subagent_cost_usd: 0.5,
1554 ..SessionCostSnapshot::default()
1555 };
1556 assert_eq!(usd_only.total_usd(), 3.0);
1557 assert_eq!(usd_only.total_cny(), 0.0);
1558 }
1559 #[test]
1560 fn late_usage_unresolved_overflow_retains_modern_known_receipts_once_after_restart() {
1561 let tmp = tempdir().unwrap();
1562 let sessions = tmp.path().join("sessions");
1563 let manager = SessionManager::new(sessions.clone()).unwrap();
1564 let session = create_saved_session_with_id_and_mode(
1565 "overflow-origin".into(),
1566 &[],
1567 "deepseek-v4-flash",
1568 tmp.path(),
1569 0,
1570 None,
1571 Some("agent"),
1572 );
1573 manager.save_session(&session).unwrap();
1574 let route = crate::cost_status::EffectiveRouteEnvelope::capture(
1575 None,
1576 ProviderKind::Deepseek,
1577 "deepseek",
1578 "deepseek-v4-flash",
1579 Some("https://api.deepseek.com/v1"),
1580 Utc::now(),
1581 );
1582 for index in 0..65 {
1583 manager
1584 .persist_late_runtime_drop(
1585 "overflow-origin",
1586 "turn",
1587 &crate::cost_status::RuntimeUsageDropRecord {
1588 reason:
1589 crate::cost_status::RuntimeUsageMissingReason::RequestOutcomeUnknown,
1590 source_id: format!("attempt-{index}"),
1591 route: route.clone(),
1592 },
1593 )
1594 .unwrap();
1595 }
1596 let missing = manager.load_session_snapshot("overflow-origin").unwrap();
1597 assert_eq!(missing.metadata.cost.unpriced_turns, 65);
1598 assert_eq!(missing.metadata.cost.missing_usage_sources.len(), 64);
1599 manager.save_session(&missing).unwrap();
1600 drop(manager);
1601 let manager = SessionManager::new(sessions).unwrap();
1602 let mut changed = route.clone();
1603 changed.model = "different-model".into();
1604 let invalid = crate::cost_status::RuntimeUsageRecord {
1605 source_id: "attempt-0".into(),
1606 usage: crate::cost_status::EffectiveRouteUsage {
1607 route: changed,
1608 usage: codewhale_models::Usage {
1609 input_tokens: 1,
1610 output_tokens: 1,
1611 ..Default::default()
1612 },
1613 },
1614 };
1615 assert_eq!(
1616 manager
1617 .persist_late_runtime_usage("overflow-origin", "turn", &invalid)
1618 .unwrap_err()
1619 .kind(),
1620 io::ErrorKind::InvalidData
1621 );
1622 for index in 0..65 {
1623 let known = crate::cost_status::RuntimeUsageRecord {
1624 source_id: format!("attempt-{index}"),
1625 usage: crate::cost_status::EffectiveRouteUsage {
1626 route: route.clone(),
1627 usage: codewhale_models::Usage {
1628 input_tokens: 1,
1629 output_tokens: 1,
1630 ..Default::default()
1631 },
1632 },
1633 };
1634 for _ in 0..2 {
1635 assert!(
1636 manager
1637 .persist_late_runtime_usage("overflow-origin", "turn", &known)
1638 .unwrap()
1639 );
1640 }
1641 }
1642 let known = manager.load_session_snapshot("overflow-origin").unwrap();
1643 assert_eq!(known.metadata.total_tokens, 130);
1644 assert_eq!(known.metadata.cost.priced_turns, 65);
1645 assert_eq!(known.metadata.cost.unpriced_turns, 1);
1646 assert!(known.metadata.cost.missing_usage_sources.is_empty());
1647 assert!(
1648 known
1649 .metadata
1650 .cost
1651 .unpriced_reasons
1652 .contains("late_usage_ledger_overflow")
1653 );
1654 manager.save_session(&known).unwrap();
1655 let replay = manager.load_session_snapshot("overflow-origin").unwrap();
1656 assert_eq!(replay.metadata.total_tokens, 130);
1657 assert_eq!(replay.metadata.cost.priced_turns, 65);
1658 assert_eq!(replay.metadata.cost.unpriced_turns, 1);
1659 let ledger = manager.load_late_usage("overflow-origin").unwrap();
1660 assert_eq!(ledger.records.len(), 65);
1661 assert!(ledger.overflowed);
1662 assert!(ledger.records.iter().all(|record| record.usage.is_some()));
1663 }
1664
1665 #[test]
1666 fn late_known_receipt_byte_exhaustion_errors_preserves_previous_records_and_gap() {
1667 let tmp = tempdir().unwrap();
1668 let manager = SessionManager::new(tmp.path().join("sessions")).unwrap();
1669 let route = crate::cost_status::EffectiveRouteEnvelope::capture(
1670 None,
1671 ProviderKind::Deepseek,
1672 "deepseek",
1673 "deepseek-v4-flash",
1674 Some("https://api.deepseek.com/v1"),
1675 Utc::now(),
1676 );
1677 let record = LateUsageRecord {
1678 source_fingerprint: crate::cost_status::usage_source_fingerprint("retained"),
1679 turn_fingerprint: crate::cost_status::usage_source_fingerprint("turn"),
1680 route: route.clone(),
1681 usage: Some(codewhale_models::Usage {
1682 input_tokens: 1,
1683 ..Default::default()
1684 }),
1685 reason: crate::cost_status::RuntimeUsageMissingReason::default(),
1686 decision: None,
1687 };
1688 let empty_bytes = serde_json::to_vec(&LateUsageLedger::default())
1689 .unwrap()
1690 .len();
1691 let record_bytes = serde_json::to_vec(&record).unwrap().len() + 1;
1692 let count =
1693 (usize::try_from(MAX_LATE_USAGE_LEDGER_BYTES).unwrap() - empty_bytes) / record_bytes;
1694 let original = LateUsageLedger {
1695 records: (0..count)
1696 .map(|index| LateUsageRecord {
1697 source_fingerprint: crate::cost_status::usage_source_fingerprint(&format!(
1698 "retained-{index}"
1699 )),
1700 ..record.clone()
1701 })
1702 .collect(),
1703 ..Default::default()
1704 };
1705 let (path, _) = manager.ensure_late_usage_paths("byte-limit").unwrap();
1706 SessionManager::write_late_usage_ledger(&path, &original).unwrap();
1707 let extra = crate::cost_status::RuntimeUsageRecord {
1708 source_id: "must-not-be-marked-handled".into(),
1709 usage: crate::cost_status::EffectiveRouteUsage {
1710 route,
1711 usage: codewhale_models::Usage {
1712 input_tokens: 1,
1713 ..Default::default()
1714 },
1715 },
1716 };
1717 assert_eq!(
1718 manager
1719 .persist_late_runtime_usage("byte-limit", "turn", &extra)
1720 .unwrap_err()
1721 .kind(),
1722 io::ErrorKind::InvalidData
1723 );
1724 let retained = manager.load_late_usage("byte-limit").unwrap();
1725 assert_eq!(retained.records.len(), original.records.len());
1726 assert!(retained.overflowed);
1727 assert!(
1728 !retained
1729 .records
1730 .iter()
1731 .any(|record| record.source_fingerprint
1732 == crate::cost_status::usage_source_fingerprint(&extra.source_id))
1733 );
1734 assert!(fs::metadata(&path).unwrap().len() <= MAX_LATE_USAGE_LEDGER_BYTES);
1735 // The opened-file boundary rejects oversized bytes independently of
1736 // the serializer; neither case silently truncates a known receipt.
1737 fs::write(
1738 &path,
1739 vec![b' '; usize::try_from(MAX_LATE_USAGE_LEDGER_BYTES + 1).unwrap()],
1740 )
1741 .unwrap();
1742 assert_eq!(
1743 manager.load_late_usage("byte-limit").unwrap_err().kind(),
1744 io::ErrorKind::InvalidData
1745 );
1746 }
1747
1747 lines RUST