lean_ctx_proxy/cold_prefix.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Big-gap cold-prefix repack prediction (#480).
3//!
4//! The proxy is deliberately cache-safe: it never rewrites the client-cached
5//! prefix (`history_prune::cached_prefix_len`), so provider prompt caches keep
6//! hitting and cheap cache reads (~0.1x) never turn into full-price writes
7//! (~1.25x) — the #448 invariant.
8//!
9//! That protection has one blind spot. Provider prompt caches EXPIRE after a TTL
10//! of inactivity. After a long idle gap (the agent asked a question, the user
11//! replies hours later) the cached prefix is already gone; the provider will
12//! re-WRITE the whole prefix on the next request regardless. Staying in
13//! "never touch the cached prefix" mode then writes the *uncompressed* prefix at
14//! full price and re-seeds a fat cache for the rest of the session.
15//!
16//! This module makes a PRE-SEND prediction — purely from elapsed idle time vs
17//! the provider's cache TTL — of whether the prefix is already cold. The trigger
18//! must be a clock, not response feedback: hit/miss is only known *after* the
19//! request that already (re-)cached the prefix, and by the next request the
20//! cache is warm again, so feedback would bust the fresh cache.
21//!
22//! Safety is paramount because the cost of a wrong "cold" guess is asymmetric (a
23//! cache write is ~12x a cache read). We therefore:
24//! * act only when the caller opted in (`repacks_cold_prefix()`),
25//! * act only on a measured idle gap well past expiry (`TTL × SAFETY_MARGIN`,
26//! with an absolute floor), skipping the ambiguous near-TTL zone entirely,
27//! * never act without a prior touch (the first sighting only sets a baseline),
28//! * and bias every ambiguity toward "warm" (do nothing).
29//!
30//! State persists across restarts (`{data_dir}/cold_prefix_touch.json`, atomic
31//! write, throttled) so an idle gap that straddles a daemon recycle is still
32//! detected — a stale on-disk timestamp is exactly what proves the gap and can
33//! only ever bias toward "warm" if lost (#499). A missing/corrupt file simply
34//! disables the optimization until a fresh baseline is recorded — a safe
35//! degradation that can never wrongly trigger.
36//!
37//! Once a conversation is judged cold and repacked, the decision is *sticky*:
38//! every later turn keeps applying the same deterministic prefix compression, so
39//! the warm follow-ups that resume active use hit the compressed prefix written
40//! at the cold turn instead of re-sending the uncompressed original and busting
41//! the freshly-seeded cache (#499). Deterministic re-compression is prefix-
42//! stable, so the latch stays cache-safe for the rest of the session.
43
44use std::collections::HashMap;
45use std::hash::{Hash, Hasher};
46use std::sync::atomic::{AtomicU64, Ordering};
47use std::sync::{Mutex, OnceLock};
48use std::time::{SystemTime, UNIX_EPOCH};
49
50use serde::{Deserialize, Serialize};
51use serde_json::Value;
52
53/// Multiplier applied to the resolved TTL before a prefix is declared cold. The
54/// provider cache is a sliding inactivity window, so `idle > TTL` already implies
55/// expiry; `× 2` keeps a safety buffer against clock skew and provider nuance.
56const SAFETY_MARGIN: u64 = 2;
57/// Absolute minimum idle (seconds) before any repack, regardless of a short
58/// per-request TTL — never repack on a gap under 10 minutes.
59const COLD_FLOOR_SECS: u64 = 600;
60/// Anthropic default cache TTL when a `cache_control` marker carries no explicit
61/// `ttl` (the API default is "5m").
62const DEFAULT_TTL_SECS: u64 = 300;
63/// Anthropic extended cache TTL (`"ttl":"1h"`).
64const HOUR_TTL_SECS: u64 = 3600;
65/// Hard cap on tracked conversations so a long-lived proxy can't grow the
66/// last-touch map without bound; the oldest entry is evicted past this.
67const MAX_TRACKED: usize = 4096;
68/// Minimum seconds between disk persists. The on-disk baseline only needs to be
69/// "fresh enough" to prove a multi-minute gap, so throttling keeps the hot path
70/// off the disk on every request without weakening the long-gap guarantee.
71const PERSIST_MIN_INTERVAL_SECS: u64 = 30;
72/// Cross-restart baseline store, in the shared data dir.
73const TOUCH_FILE: &str = "cold_prefix_touch.json";
74
75/// Per-conversation tracking state. `last_touch` is the Unix-seconds timestamp of
76/// the most recent request; `repacking` latches on once a cold gap triggered a
77/// repack, so subsequent turns stay cache-stable on the compressed prefix (#499);
78/// `system_compressed` latches on once the warm-prefix cost gate compressed the
79/// system prompt, so later turns keep sending that same prefix (#1912).
80#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
81struct ConvState {
82 last_touch: u64,
83 repacking: bool,
84 #[serde(default)]
85 system_compressed: bool,
86}
87
88fn store() -> &'static Mutex<HashMap<u64, ConvState>> {
89 static STORE: OnceLock<Mutex<HashMap<u64, ConvState>>> = OnceLock::new();
90 STORE.get_or_init(|| Mutex::new(HashMap::new()))
91}
92
93/// Wall-clock seconds of the last successful disk persist (throttle gate).
94fn last_persist() -> &'static AtomicU64 {
95 static LAST: AtomicU64 = AtomicU64::new(0);
96 &LAST
97}
98
99fn now_secs() -> u64 {
100 SystemTime::now()
101 .duration_since(UNIX_EPOCH)
102 .map_or(0, |d| d.as_secs())
103}
104
105fn hash_bytes(bytes: &[u8]) -> u64 {
106 let mut h = std::collections::hash_map::DefaultHasher::new();
107 bytes.hash(&mut h);
108 h.finish()
109}
110
111/// Stable per-conversation key: a hash of the first message with every
112/// `cache_control` marker stripped first. `messages[0]` is byte-stable across a
113/// conversation's turns *except* for its volatile cache breakpoint — clients move
114/// or retune the `cache_control` (`ephemeral`/`ttl`) marker as the prompt grows.
115/// Hashing the raw message would then change the key mid-conversation → a
116/// permanent "first sighting" that never repacks (#499). Stripping the marker
117/// keys on stable content only. Distinct conversations still differ (distinct
118/// opening turns); a collision can only make the recorded last-touch *more
119/// recent*, biasing toward "warm" — never a wrong "cold". `None` when there is no
120/// first message.
121pub(crate) fn conversation_key(messages: &[Value]) -> Option<u64> {
122 let mut first = messages.first()?.clone();
123 strip_cache_control(&mut first);
124 let bytes = serde_json::to_vec(&first).ok()?;
125 Some(hash_bytes(&bytes))
126}
127
128/// Content hash of the client-cached prefix `messages[0..cached]` with every
129/// volatile `cache_control` marker stripped — the stable identity of the prefix
130/// a provider would cache. Turn-to-turn equality means the cacheable prefix did
131/// not change; inequality means something (a rewrite, an edited earlier turn)
132/// busted it. `None` when `cached == 0` (nothing anchored to compare). Shared so
133/// [`crate::cache_attribution`] keys on the exact same stable bytes.
134pub(crate) fn cached_prefix_hash(messages: &[Value], cached: usize) -> Option<u64> {
135 let end = cached.min(messages.len());
136 if end == 0 {
137 return None;
138 }
139 let mut prefix: Vec<Value> = messages[..end].to_vec();
140 for msg in &mut prefix {
141 strip_cache_control(msg);
142 }
143 let bytes = serde_json::to_vec(&prefix).ok()?;
144 Some(hash_bytes(&bytes))
145}
146
147/// Recursively remove every `cache_control` field so a moving cache breakpoint
148/// can't change the conversation key. The marker always nests `type:ephemeral`
149/// and any `ttl` inside `cache_control`, so dropping that one field suffices.
150fn strip_cache_control(v: &mut Value) {
151 match v {
152 Value::Object(map) => {
153 map.remove("cache_control");
154 for val in map.values_mut() {
155 strip_cache_control(val);
156 }
157 }
158 Value::Array(arr) => {
159 for val in arr {
160 strip_cache_control(val);
161 }
162 }
163 _ => {}
164 }
165}
166
167fn parse_ttl_str(s: &str) -> Option<u64> {
168 match s.trim() {
169 "1h" => Some(HOUR_TTL_SECS),
170 "5m" => Some(DEFAULT_TTL_SECS),
171 _ => None,
172 }
173}
174
175/// Largest `cache_control.ttl` declared anywhere inside one message (message-,
176/// block-, or nested text-level), in seconds. `None` when no parseable ttl.
177fn max_ttl_in_message(msg: &Value) -> Option<u64> {
178 let mut best: Option<u64> = None;
179 collect_cc_ttl(msg, &mut best);
180 best
181}
182
183fn collect_cc_ttl(v: &Value, best: &mut Option<u64>) {
184 match v {
185 Value::Object(map) => {
186 if let Some(ttl) = map
187 .get("cache_control")
188 .and_then(|cc| cc.get("ttl"))
189 .and_then(Value::as_str)
190 .and_then(parse_ttl_str)
191 {
192 *best = Some(best.map_or(ttl, |b| b.max(ttl)));
193 }
194 for val in map.values() {
195 collect_cc_ttl(val, best);
196 }
197 }
198 Value::Array(arr) => {
199 for val in arr {
200 collect_cc_ttl(val, best);
201 }
202 }
203 _ => {}
204 }
205}
206
207/// Resolve the cache TTL (seconds) for the client-cached prefix `[0..cached)`.
208/// Returns the largest TTL any cached message requested, defaulting to the "5m"
209/// Anthropic default because a `cache_control` marker is present. `None` only
210/// when `cached == 0` (no marker) — in which case there is nothing to repack.
211pub(crate) fn resolved_ttl_secs(messages: &[Value], cached: usize) -> Option<u64> {
212 if cached == 0 {
213 return None;
214 }
215 let end = cached.min(messages.len());
216 let mut ttl = DEFAULT_TTL_SECS;
217 for msg in &messages[..end] {
218 if let Some(t) = max_ttl_in_message(msg) {
219 ttl = ttl.max(t);
220 }
221 }
222 Some(ttl)
223}
224
225fn evict_oldest(map: &mut HashMap<u64, ConvState>) {
226 if let Some(oldest_key) = map
227 .iter()
228 .min_by_key(|(_, s)| s.last_touch)
229 .map(|(k, _)| *k)
230 {
231 map.remove(&oldest_key);
232 }
233}
234
235/// Decide whether to repack the (predicted-cold) cached prefix for THIS request,
236/// recording this request as the conversation's latest touch.
237///
238/// Returns `true` when the conversation is already in the sticky repacking state
239/// (a prior turn went cold — keep the compressed prefix stable, #499) or when a
240/// fresh cold gap is detected: a client-cached prefix exists (`cached > 0`), a
241/// prior touch exists (so the idle gap is measurable), and the idle gap exceeds
242/// `TTL × SAFETY_MARGIN` and the absolute floor. The caller owns the opt-in gate;
243/// this is only ever called when the operator enabled it, so updating the
244/// last-touch baseline here is the intended side effect for the *next* request.
245pub fn repack_decision(messages: &[Value], cached: usize) -> bool {
246 let Some(key) = conversation_key(messages) else {
247 return false;
248 };
249 let now = now_secs();
250 let ttl = resolved_ttl_secs(messages, cached);
251
252 let (decision, changed) = {
253 let mut map = store()
254 .lock()
255 .unwrap_or_else(std::sync::PoisonError::into_inner);
256 let prev = map.get(&key).copied();
257 let was_first = prev.is_none();
258 let already_repacking = prev.is_some_and(|s| s.repacking);
259
260 // A fresh cold gap: a measurable idle past `TTL × margin` (and the floor)
261 // on a turn that actually carries a client-cached prefix.
262 let fresh_cold = match (prev, ttl) {
263 (Some(p), Some(t)) if cached > 0 => {
264 let idle = now.saturating_sub(p.last_touch);
265 idle > t.saturating_mul(SAFETY_MARGIN).max(COLD_FLOOR_SECS)
266 }
267 _ => false,
268 };
269
270 // Sticky latch: once cold→repacked, stay repacking. Deterministic re-
271 // compression keeps the prefix byte-stable so warm follow-ups hit the
272 // cache written at the cold turn instead of busting it (#499).
273 let repacking = already_repacking || fresh_cold;
274 map.insert(
275 key,
276 ConvState {
277 last_touch: now,
278 repacking,
279 ..prev.unwrap_or_default()
280 },
281 );
282 if map.len() > MAX_TRACKED {
283 evict_oldest(&mut map);
284 }
285
286 // Persist eagerly when the latch first engages or on a first sighting
287 // (both define a baseline that must survive an immediate restart);
288 // otherwise let the throttle decide.
289 let changed = was_first || (repacking && !already_repacking);
290 // Repack only when we are in the repacking state AND there is a cached
291 // prefix to act on this turn (a `cached == 0` turn prunes from 0 anyway).
292 (repacking && cached > 0, changed)
293 };
294
295 maybe_persist(changed, now);
296 decision
297}
298
299/// True once the warm-prefix cost gate compressed this conversation's system
300/// prompt (#1912). The latch keeps every later turn on the compressed prefix the
301/// provider cached — re-deciding per turn could flip back and bust it again.
302#[cfg(feature = "http-server")]
303pub(crate) fn system_compression_latched(key: u64) -> bool {
304 store()
305 .lock()
306 .unwrap_or_else(std::sync::PoisonError::into_inner)
307 .get(&key)
308 .is_some_and(|s| s.system_compressed)
309}
310
311/// Engages the system-compression latch for a conversation and persists it
312/// eagerly, so a proxy restart cannot flip the conversation back to the
313/// uncompressed prefix.
314#[cfg(feature = "http-server")]
315pub(crate) fn latch_system_compression(key: u64) {
316 let now = now_secs();
317 {
318 let mut map = store()
319 .lock()
320 .unwrap_or_else(std::sync::PoisonError::into_inner);
321 let entry = map.entry(key).or_insert(ConvState {
322 last_touch: now,
323 ..ConvState::default()
324 });
325 entry.system_compressed = true;
326 if map.len() > MAX_TRACKED {
327 evict_oldest(&mut map);
328 }
329 }
330 maybe_persist(true, now);
331}
332
333/// On-disk shape of the cross-restart baselines. `ts` is advisory (debugging);
334/// the per-conversation `last_touch` values are what prove an idle gap.
335#[derive(Debug, Default, Serialize, Deserialize)]
336struct PersistedTouch {
337 ts: u64,
338 conversations: HashMap<u64, ConvState>,
339}
340
341fn touch_path() -> Option<std::path::PathBuf> {
342 lean_ctx::core::data_dir::lean_ctx_data_dir()
343 .ok()
344 .map(|d| d.join(TOUCH_FILE))
345}
346
347/// Serializes disk read/write. Concurrent `maybe_persist` callers (and the
348/// multi-step restart test) must not interleave a stale full-map snapshot over a
349/// just-written baseline — last-writer-wins on the whole file is otherwise racy.
350fn disk_io_lock() -> &'static Mutex<()> {
351 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
352 LOCK.get_or_init(|| Mutex::new(()))
353}
354
355/// Seeds the in-memory baselines from disk on proxy startup so an idle gap that
356/// straddles a restart is still detected. Merges by most-recent `last_touch` and
357/// OR-s the sticky `repacking` latch, so a re-seed can only bias toward "warm"
358/// (or keep a latch), never toward a wrong "cold".
359pub fn resume_from_disk() {
360 let _disk = disk_io_lock()
361 .lock()
362 .unwrap_or_else(std::sync::PoisonError::into_inner);
363 resume_from_disk_unlocked();
364}
365
366fn resume_from_disk_unlocked() {
367 let Some(path) = touch_path() else {
368 return;
369 };
370 let Ok(data) = std::fs::read_to_string(&path) else {
371 return;
372 };
373 let Ok(persisted) = serde_json::from_str::<PersistedTouch>(&data) else {
374 return;
375 };
376 let mut map = store()
377 .lock()
378 .unwrap_or_else(std::sync::PoisonError::into_inner);
379 for (key, state) in persisted.conversations {
380 let entry = map.entry(key).or_insert(state);
381 if state.last_touch > entry.last_touch {
382 entry.last_touch = state.last_touch;
383 }
384 entry.repacking |= state.repacking;
385 entry.system_compressed |= state.system_compressed;
386 }
387 while map.len() > MAX_TRACKED {
388 evict_oldest(&mut map);
389 }
390}
391
392/// Persist when forced (a baseline/latch change) or when the throttle window has
393/// elapsed. The disk write happens outside the store lock.
394fn maybe_persist(force: bool, now: u64) {
395 let last = last_persist().load(Ordering::Relaxed);
396 if !force && now.saturating_sub(last) < PERSIST_MIN_INTERVAL_SECS {
397 return;
398 }
399 last_persist().store(now, Ordering::Relaxed);
400 persist_now(now);
401}
402
403/// Atomically writes the current baselines to disk (`.tmp` + rename).
404fn persist_now(now: u64) {
405 let _disk = disk_io_lock()
406 .lock()
407 .unwrap_or_else(std::sync::PoisonError::into_inner);
408 persist_now_unlocked(now);
409}
410
411fn persist_now_unlocked(now: u64) {
412 let Some(path) = touch_path() else {
413 return;
414 };
415 let conversations = {
416 let map = store()
417 .lock()
418 .unwrap_or_else(std::sync::PoisonError::into_inner);
419 map.clone()
420 };
421 let payload = PersistedTouch {
422 ts: now,
423 conversations,
424 };
425 let Ok(json) = serde_json::to_string(&payload) else {
426 return;
427 };
428 let tmp = path.with_extension("json.tmp");
429 if std::fs::write(&tmp, json).is_ok() {
430 let _ = std::fs::rename(&tmp, &path);
431 }
432}
433
434/// Test-only: pre-seed a conversation's last-touch `secs_ago` seconds in the
435/// past so a single `repack_decision` call observes a controlled idle gap
436/// (the function overwrites last-touch with `now` on every call).
437///
438/// Tests must use a *unique* first message (hence a unique `conversation_key`)
439/// so seeding one never disturbs another running in parallel — the global store
440/// is shared, so there is deliberately no global "clear" that would race.
441#[cfg(test)]
442pub(crate) fn test_seed_last_touch(messages: &[Value], secs_ago: u64) {
443 if let Some(key) = conversation_key(messages) {
444 let when = now_secs().saturating_sub(secs_ago);
445 store()
446 .lock()
447 .unwrap_or_else(std::sync::PoisonError::into_inner)
448 .insert(
449 key,
450 ConvState {
451 last_touch: when,
452 repacking: false,
453 system_compressed: false,
454 },
455 );
456 }
457}
458
459/// Test-only: drop a single conversation's in-memory baseline (simulates a proxy
460/// restart losing RAM for that key). Single-key removal stays race-free with the
461/// other tests that share the global store.
462#[cfg(test)]
463fn test_remove(messages: &[Value]) {
464 if let Some(key) = conversation_key(messages) {
465 store()
466 .lock()
467 .unwrap_or_else(std::sync::PoisonError::into_inner)
468 .remove(&key);
469 }
470}
471
472/// Test-only: persist → drop RAM → resume under `disk_io_lock` so a concurrent
473/// `maybe_persist` cannot overwrite the file in the gap between remove and resume
474/// (the flake behind `cold_baseline_survives_restart_via_disk`).
475#[cfg(test)]
476fn test_persist_drop_and_resume(messages: &[Value]) {
477 let _disk = disk_io_lock()
478 .lock()
479 .unwrap_or_else(std::sync::PoisonError::into_inner);
480 persist_now_unlocked(now_secs());
481 test_remove(messages);
482 resume_from_disk_unlocked();
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488 use serde_json::json;
489
490 fn cached_body(first_text: &str, ttl: Option<&str>) -> Vec<Value> {
491 let cc = ttl.map_or_else(
492 || json!({"type": "ephemeral"}),
493 |t| json!({"type": "ephemeral", "ttl": t}),
494 );
495 vec![
496 json!({"role": "user", "content": [
497 {"type": "text", "text": first_text, "cache_control": cc}
498 ]}),
499 json!({"role": "assistant", "content": "ok"}),
500 ]
501 }
502
503 #[test]
504 fn key_is_stable_across_turns_and_distinct_per_conversation() {
505 let mut a1 = cached_body("conversation A opening", None);
506 let a2 = {
507 let mut m = a1.clone();
508 m.push(json!({"role": "user", "content": "a follow-up turn"}));
509 m
510 };
511 let b1 = cached_body("conversation B opening", None);
512
513 let ka = conversation_key(&a1).unwrap();
514 let ka2 = conversation_key(&a2).unwrap();
515 let kb = conversation_key(&b1).unwrap();
516 assert_eq!(ka, ka2, "key must be stable as the conversation grows");
517 assert_ne!(ka, kb, "distinct conversations must get distinct keys");
518
519 // Mutating messages[0] changes the key (different conversation head).
520 a1[0] = json!({"role": "user", "content": "different head"});
521 assert_ne!(conversation_key(&a1).unwrap(), ka);
522 }
523
524 #[test]
525 fn ttl_resolves_from_marker_else_default_else_none() {
526 let hour = cached_body("x", Some("1h"));
527 assert_eq!(resolved_ttl_secs(&hour, 1), Some(HOUR_TTL_SECS));
528
529 let five = cached_body("x", Some("5m"));
530 assert_eq!(resolved_ttl_secs(&five, 1), Some(DEFAULT_TTL_SECS));
531
532 // Marker present without an explicit ttl → Anthropic "5m" default.
533 let bare = cached_body("x", None);
534 assert_eq!(resolved_ttl_secs(&bare, 1), Some(DEFAULT_TTL_SECS));
535
536 // No client-cached prefix → nothing to repack.
537 assert_eq!(resolved_ttl_secs(&bare, 0), None);
538 }
539
540 use super::test_seed_last_touch as seed;
541
542 #[test]
543 fn first_sighting_only_sets_baseline() {
544 let msgs = cached_body("first-sighting conversation", None);
545 // No prior touch → never repack, but a baseline is now recorded.
546 assert!(!repack_decision(&msgs, 1));
547 // Immediately after, idle ≈ 0 → still warm.
548 assert!(!repack_decision(&msgs, 1));
549 }
550
551 #[test]
552 fn warm_prefix_is_never_repacked() {
553 let msgs = cached_body("warm conversation", Some("5m"));
554 seed(&msgs, 60); // 1 minute idle, TTL 5m → warm
555 assert!(!repack_decision(&msgs, 1));
556 }
557
558 #[test]
559 fn large_gap_triggers_repack() {
560 let msgs = cached_body("cold conversation 5m", Some("5m"));
561 seed(&msgs, 2 * 60 * 60); // 2h idle, threshold = max(600, 600) = 600
562 assert!(repack_decision(&msgs, 1));
563 }
564
565 #[test]
566 fn cached_zero_never_repacks_even_when_idle() {
567 let msgs = cached_body("idle but uncached", None);
568 seed(&msgs, 24 * 60 * 60);
569 // cached == 0: there is no client-cached prefix to repack.
570 assert!(!repack_decision(&msgs, 0));
571 }
572
573 #[test]
574 fn hour_ttl_skips_the_ambiguous_zone() {
575 let msgs = cached_body("cold conversation 1h", Some("1h"));
576 // threshold = 3600 * 2 = 7200s. Just under → still protect.
577 seed(&msgs, 7000);
578 assert!(!repack_decision(&msgs, 1));
579 // Well past → repack.
580 seed(&msgs, 8000);
581 assert!(repack_decision(&msgs, 1));
582 }
583
584 #[test]
585 fn key_ignores_cache_control_marker() {
586 // #499 (3): the same opening content with a different — or absent —
587 // cache_control marker must map to the SAME conversation key, so a moving
588 // cache breakpoint never causes a permanent first-sighting.
589 let none = cached_body("marker-invariant conversation", None);
590 let hour = cached_body("marker-invariant conversation", Some("1h"));
591 let five = cached_body("marker-invariant conversation", Some("5m"));
592 let k = conversation_key(&none).unwrap();
593 assert_eq!(k, conversation_key(&hour).unwrap());
594 assert_eq!(k, conversation_key(&five).unwrap());
595 // Different opening content still yields a different key.
596 let other = cached_body("a different opening", Some("1h"));
597 assert_ne!(k, conversation_key(&other).unwrap());
598 }
599
600 #[test]
601 fn sticky_repack_persists_into_warm_followups() {
602 // #499 (1): the N→N+1 interaction the original tests never covered.
603 let msgs = cached_body("sticky cold-then-warm conversation", Some("5m"));
604 // Turn N: a long idle gap → cold → repack fires and latches.
605 seed(&msgs, 2 * 60 * 60);
606 assert!(
607 repack_decision(&msgs, 1),
608 "a cold gap must trigger the repack"
609 );
610 // Turn N+1, seconds later (idle ≈ 0): pre-fix this fell back to protecting
611 // the prefix and re-sent the uncompressed original, busting the cache
612 // written at turn N. The latch must keep repacking so the cold-turn cache
613 // is hit.
614 assert!(
615 repack_decision(&msgs, 1),
616 "an immediate warm follow-up must stay sticky and keep repacking"
617 );
618 assert!(
619 repack_decision(&msgs, 1),
620 "stickiness persists across the rest of the session"
621 );
622 }
623
624 #[test]
625 fn cold_baseline_survives_restart_via_disk() {
626 // #499 (2): a baseline recorded before a restart must be recoverable from
627 // disk so the long gap is still detected (RAM-only loses it).
628 let _iso = lean_ctx::core::data_dir::isolated_data_dir();
629 let msgs = cached_body("restart-survival conversation", Some("5m"));
630 seed(&msgs, 3 * 60 * 60);
631 // Atomic under disk_io_lock: parallel cold_prefix tests also call
632 // maybe_persist and would otherwise wipe this key after test_remove.
633 test_persist_drop_and_resume(&msgs);
634 assert!(
635 repack_decision(&msgs, 1),
636 "a persisted cold baseline must survive a restart and still repack"
637 );
638 }
639}