1use std::time::{Duration, SystemTime};
49
50use tokio::time::Instant;
51use tokio_util::sync::CancellationToken;
52
53use super::actor::SessionActor;
54use super::types::{AttachMode, ClientKind};
55use crate::core::config::{CacheKeepWarm, CacheTtl};
56use crate::runtime::keepwarm::{
57 CacheAnchor, KeepWarmProbe, KeepWarmUsage, PingBill, PingFailure, PingOutcome, PingReport,
58 PingTap,
59};
60
61pub const LATE_GUARD: Duration = Duration::from_secs(15);
64
65pub const BACKOFF: [Duration; 3] = [
67 Duration::from_secs(5),
68 Duration::from_secs(15),
69 Duration::from_secs(30),
70];
71
72pub const WALL_POLL: Duration = Duration::from_secs(15);
75
76pub const SHARE_LIMIT_PERCENT: u64 = 10;
82
83pub const PING_INPUT_ESTIMATE: u64 = 64;
86
87const BOUND_EPSILON: f64 = 1e-9;
89
90fn over_share(tokens: u64, prefix: u64) -> bool {
91 tokens.saturating_mul(100) > prefix.saturating_mul(SHARE_LIMIT_PERCENT)
92}
93
94pub fn governing_ttl(ttl: CacheTtl) -> Duration {
99 match ttl {
100 CacheTtl::OneHour => Duration::from_secs(3600),
101 CacheTtl::FiveMinutes | CacheTtl::Hybrid => Duration::from_secs(300),
102 }
103}
104
105pub fn safety_margin(ttl: CacheTtl) -> Duration {
107 match ttl {
108 CacheTtl::OneHour => Duration::from_secs(300),
109 CacheTtl::FiveMinutes | CacheTtl::Hybrid => Duration::from_secs(30),
110 }
111}
112
113pub fn ping_interval(ttl: CacheTtl) -> Duration {
115 governing_ttl(ttl) - safety_margin(ttl)
116}
117
118#[cfg(any(test, feature = "testing"))]
121static INTERVAL_OVERRIDE_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
122
123pub fn interval_override() -> Option<Duration> {
133 #[cfg(any(test, feature = "testing"))]
134 {
135 let ms = INTERVAL_OVERRIDE_MS.load(std::sync::atomic::Ordering::Relaxed);
136 if ms > 0 {
137 return Some(Duration::from_millis(ms));
138 }
139 }
140 None
141}
142
143#[cfg(any(test, feature = "testing"))]
145pub mod testing {
146 use std::sync::atomic::Ordering;
147 use std::time::Duration;
148
149 #[must_use = "the override is cleared when the guard drops"]
152 pub struct IntervalOverride(());
153
154 impl IntervalOverride {
155 pub fn set(interval: Duration) -> Self {
156 let ms = u64::try_from(interval.as_millis())
157 .unwrap_or(u64::MAX)
158 .max(1);
159 super::INTERVAL_OVERRIDE_MS.store(ms, Ordering::Relaxed);
160 Self(())
161 }
162 }
163
164 impl Drop for IntervalOverride {
165 fn drop(&mut self) {
166 super::INTERVAL_OVERRIDE_MS.store(0, Ordering::Relaxed);
167 }
168 }
169
170 static DUE_CHECKS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
171
172 pub fn due_checks() -> u64 {
176 DUE_CHECKS.load(Ordering::SeqCst)
177 }
178
179 pub(super) fn note_due_check() {
180 DUE_CHECKS.fetch_add(1, Ordering::SeqCst);
181 }
182}
183
184#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
186pub struct TtlEvidence {
187 pub downgraded: bool,
189 pub saw_1h: bool,
191 pub write_1h: u64,
193 pub write_5m: u64,
194}
195
196pub fn honoured_ttl(configured: CacheTtl, e: TtlEvidence) -> CacheTtl {
200 if configured != CacheTtl::OneHour {
201 return configured;
202 }
203 if e.downgraded {
204 return CacheTtl::FiveMinutes;
205 }
206 if e.write_1h > 0 {
207 return CacheTtl::OneHour;
208 }
209 if e.write_5m > 0 {
210 return CacheTtl::FiveMinutes;
211 }
212 if e.saw_1h {
213 CacheTtl::OneHour
214 } else {
215 CacheTtl::FiveMinutes
216 }
217}
218
219pub fn request_cost_usd(
222 model: &str,
223 ttl: CacheTtl,
224 read: u64,
225 write: u64,
226 input: u64,
227 output: u64,
228) -> f64 {
229 match ttl {
230 CacheTtl::OneHour => {
231 crate::pricing::calculate_cost_split(model, input, output, read, 0, write)
232 }
233 CacheTtl::FiveMinutes | CacheTtl::Hybrid => {
234 crate::pricing::calculate_cost_split(model, input, output, read, write, 0)
235 }
236 }
237}
238
239pub fn rewrite_cost_usd(model: &str, ttl: CacheTtl, prefix_tokens: u64) -> f64 {
242 request_cost_usd(model, ttl, 0, prefix_tokens, 0, 0)
243}
244
245#[derive(Debug, Clone, PartialEq)]
248pub enum PeriodEnd {
249 NothingCached,
252 UncachedInput {
254 input: u64,
255 prefix: u64,
256 },
257 BoundReached {
260 ledger_usd: f64,
261 bound_usd: f64,
262 },
263 PingTooExpensive {
266 worst_usd: f64,
267 bound_usd: f64,
268 },
269 HoursReached(u32),
271 Expired {
274 age_secs: u64,
275 },
276 PrefixChanged,
278 ContextRollover(String),
281 CacheMiss {
283 written: u64,
284 },
285 PartialMiss {
287 written: u64,
288 prefix: u64,
289 },
290 Rejected(String),
292 TransientExhausted(String),
294 CostCap {
297 cap: f64,
298 },
299 TurnStarted,
300 Compaction,
301 Disabled,
303 ParkedByRequest,
305 SessionEnding,
306 Blocked(&'static str),
308}
309
310impl std::fmt::Display for PeriodEnd {
311 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
312 match self {
313 PeriodEnd::NothingCached => f.write_str("nothing cached"),
314 PeriodEnd::UncachedInput { input, prefix } => write!(
315 f,
316 "ping paid for {input} uncached input tokens (prefix {prefix})"
317 ),
318 PeriodEnd::BoundReached {
319 ledger_usd,
320 bound_usd,
321 } => write!(
322 f,
323 "ski-rental bound reached (${ledger_usd:.4} spent of ${bound_usd:.4})"
324 ),
325 PeriodEnd::PingTooExpensive {
326 worst_usd,
327 bound_usd,
328 } => write!(
329 f,
330 "worst-case cache-hit ping ${worst_usd:.4} exceeds the ${bound_usd:.4} rewrite it would save"
331 ),
332 PeriodEnd::HoursReached(h) => write!(f, "{h}h window reached"),
333 PeriodEnd::Expired { age_secs } => write!(
334 f,
335 "cache expired or about to ({age_secs}s since last request; slept?)"
336 ),
337 PeriodEnd::PrefixChanged => f.write_str("prefix changed"),
338 PeriodEnd::ContextRollover(why) => write!(f, "{why}"),
339 PeriodEnd::CacheMiss { written } => {
340 write!(f, "ping missed the cache (wrote {written} tokens)")
341 }
342 PeriodEnd::PartialMiss { written, prefix } => write!(
343 f,
344 "ping rewrote {written} of {prefix} prefix tokens (history no longer cached)"
345 ),
346 PeriodEnd::Rejected(m) => write!(f, "ping rejected: {m}"),
347 PeriodEnd::TransientExhausted(m) => write!(f, "transient errors: {m}"),
348 PeriodEnd::CostCap { cap } => write!(f, "max_session_cost ${cap:.4} reached"),
349 PeriodEnd::TurnStarted => f.write_str("turn started"),
350 PeriodEnd::Compaction => f.write_str("compaction"),
351 PeriodEnd::Disabled => f.write_str("disabled"),
352 PeriodEnd::ParkedByRequest => f.write_str("parked by request"),
353 PeriodEnd::SessionEnding => f.write_str("session ending"),
354 PeriodEnd::Blocked(why) => write!(f, "{why}"),
355 }
356 }
357}
358
359#[derive(Debug, Clone)]
361pub struct PeriodConfig {
362 pub mode: CacheKeepWarm,
363 pub ttl: CacheTtl,
365 pub model: String,
366 pub max_tokens: u64,
368 pub interval_override: Option<Duration>,
370 pub auto_max_hours: u32,
373 pub reply_tokens: Option<u64>,
378}
379
380#[derive(Debug, Clone, Copy, PartialEq)]
387pub struct EpochCarry {
388 pub ledger_usd: f64,
389 pub pings: u32,
390 pub prefix_tokens: u64,
391 pub ping_input: u64,
392 pub idle_since: Instant,
393 pub idle_wall: SystemTime,
394}
395
396#[derive(Debug, Clone, PartialEq)]
398pub enum Fire {
399 NotYet,
400 Ping,
401 End(PeriodEnd),
402}
403
404#[derive(Debug, Clone)]
406pub struct Period {
407 pub id: u64,
408 cfg: PeriodConfig,
409 ttl: CacheTtl,
411 evidence: TtlEvidence,
412 idle_since: Instant,
413 idle_wall: SystemTime,
414 anchor: CacheAnchor,
415 fingerprint: u64,
416 prefix_tokens: u64,
417 first_write: u64,
419 bound_usd: f64,
420 ledger_usd: f64,
421 worst_next_usd: f64,
424 exposure_next_usd: f64,
427 ping_input: u64,
429 pings: u32,
430 retries: usize,
431 next_at: Instant,
432 next_wall: SystemTime,
433 in_flight: bool,
434 fired_age: Duration,
436}
437
438fn age(anchor: &CacheAnchor, now: Instant, wall: SystemTime) -> Duration {
442 let mono = now.saturating_duration_since(anchor.mono);
443 let wall = wall.duration_since(anchor.wall).unwrap_or_default();
444 mono.max(wall)
445}
446
447impl Period {
448 #[allow(clippy::too_many_arguments)]
452 pub fn start(
453 id: u64,
454 cfg: PeriodConfig,
455 fingerprint: u64,
456 last: &KeepWarmUsage,
457 evidence: TtlEvidence,
458 anchor: CacheAnchor,
459 now: Instant,
460 wall: SystemTime,
461 ) -> Result<Period, PeriodEnd> {
462 Self::start_in_epoch(
463 id,
464 cfg,
465 fingerprint,
466 last,
467 evidence,
468 anchor,
469 now,
470 wall,
471 None,
472 )
473 }
474
475 #[allow(clippy::too_many_arguments)]
478 pub fn start_in_epoch(
479 id: u64,
480 cfg: PeriodConfig,
481 fingerprint: u64,
482 last: &KeepWarmUsage,
483 evidence: TtlEvidence,
484 anchor: CacheAnchor,
485 now: Instant,
486 wall: SystemTime,
487 carry: Option<&EpochCarry>,
488 ) -> Result<Period, PeriodEnd> {
489 let cached = last.cache_read_tokens + last.cache_write_tokens;
490 if cached == 0 {
491 return Err(PeriodEnd::NothingCached);
492 }
493 let ttl = honoured_ttl(cfg.ttl, evidence);
494 let age_now = age(&anchor, now, wall);
495 if age_now + LATE_GUARD >= governing_ttl(ttl) {
496 return Err(PeriodEnd::Expired {
497 age_secs: age_now.as_secs(),
498 });
499 }
500 let reply = cfg
505 .reply_tokens
506 .map_or(last.output_tokens, |r| r.min(last.output_tokens));
507 let first_write = last.input_tokens + reply;
508 let prefix_tokens = cached + first_write;
509 let mut p = Period {
510 id,
511 cfg,
512 ttl,
513 evidence,
514 idle_since: now,
515 idle_wall: wall,
516 anchor,
517 fingerprint,
518 prefix_tokens,
519 first_write,
520 bound_usd: 0.0,
521 ledger_usd: 0.0,
522 worst_next_usd: 0.0,
523 exposure_next_usd: 0.0,
524 ping_input: PING_INPUT_ESTIMATE,
525 pings: 0,
526 retries: 0,
527 next_at: now,
528 next_wall: wall,
529 in_flight: false,
530 fired_age: Duration::ZERO,
531 };
532 if let Some(c) = carry {
533 p.ledger_usd = c.ledger_usd;
536 p.pings = c.pings;
537 p.idle_since = c.idle_since;
538 p.idle_wall = c.idle_wall;
539 if c.pings > 0 {
540 p.prefix_tokens = c.prefix_tokens;
541 p.ping_input = c.ping_input;
542 }
543 }
544 p.reprice();
545 let interval = p.interval();
547 p.next_at = (anchor.mono + interval).max(now);
548 p.next_wall = (anchor.wall + interval).max(wall);
549 if p.worst_next_usd > p.bound_usd * (1.0 + BOUND_EPSILON) {
550 return Err(PeriodEnd::PingTooExpensive {
551 worst_usd: p.worst_next_usd,
552 bound_usd: p.bound_usd,
553 });
554 }
555 if let Some(r) = p
556 .past_window(p.next_at, p.next_wall)
557 .or_else(|| p.past_bound())
558 {
559 return Err(r);
560 }
561 Ok(p)
562 }
563
564 pub fn epoch_carry(&self) -> EpochCarry {
566 EpochCarry {
567 ledger_usd: self.ledger_usd,
568 pings: self.pings,
569 prefix_tokens: self.prefix_tokens,
570 ping_input: self.ping_input,
571 idle_since: self.idle_since,
572 idle_wall: self.idle_wall,
573 }
574 }
575
576 fn reprice(&mut self) {
579 let model = self.cfg.model.as_str();
580 self.bound_usd = rewrite_cost_usd(model, self.ttl, self.prefix_tokens);
581 let write = if self.pings == 0 { self.first_write } else { 0 };
582 self.worst_next_usd = request_cost_usd(
583 model,
584 self.ttl,
585 self.prefix_tokens.saturating_sub(write),
586 write,
587 self.ping_input,
588 self.cfg.max_tokens,
589 );
590 let miss = |ttl| {
594 request_cost_usd(
595 model,
596 ttl,
597 0,
598 self.prefix_tokens,
599 self.ping_input,
600 self.cfg.max_tokens,
601 )
602 };
603 let mut miss_usd = miss(self.ttl);
604 if matches!(self.cfg.ttl, CacheTtl::OneHour | CacheTtl::Hybrid) {
605 miss_usd = miss_usd.max(miss(CacheTtl::OneHour));
606 }
607 self.exposure_next_usd = self.worst_next_usd.max(miss_usd);
608 }
609
610 fn interval(&self) -> Duration {
611 self.cfg
612 .interval_override
613 .unwrap_or_else(|| ping_interval(self.ttl))
614 }
615
616 pub fn fingerprint(&self) -> u64 {
617 self.fingerprint
618 }
619 pub fn pings(&self) -> u32 {
620 self.pings
621 }
622 pub fn ledger_usd(&self) -> f64 {
623 self.ledger_usd
624 }
625 pub fn bound_usd(&self) -> f64 {
626 self.bound_usd
627 }
628 pub fn worst_next_usd(&self) -> f64 {
632 self.worst_next_usd
633 }
634 pub fn exposure_next_usd(&self) -> f64 {
638 self.exposure_next_usd
639 }
640 pub fn prefix_tokens(&self) -> u64 {
641 self.prefix_tokens
642 }
643 pub fn next_at(&self) -> Instant {
644 self.next_at
645 }
646 pub fn mode(&self) -> CacheKeepWarm {
647 self.cfg.mode
648 }
649 pub fn ttl(&self) -> CacheTtl {
651 self.ttl
652 }
653 pub fn fired_age(&self) -> Duration {
654 self.fired_age
655 }
656 pub fn in_flight(&self) -> bool {
657 self.in_flight
658 }
659
660 pub fn set_mode(&mut self, mode: CacheKeepWarm) {
662 self.cfg.mode = mode;
663 }
664
665 pub fn deadline(&self, now: Instant, wall: SystemTime) -> Option<Instant> {
669 if self.in_flight {
670 return None;
671 }
672 let wall_left = self.next_wall.duration_since(wall).unwrap_or_default();
673 Some(self.next_at.min(now + wall_left))
674 }
675
676 fn past_window(&self, at: Instant, wall: SystemTime) -> Option<PeriodEnd> {
678 let h = match self.cfg.mode {
679 CacheKeepWarm::Hours(h) => h,
680 CacheKeepWarm::Auto if self.cfg.auto_max_hours > 0 => self.cfg.auto_max_hours,
681 _ => return None,
682 };
683 let window = Duration::from_secs(u64::from(h) * 3600);
684 let walled = wall.duration_since(self.idle_wall).unwrap_or_default();
685 (at.saturating_duration_since(self.idle_since) >= window || walled >= window)
686 .then_some(PeriodEnd::HoursReached(h))
687 }
688
689 fn past_bound(&self) -> Option<PeriodEnd> {
690 (self.ledger_usd + self.worst_next_usd > self.bound_usd * (1.0 + BOUND_EPSILON)).then_some(
691 PeriodEnd::BoundReached {
692 ledger_usd: self.ledger_usd,
693 bound_usd: self.bound_usd,
694 },
695 )
696 }
697
698 pub fn fire(
700 &mut self,
701 now: Instant,
702 wall: SystemTime,
703 session_cost: f64,
704 max_session_cost: Option<f64>,
705 ) -> Fire {
706 if self.in_flight || (now < self.next_at && wall < self.next_wall) {
707 return Fire::NotYet;
708 }
709 let age_now = age(&self.anchor, now, wall);
710 if age_now + LATE_GUARD >= governing_ttl(self.ttl) {
711 return Fire::End(PeriodEnd::Expired {
712 age_secs: age_now.as_secs(),
713 });
714 }
715 if let Some(r) = self.past_window(now, wall).or_else(|| self.past_bound()) {
716 return Fire::End(r);
717 }
718 if let Some(cap) = max_session_cost.filter(|c| c.is_finite()) {
719 if session_cost + self.exposure_next_usd >= cap {
721 return Fire::End(PeriodEnd::CostCap { cap });
722 }
723 }
724 self.in_flight = true;
725 self.fired_age = age_now;
726 Fire::Ping
727 }
728
729 pub fn charge(&mut self, cost_usd: f64) {
731 self.ledger_usd += cost_usd;
732 }
733
734 pub fn ping_sent(
739 &mut self,
740 usage: &KeepWarmUsage,
741 anchor: Option<CacheAnchor>,
742 downgraded: bool,
743 ) -> Result<(), PeriodEnd> {
744 self.in_flight = false;
745 self.retries = 0;
746 self.pings += 1;
747 let (read, write) = (usage.cache_read_tokens, usage.cache_write_tokens);
748 if read == 0 && write == 0 {
749 return Err(PeriodEnd::NothingCached);
750 }
751 if read == 0 {
752 return Err(PeriodEnd::CacheMiss { written: write });
753 }
754 if over_share(usage.input_tokens, self.prefix_tokens) {
755 return Err(PeriodEnd::UncachedInput {
756 input: usage.input_tokens,
757 prefix: self.prefix_tokens,
758 });
759 }
760 let allowed = if self.pings == 1 { self.first_write } else { 0 };
761 if over_share(write.saturating_sub(allowed), self.prefix_tokens) {
762 return Err(PeriodEnd::PartialMiss {
763 written: write,
764 prefix: self.prefix_tokens,
765 });
766 }
767 self.prefix_tokens = read + write;
769 self.ping_input = usage.input_tokens.max(1);
770 self.evidence.downgraded |= downgraded;
771 self.evidence.write_1h = usage.cache_write_1h.unwrap_or(0);
772 self.evidence.write_5m = usage.cache_write_5m.unwrap_or(0);
773 self.evidence.saw_1h |= self.evidence.write_1h > 0;
774 self.ttl = honoured_ttl(self.cfg.ttl, self.evidence);
775 self.reprice();
776 if let Some(a) = anchor.filter(|a| a.mono >= self.anchor.mono) {
777 self.anchor = a;
778 }
779 let interval = self.interval();
780 self.next_at = self.anchor.mono + interval;
781 self.next_wall = self.anchor.wall + interval;
782 if let Some(r) = self
783 .past_window(self.next_at, self.next_wall)
784 .or_else(|| self.past_bound())
785 {
786 return Err(r);
787 }
788 Ok(())
789 }
790
791 pub fn ping_failed(
795 &mut self,
796 failure: &PingFailure,
797 now: Instant,
798 wall: SystemTime,
799 ) -> Result<Duration, PeriodEnd> {
800 self.in_flight = false;
801 let msg = match failure {
802 PingFailure::Fatal(m) => return Err(PeriodEnd::Rejected(m.clone())),
803 PingFailure::Canceled => return Err(PeriodEnd::Rejected("canceled".into())),
804 PingFailure::Transient(m) => m.clone(),
805 };
806 let Some(delay) = BACKOFF.get(self.retries).copied() else {
807 return Err(PeriodEnd::TransientExhausted(msg));
808 };
809 let age_then = age(&self.anchor, now, wall) + delay;
810 if age_then + LATE_GUARD >= governing_ttl(self.ttl) {
811 return Err(PeriodEnd::TransientExhausted(format!(
812 "{msg}; no retry fits before the cache expires"
813 )));
814 }
815 if self.ledger_usd + self.worst_next_usd > self.bound_usd * (1.0 + BOUND_EPSILON) {
816 return Err(PeriodEnd::BoundReached {
817 ledger_usd: self.ledger_usd,
818 bound_usd: self.bound_usd,
819 });
820 }
821 self.retries += 1;
822 self.next_at = now + delay;
823 self.next_wall = wall + delay;
824 Ok(delay)
825 }
826}
827
828pub(crate) struct PingTask {
830 pub(crate) handle: tokio::task::JoinHandle<PingReport>,
831 cancel: CancellationToken,
832 tap: PingTap,
833 model: String,
835 worst_usd: f64,
838}
839
840impl PingTask {
841 fn bill(&self) -> PingBill {
844 self.tap.bill(&self.model, self.worst_usd)
845 }
846}
847
848#[derive(Default)]
850pub(crate) struct KeepWarmState {
851 configured: CacheKeepWarm,
853 auto_max_hours: u32,
855 epoch: Option<EpochCarry>,
859 pub(crate) override_mode: Option<CacheKeepWarm>,
861 pub(crate) override_slot: OverrideSlot,
864 pub(crate) period: Option<Period>,
865 pub(crate) task: Option<PingTask>,
866 next_period_id: u64,
867 turn_started_at: Option<Instant>,
869 last_request: Option<KeepWarmUsage>,
871 pub(crate) interactive_seen: bool,
875 last_end: Option<String>,
876 session_pings: u32,
877}
878
879pub type OverrideSlot = std::sync::Arc<std::sync::Mutex<Option<CacheKeepWarm>>>;
881
882impl KeepWarmState {
883 pub(crate) fn new(
884 configured: CacheKeepWarm,
885 auto_max_hours: u32,
886 override_slot: OverrideSlot,
887 ) -> Self {
888 let override_mode = *override_slot
889 .lock()
890 .unwrap_or_else(std::sync::PoisonError::into_inner);
891 Self {
892 configured,
893 auto_max_hours,
894 override_mode,
895 override_slot,
896 ..Self::default()
897 }
898 }
899
900 pub(crate) fn mode(&self) -> CacheKeepWarm {
901 self.override_mode.unwrap_or(self.configured)
902 }
903
904 pub(crate) fn set_override(&mut self, mode: CacheKeepWarm) {
905 self.override_mode = Some(mode);
906 *self
907 .override_slot
908 .lock()
909 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(mode);
910 }
911
912 pub(crate) fn active(&self) -> bool {
913 self.period.is_some()
914 }
915
916 pub(crate) fn deadline(&self) -> Option<Instant> {
920 self.deadline_at(Instant::now(), SystemTime::now())
921 }
922
923 fn deadline_at(&self, now: Instant, wall: SystemTime) -> Option<Instant> {
924 self.period
925 .as_ref()
926 .and_then(|p| p.deadline(now, wall))
927 .map(|d| d.min(now + WALL_POLL))
928 }
929}
930
931pub(crate) async fn keepwarm_timer(deadline: Option<Instant>) {
933 match deadline {
934 Some(t) => tokio::time::sleep_until(t).await,
935 None => std::future::pending().await,
936 }
937}
938
939pub(crate) async fn poll_keepwarm_ping(
941 task: &mut Option<PingTask>,
942) -> std::result::Result<PingReport, tokio::task::JoinError> {
943 match task {
944 Some(t) => (&mut t.handle).await,
945 None => std::future::pending().await,
946 }
947}
948
949impl SessionActor {
950 fn cache_keepwarm_blocker(&self) -> Option<&'static str> {
952 if !self.runtime.is_live() || !self.conv.is_live() || self.is_parked() {
953 return Some("session parked");
954 }
955 if self.streaming {
956 return Some("turn running");
957 }
958 if self.compact.is_some() {
959 return Some("compaction running");
960 }
961 if self.conv.queued_message.is_some() || !self.event_queue.is_empty() {
962 return Some("work queued");
963 }
964 if !self.pending_prompts.is_empty() {
965 return Some("prompt pending");
966 }
967 if self.driver.is_some() || self.driver_pending.is_some() {
968 return Some("session driver armed");
969 }
970 None
971 }
972
973 pub(crate) fn cache_keepwarm_note_attach(&mut self, kind: ClientKind, mode: AttachMode) {
979 if mode == AttachMode::Observe {
980 return;
981 }
982 if matches!(kind, ClientKind::Tui | ClientKind::Attach) {
983 self.cache_keepwarm.interactive_seen = true;
984 }
985 }
986
987 pub(crate) fn cache_keepwarm_note_usage(&mut self, usage: KeepWarmUsage) {
990 self.cache_keepwarm.last_request = Some(usage);
991 self.cache_keepwarm.epoch = None;
993 }
994
995 pub(crate) fn cache_keepwarm_turn_started(&mut self) {
998 self.cache_keepwarm_end(PeriodEnd::TurnStarted);
999 self.cache_keepwarm.turn_started_at = Some(Instant::now());
1000 self.cache_keepwarm.last_request = None;
1001 }
1002
1003 pub(crate) fn cache_keepwarm_compaction(&mut self) {
1006 self.cache_keepwarm_end(PeriodEnd::Compaction);
1007 self.cache_keepwarm.turn_started_at = None;
1008 self.cache_keepwarm.last_request = None;
1009 self.cache_keepwarm.epoch = None;
1010 }
1011
1012 pub(crate) fn cache_keepwarm_conversation_replaced(&mut self) {
1019 self.cache_keepwarm_end(PeriodEnd::PrefixChanged);
1020 self.cache_keepwarm.turn_started_at = None;
1021 self.cache_keepwarm.last_request = None;
1022 self.cache_keepwarm.epoch = None;
1023 }
1024
1025 pub(crate) fn cache_keepwarm_park_requested(&mut self) {
1028 if self.cache_keepwarm.active() && self.can_park_ignoring_keepwarm() {
1029 self.cache_keepwarm_end(PeriodEnd::ParkedByRequest);
1030 }
1031 }
1032
1033 fn cache_keepwarm_book(&mut self, usage: &KeepWarmUsage, cost: f64) {
1038 if (cost <= 0.0 && *usage == KeepWarmUsage::default()) || !self.conv.is_live() {
1039 return;
1040 }
1041 self.conv.session_cost += cost;
1042 self.conv.keepwarm_cost += cost;
1043 self.conv.total_input_tokens += usage.input_tokens;
1044 self.conv.total_output_tokens += usage.output_tokens;
1045 self.conv.total_cache_read_tokens += usage.cache_read_tokens;
1046 self.conv.total_cache_creation_tokens += usage.cache_write_tokens;
1047 let t = &mut self.conv.keepwarm_tokens;
1048 t.input += usage.input_tokens;
1049 t.output += usage.output_tokens;
1050 t.cache_read += usage.cache_read_tokens;
1051 t.cache_creation += usage.cache_write_tokens;
1052 if let Some(p) = self.cache_keepwarm.period.as_mut() {
1053 p.charge(cost);
1054 }
1055 self.emit_conversation();
1056 self.request_save();
1062 }
1063
1064 fn cache_keepwarm_abort_ping(&mut self) {
1068 let Some(task) = self.cache_keepwarm.task.take() else {
1069 return;
1070 };
1071 task.cancel.cancel();
1072 task.handle.abort();
1073 let bill = task.bill();
1074 if bill.cost_usd > 0.0 {
1075 tracing::info!(
1076 session = %self.id,
1077 cost_usd = bill.cost_usd,
1078 booking = ?bill.booking,
1079 "cache keep-warm ping aborted in flight; booked"
1080 );
1081 }
1082 self.cache_keepwarm_book(&bill.usage, bill.cost_usd);
1083 }
1084
1085 pub(crate) fn cache_keepwarm_end(&mut self, reason: PeriodEnd) {
1087 self.cache_keepwarm_abort_ping();
1088 let Some(p) = self.cache_keepwarm.period.take() else {
1089 return;
1090 };
1091 tracing::info!(
1092 session = %self.id,
1093 period = p.id,
1094 pings = p.pings(),
1095 ledger_usd = p.ledger_usd(),
1096 bound_usd = p.bound_usd(),
1097 reason = %reason,
1098 "cache keep-warm period ended"
1099 );
1100 self.cache_keepwarm.last_end = Some(reason.to_string());
1101 self.cache_keepwarm.epoch = Some(p.epoch_carry());
1103 self.rearm_park();
1105 }
1106
1107 pub(crate) async fn cache_keepwarm_on_idle(&mut self) {
1110 let Some(turn_started_at) = self.cache_keepwarm.turn_started_at.take() else {
1111 return;
1112 };
1113 self.cache_keepwarm_try_start(Some(turn_started_at)).await;
1114 }
1115
1116 async fn cache_keepwarm_try_start(&mut self, turn_started_at: Option<Instant>) {
1121 if self.cache_keepwarm.active() {
1122 return;
1123 }
1124 let mode = self.cache_keepwarm.mode();
1125 if mode.is_off() || !(self.cache_keepwarm.interactive_seen || self.keep_warm) {
1128 return;
1129 }
1130 let Some(anchor) = self
1132 .runtime
1133 .cache_anchor()
1134 .filter(|a| turn_started_at.is_none_or(|t| a.mono >= t))
1135 else {
1136 return;
1137 };
1138 let Some(last) = self.cache_keepwarm.last_request.clone() else {
1139 return;
1140 };
1141 if let Some(why) = self.cache_keepwarm_blocker() {
1142 self.cache_keepwarm.last_end = Some(format!("not started: {why}"));
1143 return;
1144 }
1145 if let Some(why) = self.runtime.cache_keepwarm_ineligibility() {
1146 self.cache_keepwarm.last_end = Some(format!("not started: {why}"));
1147 return;
1148 }
1149 if !crate::runtime::keepwarm::history_ready(&self.conv.api_messages) {
1150 return;
1151 }
1152 let probe: KeepWarmProbe = match self
1153 .runtime
1154 .cache_keepwarm_probe(&self.conv.api_messages)
1155 .await
1156 {
1157 Ok(p) => p,
1158 Err(e) => {
1159 tracing::info!(session = %self.id, "cache keep-warm not started: {e}");
1160 self.cache_keepwarm.last_end = Some(format!("not started: {e}"));
1161 return;
1162 }
1163 };
1164 if let Some(why) = probe.context_blocks {
1165 let reason = PeriodEnd::ContextRollover(why);
1166 tracing::info!(session = %self.id, reason = %reason, "cache keep-warm not started");
1167 self.cache_keepwarm.last_end = Some(format!("not started: {reason}"));
1168 return;
1169 }
1170 let cfg = PeriodConfig {
1171 mode,
1172 ttl: self.runtime.cache_ttl(),
1173 model: self.runtime.model().to_string(),
1174 max_tokens: probe.max_tokens,
1175 interval_override: interval_override(),
1176 auto_max_hours: self.cache_keepwarm.auto_max_hours,
1177 reply_tokens: Some(crate::runtime::keepwarm::reply_prefix_tokens(
1178 &self.conv.api_messages,
1179 )),
1180 };
1181 let evidence = TtlEvidence {
1182 downgraded: self.runtime.cache_ttl_downgraded(),
1183 saw_1h: self.runtime.cache_1h_honoured(),
1184 write_1h: last.cache_write_1h.unwrap_or(0),
1185 write_5m: last.cache_write_5m.unwrap_or(0),
1186 };
1187 self.cache_keepwarm.next_period_id += 1;
1188 let id = self.cache_keepwarm.next_period_id;
1189 let now = Instant::now();
1190 let carry = self.cache_keepwarm.epoch;
1191 match Period::start_in_epoch(
1192 id,
1193 cfg,
1194 probe.fingerprint,
1195 &last,
1196 evidence,
1197 anchor,
1198 now,
1199 SystemTime::now(),
1200 carry.as_ref(),
1201 ) {
1202 Ok(p) => {
1203 tracing::info!(
1204 session = %self.id,
1205 period = id,
1206 mode = %mode,
1207 ttl_secs = governing_ttl(p.ttl()).as_secs(),
1208 first_ping_in_secs = p.next_at().saturating_duration_since(now).as_secs(),
1209 prefix_tokens = p.prefix_tokens(),
1210 bound_usd = p.bound_usd(),
1211 worst_ping_usd = p.exposure_next_usd(),
1212 "cache keep-warm period started"
1213 );
1214 self.cache_keepwarm.period = Some(p);
1215 }
1216 Err(reason) => {
1217 tracing::info!(session = %self.id, reason = %reason, "cache keep-warm not started");
1218 self.cache_keepwarm.last_end = Some(format!("not started: {reason}"));
1219 }
1220 }
1221 }
1222
1223 pub(crate) async fn on_cache_keepwarm_due(&mut self) {
1225 #[cfg(any(test, feature = "testing"))]
1226 testing::note_due_check();
1227 if self.holds(super::actor::WorkSource::KeepWarmTimer)
1232 || self.cache_keepwarm.period.is_none()
1233 || self.cache_keepwarm.task.is_some()
1234 {
1235 return;
1236 }
1237 let (now, wall) = (Instant::now(), SystemTime::now());
1238 if self
1240 .cache_keepwarm
1241 .period
1242 .as_ref()
1243 .and_then(|p| p.deadline(now, wall))
1244 .is_some_and(|d| d > now)
1245 {
1246 return;
1247 }
1248 if let Some(why) = self.cache_keepwarm_blocker() {
1249 self.cache_keepwarm_end(PeriodEnd::Blocked(why));
1250 return;
1251 }
1252 if !crate::runtime::keepwarm::history_ready(&self.conv.api_messages) {
1253 self.cache_keepwarm_end(PeriodEnd::PrefixChanged);
1256 return;
1257 }
1258 match self
1261 .runtime
1262 .cache_keepwarm_probe(&self.conv.api_messages)
1263 .await
1264 {
1265 Ok(probe) => {
1266 if let Some(why) = probe.context_blocks {
1267 self.cache_keepwarm_end(PeriodEnd::ContextRollover(why));
1268 return;
1269 }
1270 }
1271 Err(e) => {
1272 self.cache_keepwarm_end(PeriodEnd::Rejected(e.to_string()));
1273 return;
1274 }
1275 }
1276 let session_cost = self.conv.session_cost;
1277 let cap = self.config.max_session_cost;
1278 let Some(p) = self.cache_keepwarm.period.as_mut() else {
1279 return;
1280 };
1281 match p.fire(Instant::now(), SystemTime::now(), session_cost, cap) {
1282 Fire::NotYet => {}
1283 Fire::End(reason) => self.cache_keepwarm_end(reason),
1284 Fire::Ping => {
1285 let expect = p.fingerprint();
1286 let worst_usd = p.worst_next_usd();
1287 let history = self.conv.api_messages.clone();
1291 let mut runtime = self.runtime.clone();
1292 runtime.share_ttl_latches(&self.runtime);
1293 let cancel = CancellationToken::new();
1294 let token = cancel.clone();
1295 let tap = PingTap::default();
1296 let task_tap = tap.clone();
1297 let handle = tokio::spawn(async move {
1298 runtime
1299 .cache_keepwarm_ping(&history, expect, &token, &task_tap)
1300 .await
1301 });
1302 self.cache_keepwarm.task = Some(PingTask {
1303 handle,
1304 cancel,
1305 tap,
1306 model: self.runtime.model().to_string(),
1307 worst_usd,
1308 });
1309 }
1310 }
1311 }
1312
1313 pub(crate) fn on_cache_keepwarm_done(
1315 &mut self,
1316 result: std::result::Result<PingReport, tokio::task::JoinError>,
1317 ) {
1318 let Some(task) = self.cache_keepwarm.task.take() else {
1319 return;
1320 };
1321 let report = match result {
1322 Ok(r) => r,
1323 Err(e) => PingReport {
1324 result: Err(if e.is_cancelled() {
1325 PingFailure::Canceled
1326 } else {
1327 PingFailure::Fatal(format!("ping task: {e}"))
1328 }),
1329 usage: task.tap.usage(),
1330 notices: Vec::new(),
1331 },
1332 };
1333 let bill = task.bill();
1335 let cost = bill.cost_usd;
1336 self.cache_keepwarm_book(&bill.usage, cost);
1337 for notice in &report.notices {
1338 self.emit(crate::session::SessionEventWire::SystemNotice(
1339 notice.clone(),
1340 ));
1341 }
1342 if self.cache_keepwarm.period.is_none() {
1343 return;
1344 }
1345 match report.result {
1346 Ok(PingOutcome::Sent) => {
1347 let usage = report.usage;
1348 self.cache_keepwarm.session_pings += 1;
1349 let anchor = self.runtime.cache_anchor();
1350 let downgraded = self.runtime.cache_ttl_downgraded();
1351 let Some(p) = self.cache_keepwarm.period.as_mut() else {
1352 return;
1353 };
1354 let result = p.ping_sent(&usage, anchor, downgraded);
1355 tracing::info!(
1356 session = %self.id,
1357 period = p.id,
1358 ping = p.pings(),
1359 age_secs = p.fired_age().as_secs(),
1360 ttl_secs = governing_ttl(p.ttl()).as_secs(),
1361 cache_read = usage.cache_read_tokens,
1362 cache_write = usage.cache_write_tokens,
1363 input = usage.input_tokens,
1364 output = usage.output_tokens,
1365 cost_usd = cost,
1366 ledger_usd = p.ledger_usd(),
1367 bound_usd = p.bound_usd(),
1368 "cache keep-warm ping"
1369 );
1370 if let Err(reason) = result {
1371 if matches!(
1372 reason,
1373 PeriodEnd::PartialMiss { .. }
1374 | PeriodEnd::CacheMiss { .. }
1375 | PeriodEnd::UncachedInput { .. }
1376 | PeriodEnd::NothingCached
1377 ) {
1378 tracing::warn!(
1379 session = %self.id,
1380 reason = %reason,
1381 "cache keep-warm ping did not refresh the cache"
1382 );
1383 }
1384 self.cache_keepwarm_end(reason);
1385 return;
1386 }
1387 if let Some(cap) = self.config.max_session_cost.filter(|c| c.is_finite()) {
1388 if self.conv.session_cost >= cap {
1389 self.cache_keepwarm_end(PeriodEnd::CostCap { cap });
1390 }
1391 }
1392 }
1393 Ok(PingOutcome::PrefixChanged { .. }) => {
1394 self.cache_keepwarm_end(PeriodEnd::PrefixChanged);
1395 }
1396 Err(failure) => {
1397 let Some(p) = self.cache_keepwarm.period.as_mut() else {
1398 return;
1399 };
1400 match p.ping_failed(&failure, Instant::now(), SystemTime::now()) {
1401 Ok(delay) => tracing::info!(
1402 session = %self.id,
1403 period = p.id,
1404 retry_in_secs = delay.as_secs(),
1405 "cache keep-warm ping failed ({failure}); retrying"
1406 ),
1407 Err(reason) => self.cache_keepwarm_end(reason),
1408 }
1409 }
1410 }
1411 }
1412
1413 pub(crate) async fn cache_keepwarm_recheck(&mut self) -> Option<bool> {
1417 let expect = self.cache_keepwarm.period.as_ref()?.fingerprint();
1418 if self.cache_keepwarm.period.as_ref()?.in_flight() {
1419 return Some(true);
1420 }
1421 let matches = crate::runtime::keepwarm::history_ready(&self.conv.api_messages)
1422 && self
1423 .runtime
1424 .cache_keepwarm_probe(&self.conv.api_messages)
1425 .await
1426 .is_ok_and(|p| p.fingerprint == expect);
1427 if !matches {
1428 self.cache_keepwarm_end(PeriodEnd::PrefixChanged);
1429 }
1430 Some(matches)
1431 }
1432
1433 pub(crate) fn cache_keepwarm_status_json(
1435 &self,
1436 prefix_matches: Option<bool>,
1437 ) -> serde_json::Value {
1438 let kw = &self.cache_keepwarm;
1439 let (now, wall) = (Instant::now(), SystemTime::now());
1440 let period = kw.period.as_ref().map(|p| {
1441 let next = p
1443 .deadline(now, wall)
1444 .map(|d| d.saturating_duration_since(now).as_secs());
1445 serde_json::json!({
1446 "id": p.id,
1447 "ttl_secs": governing_ttl(p.ttl()).as_secs(),
1448 "pings": p.pings(),
1449 "ledger_usd": p.ledger_usd(),
1450 "bound_usd": p.bound_usd(),
1451 "worst_ping_usd": p.exposure_next_usd(),
1452 "prefix_tokens": p.prefix_tokens(),
1453 "next_ping_in_secs": next.unwrap_or(0),
1454 "in_flight": p.in_flight(),
1455 })
1456 });
1457 serde_json::json!({
1458 "mode": kw.mode().to_string(),
1459 "active": kw.active(),
1460 "period": period,
1461 "prefix_matches": prefix_matches,
1462 "session_pings": kw.session_pings,
1463 "session_keepwarm_usd": self.conv.keepwarm_cost,
1464 "last_end": kw.last_end,
1465 })
1466 }
1467
1468 pub(crate) async fn cache_keepwarm_command(&mut self, arg: &str) -> serde_json::Value {
1470 let arg = arg.trim();
1471 if !(arg.is_empty() || arg.eq_ignore_ascii_case("status")) {
1472 let Some(mode) = CacheKeepWarm::parse(arg) else {
1473 return serde_json::json!({
1474 "kind": "error",
1475 "text": format!(
1476 "usage: /keepwarm [status|off|auto|<N>h] (N = 1–{}; unknown: {arg})",
1477 crate::core::config::MAX_CACHE_KEEPWARM_HOURS
1478 ),
1479 });
1480 };
1481 self.cache_keepwarm.set_override(mode);
1482 if self.keep_warm {
1483 self.note_pin(); }
1485 if mode.is_off() {
1486 self.cache_keepwarm_end(PeriodEnd::Disabled);
1487 } else if let Some(p) = self.cache_keepwarm.period.as_mut() {
1488 p.set_mode(mode);
1489 } else {
1490 self.cache_keepwarm_try_start(None).await;
1492 }
1493 }
1494 let prefix_matches = self.cache_keepwarm_recheck().await;
1495 let status = self.cache_keepwarm_status_json(prefix_matches);
1496 serde_json::json!({
1497 "kind": "output",
1498 "text": render_status(&status),
1499 "keepwarm": status,
1500 })
1501 }
1502}
1503
1504pub(crate) fn render_status(s: &serde_json::Value) -> String {
1506 let mode = s["mode"].as_str().unwrap_or("off");
1507 let spent = s["session_keepwarm_usd"].as_f64().unwrap_or(0.0);
1508 let pings = s["session_pings"].as_u64().unwrap_or(0);
1509 let mut out = format!("cache keep-warm: {mode} — this session: {pings} pings, ${spent:.4}");
1510 let p = &s["period"];
1511 if p.is_object() {
1512 out.push_str(&format!(
1513 "\nactive: ping {} due in {}s (TTL {}s); ${:.4} spent of ${:.4} bound; prefix {}",
1514 p["pings"].as_u64().unwrap_or(0) + 1,
1515 p["next_ping_in_secs"].as_u64().unwrap_or(0),
1516 p["ttl_secs"].as_u64().unwrap_or(0),
1517 p["ledger_usd"].as_f64().unwrap_or(0.0),
1518 p["bound_usd"].as_f64().unwrap_or(0.0),
1519 match s["prefix_matches"].as_bool() {
1520 Some(true) => "unchanged",
1521 Some(false) => "changed",
1522 None => "unchecked",
1523 },
1524 ));
1525 } else {
1526 out.push_str("\nidle period: none");
1527 }
1528 if let Some(last) = s["last_end"].as_str() {
1529 out.push_str(&format!("\nlast: {last}"));
1530 }
1531 out
1532}
1533
1534#[cfg(test)]
1535mod tests {
1536 use super::*;
1537
1538 const MIN: Duration = Duration::from_secs(60);
1539 const OPUS: &str = "claude-opus-4-7";
1540 const SONNET: &str = "claude-sonnet-4-6";
1541 const P: u64 = 300_000;
1542
1543 fn cfg(mode: CacheKeepWarm, ttl: CacheTtl, model: &str) -> PeriodConfig {
1545 cfg_max(mode, ttl, model, 1)
1546 }
1547
1548 fn cfg_max(mode: CacheKeepWarm, ttl: CacheTtl, model: &str, max_tokens: u64) -> PeriodConfig {
1549 PeriodConfig {
1550 mode,
1551 ttl,
1552 model: model.to_string(),
1553 max_tokens,
1554 interval_override: None,
1555 auto_max_hours: 0,
1556 reply_tokens: None,
1557 }
1558 }
1559
1560 fn ev_1h() -> TtlEvidence {
1562 TtlEvidence {
1563 saw_1h: true,
1564 ..TtlEvidence::default()
1565 }
1566 }
1567
1568 fn anchor_at(mono: Instant, wall: SystemTime) -> CacheAnchor {
1569 CacheAnchor { mono, wall }
1570 }
1571
1572 fn usage(model: &str, read: u64, write: u64, input: u64, out: u64) -> KeepWarmUsage {
1573 KeepWarmUsage {
1574 input_tokens: input,
1575 output_tokens: out,
1576 cache_read_tokens: read,
1577 cache_write_tokens: write,
1578 cache_write_5m: None,
1579 cache_write_1h: None,
1580 model: Some(model.to_string()),
1581 }
1582 }
1583
1584 fn last_read(model: &str) -> KeepWarmUsage {
1586 usage(model, P, 0, 0, 0)
1587 }
1588
1589 fn start(c: PeriodConfig, last: &KeepWarmUsage, t0: Instant, w0: SystemTime) -> Period {
1590 Period::start(1, c, 7, last, ev_1h(), anchor_at(t0, w0), t0, w0).expect("starts")
1591 }
1592
1593 fn run_from(
1597 cfg: PeriodConfig,
1598 last: &KeepWarmUsage,
1599 usages: &[KeepWarmUsage],
1600 ) -> (u32, f64, f64, PeriodEnd, Duration) {
1601 let t0 = Instant::now();
1602 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
1603 let model = cfg.model.clone();
1604 let mut p = start(cfg, last, t0, w0);
1605 let mut i = 0;
1606 loop {
1607 let at = p.next_at();
1608 let wall = w0 + at.saturating_duration_since(t0);
1609 match p.fire(at, wall, 0.0, None) {
1610 Fire::Ping => {}
1611 Fire::End(r) => {
1612 return (p.pings(), p.ledger_usd(), p.bound_usd(), r, at - t0);
1613 }
1614 Fire::NotYet => panic!("due but not fired"),
1615 }
1616 let u = &usages[i.min(usages.len() - 1)];
1617 i += 1;
1618 p.charge(u.cost_usd(&model));
1619 if let Err(r) = p.ping_sent(u, Some(anchor_at(at, wall)), false) {
1620 return (p.pings(), p.ledger_usd(), p.bound_usd(), r, at - t0);
1621 }
1622 assert!(i < 1000, "runaway");
1623 }
1624 }
1625
1626 fn run(cfg: PeriodConfig, usages: &[KeepWarmUsage]) -> (u32, f64, f64, PeriodEnd, Duration) {
1627 let last = last_read(&cfg.model);
1628 run_from(cfg, &last, usages)
1629 }
1630
1631 #[test]
1636 fn review_205_possible_cache_miss_must_fit_admission_cap() {
1637 let t0 = Instant::now();
1638 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
1639 let mut p = start(
1640 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1641 &last_read(OPUS),
1642 t0,
1643 w0,
1644 );
1645 let at = p.next_at();
1646 let wall = w0 + at.saturating_duration_since(t0);
1647 let cap = 0.30;
1648 let decision = p.fire(at, wall, 0.0, Some(cap));
1649 let miss = KeepWarmUsage {
1650 cache_write_tokens: P,
1651 cache_write_1h: Some(P),
1652 ..KeepWarmUsage::default()
1653 };
1654 println!(
1655 "REVIEW estimated_worst={} possible_write={} cap={cap} decision={decision:?}",
1656 p.worst_next_usd,
1657 miss.cost_usd(OPUS)
1658 );
1659 if matches!(decision, Fire::Ping) {
1660 assert!(
1661 miss.cost_usd(OPUS) <= cap,
1662 "admitted request can exceed its session cost cap on a cache miss"
1663 );
1664 }
1665 }
1666
1667 #[test]
1671 fn a_cap_with_room_for_a_cache_miss_still_pings() {
1672 let t0 = Instant::now();
1673 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
1674 let mut p = start(
1675 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1676 &last_read(OPUS),
1677 t0,
1678 w0,
1679 );
1680 let exposure = p.exposure_next_usd();
1682 let miss = usage(OPUS, 0, P, PING_INPUT_ESTIMATE, 1);
1683 let miss = KeepWarmUsage {
1684 cache_write_1h: Some(P),
1685 ..miss
1686 };
1687 assert!((exposure - miss.cost_usd(OPUS)).abs() < 1e-9, "{exposure}");
1688 assert!(p.worst_next_usd() < p.bound_usd(), "bound basis is the read");
1689 let at = p.next_at();
1690 let wall = w0 + at.saturating_duration_since(t0);
1691 assert_eq!(
1692 p.fire(at, wall, 0.0, Some(3.0)),
1693 Fire::End(PeriodEnd::CostCap { cap: 3.0 })
1694 );
1695 assert_eq!(p.fire(at, wall, 0.0, Some(3.01)), Fire::Ping);
1696 }
1697
1698 #[test]
1699 fn ski_rental_1h_is_nineteen_pure_reads_over_about_17h() {
1700 let (pings, ledger, bound, reason, elapsed) = run(
1704 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1705 &[usage(OPUS, P, 0, 0, 0)],
1706 );
1707 assert!((bound - 3.0).abs() < 1e-9, "{bound}");
1708 assert_eq!(pings, 19);
1709 assert!(ledger <= bound * (1.0 + 1e-9));
1710 assert!(matches!(reason, PeriodEnd::BoundReached { .. }), "{reason}");
1711 assert_eq!(elapsed, 19 * 55 * MIN);
1712 }
1713
1714 #[test]
1715 fn ski_rental_5m_is_twelve_pure_reads_over_about_55_min() {
1716 let (pings, ledger, bound, reason, elapsed) = run(
1718 cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS),
1719 &[usage(OPUS, P, 0, 0, 0)],
1720 );
1721 assert!((bound - 1.875).abs() < 1e-9, "{bound}");
1722 assert_eq!(pings, 12);
1723 assert!((ledger - 1.8).abs() < 1e-9);
1724 assert!(matches!(reason, PeriodEnd::BoundReached { .. }), "{reason}");
1725 assert_eq!(elapsed, 12 * Duration::from_secs(270));
1726 }
1727
1728 #[test]
1729 fn measured_overhead_and_first_write_are_in_the_ledger() {
1730 let last = usage(OPUS, P - 2_000, 0, 0, 2_000);
1734 let first = usage(OPUS, P - 2_000, 2_000, 40, 1);
1735 let steady = usage(OPUS, P, 0, 40, 1);
1736 let (pings, ledger, bound, reason, _) = run_from(
1737 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1738 &last,
1739 &[first.clone(), steady.clone()],
1740 );
1741 assert!(matches!(reason, PeriodEnd::BoundReached { .. }), "{reason}");
1742 let expected_ledger = first.cost_usd(OPUS) + f64::from(pings - 1) * steady.cost_usd(OPUS);
1743 assert!(
1744 (ledger - expected_ledger).abs() < 1e-9,
1745 "ledger is the measured sum"
1746 );
1747 assert!(ledger <= bound * (1.0 + 1e-9), "never past the bound");
1748 assert!(ledger + steady.cost_usd(OPUS) > bound);
1749 assert_eq!(pings, 19);
1750 }
1751
1752 #[test]
1753 fn an_expensive_ping_stops_the_period_early() {
1754 let (heavy, ..) = run(
1757 cfg_max(CacheKeepWarm::Auto, CacheTtl::OneHour, SONNET, 5_001),
1758 &[usage(SONNET, P, 0, 40, 5_000)],
1759 );
1760 let (light, ..) = run(
1761 cfg_max(CacheKeepWarm::Auto, CacheTtl::OneHour, SONNET, 31),
1762 &[usage(SONNET, P, 0, 40, 30)],
1763 );
1764 assert_eq!(heavy, 10, "rewrite $1.80 / ($0.09 read + $0.075 thinking)");
1765 assert_eq!(light, 19);
1766 }
1767
1768 #[test]
1769 fn budgeted_thinking_output_is_accounted() {
1770 let u = usage(SONNET, P, 0, 40, 300);
1771 let read_only = usage(SONNET, P, 0, 0, 0);
1772 let thinking_usd = u.cost_usd(SONNET) - read_only.cost_usd(SONNET);
1773 assert!((thinking_usd - (300.0 * 15.0 + 40.0 * 3.0) / 1e6).abs() < 1e-12);
1775 let (pings, ledger, ..) = run(
1776 cfg_max(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, SONNET, 301),
1777 std::slice::from_ref(&u),
1778 );
1779 assert!((ledger - f64::from(pings) * u.cost_usd(SONNET)).abs() < 1e-9);
1780 }
1781
1782 #[test]
1787 fn budgeted_thinking_worst_case_must_fit_the_bound() {
1788 let t0 = Instant::now();
1789 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(2_000);
1790 let small = usage(SONNET, 20_000, 0, 0, 0);
1791 let err = Period::start(
1792 1,
1793 cfg_max(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, SONNET, 32_769),
1794 7,
1795 &small,
1796 TtlEvidence::default(),
1797 anchor_at(t0, w0),
1798 t0,
1799 w0,
1800 )
1801 .unwrap_err();
1802 match err {
1803 PeriodEnd::PingTooExpensive {
1804 worst_usd,
1805 bound_usd,
1806 } => {
1807 assert!((bound_usd - 0.075).abs() < 1e-9, "{bound_usd}");
1808 assert!(worst_usd > 0.49, "{worst_usd}");
1809 }
1810 other => panic!("{other}"),
1811 }
1812 let p = start(
1815 cfg_max(CacheKeepWarm::Auto, CacheTtl::OneHour, SONNET, 32_769),
1816 &usage(SONNET, P, 0, 0, 0),
1817 t0,
1818 w0,
1819 );
1820 assert!(p.worst_next_usd() > 0.58 && p.worst_next_usd() < p.bound_usd());
1821 }
1822
1823 #[test]
1824 fn max_session_cost_is_checked_against_the_worst_case() {
1825 let t0 = Instant::now();
1826 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(100_000);
1827 let mut p = start(
1830 cfg_max(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS, 10_001),
1831 &last_read(OPUS),
1832 t0,
1833 w0,
1834 );
1835 let at = p.next_at();
1836 let wat = w0 + (at - t0);
1837 assert_eq!(
1839 p.fire(at, wat, 4.70, Some(5.0)),
1840 Fire::End(PeriodEnd::CostCap { cap: 5.0 })
1841 );
1842 assert_eq!(
1845 p.fire(at, wat, 4.50, Some(5.0)),
1846 Fire::End(PeriodEnd::CostCap { cap: 5.0 })
1847 );
1848 assert_eq!(p.fire(at, wat, 1.70, Some(5.0)), Fire::Ping);
1850 }
1851
1852 #[test]
1857 fn small_prompt_below_the_cache_minimum_never_starts() {
1858 let t0 = Instant::now();
1859 let w0 = SystemTime::UNIX_EPOCH;
1860 let below_minimum = usage(OPUS, 0, 0, 800, 20);
1861 assert_eq!(
1862 Period::start(
1863 1,
1864 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1865 7,
1866 &below_minimum,
1867 ev_1h(),
1868 anchor_at(t0, w0),
1869 t0,
1870 w0
1871 )
1872 .unwrap_err(),
1873 PeriodEnd::NothingCached
1874 );
1875 }
1876
1877 fn fired(c: PeriodConfig) -> (Period, Instant, SystemTime) {
1878 let t0 = Instant::now();
1879 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(90_000);
1880 let mut p = start(c, &last_read(OPUS), t0, w0);
1881 let at = p.next_at();
1882 let wat = w0 + (at - t0);
1883 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
1884 (p, at, wat)
1885 }
1886
1887 #[test]
1888 fn a_ping_that_cached_nothing_ends_the_period() {
1889 let (mut p, ..) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
1890 assert_eq!(
1891 p.ping_sent(&usage(OPUS, 0, 0, P, 1), None, false),
1892 Err(PeriodEnd::NothingCached)
1893 );
1894 }
1895
1896 #[test]
1897 fn a_ping_paying_for_a_large_uncached_share_ends_the_period() {
1898 let (mut p, ..) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
1900 assert_eq!(
1901 p.ping_sent(&usage(OPUS, P - 60_000, 0, 60_000, 1), None, false),
1902 Err(PeriodEnd::UncachedInput {
1903 input: 60_000,
1904 prefix: P
1905 })
1906 );
1907 let (mut p, ..) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
1909 assert_eq!(p.ping_sent(&usage(OPUS, P, 0, 40, 1), None, false), Ok(()));
1910 }
1911
1912 #[test]
1913 fn a_ping_that_writes_instead_of_reading_ends_the_period() {
1914 let (mut p, ..) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
1915 assert_eq!(
1916 p.ping_sent(&usage(OPUS, 0, P, 40, 1), None, false),
1917 Err(PeriodEnd::CacheMiss { written: P })
1918 );
1919 }
1920
1921 #[test]
1924 fn a_partial_miss_after_ping_one_ends_the_period() {
1925 let (mut p, at, wat) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
1926 p.ping_sent(&usage(OPUS, P, 0, 40, 1), Some(anchor_at(at, wat)), false)
1927 .unwrap();
1928 let at = p.next_at();
1929 let wat = wat + (at - p.anchor.mono);
1930 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
1931 assert_eq!(
1932 p.ping_sent(&usage(OPUS, P - 50_000, 50_000, 40, 1), None, false),
1933 Err(PeriodEnd::PartialMiss {
1934 written: 50_000,
1935 prefix: P
1936 })
1937 );
1938 }
1939
1940 #[test]
1941 fn ping_one_may_write_the_last_reply() {
1942 let t0 = Instant::now();
1943 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(3_000);
1944 let last = usage(OPUS, 250_000, 0, 0, 50_000);
1946 let mut p = start(
1947 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1948 &last,
1949 t0,
1950 w0,
1951 );
1952 assert_eq!(p.prefix_tokens(), P);
1953 let at = p.next_at();
1954 assert_eq!(p.fire(at, w0 + (at - t0), 0.0, None), Fire::Ping);
1955 assert_eq!(
1956 p.ping_sent(&usage(OPUS, 250_000, 50_000, 40, 1), None, false),
1957 Ok(()),
1958 "the reply's write is expected on ping 1"
1959 );
1960 }
1961
1962 #[test]
1965 fn honoured_ttl_needs_evidence_for_1h() {
1966 use CacheTtl::*;
1967 let none = TtlEvidence::default();
1968 assert_eq!(honoured_ttl(FiveMinutes, ev_1h()), FiveMinutes);
1969 assert_eq!(honoured_ttl(Hybrid, ev_1h()), Hybrid);
1970 assert_eq!(honoured_ttl(OneHour, none), FiveMinutes, "no evidence");
1971 assert_eq!(honoured_ttl(OneHour, ev_1h()), OneHour);
1972 let wrote_1h = TtlEvidence {
1973 write_1h: 10,
1974 ..none
1975 };
1976 assert_eq!(honoured_ttl(OneHour, wrote_1h), OneHour);
1977 let wrote_5m = TtlEvidence {
1978 saw_1h: true,
1979 write_5m: 10,
1980 ..none
1981 };
1982 assert_eq!(
1983 honoured_ttl(OneHour, wrote_5m),
1984 FiveMinutes,
1985 "5m-only write"
1986 );
1987 let downgraded = TtlEvidence {
1988 downgraded: true,
1989 write_1h: 10,
1990 saw_1h: true,
1991 ..none
1992 };
1993 assert_eq!(honoured_ttl(OneHour, downgraded), FiveMinutes, "latch wins");
1994 }
1995
1996 #[test]
2000 fn a_silent_downgrade_governs_by_5m() {
2001 let t0 = Instant::now();
2002 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(4_000);
2003 let downgraded = TtlEvidence {
2004 downgraded: true,
2005 ..TtlEvidence::default()
2006 };
2007 let p = Period::start(
2008 1,
2009 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
2010 7,
2011 &last_read(OPUS),
2012 downgraded,
2013 anchor_at(t0, w0),
2014 t0,
2015 w0,
2016 )
2017 .unwrap();
2018 assert_eq!(p.ttl(), CacheTtl::FiveMinutes);
2019 assert_eq!(p.next_at(), t0 + Duration::from_secs(270));
2020 assert!((p.bound_usd() - 1.875).abs() < 1e-9);
2021
2022 let mut p = Period::start(
2025 2,
2026 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
2027 7,
2028 &usage(OPUS, P - 1_000, 0, 0, 1_000),
2029 TtlEvidence::default(),
2030 anchor_at(t0, w0),
2031 t0,
2032 w0,
2033 )
2034 .unwrap();
2035 assert_eq!(p.ttl(), CacheTtl::FiveMinutes);
2036 let at = p.next_at();
2037 let wat = w0 + (at - t0);
2038 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2039 let mut first = usage(OPUS, P - 1_000, 1_000, 40, 1);
2040 first.cache_write_1h = Some(1_000);
2041 first.cache_write_5m = Some(0);
2042 p.ping_sent(&first, Some(anchor_at(at, wat)), false)
2043 .unwrap();
2044 assert_eq!(p.ttl(), CacheTtl::OneHour);
2045 assert_eq!(p.next_at(), at + 55 * MIN);
2046 let at = p.next_at();
2047 let wat = wat + 55 * MIN;
2048 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2049 p.ping_sent(&usage(OPUS, P, 0, 40, 1), Some(anchor_at(at, wat)), true)
2050 .unwrap();
2051 assert_eq!(p.ttl(), CacheTtl::FiveMinutes);
2052 assert_eq!(p.next_at(), at + Duration::from_secs(270));
2053 }
2054
2055 #[test]
2058 fn five_minute_pings_are_scheduled_from_the_anchor() {
2059 let t0 = Instant::now();
2060 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(5_000);
2061 let c = cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS);
2062 let idle = t0 + MIN;
2064 let mut p = Period::start(
2065 1,
2066 c,
2067 7,
2068 &last_read(OPUS),
2069 ev_1h(),
2070 anchor_at(t0, w0),
2071 idle,
2072 w0 + MIN,
2073 )
2074 .unwrap();
2075 assert_eq!(
2076 p.deadline(idle, w0 + MIN),
2077 Some(t0 + Duration::from_secs(270))
2078 );
2079 assert_eq!(
2080 p.fire(
2081 t0 + Duration::from_secs(200),
2082 w0 + Duration::from_secs(200),
2083 0.0,
2084 None
2085 ),
2086 Fire::NotYet
2087 );
2088 let at = t0 + Duration::from_secs(270);
2089 let wat = w0 + Duration::from_secs(270);
2090 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2091 assert_eq!(p.deadline(at, wat), None, "no second ping while in flight");
2092 assert_eq!(p.fire(at, wat, 0.0, None), Fire::NotYet);
2093 let sent = anchor_at(at + Duration::from_secs(2), wat + Duration::from_secs(2));
2095 p.ping_sent(&usage(OPUS, P, 0, 0, 1), Some(sent), false)
2096 .unwrap();
2097 assert_eq!(
2098 p.deadline(sent.mono, sent.wall),
2099 Some(sent.mono + Duration::from_secs(270))
2100 );
2101 }
2102
2103 #[test]
2104 fn hybrid_is_governed_by_the_5m_history_marker() {
2105 assert_eq!(governing_ttl(CacheTtl::Hybrid), Duration::from_secs(300));
2106 assert_eq!(governing_ttl(CacheTtl::OneHour), Duration::from_secs(3600));
2107 assert_eq!(ping_interval(CacheTtl::Hybrid), Duration::from_secs(270));
2108 assert!((rewrite_cost_usd(OPUS, CacheTtl::Hybrid, P) - 1.875).abs() < 1e-9);
2110 let (pings, ..) = run(
2111 cfg(CacheKeepWarm::Auto, CacheTtl::Hybrid, OPUS),
2112 &[usage(OPUS, P, 0, 0, 0)],
2113 );
2114 assert_eq!(pings, 12);
2115 }
2116
2117 #[test]
2118 fn a_turn_that_streamed_past_the_interval_pings_at_once_or_not_at_all() {
2119 let t0 = Instant::now();
2120 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(9_000);
2121 let c = || cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS);
2122 let at = |s: u64| (t0 + Duration::from_secs(s), w0 + Duration::from_secs(s));
2123 let (idle, widle) = at(280);
2124 let p = Period::start(
2125 1,
2126 c(),
2127 7,
2128 &last_read(OPUS),
2129 ev_1h(),
2130 anchor_at(t0, w0),
2131 idle,
2132 widle,
2133 )
2134 .unwrap();
2135 assert_eq!(p.deadline(idle, widle), Some(idle), "due immediately");
2136 let (late, wlate) = at(290);
2137 let err = Period::start(
2138 1,
2139 c(),
2140 7,
2141 &last_read(OPUS),
2142 ev_1h(),
2143 anchor_at(t0, w0),
2144 late,
2145 wlate,
2146 )
2147 .unwrap_err();
2148 assert!(matches!(err, PeriodEnd::Expired { age_secs: 290 }), "{err}");
2149 }
2150
2151 #[test]
2152 fn sleep_past_expiry_ends_the_period_without_pinging() {
2153 let t0 = Instant::now();
2154 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(50_000);
2155 for ttl in [CacheTtl::FiveMinutes, CacheTtl::OneHour] {
2156 let c = cfg(CacheKeepWarm::Auto, ttl, OPUS);
2157 let mut p = start(c, &last_read(OPUS), t0, w0);
2158 let due = p.next_at();
2159 let wall = w0 + Duration::from_secs(3 * 3600);
2162 match p.fire(due, wall, 0.0, None) {
2163 Fire::End(PeriodEnd::Expired { age_secs }) => assert_eq!(age_secs, 3 * 3600),
2164 other => panic!("{ttl:?}: {other:?}"),
2165 }
2166 }
2167 }
2168
2169 #[test]
2174 fn a_suspend_pulls_the_deadline_forward_by_the_wall_clock() {
2175 let t0 = Instant::now();
2176 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(60_000);
2177 let mut p = start(
2178 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
2179 &last_read(OPUS),
2180 t0,
2181 w0,
2182 );
2183 let (now, wall) = (t0 + 10 * MIN, w0 + 40 * MIN);
2185 assert_eq!(p.deadline(now, wall), Some(now + 15 * MIN));
2186 assert_eq!(p.fire(now, wall, 0.0, None), Fire::NotYet);
2187 let (now, wall) = (now + 15 * MIN, wall + 15 * MIN);
2190 assert_eq!(p.fire(now, wall, 0.0, None), Fire::Ping);
2191 assert_eq!(p.fired_age(), 55 * MIN);
2192 }
2193
2194 #[test]
2195 fn the_actor_polls_the_wall_clock_while_a_period_is_active() {
2196 assert!(WALL_POLL < safety_margin(CacheTtl::FiveMinutes));
2197 let t0 = Instant::now();
2198 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(61_000);
2199 let mut kw = KeepWarmState::default();
2200 assert_eq!(kw.deadline_at(t0, w0), None, "no period: no timer");
2201 kw.period = Some(start(
2203 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
2204 &last_read(OPUS),
2205 t0,
2206 w0,
2207 ));
2208 assert_eq!(kw.deadline_at(t0, w0), Some(t0 + WALL_POLL));
2209 let (near, wnear) = (
2211 t0 + 55 * MIN - Duration::from_secs(3),
2212 w0 + 55 * MIN - Duration::from_secs(3),
2213 );
2214 assert_eq!(kw.deadline_at(near, wnear), Some(t0 + 55 * MIN));
2215 let p = kw.period.as_mut().unwrap();
2217 assert_eq!(p.fire(t0 + 55 * MIN, w0 + 55 * MIN, 0.0, None), Fire::Ping);
2218 assert_eq!(kw.deadline_at(t0 + 55 * MIN, w0 + 55 * MIN), None);
2219 }
2220
2221 #[test]
2224 fn transient_errors_back_off_within_the_ttl_then_end() {
2225 let t0 = Instant::now();
2226 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(70_000);
2227 let mut p = start(
2228 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
2229 &last_read(OPUS),
2230 t0,
2231 w0,
2232 );
2233 let mut now = p.next_at();
2234 let transient = PingFailure::Transient("HTTP 529".into());
2235 for want in BACKOFF {
2236 let wall = w0 + now.saturating_duration_since(t0);
2237 assert_eq!(p.fire(now, wall, 0.0, None), Fire::Ping);
2238 assert_eq!(p.ping_failed(&transient, now, wall), Ok(want));
2239 assert_eq!(p.deadline(now, wall), Some(now + want));
2240 now += want;
2241 }
2242 let wall = w0 + now.saturating_duration_since(t0);
2243 assert_eq!(p.fire(now, wall, 0.0, None), Fire::Ping);
2244 assert!(matches!(
2245 p.ping_failed(&transient, now, wall),
2246 Err(PeriodEnd::TransientExhausted(_))
2247 ));
2248
2249 let mut p = start(
2251 cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS),
2252 &last_read(OPUS),
2253 t0,
2254 w0,
2255 );
2256 let at = p.next_at();
2257 let wat = w0 + Duration::from_secs(270);
2258 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2259 assert_eq!(p.ping_failed(&transient, at, wat), Ok(BACKOFF[0]));
2260 let at = at + BACKOFF[0];
2261 let wat = wat + BACKOFF[0];
2262 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2263 assert!(matches!(
2264 p.ping_failed(&transient, at, wat),
2265 Err(PeriodEnd::TransientExhausted(_))
2266 ));
2267 }
2268
2269 #[test]
2271 fn failed_ping_spend_is_in_the_ledger() {
2272 let (mut p, at, wat) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
2273 p.charge(1.80);
2274 assert!(matches!(
2275 p.ping_failed(&PingFailure::Transient("x".into()), at, wat),
2276 Err(PeriodEnd::BoundReached { .. })
2277 ));
2278 }
2279
2280 #[test]
2281 fn a_4xx_ends_the_period() {
2282 let (mut p, at, wat) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS));
2283 let err = p
2284 .ping_failed(&PingFailure::Fatal("HTTP 400".into()), at, wat)
2285 .unwrap_err();
2286 assert_eq!(err, PeriodEnd::Rejected("HTTP 400".into()));
2287 }
2288
2289 #[test]
2290 fn hours_window_and_bound_whichever_first() {
2291 let (pings, _, _, reason, _) = run(
2293 cfg(CacheKeepWarm::Hours(1), CacheTtl::OneHour, OPUS),
2294 &[usage(OPUS, P, 0, 0, 0)],
2295 );
2296 assert_eq!((pings, reason), (1, PeriodEnd::HoursReached(1)));
2297 let (pings, _, _, reason, _) = run(
2299 cfg(CacheKeepWarm::Hours(2), CacheTtl::FiveMinutes, OPUS),
2300 &[usage(OPUS, P, 0, 0, 0)],
2301 );
2302 assert_eq!(pings, 12);
2303 assert!(matches!(reason, PeriodEnd::BoundReached { .. }));
2304 let t0 = Instant::now();
2306 let w0 = SystemTime::UNIX_EPOCH;
2307 let mut c = cfg(CacheKeepWarm::Hours(1), CacheTtl::OneHour, OPUS);
2308 c.interval_override = Some(Duration::from_secs(2 * 3600));
2309 assert_eq!(
2310 Period::start(
2311 1,
2312 c,
2313 7,
2314 &last_read(OPUS),
2315 ev_1h(),
2316 anchor_at(t0, w0),
2317 t0,
2318 w0
2319 )
2320 .unwrap_err(),
2321 PeriodEnd::HoursReached(1)
2322 );
2323 }
2324
2325 #[test]
2331 #[serial_test::serial(synaps_keepwarm_interval)]
2332 fn the_interval_override_is_set_only_by_code_never_by_the_environment() {
2333 let _env = crate::test_env::EnvVarGuard::set("SYNAPS_CACHE_KEEPWARM_INTERVAL_MS", "250");
2334 assert_eq!(interval_override(), None, "the old env var does nothing");
2335 {
2336 let _g = testing::IntervalOverride::set(Duration::from_millis(250));
2337 assert_eq!(interval_override(), Some(Duration::from_millis(250)));
2338 }
2339 assert_eq!(interval_override(), None, "cleared with the guard");
2340 }
2341
2342 #[test]
2347 fn a_re_enabled_period_continues_the_exhausted_ledger() {
2348 let t0 = Instant::now();
2349 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(80_000);
2350 let c = || cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS);
2351 let mut p = start(c(), &last_read(OPUS), t0, w0);
2352 let read = usage(OPUS, P, 0, 0, 0);
2353 let end = loop {
2354 let at = p.next_at();
2355 let wat = w0 + (at - t0);
2356 match p.fire(at, wat, 0.0, None) {
2357 Fire::Ping => {}
2358 Fire::End(r) => break r,
2359 Fire::NotYet => unreachable!(),
2360 }
2361 p.charge(read.cost_usd(OPUS));
2362 if let Err(r) = p.ping_sent(&read, Some(anchor_at(at, wat)), false) {
2363 break r;
2364 }
2365 };
2366 assert!(matches!(end, PeriodEnd::BoundReached { .. }), "{end}");
2367 let carry = p.epoch_carry();
2368 assert_eq!(carry.pings, 19);
2369 let anchor = p.anchor;
2371 let err = Period::start_in_epoch(
2372 2,
2373 c(),
2374 7,
2375 &last_read(OPUS),
2376 ev_1h(),
2377 anchor,
2378 anchor.mono,
2379 anchor.wall,
2380 Some(&carry),
2381 )
2382 .unwrap_err();
2383 assert!(matches!(err, PeriodEnd::BoundReached { .. }), "{err}");
2384 assert!(Period::start_in_epoch(
2386 3,
2387 c(),
2388 7,
2389 &last_read(OPUS),
2390 ev_1h(),
2391 anchor,
2392 anchor.mono,
2393 anchor.wall,
2394 None
2395 )
2396 .is_ok());
2397 }
2398
2399 #[test]
2402 fn ping_one_write_allowance_is_never_re_granted_in_an_epoch() {
2403 let t0 = Instant::now();
2404 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(81_000);
2405 let c = || cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS);
2406 let last = usage(OPUS, 250_000, 0, 0, 50_000);
2407 let mut p = start(c(), &last, t0, w0);
2408 let at = p.next_at();
2409 let wat = w0 + (at - t0);
2410 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2411 let first = usage(OPUS, 250_000, 50_000, 40, 1);
2412 p.charge(first.cost_usd(OPUS));
2413 p.ping_sent(&first, Some(anchor_at(at, wat)), false)
2414 .unwrap();
2415 let carry = p.epoch_carry();
2416 let mut q = Period::start_in_epoch(
2418 2,
2419 c(),
2420 7,
2421 &last,
2422 ev_1h(),
2423 anchor_at(at, wat),
2424 at,
2425 wat,
2426 Some(&carry),
2427 )
2428 .unwrap();
2429 assert_eq!(q.pings(), 1);
2430 assert!((q.ledger_usd() - first.cost_usd(OPUS)).abs() < 1e-12);
2431 let at2 = q.next_at();
2432 let wat2 = wat + (at2 - at);
2433 assert_eq!(q.fire(at2, wat2, 0.0, None), Fire::Ping);
2434 assert_eq!(
2436 q.ping_sent(&usage(OPUS, 250_000, 50_000, 40, 1), None, false),
2437 Err(PeriodEnd::PartialMiss {
2438 written: 50_000,
2439 prefix: P
2440 })
2441 );
2442 }
2443
2444 #[test]
2450 fn ping_counts_per_multiplier_and_the_auto_cap() {
2451 for (model, uncapped, capped) in [
2452 ("claude-opus-4-7", 19, 19),
2453 ("claude-opus-5-5", 39, 26),
2454 ("claude-fable-5-1", 79, 26),
2455 ] {
2456 let mut c = cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, model);
2457 let (pings, _, _, reason, elapsed) = run(c.clone(), &[usage(model, P, 0, 0, 0)]);
2458 assert_eq!(pings, uncapped, "{model}");
2459 assert!(
2460 matches!(reason, PeriodEnd::BoundReached { .. }),
2461 "{model}: {reason}"
2462 );
2463 assert_eq!(elapsed, uncapped * 55 * MIN, "{model}");
2464 c.auto_max_hours = 24;
2465 let (pings, _, _, reason, _) = run(c, &[usage(model, P, 0, 0, 0)]);
2466 assert_eq!(pings, capped, "{model}");
2467 if capped < uncapped {
2468 assert_eq!(reason, PeriodEnd::HoursReached(24), "{model}");
2469 }
2470 }
2471 for (model, want) in [
2473 ("claude-opus-4-7", 12),
2474 ("claude-opus-5-5", 24),
2475 ("claude-fable-5-1", 49),
2476 ] {
2477 let mut c = cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, model);
2478 c.auto_max_hours = 24;
2479 let (pings, ..) = run(c, &[usage(model, P, 0, 0, 0)]);
2480 assert_eq!(pings, want, "{model} 5m");
2481 }
2482 let mut c = cfg(
2484 CacheKeepWarm::Hours(48),
2485 CacheTtl::OneHour,
2486 "claude-fable-5-1",
2487 );
2488 c.auto_max_hours = 24;
2489 let (pings, ..) = run(c, &[usage("claude-fable-5-1", P, 0, 0, 0)]);
2490 assert_eq!(pings, 52, "48h window: 52 pings at 55 min");
2491 }
2492
2493 #[test]
2496 fn first_write_counts_only_what_the_prefix_will_hold() {
2497 let t0 = Instant::now();
2498 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(82_000);
2499 let last = usage(OPUS, 250_000, 0, 1_000, 40_000);
2503 let mut c = cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS);
2504 let billed = start(c.clone(), &last, t0, w0);
2505 assert_eq!(billed.prefix_tokens(), 291_000, "without the estimate");
2506 c.reply_tokens = Some(2_000);
2507 let p = start(c.clone(), &last, t0, w0);
2508 assert_eq!(p.prefix_tokens(), 253_000);
2509 c.reply_tokens = Some(90_000);
2511 assert_eq!(start(c, &last, t0, w0).prefix_tokens(), 291_000);
2512 }
2513
2514 #[test]
2515 fn status_renders_spend_and_period() {
2516 let s = serde_json::json!({
2517 "mode": "auto",
2518 "active": true,
2519 "period": {"id": 3, "ttl_secs": 300, "pings": 2, "ledger_usd": 0.3,
2520 "bound_usd": 1.875, "prefix_tokens": P, "next_ping_in_secs": 120,
2521 "in_flight": false},
2522 "prefix_matches": true,
2523 "session_pings": 5,
2524 "session_keepwarm_usd": 0.75,
2525 "last_end": "prefix changed",
2526 });
2527 let text = render_status(&s);
2528 assert!(text.contains("auto"), "{text}");
2529 assert!(text.contains("5 pings, $0.7500"), "{text}");
2530 assert!(text.contains("ping 3 due in 120s"), "{text}");
2531 assert!(text.contains("$0.3000 spent of $1.8750 bound"), "{text}");
2532 assert!(text.contains("prefix unchanged"), "{text}");
2533 assert!(text.contains("last: prefix changed"), "{text}");
2534 }
2535}