Skip to main content

agent_engine/session/
cache_keepwarm.rs

1//! Prompt-cache keep-warm policy (task #444), owned by the session actor.
2//!
3//! Why the actor: it is the one place that knows a session is idle (turn
4//! finished, nothing queued, no compaction, no prompt, no driver), when it
5//! parks or ends, and what it has spent. The ping itself is
6//! `Runtime::cache_keepwarm_ping` (`runtime::keepwarm`).
7//!
8//! # Policy (docs/specs/cache-ttl-spec.md §7)
9//!
10//! An **idle period** starts when a turn whose last Anthropic request was
11//! accepted AND cached something (`cache_read + cache_write > 0`) ends with a
12//! finished assistant reply, `cache_keepwarm` is not `off`, and the next turn
13//! would not roll the context over first. It holds:
14//!
15//! - **the anchor**: send time of the last accepted request (monotonic +
16//!   wall clock). The provider's TTL runs from the start of that request.
17//! - **the honoured TTL**: what the server actually keeps, not what is
18//!   configured. `1h` only with evidence (a 1h write in the last request or
19//!   earlier in the session, no silent-downgrade latch); otherwise `5m`.
20//!   `hybrid` is governed by its 5m history marker. Pings are due at
21//!   `anchor + TTL − margin` (55 min / 4.5 min), on whichever of the
22//!   monotonic and wall clocks comes first (the monotonic clock stops while
23//!   the machine sleeps; the provider's TTL does not).
24//! - **the prefix fingerprint** recorded at idle. Every ping re-builds the
25//!   prefix and is refused (period ends) if it no longer matches.
26//! - **a ski-rental ledger** of measured spend. A ping is only sent while
27//!   `ledger + worst-case ping ≤ bound`, where the bound is re-writing the
28//!   prefix at the honoured TTL's write price, and the worst case is a full
29//!   read of the prefix + the known write (ping 1: the last turn's uncached
30//!   tail and reply) + uncached input + `max_tokens` of output. Under
31//!   budgeted thinking `max_tokens` is `budget + 1`, so such pings only run
32//!   for prefixes large enough that one worst-case ping fits the bound.
33//! - **the cap exposure**: `max_session_cost` is a hard cap, so admission
34//!   prices what a dispatched ping can actually bill — the larger of that
35//!   read-path worst case and a full miss (the whole prefix written at the
36//!   1h price if the request carries 1h markers, else the honoured TTL's,
37//!   plus the same uncached input and `max_tokens` of output). The
38//!   ski-rental bound stays on the read path: a miss *is* the rewrite and
39//!   ends the period when it happens.
40//!
41//! The period ends on the first of: bound, `<N>h` window, prefix change,
42//! context rollover ahead, a ping that cached nothing / missed / rewrote a
43//! large share of the prefix / paid for a large uncached share, expiry, a
44//! 4xx, exhausted transient retries, `max_session_cost`, a turn or
45//! compaction starting, an explicit park, `/keepwarm off`, or the session
46//! ending. While a period is active the session does not auto-park.
47
48use std::time::{Duration, SystemTime};
49
50use tokio::time::Instant;
51use tokio_util::sync::CancellationToken;
52
53use super::actor::SessionActor;
54use super::types::ClientKind;
55use crate::core::config::{CacheKeepWarm, CacheTtl};
56use crate::runtime::keepwarm::{
57    CacheAnchor, KeepWarmProbe, KeepWarmUsage, PingBill, PingFailure, PingOutcome, PingReport,
58    PingTap,
59};
60
61/// No ping may start once the cache is this close to expiry: the request
62/// still has to reach the provider (connect, upload a large body).
63pub const LATE_GUARD: Duration = Duration::from_secs(15);
64
65/// Delays before retrying a transient ping failure; then the period ends.
66pub const BACKOFF: [Duration; 3] = [
67    Duration::from_secs(5),
68    Duration::from_secs(15),
69    Duration::from_secs(30),
70];
71
72/// While a period is active the actor re-reads the wall clock at least this
73/// often: after a suspend the monotonic deadline is late by the time slept.
74pub const WALL_POLL: Duration = Duration::from_secs(15);
75
76/// A ping whose uncached input, or (after ping 1) whose cache write, exceeds
77/// this share of the prefix ends the period: it is paying for uncached
78/// tokens or rewriting history, not refreshing a cache. 10%: the trailing
79/// ping message is ~40 tokens, under 4% of the smallest cacheable prefix
80/// (1,024 tokens), so a healthy ping never comes close.
81pub const SHARE_LIMIT_PERCENT: u64 = 10;
82
83/// Estimated uncached input of a ping before one has been measured: the
84/// trailing ping message plus framing.
85pub const PING_INPUT_ESTIMATE: u64 = 64;
86
87/// Relative slack for the bound comparison (f64 sums of equal ping costs).
88const 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
94/// TTL of the entry that covers the history prefix: the shortest TTL among
95/// the breakpoints a ping must keep alive. Anthropic requires longer TTLs to
96/// precede shorter ones, so under `hybrid` the history (tail) marker is the
97/// 5m one.
98pub 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
105/// Safety margin before the TTL: 5 min for 1h, 30 s for 5m.
106pub 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
113/// Ping interval for `ttl`, measured from the anchor: TTL − margin.
114pub fn ping_interval(ttl: CacheTtl) -> Duration {
115    governing_ttl(ttl) - safety_margin(ttl)
116}
117
118/// The ping interval override, in ms (0 = none). Test-only: written solely
119/// by [`testing::IntervalOverride`], which no binary calls.
120#[cfg(any(test, feature = "testing"))]
121static INTERVAL_OVERRIDE_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
122
123/// Test seam replacing `ping_interval(honoured TTL)`.
124///
125/// Not an environment variable on purpose: `cargo test` feature-unifies
126/// `agent-engine/testing` into the dev build of the `synaps` binary, so an
127/// env-driven override would be live in a runnable binary. This one is set
128/// only by code ([`testing::IntervalOverride`]), compiled only under
129/// `cfg(test)` / the `testing` feature, and no binary target calls it: no
130/// binary input (env, config, argv) can reach it. In a build without the
131/// feature it is a constant `None`.
132pub 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/// Test-only controls (`cfg(test)` / `testing` feature).
144#[cfg(any(test, feature = "testing"))]
145pub mod testing {
146    use std::sync::atomic::Ordering;
147    use std::time::Duration;
148
149    /// Shortens the keep-warm ping interval for every session in this
150    /// process while alive (tests that use it must be `#[serial]`).
151    #[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
171/// Evidence about the TTL the server honours for this session.
172#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
173pub struct TtlEvidence {
174    /// The session's silent-downgrade latch fired (1h requested, 5m kept).
175    pub downgraded: bool,
176    /// A request of this session wrote a 1h entry.
177    pub saw_1h: bool,
178    /// The last request's 1h / 5m write buckets.
179    pub write_1h: u64,
180    pub write_5m: u64,
181}
182
183/// The TTL the server actually honours for the history entry. `1h` needs
184/// positive evidence; with none (or a downgrade) a `1h` configuration is
185/// scheduled — and its rewrite priced — as `5m`.
186pub fn honoured_ttl(configured: CacheTtl, e: TtlEvidence) -> CacheTtl {
187    if configured != CacheTtl::OneHour {
188        return configured;
189    }
190    if e.downgraded {
191        return CacheTtl::FiveMinutes;
192    }
193    if e.write_1h > 0 {
194        return CacheTtl::OneHour;
195    }
196    if e.write_5m > 0 {
197        return CacheTtl::FiveMinutes;
198    }
199    if e.saw_1h {
200        CacheTtl::OneHour
201    } else {
202        CacheTtl::FiveMinutes
203    }
204}
205
206/// Cost of one request: `read` cache reads, `write` cache writes at `ttl`'s
207/// write price, `input` uncached input, `output` output tokens.
208pub fn request_cost_usd(
209    model: &str,
210    ttl: CacheTtl,
211    read: u64,
212    write: u64,
213    input: u64,
214    output: u64,
215) -> f64 {
216    match ttl {
217        CacheTtl::OneHour => {
218            crate::pricing::calculate_cost_split(model, input, output, read, 0, write)
219        }
220        CacheTtl::FiveMinutes | CacheTtl::Hybrid => {
221            crate::pricing::calculate_cost_split(model, input, output, read, write, 0)
222        }
223    }
224}
225
226/// Cost of re-writing `prefix_tokens` at the TTL's write price: what the
227/// next turn pays if the cache lapses — the ski-rental "buy" price.
228pub fn rewrite_cost_usd(model: &str, ttl: CacheTtl, prefix_tokens: u64) -> f64 {
229    request_cost_usd(model, ttl, 0, prefix_tokens, 0, 0)
230}
231
232/// Why a period ended (or never started). Logged once, shown by
233/// `/keepwarm status`.
234#[derive(Debug, Clone, PartialEq)]
235pub enum PeriodEnd {
236    /// The last request (or a ping) cached nothing: the prefix is below the
237    /// model's cache minimum, or caching is off for it.
238    NothingCached,
239    /// A ping paid for a large uncached share of the prefix.
240    UncachedInput {
241        input: u64,
242        prefix: u64,
243    },
244    /// Ski-rental bound: the next worst-case ping would push the ledger past
245    /// the rewrite it prevents.
246    BoundReached {
247        ledger_usd: f64,
248        bound_usd: f64,
249    },
250    /// Not even one worst-case ping fits the bound (budgeted thinking on a
251    /// small prefix).
252    PingTooExpensive {
253        worst_usd: f64,
254        bound_usd: f64,
255    },
256    /// `<N>h` window elapsed.
257    HoursReached(u32),
258    /// The cache is (nearly) expired: the machine slept, or the session went
259    /// idle too late. A ping now would be a full write.
260    Expired {
261        age_secs: u64,
262    },
263    /// The next turn's prefix is no longer what is cached.
264    PrefixChanged,
265    /// The next turn would roll the context over / hard-stop before its
266    /// first request: what is cached is not what it sends.
267    ContextRollover(String),
268    /// A ping wrote the prefix instead of reading it.
269    CacheMiss {
270        written: u64,
271    },
272    /// A ping rewrote a large share of the prefix (partial miss).
273    PartialMiss {
274        written: u64,
275        prefix: u64,
276    },
277    /// 4xx / auth / configuration: never retried.
278    Rejected(String),
279    /// Transient errors outlasted the backoff (or the TTL).
280    TransientExhausted(String),
281    /// `max_session_cost` reached (or the next ping would cross it if it
282    /// missed the cache and rewrote the whole prefix).
283    CostCap {
284        cap: f64,
285    },
286    TurnStarted,
287    Compaction,
288    /// `/keepwarm off`.
289    Disabled,
290    /// An explicit park request (not the idle auto-park, which waits).
291    ParkedByRequest,
292    SessionEnding,
293    /// Something else owns the session (driver, queued work, prompt).
294    Blocked(&'static str),
295}
296
297impl std::fmt::Display for PeriodEnd {
298    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299        match self {
300            PeriodEnd::NothingCached => f.write_str("nothing cached"),
301            PeriodEnd::UncachedInput { input, prefix } => write!(
302                f,
303                "ping paid for {input} uncached input tokens (prefix {prefix})"
304            ),
305            PeriodEnd::BoundReached {
306                ledger_usd,
307                bound_usd,
308            } => write!(
309                f,
310                "ski-rental bound reached (${ledger_usd:.4} spent of ${bound_usd:.4})"
311            ),
312            PeriodEnd::PingTooExpensive {
313                worst_usd,
314                bound_usd,
315            } => write!(
316                f,
317                "worst-case cache-hit ping ${worst_usd:.4} exceeds the ${bound_usd:.4} rewrite it would save"
318            ),
319            PeriodEnd::HoursReached(h) => write!(f, "{h}h window reached"),
320            PeriodEnd::Expired { age_secs } => write!(
321                f,
322                "cache expired or about to ({age_secs}s since last request; slept?)"
323            ),
324            PeriodEnd::PrefixChanged => f.write_str("prefix changed"),
325            PeriodEnd::ContextRollover(why) => write!(f, "{why}"),
326            PeriodEnd::CacheMiss { written } => {
327                write!(f, "ping missed the cache (wrote {written} tokens)")
328            }
329            PeriodEnd::PartialMiss { written, prefix } => write!(
330                f,
331                "ping rewrote {written} of {prefix} prefix tokens (history no longer cached)"
332            ),
333            PeriodEnd::Rejected(m) => write!(f, "ping rejected: {m}"),
334            PeriodEnd::TransientExhausted(m) => write!(f, "transient errors: {m}"),
335            PeriodEnd::CostCap { cap } => write!(f, "max_session_cost ${cap:.4} reached"),
336            PeriodEnd::TurnStarted => f.write_str("turn started"),
337            PeriodEnd::Compaction => f.write_str("compaction"),
338            PeriodEnd::Disabled => f.write_str("disabled"),
339            PeriodEnd::ParkedByRequest => f.write_str("parked by request"),
340            PeriodEnd::SessionEnding => f.write_str("session ending"),
341            PeriodEnd::Blocked(why) => write!(f, "{why}"),
342        }
343    }
344}
345
346/// What the period needs to know about the session at start.
347#[derive(Debug, Clone)]
348pub struct PeriodConfig {
349    pub mode: CacheKeepWarm,
350    /// The configured `cache_ttl` (the honoured one is derived from it).
351    pub ttl: CacheTtl,
352    pub model: String,
353    /// The ping's `max_tokens` (worst-case output).
354    pub max_tokens: u64,
355    /// Replaces `ping_interval(honoured TTL)` (test seam only).
356    pub interval_override: Option<Duration>,
357    /// Longest an `auto` period may run, in hours
358    /// (`cache_keepwarm_auto_max_hours`; 0 = no cap). `<N>h` ignores it.
359    pub auto_max_hours: u32,
360    /// Tokens of the last reply that the cached prefix will actually hold
361    /// (thinking stripped on resend, see
362    /// `runtime::keepwarm::reply_prefix_tokens`). `None`: the reply's
363    /// billed `output_tokens`.
364    pub reply_tokens: Option<u64>,
365}
366
367/// What a cache epoch carries from one period to the next. An epoch is the
368/// life of one real request's cache entry: only a new real request (new
369/// usage, new anchor) starts a fresh one. Ending a period (e.g. `/keepwarm
370/// off`) and starting another without a real turn in between CONTINUES the
371/// epoch: same ledger, same idle start, ping 1's write allowance not
372/// re-granted.
373#[derive(Debug, Clone, Copy, PartialEq)]
374pub struct EpochCarry {
375    pub ledger_usd: f64,
376    pub pings: u32,
377    pub prefix_tokens: u64,
378    pub ping_input: u64,
379    pub idle_since: Instant,
380    pub idle_wall: SystemTime,
381}
382
383/// Timer decision.
384#[derive(Debug, Clone, PartialEq)]
385pub enum Fire {
386    NotYet,
387    Ping,
388    End(PeriodEnd),
389}
390
391/// One idle period. Pure: every clock reading is passed in.
392#[derive(Debug, Clone)]
393pub struct Period {
394    pub id: u64,
395    cfg: PeriodConfig,
396    /// The TTL the server honours (see [`honoured_ttl`]).
397    ttl: CacheTtl,
398    evidence: TtlEvidence,
399    idle_since: Instant,
400    idle_wall: SystemTime,
401    anchor: CacheAnchor,
402    fingerprint: u64,
403    prefix_tokens: u64,
404    /// Ping 1 legitimately writes the last turn's uncached tail and reply.
405    first_write: u64,
406    bound_usd: f64,
407    ledger_usd: f64,
408    /// Read-path worst case of the next ping (cache hit, `max_tokens` of
409    /// output): the ski-rental basis.
410    worst_next_usd: f64,
411    /// What the next ping can bill if dispatched, a full cache miss
412    /// included: the `max_session_cost` admission basis.
413    exposure_next_usd: f64,
414    /// Measured uncached input of the last ping.
415    ping_input: u64,
416    pings: u32,
417    retries: usize,
418    next_at: Instant,
419    next_wall: SystemTime,
420    in_flight: bool,
421    /// Cache age when the in-flight ping was fired (for the log line).
422    fired_age: Duration,
423}
424
425/// Cache age: the larger of monotonic and wall-clock elapsed time since the
426/// anchor. The monotonic clock stops while the machine sleeps; the
427/// provider's TTL does not.
428fn age(anchor: &CacheAnchor, now: Instant, wall: SystemTime) -> Duration {
429    let mono = now.saturating_duration_since(anchor.mono);
430    let wall = wall.duration_since(anchor.wall).unwrap_or_default();
431    mono.max(wall)
432}
433
434impl Period {
435    /// Start a period at `now` (the session just went idle). `last` is the
436    /// usage of the turn's last request; `evidence` what the session knows
437    /// about the TTL the server honours.
438    #[allow(clippy::too_many_arguments)]
439    pub fn start(
440        id: u64,
441        cfg: PeriodConfig,
442        fingerprint: u64,
443        last: &KeepWarmUsage,
444        evidence: TtlEvidence,
445        anchor: CacheAnchor,
446        now: Instant,
447        wall: SystemTime,
448    ) -> Result<Period, PeriodEnd> {
449        Self::start_in_epoch(
450            id,
451            cfg,
452            fingerprint,
453            last,
454            evidence,
455            anchor,
456            now,
457            wall,
458            None,
459        )
460    }
461
462    /// [`start`](Self::start), continuing `carry` when the cache epoch has
463    /// not changed since an earlier period (see [`EpochCarry`]).
464    #[allow(clippy::too_many_arguments)]
465    pub fn start_in_epoch(
466        id: u64,
467        cfg: PeriodConfig,
468        fingerprint: u64,
469        last: &KeepWarmUsage,
470        evidence: TtlEvidence,
471        anchor: CacheAnchor,
472        now: Instant,
473        wall: SystemTime,
474        carry: Option<&EpochCarry>,
475    ) -> Result<Period, PeriodEnd> {
476        let cached = last.cache_read_tokens + last.cache_write_tokens;
477        if cached == 0 {
478            return Err(PeriodEnd::NothingCached);
479        }
480        let ttl = honoured_ttl(cfg.ttl, evidence);
481        let age_now = age(&anchor, now, wall);
482        if age_now + LATE_GUARD >= governing_ttl(ttl) {
483            return Err(PeriodEnd::Expired {
484                age_secs: age_now.as_secs(),
485            });
486        }
487        // The next turn re-sends what is cached plus the last request's
488        // uncached tail and its reply; ping 1 caches those. Only what the
489        // prefix will hold counts: output the API strips on resend
490        // (thinking) is billed once and never cached.
491        let reply = cfg
492            .reply_tokens
493            .map_or(last.output_tokens, |r| r.min(last.output_tokens));
494        let first_write = last.input_tokens + reply;
495        let prefix_tokens = cached + first_write;
496        let mut p = Period {
497            id,
498            cfg,
499            ttl,
500            evidence,
501            idle_since: now,
502            idle_wall: wall,
503            anchor,
504            fingerprint,
505            prefix_tokens,
506            first_write,
507            bound_usd: 0.0,
508            ledger_usd: 0.0,
509            worst_next_usd: 0.0,
510            exposure_next_usd: 0.0,
511            ping_input: PING_INPUT_ESTIMATE,
512            pings: 0,
513            retries: 0,
514            next_at: now,
515            next_wall: wall,
516            in_flight: false,
517            fired_age: Duration::ZERO,
518        };
519        if let Some(c) = carry {
520            // Same epoch: the ledger, the ping count (so ping 1's write
521            // allowance is never re-granted), and the idle start continue.
522            p.ledger_usd = c.ledger_usd;
523            p.pings = c.pings;
524            p.idle_since = c.idle_since;
525            p.idle_wall = c.idle_wall;
526            if c.pings > 0 {
527                p.prefix_tokens = c.prefix_tokens;
528                p.ping_input = c.ping_input;
529            }
530        }
531        p.reprice();
532        // Due at anchor + interval; a turn that streamed past it is due now.
533        let interval = p.interval();
534        p.next_at = (anchor.mono + interval).max(now);
535        p.next_wall = (anchor.wall + interval).max(wall);
536        if p.worst_next_usd > p.bound_usd * (1.0 + BOUND_EPSILON) {
537            return Err(PeriodEnd::PingTooExpensive {
538                worst_usd: p.worst_next_usd,
539                bound_usd: p.bound_usd,
540            });
541        }
542        if let Some(r) = p
543            .past_window(p.next_at, p.next_wall)
544            .or_else(|| p.past_bound())
545        {
546            return Err(r);
547        }
548        Ok(p)
549    }
550
551    /// What the next period of this cache epoch continues from.
552    pub fn epoch_carry(&self) -> EpochCarry {
553        EpochCarry {
554            ledger_usd: self.ledger_usd,
555            pings: self.pings,
556            prefix_tokens: self.prefix_tokens,
557            ping_input: self.ping_input,
558            idle_since: self.idle_since,
559            idle_wall: self.idle_wall,
560        }
561    }
562
563    /// Bound, read-path worst case and cap exposure for the current prefix,
564    /// TTL and ping count.
565    fn reprice(&mut self) {
566        let model = self.cfg.model.as_str();
567        self.bound_usd = rewrite_cost_usd(model, self.ttl, self.prefix_tokens);
568        let write = if self.pings == 0 { self.first_write } else { 0 };
569        self.worst_next_usd = request_cost_usd(
570            model,
571            self.ttl,
572            self.prefix_tokens.saturating_sub(write),
573            write,
574            self.ping_input,
575            self.cfg.max_tokens,
576        );
577        // A miss rewrites the whole prefix. The ping carries the configured
578        // markers, so under `1h` / `hybrid` that write can be billed at the
579        // 1h price even while the honoured TTL is 5m: take the dearer one.
580        let miss = |ttl| {
581            request_cost_usd(
582                model,
583                ttl,
584                0,
585                self.prefix_tokens,
586                self.ping_input,
587                self.cfg.max_tokens,
588            )
589        };
590        let mut miss_usd = miss(self.ttl);
591        if matches!(self.cfg.ttl, CacheTtl::OneHour | CacheTtl::Hybrid) {
592            miss_usd = miss_usd.max(miss(CacheTtl::OneHour));
593        }
594        self.exposure_next_usd = self.worst_next_usd.max(miss_usd);
595    }
596
597    fn interval(&self) -> Duration {
598        self.cfg
599            .interval_override
600            .unwrap_or_else(|| ping_interval(self.ttl))
601    }
602
603    pub fn fingerprint(&self) -> u64 {
604        self.fingerprint
605    }
606    pub fn pings(&self) -> u32 {
607        self.pings
608    }
609    pub fn ledger_usd(&self) -> f64 {
610        self.ledger_usd
611    }
612    pub fn bound_usd(&self) -> f64 {
613        self.bound_usd
614    }
615    /// Read-path worst case of the next ping (cache hit, `max_tokens` of
616    /// output): what the ski-rental bound checks use, and what an aborted
617    /// in-flight ping is booked at.
618    pub fn worst_next_usd(&self) -> f64 {
619        self.worst_next_usd
620    }
621    /// Most the next ping can bill once dispatched, a full cache miss
622    /// included (≥ [`worst_next_usd`](Self::worst_next_usd)): what the
623    /// `max_session_cost` admission check uses.
624    pub fn exposure_next_usd(&self) -> f64 {
625        self.exposure_next_usd
626    }
627    pub fn prefix_tokens(&self) -> u64 {
628        self.prefix_tokens
629    }
630    pub fn next_at(&self) -> Instant {
631        self.next_at
632    }
633    pub fn mode(&self) -> CacheKeepWarm {
634        self.cfg.mode
635    }
636    /// The honoured TTL the period is governed by.
637    pub fn ttl(&self) -> CacheTtl {
638        self.ttl
639    }
640    pub fn fired_age(&self) -> Duration {
641        self.fired_age
642    }
643    pub fn in_flight(&self) -> bool {
644        self.in_flight
645    }
646
647    /// `/keepwarm auto|<N>h` while a period runs: the new window applies.
648    pub fn set_mode(&mut self, mode: CacheKeepWarm) {
649        self.cfg.mode = mode;
650    }
651
652    /// When the next ping is due: the earlier of the monotonic deadline and
653    /// the wall-clock deadline mapped onto `now` (after a suspend the
654    /// monotonic one is late by the time slept). `None` while in flight.
655    pub fn deadline(&self, now: Instant, wall: SystemTime) -> Option<Instant> {
656        if self.in_flight {
657            return None;
658        }
659        let wall_left = self.next_wall.duration_since(wall).unwrap_or_default();
660        Some(self.next_at.min(now + wall_left))
661    }
662
663    /// `<N>h` window, or `auto`'s `auto_max_hours` cap, from the idle start.
664    fn past_window(&self, at: Instant, wall: SystemTime) -> Option<PeriodEnd> {
665        let h = match self.cfg.mode {
666            CacheKeepWarm::Hours(h) => h,
667            CacheKeepWarm::Auto if self.cfg.auto_max_hours > 0 => self.cfg.auto_max_hours,
668            _ => return None,
669        };
670        let window = Duration::from_secs(u64::from(h) * 3600);
671        let walled = wall.duration_since(self.idle_wall).unwrap_or_default();
672        (at.saturating_duration_since(self.idle_since) >= window || walled >= window)
673            .then_some(PeriodEnd::HoursReached(h))
674    }
675
676    fn past_bound(&self) -> Option<PeriodEnd> {
677        (self.ledger_usd + self.worst_next_usd > self.bound_usd * (1.0 + BOUND_EPSILON)).then_some(
678            PeriodEnd::BoundReached {
679                ledger_usd: self.ledger_usd,
680                bound_usd: self.bound_usd,
681            },
682        )
683    }
684
685    /// The timer fired. `Ping` marks the ping in flight.
686    pub fn fire(
687        &mut self,
688        now: Instant,
689        wall: SystemTime,
690        session_cost: f64,
691        max_session_cost: Option<f64>,
692    ) -> Fire {
693        if self.in_flight || (now < self.next_at && wall < self.next_wall) {
694            return Fire::NotYet;
695        }
696        let age_now = age(&self.anchor, now, wall);
697        if age_now + LATE_GUARD >= governing_ttl(self.ttl) {
698            return Fire::End(PeriodEnd::Expired {
699                age_secs: age_now.as_secs(),
700            });
701        }
702        if let Some(r) = self.past_window(now, wall).or_else(|| self.past_bound()) {
703            return Fire::End(r);
704        }
705        if let Some(cap) = max_session_cost.filter(|c| c.is_finite()) {
706            // A hard cap: admit only what fits even if the ping misses.
707            if session_cost + self.exposure_next_usd >= cap {
708                return Fire::End(PeriodEnd::CostCap { cap });
709            }
710        }
711        self.in_flight = true;
712        self.fired_age = age_now;
713        Fire::Ping
714    }
715
716    /// Book spend that belongs to this period (every ping, failed or not).
717    pub fn charge(&mut self, cost_usd: f64) {
718        self.ledger_usd += cost_usd;
719    }
720
721    /// The in-flight ping was sent and accepted (its cost already
722    /// [`charge`](Self::charge)d); `anchor` is the runtime's anchor after it,
723    /// `downgraded` the session's silent-downgrade latch. Schedules the next
724    /// ping or says why the period is over.
725    pub fn ping_sent(
726        &mut self,
727        usage: &KeepWarmUsage,
728        anchor: Option<CacheAnchor>,
729        downgraded: bool,
730    ) -> Result<(), PeriodEnd> {
731        self.in_flight = false;
732        self.retries = 0;
733        self.pings += 1;
734        let (read, write) = (usage.cache_read_tokens, usage.cache_write_tokens);
735        if read == 0 && write == 0 {
736            return Err(PeriodEnd::NothingCached);
737        }
738        if read == 0 {
739            return Err(PeriodEnd::CacheMiss { written: write });
740        }
741        if over_share(usage.input_tokens, self.prefix_tokens) {
742            return Err(PeriodEnd::UncachedInput {
743                input: usage.input_tokens,
744                prefix: self.prefix_tokens,
745            });
746        }
747        let allowed = if self.pings == 1 { self.first_write } else { 0 };
748        if over_share(write.saturating_sub(allowed), self.prefix_tokens) {
749            return Err(PeriodEnd::PartialMiss {
750                written: write,
751                prefix: self.prefix_tokens,
752            });
753        }
754        // What the server reports from now on is the prefix.
755        self.prefix_tokens = read + write;
756        self.ping_input = usage.input_tokens.max(1);
757        self.evidence.downgraded |= downgraded;
758        self.evidence.write_1h = usage.cache_write_1h.unwrap_or(0);
759        self.evidence.write_5m = usage.cache_write_5m.unwrap_or(0);
760        self.evidence.saw_1h |= self.evidence.write_1h > 0;
761        self.ttl = honoured_ttl(self.cfg.ttl, self.evidence);
762        self.reprice();
763        if let Some(a) = anchor.filter(|a| a.mono >= self.anchor.mono) {
764            self.anchor = a;
765        }
766        let interval = self.interval();
767        self.next_at = self.anchor.mono + interval;
768        self.next_wall = self.anchor.wall + interval;
769        if let Some(r) = self
770            .past_window(self.next_at, self.next_wall)
771            .or_else(|| self.past_bound())
772        {
773            return Err(r);
774        }
775        Ok(())
776    }
777
778    /// The in-flight ping failed (its cost, if any, already charged).
779    /// Transient failures retry with bounded backoff as long as the retry
780    /// still lands before expiry.
781    pub fn ping_failed(
782        &mut self,
783        failure: &PingFailure,
784        now: Instant,
785        wall: SystemTime,
786    ) -> Result<Duration, PeriodEnd> {
787        self.in_flight = false;
788        let msg = match failure {
789            PingFailure::Fatal(m) => return Err(PeriodEnd::Rejected(m.clone())),
790            PingFailure::Canceled => return Err(PeriodEnd::Rejected("canceled".into())),
791            PingFailure::Transient(m) => m.clone(),
792        };
793        let Some(delay) = BACKOFF.get(self.retries).copied() else {
794            return Err(PeriodEnd::TransientExhausted(msg));
795        };
796        let age_then = age(&self.anchor, now, wall) + delay;
797        if age_then + LATE_GUARD >= governing_ttl(self.ttl) {
798            return Err(PeriodEnd::TransientExhausted(format!(
799                "{msg}; no retry fits before the cache expires"
800            )));
801        }
802        if self.ledger_usd + self.worst_next_usd > self.bound_usd * (1.0 + BOUND_EPSILON) {
803            return Err(PeriodEnd::BoundReached {
804                ledger_usd: self.ledger_usd,
805                bound_usd: self.bound_usd,
806            });
807        }
808        self.retries += 1;
809        self.next_at = now + delay;
810        self.next_wall = wall + delay;
811        Ok(delay)
812    }
813}
814
815/// One in-flight ping.
816pub(crate) struct PingTask {
817    pub(crate) handle: tokio::task::JoinHandle<PingReport>,
818    cancel: CancellationToken,
819    tap: PingTap,
820    /// Pricing model at send time (the runtime may be gone when it ends).
821    model: String,
822    /// The period's worst case for this ping at send time: what it is
823    /// booked at if it was dispatched but no usage arrived.
824    worst_usd: f64,
825}
826
827impl PingTask {
828    /// The ONE booking rule (`runtime::keepwarm::ping_booking`) applied to
829    /// this ping; every path that ends a ping books through here.
830    fn bill(&self) -> PingBill {
831        self.tap.bill(&self.model, self.worst_usd)
832    }
833}
834
835/// Actor-owned keep-warm state.
836#[derive(Default)]
837pub(crate) struct KeepWarmState {
838    /// `cache_keepwarm` from config at session create.
839    configured: CacheKeepWarm,
840    /// `cache_keepwarm_auto_max_hours` from config at session create.
841    auto_max_hours: u32,
842    /// The current cache epoch's carry (see [`EpochCarry`]); `None` until a
843    /// period of this epoch ended. Reset by every real request's usage, a
844    /// compaction and a conversation swap.
845    epoch: Option<EpochCarry>,
846    /// `/keepwarm …` for this session (survives park and daemon reload).
847    pub(crate) override_mode: Option<CacheKeepWarm>,
848    /// Shared with the session handle so a daemon reload that cannot
849    /// checkpoint the actor still carries the override (see `reload.rs`).
850    pub(crate) override_slot: OverrideSlot,
851    pub(crate) period: Option<Period>,
852    pub(crate) task: Option<PingTask>,
853    next_period_id: u64,
854    /// Set by `start_turn`, consumed by the next `Idle`.
855    turn_started_at: Option<Instant>,
856    /// Usage of the latest real request (`None` until one reported).
857    last_request: Option<KeepWarmUsage>,
858    /// A TUI/attach client has been attached at some point. Script/RPC-only
859    /// sessions (nobody will be back in human time) never keep warm.
860    pub(crate) interactive_seen: bool,
861    last_end: Option<String>,
862    session_pings: u32,
863}
864
865/// The per-session `/keepwarm` override, readable without the actor.
866pub type OverrideSlot = std::sync::Arc<std::sync::Mutex<Option<CacheKeepWarm>>>;
867
868impl KeepWarmState {
869    pub(crate) fn new(
870        configured: CacheKeepWarm,
871        auto_max_hours: u32,
872        override_slot: OverrideSlot,
873    ) -> Self {
874        let override_mode = *override_slot
875            .lock()
876            .unwrap_or_else(std::sync::PoisonError::into_inner);
877        Self {
878            configured,
879            auto_max_hours,
880            override_mode,
881            override_slot,
882            ..Self::default()
883        }
884    }
885
886    pub(crate) fn mode(&self) -> CacheKeepWarm {
887        self.override_mode.unwrap_or(self.configured)
888    }
889
890    pub(crate) fn set_override(&mut self, mode: CacheKeepWarm) {
891        self.override_mode = Some(mode);
892        *self
893            .override_slot
894            .lock()
895            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(mode);
896    }
897
898    pub(crate) fn active(&self) -> bool {
899        self.period.is_some()
900    }
901
902    /// When the actor's timer should next wake: the ping deadline, but never
903    /// later than [`WALL_POLL`] (re-reads the wall clock after a suspend).
904    /// `None` (no timer) with no period or while a ping is in flight.
905    pub(crate) fn deadline(&self) -> Option<Instant> {
906        self.deadline_at(Instant::now(), SystemTime::now())
907    }
908
909    fn deadline_at(&self, now: Instant, wall: SystemTime) -> Option<Instant> {
910        self.period
911            .as_ref()
912            .and_then(|p| p.deadline(now, wall))
913            .map(|d| d.min(now + WALL_POLL))
914    }
915}
916
917/// `select!` arm: the next ping is due (or the wall clock needs a look).
918pub(crate) async fn keepwarm_timer(deadline: Option<Instant>) {
919    match deadline {
920        Some(t) => tokio::time::sleep_until(t).await,
921        None => std::future::pending().await,
922    }
923}
924
925/// `select!` arm: the in-flight ping finished.
926pub(crate) async fn poll_keepwarm_ping(
927    task: &mut Option<PingTask>,
928) -> std::result::Result<PingReport, tokio::task::JoinError> {
929    match task {
930        Some(t) => (&mut t.handle).await,
931        None => std::future::pending().await,
932    }
933}
934
935impl SessionActor {
936    /// Why keep-warm must not ping right now (`None` = clear).
937    fn cache_keepwarm_blocker(&self) -> Option<&'static str> {
938        if !self.runtime.is_live() || !self.conv.is_live() || self.is_parked() {
939            return Some("session parked");
940        }
941        if self.streaming {
942            return Some("turn running");
943        }
944        if self.compact.is_some() {
945            return Some("compaction running");
946        }
947        if self.conv.queued_message.is_some() || !self.event_queue.is_empty() {
948            return Some("work queued");
949        }
950        if !self.pending_prompts.is_empty() {
951            return Some("prompt pending");
952        }
953        if self.driver.is_some() || self.driver_pending.is_some() {
954            return Some("session driver armed");
955        }
956        None
957    }
958
959    /// A client attached: TUI/attach clients mark the session interactive.
960    pub(crate) fn cache_keepwarm_note_attach(&mut self, kind: ClientKind) {
961        if matches!(kind, ClientKind::Tui | ClientKind::Attach) {
962            self.cache_keepwarm.interactive_seen = true;
963        }
964    }
965
966    /// A provider Usage event of the running turn (one per request): the
967    /// latest request's usage decides whether anything is cached at all.
968    pub(crate) fn cache_keepwarm_note_usage(&mut self, usage: KeepWarmUsage) {
969        self.cache_keepwarm.last_request = Some(usage);
970        // A real request refreshed/rewrote the cache: a new epoch.
971        self.cache_keepwarm.epoch = None;
972    }
973
974    /// A real turn is starting: the idle period is over, any ping in flight
975    /// is aborted (and booked). The next `Idle` may start a new period.
976    pub(crate) fn cache_keepwarm_turn_started(&mut self) {
977        self.cache_keepwarm_end(PeriodEnd::TurnStarted);
978        self.cache_keepwarm.turn_started_at = Some(Instant::now());
979        self.cache_keepwarm.last_request = None;
980    }
981
982    /// Compaction replaces the history: nothing is worth keeping warm until
983    /// the next real turn.
984    pub(crate) fn cache_keepwarm_compaction(&mut self) {
985        self.cache_keepwarm_end(PeriodEnd::Compaction);
986        self.cache_keepwarm.turn_started_at = None;
987        self.cache_keepwarm.last_request = None;
988        self.cache_keepwarm.epoch = None;
989    }
990
991    /// `/clear` (`NewSession`) or `/resume` is about to replace the
992    /// conversation. Call it BEFORE the swap: the period ends at once
993    /// (prefix changed), a ping in flight is aborted, and its cost is booked
994    /// to the conversation it was keeping warm — the old one, which the swap
995    /// then saves. Nothing of the old conversation may start a period on
996    /// the new one.
997    pub(crate) fn cache_keepwarm_conversation_replaced(&mut self) {
998        self.cache_keepwarm_end(PeriodEnd::PrefixChanged);
999        self.cache_keepwarm.turn_started_at = None;
1000        self.cache_keepwarm.last_request = None;
1001        self.cache_keepwarm.epoch = None;
1002    }
1003
1004    /// An explicit park request: an active period ends ("parked by
1005    /// request") so the park can proceed. The idle auto-park still waits.
1006    pub(crate) fn cache_keepwarm_park_requested(&mut self) {
1007        if self.cache_keepwarm.active() && self.can_park_ignoring_keepwarm() {
1008            self.cache_keepwarm_end(PeriodEnd::ParkedByRequest);
1009        }
1010    }
1011
1012    /// Book keep-warm spend: session cost (so `max_session_cost` sees it),
1013    /// the labelled keep-warm cost, token totals (shown separately), and the
1014    /// active period's ledger — then queue a session save so the charge is
1015    /// durable without waiting for Save/Park/End.
1016    fn cache_keepwarm_book(&mut self, usage: &KeepWarmUsage, cost: f64) {
1017        if (cost <= 0.0 && *usage == KeepWarmUsage::default()) || !self.conv.is_live() {
1018            return;
1019        }
1020        self.conv.session_cost += cost;
1021        self.conv.keepwarm_cost += cost;
1022        self.conv.total_input_tokens += usage.input_tokens;
1023        self.conv.total_output_tokens += usage.output_tokens;
1024        self.conv.total_cache_read_tokens += usage.cache_read_tokens;
1025        self.conv.total_cache_creation_tokens += usage.cache_write_tokens;
1026        let t = &mut self.conv.keepwarm_tokens;
1027        t.input += usage.input_tokens;
1028        t.output += usage.output_tokens;
1029        t.cache_read += usage.cache_read_tokens;
1030        t.cache_creation += usage.cache_write_tokens;
1031        if let Some(p) = self.cache_keepwarm.period.as_mut() {
1032            p.charge(cost);
1033        }
1034        self.emit_conversation();
1035        // Settled spend must survive a crash during idle (otherwise the
1036        // charge is lost and `max_session_cost` headroom restored). The
1037        // ordinary session save, queued on the persister: never blocks the
1038        // actor. The keep-warm labels stay snapshot-only (never journaled);
1039        // in journal mode the journaled session cost carries the charge.
1040        self.request_save();
1041    }
1042
1043    /// Abort the in-flight ping, if any, and book what it may have cost
1044    /// ([`PingTask::bill`]: real usage, else the worst case once dispatched,
1045    /// else nothing).
1046    fn cache_keepwarm_abort_ping(&mut self) {
1047        let Some(task) = self.cache_keepwarm.task.take() else {
1048            return;
1049        };
1050        task.cancel.cancel();
1051        task.handle.abort();
1052        let bill = task.bill();
1053        if bill.cost_usd > 0.0 {
1054            tracing::info!(
1055                session = %self.id,
1056                cost_usd = bill.cost_usd,
1057                booking = ?bill.booking,
1058                "cache keep-warm ping aborted in flight; booked"
1059            );
1060        }
1061        self.cache_keepwarm_book(&bill.usage, bill.cost_usd);
1062    }
1063
1064    /// End the active period (and abort any ping) with one final log line.
1065    pub(crate) fn cache_keepwarm_end(&mut self, reason: PeriodEnd) {
1066        self.cache_keepwarm_abort_ping();
1067        let Some(p) = self.cache_keepwarm.period.take() else {
1068            return;
1069        };
1070        tracing::info!(
1071            session = %self.id,
1072            period = p.id,
1073            pings = p.pings(),
1074            ledger_usd = p.ledger_usd(),
1075            bound_usd = p.bound_usd(),
1076            reason = %reason,
1077            "cache keep-warm period ended"
1078        );
1079        self.cache_keepwarm.last_end = Some(reason.to_string());
1080        // A later period of the same cache epoch continues from here.
1081        self.cache_keepwarm.epoch = Some(p.epoch_carry());
1082        // A period defers parking; now the session may park normally.
1083        self.rearm_park();
1084    }
1085
1086    /// The session announced `Idle`: start a period if the turn that just
1087    /// ended had an Anthropic request accepted and keep-warm is on.
1088    pub(crate) async fn cache_keepwarm_on_idle(&mut self) {
1089        let Some(turn_started_at) = self.cache_keepwarm.turn_started_at.take() else {
1090            return;
1091        };
1092        self.cache_keepwarm_try_start(Some(turn_started_at)).await;
1093    }
1094
1095    /// Start a period now if eligible. `turn_started_at`: the anchor must be
1096    /// from this turn (on `Idle`); `None` (a `/keepwarm` command on an idle
1097    /// session) accepts the current anchor as long as the last request's
1098    /// usage is still the latest.
1099    async fn cache_keepwarm_try_start(&mut self, turn_started_at: Option<Instant>) {
1100        if self.cache_keepwarm.active() {
1101            return;
1102        }
1103        let mode = self.cache_keepwarm.mode();
1104        if mode.is_off() || !self.cache_keepwarm.interactive_seen {
1105            return;
1106        }
1107        // Never before a real Anthropic request of THIS turn was accepted.
1108        let Some(anchor) = self
1109            .runtime
1110            .cache_anchor()
1111            .filter(|a| turn_started_at.map_or(true, |t| a.mono >= t))
1112        else {
1113            return;
1114        };
1115        let Some(last) = self.cache_keepwarm.last_request.clone() else {
1116            return;
1117        };
1118        if let Some(why) = self.cache_keepwarm_blocker() {
1119            self.cache_keepwarm.last_end = Some(format!("not started: {why}"));
1120            return;
1121        }
1122        if let Some(why) = self.runtime.cache_keepwarm_ineligibility() {
1123            self.cache_keepwarm.last_end = Some(format!("not started: {why}"));
1124            return;
1125        }
1126        if !crate::runtime::keepwarm::history_ready(&self.conv.api_messages) {
1127            return;
1128        }
1129        let probe: KeepWarmProbe = match self
1130            .runtime
1131            .cache_keepwarm_probe(&self.conv.api_messages)
1132            .await
1133        {
1134            Ok(p) => p,
1135            Err(e) => {
1136                tracing::info!(session = %self.id, "cache keep-warm not started: {e}");
1137                self.cache_keepwarm.last_end = Some(format!("not started: {e}"));
1138                return;
1139            }
1140        };
1141        if let Some(why) = probe.context_blocks {
1142            let reason = PeriodEnd::ContextRollover(why);
1143            tracing::info!(session = %self.id, reason = %reason, "cache keep-warm not started");
1144            self.cache_keepwarm.last_end = Some(format!("not started: {reason}"));
1145            return;
1146        }
1147        let cfg = PeriodConfig {
1148            mode,
1149            ttl: self.runtime.cache_ttl(),
1150            model: self.runtime.model().to_string(),
1151            max_tokens: probe.max_tokens,
1152            interval_override: interval_override(),
1153            auto_max_hours: self.cache_keepwarm.auto_max_hours,
1154            reply_tokens: Some(crate::runtime::keepwarm::reply_prefix_tokens(
1155                &self.conv.api_messages,
1156            )),
1157        };
1158        let evidence = TtlEvidence {
1159            downgraded: self.runtime.cache_ttl_downgraded(),
1160            saw_1h: self.runtime.cache_1h_honoured(),
1161            write_1h: last.cache_write_1h.unwrap_or(0),
1162            write_5m: last.cache_write_5m.unwrap_or(0),
1163        };
1164        self.cache_keepwarm.next_period_id += 1;
1165        let id = self.cache_keepwarm.next_period_id;
1166        let now = Instant::now();
1167        let carry = self.cache_keepwarm.epoch;
1168        match Period::start_in_epoch(
1169            id,
1170            cfg,
1171            probe.fingerprint,
1172            &last,
1173            evidence,
1174            anchor,
1175            now,
1176            SystemTime::now(),
1177            carry.as_ref(),
1178        ) {
1179            Ok(p) => {
1180                tracing::info!(
1181                    session = %self.id,
1182                    period = id,
1183                    mode = %mode,
1184                    ttl_secs = governing_ttl(p.ttl()).as_secs(),
1185                    first_ping_in_secs = p.next_at().saturating_duration_since(now).as_secs(),
1186                    prefix_tokens = p.prefix_tokens(),
1187                    bound_usd = p.bound_usd(),
1188                    worst_ping_usd = p.exposure_next_usd(),
1189                    "cache keep-warm period started"
1190                );
1191                self.cache_keepwarm.period = Some(p);
1192            }
1193            Err(reason) => {
1194                tracing::info!(session = %self.id, reason = %reason, "cache keep-warm not started");
1195                self.cache_keepwarm.last_end = Some(format!("not started: {reason}"));
1196            }
1197        }
1198    }
1199
1200    /// `select!` arm: a ping may be due.
1201    pub(crate) async fn on_cache_keepwarm_due(&mut self) {
1202        if self.cache_keepwarm.period.is_none() || self.cache_keepwarm.task.is_some() {
1203            return;
1204        }
1205        let (now, wall) = (Instant::now(), SystemTime::now());
1206        // A wall-clock poll before anything is due: nothing to do.
1207        if self
1208            .cache_keepwarm
1209            .period
1210            .as_ref()
1211            .and_then(|p| p.deadline(now, wall))
1212            .is_some_and(|d| d > now)
1213        {
1214            return;
1215        }
1216        if let Some(why) = self.cache_keepwarm_blocker() {
1217            self.cache_keepwarm_end(PeriodEnd::Blocked(why));
1218            return;
1219        }
1220        if !crate::runtime::keepwarm::history_ready(&self.conv.api_messages) {
1221            // /clear, /resume onto an unfinished session, an interrupted
1222            // edit: the prefix is not the one that is cached.
1223            self.cache_keepwarm_end(PeriodEnd::PrefixChanged);
1224            return;
1225        }
1226        // The same pre-request assessment a real turn makes: if the next
1227        // turn would roll over first, the cached prefix is not what it sends.
1228        match self
1229            .runtime
1230            .cache_keepwarm_probe(&self.conv.api_messages)
1231            .await
1232        {
1233            Ok(probe) => {
1234                if let Some(why) = probe.context_blocks {
1235                    self.cache_keepwarm_end(PeriodEnd::ContextRollover(why));
1236                    return;
1237                }
1238            }
1239            Err(e) => {
1240                self.cache_keepwarm_end(PeriodEnd::Rejected(e.to_string()));
1241                return;
1242            }
1243        }
1244        let session_cost = self.conv.session_cost;
1245        let cap = self.config.max_session_cost;
1246        let Some(p) = self.cache_keepwarm.period.as_mut() else {
1247            return;
1248        };
1249        match p.fire(Instant::now(), SystemTime::now(), session_cost, cap) {
1250            Fire::NotYet => {}
1251            Fire::End(reason) => self.cache_keepwarm_end(reason),
1252            Fire::Ping => {
1253                let expect = p.fingerprint();
1254                let worst_usd = p.worst_next_usd();
1255                // A detached clone sharing the anchor and the TTL latches:
1256                // the actor keeps serving clients while the ping runs; a
1257                // turn start aborts it.
1258                let history = self.conv.api_messages.clone();
1259                let mut runtime = self.runtime.clone();
1260                runtime.share_ttl_latches(&self.runtime);
1261                let cancel = CancellationToken::new();
1262                let token = cancel.clone();
1263                let tap = PingTap::default();
1264                let task_tap = tap.clone();
1265                let handle = tokio::spawn(async move {
1266                    runtime
1267                        .cache_keepwarm_ping(&history, expect, &token, &task_tap)
1268                        .await
1269                });
1270                self.cache_keepwarm.task = Some(PingTask {
1271                    handle,
1272                    cancel,
1273                    tap,
1274                    model: self.runtime.model().to_string(),
1275                    worst_usd,
1276                });
1277            }
1278        }
1279    }
1280
1281    /// `select!` arm: the in-flight ping finished.
1282    pub(crate) fn on_cache_keepwarm_done(
1283        &mut self,
1284        result: std::result::Result<PingReport, tokio::task::JoinError>,
1285    ) {
1286        let Some(task) = self.cache_keepwarm.task.take() else {
1287            return;
1288        };
1289        let report = match result {
1290            Ok(r) => r,
1291            Err(e) => PingReport {
1292                result: Err(if e.is_cancelled() {
1293                    PingFailure::Canceled
1294                } else {
1295                    PingFailure::Fatal(format!("ping task: {e}"))
1296                }),
1297                usage: task.tap.usage(),
1298                notices: Vec::new(),
1299            },
1300        };
1301        // Book on EVERY path (success, failure, panic) by the one rule.
1302        let bill = task.bill();
1303        let cost = bill.cost_usd;
1304        self.cache_keepwarm_book(&bill.usage, cost);
1305        for notice in &report.notices {
1306            self.emit(crate::session::SessionEventWire::SystemNotice(
1307                notice.clone(),
1308            ));
1309        }
1310        if self.cache_keepwarm.period.is_none() {
1311            return;
1312        }
1313        match report.result {
1314            Ok(PingOutcome::Sent) => {
1315                let usage = report.usage;
1316                self.cache_keepwarm.session_pings += 1;
1317                let anchor = self.runtime.cache_anchor();
1318                let downgraded = self.runtime.cache_ttl_downgraded();
1319                let Some(p) = self.cache_keepwarm.period.as_mut() else {
1320                    return;
1321                };
1322                let result = p.ping_sent(&usage, anchor, downgraded);
1323                tracing::info!(
1324                    session = %self.id,
1325                    period = p.id,
1326                    ping = p.pings(),
1327                    age_secs = p.fired_age().as_secs(),
1328                    ttl_secs = governing_ttl(p.ttl()).as_secs(),
1329                    cache_read = usage.cache_read_tokens,
1330                    cache_write = usage.cache_write_tokens,
1331                    input = usage.input_tokens,
1332                    output = usage.output_tokens,
1333                    cost_usd = cost,
1334                    ledger_usd = p.ledger_usd(),
1335                    bound_usd = p.bound_usd(),
1336                    "cache keep-warm ping"
1337                );
1338                if let Err(reason) = result {
1339                    if matches!(
1340                        reason,
1341                        PeriodEnd::PartialMiss { .. }
1342                            | PeriodEnd::CacheMiss { .. }
1343                            | PeriodEnd::UncachedInput { .. }
1344                            | PeriodEnd::NothingCached
1345                    ) {
1346                        tracing::warn!(
1347                            session = %self.id,
1348                            reason = %reason,
1349                            "cache keep-warm ping did not refresh the cache"
1350                        );
1351                    }
1352                    self.cache_keepwarm_end(reason);
1353                    return;
1354                }
1355                if let Some(cap) = self.config.max_session_cost.filter(|c| c.is_finite()) {
1356                    if self.conv.session_cost >= cap {
1357                        self.cache_keepwarm_end(PeriodEnd::CostCap { cap });
1358                    }
1359                }
1360            }
1361            Ok(PingOutcome::PrefixChanged { .. }) => {
1362                self.cache_keepwarm_end(PeriodEnd::PrefixChanged);
1363            }
1364            Err(failure) => {
1365                let Some(p) = self.cache_keepwarm.period.as_mut() else {
1366                    return;
1367                };
1368                match p.ping_failed(&failure, Instant::now(), SystemTime::now()) {
1369                    Ok(delay) => tracing::info!(
1370                        session = %self.id,
1371                        period = p.id,
1372                        retry_in_secs = delay.as_secs(),
1373                        "cache keep-warm ping failed ({failure}); retrying"
1374                    ),
1375                    Err(reason) => self.cache_keepwarm_end(reason),
1376                }
1377            }
1378        }
1379    }
1380
1381    /// Re-check the active period's fingerprint now (status, last detach).
1382    /// Ends the period if the prefix changed. Returns whether it matches
1383    /// (`None` = no period / could not build).
1384    pub(crate) async fn cache_keepwarm_recheck(&mut self) -> Option<bool> {
1385        let expect = self.cache_keepwarm.period.as_ref()?.fingerprint();
1386        if self.cache_keepwarm.period.as_ref()?.in_flight() {
1387            return Some(true);
1388        }
1389        let matches = crate::runtime::keepwarm::history_ready(&self.conv.api_messages)
1390            && self
1391                .runtime
1392                .cache_keepwarm_probe(&self.conv.api_messages)
1393                .await
1394                .is_ok_and(|p| p.fingerprint == expect);
1395        if !matches {
1396            self.cache_keepwarm_end(PeriodEnd::PrefixChanged);
1397        }
1398        Some(matches)
1399    }
1400
1401    /// Structured keep-warm state (`/keepwarm status`, `Status` query).
1402    pub(crate) fn cache_keepwarm_status_json(
1403        &self,
1404        prefix_matches: Option<bool>,
1405    ) -> serde_json::Value {
1406        let kw = &self.cache_keepwarm;
1407        let (now, wall) = (Instant::now(), SystemTime::now());
1408        let period = kw.period.as_ref().map(|p| {
1409            // Wall-clock aware: after a suspend the monotonic deadline lags.
1410            let next = p
1411                .deadline(now, wall)
1412                .map(|d| d.saturating_duration_since(now).as_secs());
1413            serde_json::json!({
1414                "id": p.id,
1415                "ttl_secs": governing_ttl(p.ttl()).as_secs(),
1416                "pings": p.pings(),
1417                "ledger_usd": p.ledger_usd(),
1418                "bound_usd": p.bound_usd(),
1419                "worst_ping_usd": p.exposure_next_usd(),
1420                "prefix_tokens": p.prefix_tokens(),
1421                "next_ping_in_secs": next.unwrap_or(0),
1422                "in_flight": p.in_flight(),
1423            })
1424        });
1425        serde_json::json!({
1426            "mode": kw.mode().to_string(),
1427            "active": kw.active(),
1428            "period": period,
1429            "prefix_matches": prefix_matches,
1430            "session_pings": kw.session_pings,
1431            "session_keepwarm_usd": self.conv.keepwarm_cost,
1432            "last_end": kw.last_end,
1433        })
1434    }
1435
1436    /// `/keepwarm [status|off|auto|<N>h]` (actor-owned, like `/saveas`).
1437    pub(crate) async fn cache_keepwarm_command(&mut self, arg: &str) -> serde_json::Value {
1438        let arg = arg.trim();
1439        if !(arg.is_empty() || arg.eq_ignore_ascii_case("status")) {
1440            let Some(mode) = CacheKeepWarm::parse(arg) else {
1441                return serde_json::json!({
1442                    "kind": "error",
1443                    "text": format!(
1444                        "usage: /keepwarm [status|off|auto|<N>h] (N = 1–{}; unknown: {arg})",
1445                        crate::core::config::MAX_CACHE_KEEPWARM_HOURS
1446                    ),
1447                });
1448            };
1449            self.cache_keepwarm.set_override(mode);
1450            if mode.is_off() {
1451                self.cache_keepwarm_end(PeriodEnd::Disabled);
1452            } else if let Some(p) = self.cache_keepwarm.period.as_mut() {
1453                p.set_mode(mode);
1454            } else {
1455                // Already idle: start now if eligible.
1456                self.cache_keepwarm_try_start(None).await;
1457            }
1458        }
1459        let prefix_matches = self.cache_keepwarm_recheck().await;
1460        let status = self.cache_keepwarm_status_json(prefix_matches);
1461        serde_json::json!({
1462            "kind": "output",
1463            "text": render_status(&status),
1464            "keepwarm": status,
1465        })
1466    }
1467}
1468
1469/// One human line (plus detail) for `/keepwarm status`.
1470pub(crate) fn render_status(s: &serde_json::Value) -> String {
1471    let mode = s["mode"].as_str().unwrap_or("off");
1472    let spent = s["session_keepwarm_usd"].as_f64().unwrap_or(0.0);
1473    let pings = s["session_pings"].as_u64().unwrap_or(0);
1474    let mut out = format!("cache keep-warm: {mode} — this session: {pings} pings, ${spent:.4}");
1475    let p = &s["period"];
1476    if p.is_object() {
1477        out.push_str(&format!(
1478            "\nactive: ping {} due in {}s (TTL {}s); ${:.4} spent of ${:.4} bound; prefix {}",
1479            p["pings"].as_u64().unwrap_or(0) + 1,
1480            p["next_ping_in_secs"].as_u64().unwrap_or(0),
1481            p["ttl_secs"].as_u64().unwrap_or(0),
1482            p["ledger_usd"].as_f64().unwrap_or(0.0),
1483            p["bound_usd"].as_f64().unwrap_or(0.0),
1484            match s["prefix_matches"].as_bool() {
1485                Some(true) => "unchanged",
1486                Some(false) => "changed",
1487                None => "unchecked",
1488            },
1489        ));
1490    } else {
1491        out.push_str("\nidle period: none");
1492    }
1493    if let Some(last) = s["last_end"].as_str() {
1494        out.push_str(&format!("\nlast: {last}"));
1495    }
1496    out
1497}
1498
1499#[cfg(test)]
1500mod tests {
1501    use super::*;
1502
1503    const MIN: Duration = Duration::from_secs(60);
1504    const OPUS: &str = "claude-opus-4-7";
1505    const SONNET: &str = "claude-sonnet-4-6";
1506    const P: u64 = 300_000;
1507
1508    /// Streaming ping with thinking off / adaptive: `max_tokens` 1.
1509    fn cfg(mode: CacheKeepWarm, ttl: CacheTtl, model: &str) -> PeriodConfig {
1510        cfg_max(mode, ttl, model, 1)
1511    }
1512
1513    fn cfg_max(mode: CacheKeepWarm, ttl: CacheTtl, model: &str, max_tokens: u64) -> PeriodConfig {
1514        PeriodConfig {
1515            mode,
1516            ttl,
1517            model: model.to_string(),
1518            max_tokens,
1519            interval_override: None,
1520            auto_max_hours: 0,
1521            reply_tokens: None,
1522        }
1523    }
1524
1525    /// 1h honoured: the session has written a 1h entry.
1526    fn ev_1h() -> TtlEvidence {
1527        TtlEvidence {
1528            saw_1h: true,
1529            ..TtlEvidence::default()
1530        }
1531    }
1532
1533    fn anchor_at(mono: Instant, wall: SystemTime) -> CacheAnchor {
1534        CacheAnchor { mono, wall }
1535    }
1536
1537    fn usage(model: &str, read: u64, write: u64, input: u64, out: u64) -> KeepWarmUsage {
1538        KeepWarmUsage {
1539            input_tokens: input,
1540            output_tokens: out,
1541            cache_read_tokens: read,
1542            cache_write_tokens: write,
1543            cache_write_5m: None,
1544            cache_write_1h: None,
1545            model: Some(model.to_string()),
1546        }
1547    }
1548
1549    /// The last real request read the whole `P` prefix (no tail to write).
1550    fn last_read(model: &str) -> KeepWarmUsage {
1551        usage(model, P, 0, 0, 0)
1552    }
1553
1554    fn start(c: PeriodConfig, last: &KeepWarmUsage, t0: Instant, w0: SystemTime) -> Period {
1555        Period::start(1, c, 7, last, ev_1h(), anchor_at(t0, w0), t0, w0).expect("starts")
1556    }
1557
1558    /// Drive a period with the given ping usages (last one repeats) until it
1559    /// ends. Every ping is sent exactly when due and moves the anchor.
1560    /// Returns (pings, ledger, bound, reason, elapsed since idle).
1561    fn run_from(
1562        cfg: PeriodConfig,
1563        last: &KeepWarmUsage,
1564        usages: &[KeepWarmUsage],
1565    ) -> (u32, f64, f64, PeriodEnd, Duration) {
1566        let t0 = Instant::now();
1567        let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
1568        let model = cfg.model.clone();
1569        let mut p = start(cfg, last, t0, w0);
1570        let mut i = 0;
1571        loop {
1572            let at = p.next_at();
1573            let wall = w0 + at.saturating_duration_since(t0);
1574            match p.fire(at, wall, 0.0, None) {
1575                Fire::Ping => {}
1576                Fire::End(r) => {
1577                    return (p.pings(), p.ledger_usd(), p.bound_usd(), r, at - t0);
1578                }
1579                Fire::NotYet => panic!("due but not fired"),
1580            }
1581            let u = &usages[i.min(usages.len() - 1)];
1582            i += 1;
1583            p.charge(u.cost_usd(&model));
1584            if let Err(r) = p.ping_sent(u, Some(anchor_at(at, wall)), false) {
1585                return (p.pings(), p.ledger_usd(), p.bound_usd(), r, at - t0);
1586            }
1587            assert!(i < 1000, "runaway");
1588        }
1589    }
1590
1591    fn run(cfg: PeriodConfig, usages: &[KeepWarmUsage]) -> (u32, f64, f64, PeriodEnd, Duration) {
1592        let last = last_read(&cfg.model);
1593        run_from(cfg, &last, usages)
1594    }
1595
1596    // ── ski-rental bound, both TTLs, measured costs, worst case ──
1597
1598    /// Review #205: a dispatched ping can miss and rewrite the whole prefix
1599    /// at the honoured TTL. The hard cap must admit only what fits even then.
1600    #[test]
1601    fn review_205_possible_cache_miss_must_fit_admission_cap() {
1602        let t0 = Instant::now();
1603        let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
1604        let mut p = start(
1605            cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1606            &last_read(OPUS),
1607            t0,
1608            w0,
1609        );
1610        let at = p.next_at();
1611        let wall = w0 + at.saturating_duration_since(t0);
1612        let cap = 0.30;
1613        let decision = p.fire(at, wall, 0.0, Some(cap));
1614        let miss = KeepWarmUsage {
1615            cache_write_tokens: P,
1616            cache_write_1h: Some(P),
1617            ..KeepWarmUsage::default()
1618        };
1619        println!(
1620            "REVIEW estimated_worst={} possible_write={} cap={cap} decision={decision:?}",
1621            p.worst_next_usd,
1622            miss.cost_usd(OPUS)
1623        );
1624        if matches!(decision, Fire::Ping) {
1625            assert!(
1626                miss.cost_usd(OPUS) <= cap,
1627                "admitted request can exceed its session cost cap on a cache miss"
1628            );
1629        }
1630    }
1631
1632    /// The miss-priced admission must not kill the feature: a cap with room
1633    /// for a full miss still pings, and the ski-rental bound stays on the
1634    /// read path.
1635    #[test]
1636    fn a_cap_with_room_for_a_cache_miss_still_pings() {
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        // Full miss at 1h: 300k × $10/MTok = $3.00, + 64 input + 1 output.
1646        let exposure = p.exposure_next_usd();
1647        let miss = usage(OPUS, 0, P, PING_INPUT_ESTIMATE, 1);
1648        let miss = KeepWarmUsage {
1649            cache_write_1h: Some(P),
1650            ..miss
1651        };
1652        assert!((exposure - miss.cost_usd(OPUS)).abs() < 1e-9, "{exposure}");
1653        assert!(p.worst_next_usd() < p.bound_usd(), "bound basis is the read");
1654        let at = p.next_at();
1655        let wall = w0 + at.saturating_duration_since(t0);
1656        assert_eq!(
1657            p.fire(at, wall, 0.0, Some(3.0)),
1658            Fire::End(PeriodEnd::CostCap { cap: 3.0 })
1659        );
1660        assert_eq!(p.fire(at, wall, 0.0, Some(3.01)), Fire::Ping);
1661    }
1662
1663    #[test]
1664    fn ski_rental_1h_is_nineteen_pure_reads_over_about_17h() {
1665        // A pure read of a 300k prefix: $0.15 on Opus; rewrite at 1h = $3.00.
1666        // The pre-check uses the WORST case (read + 1 output token), so the
1667        // 20th read ($3.00 exactly + $0.000025) no longer fits.
1668        let (pings, ledger, bound, reason, elapsed) = run(
1669            cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1670            &[usage(OPUS, P, 0, 0, 0)],
1671        );
1672        assert!((bound - 3.0).abs() < 1e-9, "{bound}");
1673        assert_eq!(pings, 19);
1674        assert!(ledger <= bound * (1.0 + 1e-9));
1675        assert!(matches!(reason, PeriodEnd::BoundReached { .. }), "{reason}");
1676        assert_eq!(elapsed, 19 * 55 * MIN);
1677    }
1678
1679    #[test]
1680    fn ski_rental_5m_is_twelve_pure_reads_over_about_55_min() {
1681        // Rewrite at 5m = 300k × $6.25/MTok = $1.875; 12 reads = $1.80.
1682        let (pings, ledger, bound, reason, elapsed) = run(
1683            cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS),
1684            &[usage(OPUS, P, 0, 0, 0)],
1685        );
1686        assert!((bound - 1.875).abs() < 1e-9, "{bound}");
1687        assert_eq!(pings, 12);
1688        assert!((ledger - 1.8).abs() < 1e-9);
1689        assert!(matches!(reason, PeriodEnd::BoundReached { .. }), "{reason}");
1690        assert_eq!(elapsed, 12 * Duration::from_secs(270));
1691    }
1692
1693    #[test]
1694    fn measured_overhead_and_first_write_are_in_the_ledger() {
1695        // The last turn read 298k and produced a 2k-token reply: ping 1
1696        // writes that reply (1h price), every ping carries 40 uncached input
1697        // tokens and 1 output token.
1698        let last = usage(OPUS, P - 2_000, 0, 0, 2_000);
1699        let first = usage(OPUS, P - 2_000, 2_000, 40, 1);
1700        let steady = usage(OPUS, P, 0, 40, 1);
1701        let (pings, ledger, bound, reason, _) = run_from(
1702            cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1703            &last,
1704            &[first.clone(), steady.clone()],
1705        );
1706        assert!(matches!(reason, PeriodEnd::BoundReached { .. }), "{reason}");
1707        let expected_ledger = first.cost_usd(OPUS) + f64::from(pings - 1) * steady.cost_usd(OPUS);
1708        assert!(
1709            (ledger - expected_ledger).abs() < 1e-9,
1710            "ledger is the measured sum"
1711        );
1712        assert!(ledger <= bound * (1.0 + 1e-9), "never past the bound");
1713        assert!(ledger + steady.cost_usd(OPUS) > bound);
1714        assert_eq!(pings, 19);
1715    }
1716
1717    #[test]
1718    fn an_expensive_ping_stops_the_period_early() {
1719        // Budgeted thinking that actually thought: 5k output tokens on
1720        // Sonnet 4.6 = $0.075 per ping on top of a $0.09 read.
1721        let (heavy, ..) = run(
1722            cfg_max(CacheKeepWarm::Auto, CacheTtl::OneHour, SONNET, 5_001),
1723            &[usage(SONNET, P, 0, 40, 5_000)],
1724        );
1725        let (light, ..) = run(
1726            cfg_max(CacheKeepWarm::Auto, CacheTtl::OneHour, SONNET, 31),
1727            &[usage(SONNET, P, 0, 40, 30)],
1728        );
1729        assert_eq!(heavy, 10, "rewrite $1.80 / ($0.09 read + $0.075 thinking)");
1730        assert_eq!(light, 19);
1731    }
1732
1733    #[test]
1734    fn budgeted_thinking_output_is_accounted() {
1735        let u = usage(SONNET, P, 0, 40, 300);
1736        let read_only = usage(SONNET, P, 0, 0, 0);
1737        let thinking_usd = u.cost_usd(SONNET) - read_only.cost_usd(SONNET);
1738        // 300 thinking/output tokens at $15/MTok + 40 input at $3/MTok.
1739        assert!((thinking_usd - (300.0 * 15.0 + 40.0 * 3.0) / 1e6).abs() < 1e-12);
1740        let (pings, ledger, ..) = run(
1741            cfg_max(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, SONNET, 301),
1742            std::slice::from_ref(&u),
1743        );
1744        assert!((ledger - f64::from(pings) * u.cost_usd(SONNET)).abs() < 1e-9);
1745    }
1746
1747    /// Item: budgeted thinking can bill up to `max_tokens` of output. Sonnet
1748    /// 4.6 at xhigh (budget 32,768 → `max_tokens` 32,769) under 5m with a
1749    /// 20k prefix: one worst-case ping ($0.49) costs more than the $0.075
1750    /// rewrite it would save, so no ping is ever sent.
1751    #[test]
1752    fn budgeted_thinking_worst_case_must_fit_the_bound() {
1753        let t0 = Instant::now();
1754        let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(2_000);
1755        let small = usage(SONNET, 20_000, 0, 0, 0);
1756        let err = Period::start(
1757            1,
1758            cfg_max(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, SONNET, 32_769),
1759            7,
1760            &small,
1761            TtlEvidence::default(),
1762            anchor_at(t0, w0),
1763            t0,
1764            w0,
1765        )
1766        .unwrap_err();
1767        match err {
1768            PeriodEnd::PingTooExpensive {
1769                worst_usd,
1770                bound_usd,
1771            } => {
1772                assert!((bound_usd - 0.075).abs() < 1e-9, "{bound_usd}");
1773                assert!(worst_usd > 0.49, "{worst_usd}");
1774            }
1775            other => panic!("{other}"),
1776        }
1777        // A large prefix under 1h does fit: budgeted thinking runs there,
1778        // and the worst case is what every pre-check charges.
1779        let p = start(
1780            cfg_max(CacheKeepWarm::Auto, CacheTtl::OneHour, SONNET, 32_769),
1781            &usage(SONNET, P, 0, 0, 0),
1782            t0,
1783            w0,
1784        );
1785        assert!(p.worst_next_usd() > 0.58 && p.worst_next_usd() < p.bound_usd());
1786    }
1787
1788    #[test]
1789    fn max_session_cost_is_checked_against_the_worst_case() {
1790        let t0 = Instant::now();
1791        let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(100_000);
1792        // Opus, 1h, 300k: a read is $0.15 but budgeted thinking could bill
1793        // 10,001 output tokens ($0.25) on top.
1794        let mut p = start(
1795            cfg_max(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS, 10_001),
1796            &last_read(OPUS),
1797            t0,
1798            w0,
1799        );
1800        let at = p.next_at();
1801        let wat = w0 + (at - t0);
1802        // $4.70 spent, cap $5.00: a read alone fits, the worst case does not.
1803        assert_eq!(
1804            p.fire(at, wat, 4.70, Some(5.0)),
1805            Fire::End(PeriodEnd::CostCap { cap: 5.0 })
1806        );
1807        // $4.50 spent: the read-path worst case ($0.40) would fit, but a miss
1808        // rewriting the prefix at 1h ($3.00) + the thinking ($0.25) would not.
1809        assert_eq!(
1810            p.fire(at, wat, 4.50, Some(5.0)),
1811            Fire::End(PeriodEnd::CostCap { cap: 5.0 })
1812        );
1813        // $1.70 spent: even a full miss (~$3.2503) stays under the cap.
1814        assert_eq!(p.fire(at, wat, 1.70, Some(5.0)), Fire::Ping);
1815    }
1816
1817    // ── "nothing cached" guards ──
1818
1819    /// A prompt below the model's cache minimum: the API returns
1820    /// read = write = 0 (no error). No period starts.
1821    #[test]
1822    fn small_prompt_below_the_cache_minimum_never_starts() {
1823        let t0 = Instant::now();
1824        let w0 = SystemTime::UNIX_EPOCH;
1825        let below_minimum = usage(OPUS, 0, 0, 800, 20);
1826        assert_eq!(
1827            Period::start(
1828                1,
1829                cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1830                7,
1831                &below_minimum,
1832                ev_1h(),
1833                anchor_at(t0, w0),
1834                t0,
1835                w0
1836            )
1837            .unwrap_err(),
1838            PeriodEnd::NothingCached
1839        );
1840    }
1841
1842    fn fired(c: PeriodConfig) -> (Period, Instant, SystemTime) {
1843        let t0 = Instant::now();
1844        let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(90_000);
1845        let mut p = start(c, &last_read(OPUS), t0, w0);
1846        let at = p.next_at();
1847        let wat = w0 + (at - t0);
1848        assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
1849        (p, at, wat)
1850    }
1851
1852    #[test]
1853    fn a_ping_that_cached_nothing_ends_the_period() {
1854        let (mut p, ..) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
1855        assert_eq!(
1856            p.ping_sent(&usage(OPUS, 0, 0, P, 1), None, false),
1857            Err(PeriodEnd::NothingCached)
1858        );
1859    }
1860
1861    #[test]
1862    fn a_ping_paying_for_a_large_uncached_share_ends_the_period() {
1863        // 20% of the prefix came back as uncached input (> 10%).
1864        let (mut p, ..) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
1865        assert_eq!(
1866            p.ping_sent(&usage(OPUS, P - 60_000, 0, 60_000, 1), None, false),
1867            Err(PeriodEnd::UncachedInput {
1868                input: 60_000,
1869                prefix: P
1870            })
1871        );
1872        // A normal ping message (40 tokens) is nowhere near.
1873        let (mut p, ..) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
1874        assert_eq!(p.ping_sent(&usage(OPUS, P, 0, 40, 1), None, false), Ok(()));
1875    }
1876
1877    #[test]
1878    fn a_ping_that_writes_instead_of_reading_ends_the_period() {
1879        let (mut p, ..) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
1880        assert_eq!(
1881            p.ping_sent(&usage(OPUS, 0, P, 40, 1), None, false),
1882            Err(PeriodEnd::CacheMiss { written: P })
1883        );
1884    }
1885
1886    /// After ping 1, a ping that rewrites a large share of the prefix
1887    /// (history no longer cached past some point) ends the period.
1888    #[test]
1889    fn a_partial_miss_after_ping_one_ends_the_period() {
1890        let (mut p, at, wat) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
1891        p.ping_sent(&usage(OPUS, P, 0, 40, 1), Some(anchor_at(at, wat)), false)
1892            .unwrap();
1893        let at = p.next_at();
1894        let wat = wat + (at - p.anchor.mono);
1895        assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
1896        assert_eq!(
1897            p.ping_sent(&usage(OPUS, P - 50_000, 50_000, 40, 1), None, false),
1898            Err(PeriodEnd::PartialMiss {
1899                written: 50_000,
1900                prefix: P
1901            })
1902        );
1903    }
1904
1905    #[test]
1906    fn ping_one_may_write_the_last_reply() {
1907        let t0 = Instant::now();
1908        let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(3_000);
1909        // The last turn: 250k read, 50k-token reply (a long tool result).
1910        let last = usage(OPUS, 250_000, 0, 0, 50_000);
1911        let mut p = start(
1912            cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1913            &last,
1914            t0,
1915            w0,
1916        );
1917        assert_eq!(p.prefix_tokens(), P);
1918        let at = p.next_at();
1919        assert_eq!(p.fire(at, w0 + (at - t0), 0.0, None), Fire::Ping);
1920        assert_eq!(
1921            p.ping_sent(&usage(OPUS, 250_000, 50_000, 40, 1), None, false),
1922            Ok(()),
1923            "the reply's write is expected on ping 1"
1924        );
1925    }
1926
1927    // ── honoured TTL (silent downgrade) ──
1928
1929    #[test]
1930    fn honoured_ttl_needs_evidence_for_1h() {
1931        use CacheTtl::*;
1932        let none = TtlEvidence::default();
1933        assert_eq!(honoured_ttl(FiveMinutes, ev_1h()), FiveMinutes);
1934        assert_eq!(honoured_ttl(Hybrid, ev_1h()), Hybrid);
1935        assert_eq!(honoured_ttl(OneHour, none), FiveMinutes, "no evidence");
1936        assert_eq!(honoured_ttl(OneHour, ev_1h()), OneHour);
1937        let wrote_1h = TtlEvidence {
1938            write_1h: 10,
1939            ..none
1940        };
1941        assert_eq!(honoured_ttl(OneHour, wrote_1h), OneHour);
1942        let wrote_5m = TtlEvidence {
1943            saw_1h: true,
1944            write_5m: 10,
1945            ..none
1946        };
1947        assert_eq!(
1948            honoured_ttl(OneHour, wrote_5m),
1949            FiveMinutes,
1950            "5m-only write"
1951        );
1952        let downgraded = TtlEvidence {
1953            downgraded: true,
1954            write_1h: 10,
1955            saw_1h: true,
1956            ..none
1957        };
1958        assert_eq!(honoured_ttl(OneHour, downgraded), FiveMinutes, "latch wins");
1959    }
1960
1961    /// `cache_ttl = 1h` but the server honours 5m: the period runs on the
1962    /// 5m schedule and is bounded by the 5m rewrite, never sleeping 55 min
1963    /// into a lapsed cache.
1964    #[test]
1965    fn a_silent_downgrade_governs_by_5m() {
1966        let t0 = Instant::now();
1967        let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(4_000);
1968        let downgraded = TtlEvidence {
1969            downgraded: true,
1970            ..TtlEvidence::default()
1971        };
1972        let p = Period::start(
1973            1,
1974            cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1975            7,
1976            &last_read(OPUS),
1977            downgraded,
1978            anchor_at(t0, w0),
1979            t0,
1980            w0,
1981        )
1982        .unwrap();
1983        assert_eq!(p.ttl(), CacheTtl::FiveMinutes);
1984        assert_eq!(p.next_at(), t0 + Duration::from_secs(270));
1985        assert!((p.bound_usd() - 1.875).abs() < 1e-9);
1986
1987        // No evidence at start → 5m; ping 1 writes the reply at 1h → 1h;
1988        // a later ping trips the session latch → back to 5m.
1989        let mut p = Period::start(
1990            2,
1991            cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1992            7,
1993            &usage(OPUS, P - 1_000, 0, 0, 1_000),
1994            TtlEvidence::default(),
1995            anchor_at(t0, w0),
1996            t0,
1997            w0,
1998        )
1999        .unwrap();
2000        assert_eq!(p.ttl(), CacheTtl::FiveMinutes);
2001        let at = p.next_at();
2002        let wat = w0 + (at - t0);
2003        assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2004        let mut first = usage(OPUS, P - 1_000, 1_000, 40, 1);
2005        first.cache_write_1h = Some(1_000);
2006        first.cache_write_5m = Some(0);
2007        p.ping_sent(&first, Some(anchor_at(at, wat)), false)
2008            .unwrap();
2009        assert_eq!(p.ttl(), CacheTtl::OneHour);
2010        assert_eq!(p.next_at(), at + 55 * MIN);
2011        let at = p.next_at();
2012        let wat = wat + 55 * MIN;
2013        assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2014        p.ping_sent(&usage(OPUS, P, 0, 40, 1), Some(anchor_at(at, wat)), true)
2015            .unwrap();
2016        assert_eq!(p.ttl(), CacheTtl::FiveMinutes);
2017        assert_eq!(p.next_at(), at + Duration::from_secs(270));
2018    }
2019
2020    // ── scheduling: 5m, hybrid, anchor, sleep ──
2021
2022    #[test]
2023    fn five_minute_pings_are_scheduled_from_the_anchor() {
2024        let t0 = Instant::now();
2025        let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(5_000);
2026        let c = cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS);
2027        // The turn's last request was accepted at t0; idle a minute later.
2028        let idle = t0 + MIN;
2029        let mut p = Period::start(
2030            1,
2031            c,
2032            7,
2033            &last_read(OPUS),
2034            ev_1h(),
2035            anchor_at(t0, w0),
2036            idle,
2037            w0 + MIN,
2038        )
2039        .unwrap();
2040        assert_eq!(
2041            p.deadline(idle, w0 + MIN),
2042            Some(t0 + Duration::from_secs(270))
2043        );
2044        assert_eq!(
2045            p.fire(
2046                t0 + Duration::from_secs(200),
2047                w0 + Duration::from_secs(200),
2048                0.0,
2049                None
2050            ),
2051            Fire::NotYet
2052        );
2053        let at = t0 + Duration::from_secs(270);
2054        let wat = w0 + Duration::from_secs(270);
2055        assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2056        assert_eq!(p.deadline(at, wat), None, "no second ping while in flight");
2057        assert_eq!(p.fire(at, wat, 0.0, None), Fire::NotYet);
2058        // The ping actually reached the provider 2s after firing.
2059        let sent = anchor_at(at + Duration::from_secs(2), wat + Duration::from_secs(2));
2060        p.ping_sent(&usage(OPUS, P, 0, 0, 1), Some(sent), false)
2061            .unwrap();
2062        assert_eq!(
2063            p.deadline(sent.mono, sent.wall),
2064            Some(sent.mono + Duration::from_secs(270))
2065        );
2066    }
2067
2068    #[test]
2069    fn hybrid_is_governed_by_the_5m_history_marker() {
2070        assert_eq!(governing_ttl(CacheTtl::Hybrid), Duration::from_secs(300));
2071        assert_eq!(governing_ttl(CacheTtl::OneHour), Duration::from_secs(3600));
2072        assert_eq!(ping_interval(CacheTtl::Hybrid), Duration::from_secs(270));
2073        // Priced at the 5m write rate (the entry that lapses), not 1h.
2074        assert!((rewrite_cost_usd(OPUS, CacheTtl::Hybrid, P) - 1.875).abs() < 1e-9);
2075        let (pings, ..) = run(
2076            cfg(CacheKeepWarm::Auto, CacheTtl::Hybrid, OPUS),
2077            &[usage(OPUS, P, 0, 0, 0)],
2078        );
2079        assert_eq!(pings, 12);
2080    }
2081
2082    #[test]
2083    fn a_turn_that_streamed_past_the_interval_pings_at_once_or_not_at_all() {
2084        let t0 = Instant::now();
2085        let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(9_000);
2086        let c = || cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS);
2087        let at = |s: u64| (t0 + Duration::from_secs(s), w0 + Duration::from_secs(s));
2088        let (idle, widle) = at(280);
2089        let p = Period::start(
2090            1,
2091            c(),
2092            7,
2093            &last_read(OPUS),
2094            ev_1h(),
2095            anchor_at(t0, w0),
2096            idle,
2097            widle,
2098        )
2099        .unwrap();
2100        assert_eq!(p.deadline(idle, widle), Some(idle), "due immediately");
2101        let (late, wlate) = at(290);
2102        let err = Period::start(
2103            1,
2104            c(),
2105            7,
2106            &last_read(OPUS),
2107            ev_1h(),
2108            anchor_at(t0, w0),
2109            late,
2110            wlate,
2111        )
2112        .unwrap_err();
2113        assert!(matches!(err, PeriodEnd::Expired { age_secs: 290 }), "{err}");
2114    }
2115
2116    #[test]
2117    fn sleep_past_expiry_ends_the_period_without_pinging() {
2118        let t0 = Instant::now();
2119        let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(50_000);
2120        for ttl in [CacheTtl::FiveMinutes, CacheTtl::OneHour] {
2121            let c = cfg(CacheKeepWarm::Auto, ttl, OPUS);
2122            let mut p = start(c, &last_read(OPUS), t0, w0);
2123            let due = p.next_at();
2124            // Monotonic time says "on schedule"; the wall clock says the
2125            // laptop was closed for 3 hours.
2126            let wall = w0 + Duration::from_secs(3 * 3600);
2127            match p.fire(due, wall, 0.0, None) {
2128                Fire::End(PeriodEnd::Expired { age_secs }) => assert_eq!(age_secs, 3 * 3600),
2129                other => panic!("{ttl:?}: {other:?}"),
2130            }
2131        }
2132    }
2133
2134    /// The machine sleeps 30 min starting at minute 10 of a 1h period. On
2135    /// wake the monotonic clock says minute 10 (deadline 45 min away), the
2136    /// wall clock says minute 40: the deadline moves to minute 55 wall, and
2137    /// the ping fires before the cache expires.
2138    #[test]
2139    fn a_suspend_pulls_the_deadline_forward_by_the_wall_clock() {
2140        let t0 = Instant::now();
2141        let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(60_000);
2142        let mut p = start(
2143            cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
2144            &last_read(OPUS),
2145            t0,
2146            w0,
2147        );
2148        // Wake: monotonic minute 10, wall minute 40.
2149        let (now, wall) = (t0 + 10 * MIN, w0 + 40 * MIN);
2150        assert_eq!(p.deadline(now, wall), Some(now + 15 * MIN));
2151        assert_eq!(p.fire(now, wall, 0.0, None), Fire::NotYet);
2152        // 15 min later (monotonic minute 25, wall minute 55): due, not
2153        // expired (55 min + 15 s guard < 60 min).
2154        let (now, wall) = (now + 15 * MIN, wall + 15 * MIN);
2155        assert_eq!(p.fire(now, wall, 0.0, None), Fire::Ping);
2156        assert_eq!(p.fired_age(), 55 * MIN);
2157    }
2158
2159    #[test]
2160    fn the_actor_polls_the_wall_clock_while_a_period_is_active() {
2161        assert!(WALL_POLL < safety_margin(CacheTtl::FiveMinutes));
2162        let t0 = Instant::now();
2163        let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(61_000);
2164        let mut kw = KeepWarmState::default();
2165        assert_eq!(kw.deadline_at(t0, w0), None, "no period: no timer");
2166        // A 1h period: the ping is 55 min away, the timer wakes at WALL_POLL.
2167        kw.period = Some(start(
2168            cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
2169            &last_read(OPUS),
2170            t0,
2171            w0,
2172        ));
2173        assert_eq!(kw.deadline_at(t0, w0), Some(t0 + WALL_POLL));
2174        // Due sooner than WALL_POLL: the ping deadline itself.
2175        let (near, wnear) = (
2176            t0 + 55 * MIN - Duration::from_secs(3),
2177            w0 + 55 * MIN - Duration::from_secs(3),
2178        );
2179        assert_eq!(kw.deadline_at(near, wnear), Some(t0 + 55 * MIN));
2180        // In flight: no timer (the ping's completion wakes the actor).
2181        let p = kw.period.as_mut().unwrap();
2182        assert_eq!(p.fire(t0 + 55 * MIN, w0 + 55 * MIN, 0.0, None), Fire::Ping);
2183        assert_eq!(kw.deadline_at(t0 + 55 * MIN, w0 + 55 * MIN), None);
2184    }
2185
2186    // ── robustness ──
2187
2188    #[test]
2189    fn transient_errors_back_off_within_the_ttl_then_end() {
2190        let t0 = Instant::now();
2191        let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(70_000);
2192        let mut p = start(
2193            cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
2194            &last_read(OPUS),
2195            t0,
2196            w0,
2197        );
2198        let mut now = p.next_at();
2199        let transient = PingFailure::Transient("HTTP 529".into());
2200        for want in BACKOFF {
2201            let wall = w0 + now.saturating_duration_since(t0);
2202            assert_eq!(p.fire(now, wall, 0.0, None), Fire::Ping);
2203            assert_eq!(p.ping_failed(&transient, now, wall), Ok(want));
2204            assert_eq!(p.deadline(now, wall), Some(now + want));
2205            now += want;
2206        }
2207        let wall = w0 + now.saturating_duration_since(t0);
2208        assert_eq!(p.fire(now, wall, 0.0, None), Fire::Ping);
2209        assert!(matches!(
2210            p.ping_failed(&transient, now, wall),
2211            Err(PeriodEnd::TransientExhausted(_))
2212        ));
2213
2214        // 5m: at 270s only one 5s retry fits before the late guard.
2215        let mut p = start(
2216            cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS),
2217            &last_read(OPUS),
2218            t0,
2219            w0,
2220        );
2221        let at = p.next_at();
2222        let wat = w0 + Duration::from_secs(270);
2223        assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2224        assert_eq!(p.ping_failed(&transient, at, wat), Ok(BACKOFF[0]));
2225        let at = at + BACKOFF[0];
2226        let wat = wat + BACKOFF[0];
2227        assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2228        assert!(matches!(
2229            p.ping_failed(&transient, at, wat),
2230            Err(PeriodEnd::TransientExhausted(_))
2231        ));
2232    }
2233
2234    /// Failed pings that were billed count against the bound too.
2235    #[test]
2236    fn failed_ping_spend_is_in_the_ledger() {
2237        let (mut p, at, wat) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
2238        p.charge(1.80);
2239        assert!(matches!(
2240            p.ping_failed(&PingFailure::Transient("x".into()), at, wat),
2241            Err(PeriodEnd::BoundReached { .. })
2242        ));
2243    }
2244
2245    #[test]
2246    fn a_4xx_ends_the_period() {
2247        let (mut p, at, wat) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS));
2248        let err = p
2249            .ping_failed(&PingFailure::Fatal("HTTP 400".into()), at, wat)
2250            .unwrap_err();
2251        assert_eq!(err, PeriodEnd::Rejected("HTTP 400".into()));
2252    }
2253
2254    #[test]
2255    fn hours_window_and_bound_whichever_first() {
2256        // 1h TTL, 1h window: one ping at 55 min, the next would be at 110.
2257        let (pings, _, _, reason, _) = run(
2258            cfg(CacheKeepWarm::Hours(1), CacheTtl::OneHour, OPUS),
2259            &[usage(OPUS, P, 0, 0, 0)],
2260        );
2261        assert_eq!((pings, reason), (1, PeriodEnd::HoursReached(1)));
2262        // 5m TTL, 2h window: the bound (12 pings, ~54 min) comes first.
2263        let (pings, _, _, reason, _) = run(
2264            cfg(CacheKeepWarm::Hours(2), CacheTtl::FiveMinutes, OPUS),
2265            &[usage(OPUS, P, 0, 0, 0)],
2266        );
2267        assert_eq!(pings, 12);
2268        assert!(matches!(reason, PeriodEnd::BoundReached { .. }));
2269        // A window shorter than the first interval never starts.
2270        let t0 = Instant::now();
2271        let w0 = SystemTime::UNIX_EPOCH;
2272        let mut c = cfg(CacheKeepWarm::Hours(1), CacheTtl::OneHour, OPUS);
2273        c.interval_override = Some(Duration::from_secs(2 * 3600));
2274        assert_eq!(
2275            Period::start(
2276                1,
2277                c,
2278                7,
2279                &last_read(OPUS),
2280                ev_1h(),
2281                anchor_at(t0, w0),
2282                t0,
2283                w0
2284            )
2285            .unwrap_err(),
2286            PeriodEnd::HoursReached(1)
2287        );
2288    }
2289
2290    /// The interval override is reachable only from code (the
2291    /// `testing::IntervalOverride` guard, compiled under `cfg(test)` / the
2292    /// `testing` feature and called by no binary), never from the
2293    /// environment — so a dev `synaps` binary built by `cargo test` with the
2294    /// feature unified in cannot be told to ping faster.
2295    #[test]
2296    #[serial_test::serial(synaps_keepwarm_interval)]
2297    fn the_interval_override_is_set_only_by_code_never_by_the_environment() {
2298        let _env = crate::test_env::EnvVarGuard::set("SYNAPS_CACHE_KEEPWARM_INTERVAL_MS", "250");
2299        assert_eq!(interval_override(), None, "the old env var does nothing");
2300        {
2301            let _g = testing::IntervalOverride::set(Duration::from_millis(250));
2302            assert_eq!(interval_override(), Some(Duration::from_millis(250)));
2303        }
2304        assert_eq!(interval_override(), None, "cleared with the guard");
2305    }
2306
2307    // ── cache epochs (I-1) ──
2308
2309    /// BoundReached → `/keepwarm off` → `auto` in the same epoch: the
2310    /// ledger continues, so the re-enabled period does not start.
2311    #[test]
2312    fn a_re_enabled_period_continues_the_exhausted_ledger() {
2313        let t0 = Instant::now();
2314        let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(80_000);
2315        let c = || cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS);
2316        let mut p = start(c(), &last_read(OPUS), t0, w0);
2317        let read = usage(OPUS, P, 0, 0, 0);
2318        let end = loop {
2319            let at = p.next_at();
2320            let wat = w0 + (at - t0);
2321            match p.fire(at, wat, 0.0, None) {
2322                Fire::Ping => {}
2323                Fire::End(r) => break r,
2324                Fire::NotYet => unreachable!(),
2325            }
2326            p.charge(read.cost_usd(OPUS));
2327            if let Err(r) = p.ping_sent(&read, Some(anchor_at(at, wat)), false) {
2328                break r;
2329            }
2330        };
2331        assert!(matches!(end, PeriodEnd::BoundReached { .. }), "{end}");
2332        let carry = p.epoch_carry();
2333        assert_eq!(carry.pings, 19);
2334        // Re-enabled right after the last ping (its anchor): still bound.
2335        let anchor = p.anchor;
2336        let err = Period::start_in_epoch(
2337            2,
2338            c(),
2339            7,
2340            &last_read(OPUS),
2341            ev_1h(),
2342            anchor,
2343            anchor.mono,
2344            anchor.wall,
2345            Some(&carry),
2346        )
2347        .unwrap_err();
2348        assert!(matches!(err, PeriodEnd::BoundReached { .. }), "{err}");
2349        // A new epoch (no carry) would have started afresh.
2350        assert!(Period::start_in_epoch(
2351            3,
2352            c(),
2353            7,
2354            &last_read(OPUS),
2355            ev_1h(),
2356            anchor,
2357            anchor.mono,
2358            anchor.wall,
2359            None
2360        )
2361        .is_ok());
2362    }
2363
2364    /// Ping 1's write allowance belongs to the epoch: a period resumed after
2365    /// ping 1 never re-grants it, and the ledger carries over.
2366    #[test]
2367    fn ping_one_write_allowance_is_never_re_granted_in_an_epoch() {
2368        let t0 = Instant::now();
2369        let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(81_000);
2370        let c = || cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS);
2371        let last = usage(OPUS, 250_000, 0, 0, 50_000);
2372        let mut p = start(c(), &last, t0, w0);
2373        let at = p.next_at();
2374        let wat = w0 + (at - t0);
2375        assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2376        let first = usage(OPUS, 250_000, 50_000, 40, 1);
2377        p.charge(first.cost_usd(OPUS));
2378        p.ping_sent(&first, Some(anchor_at(at, wat)), false)
2379            .unwrap();
2380        let carry = p.epoch_carry();
2381        // `/keepwarm off` then `auto`: same epoch.
2382        let mut q = Period::start_in_epoch(
2383            2,
2384            c(),
2385            7,
2386            &last,
2387            ev_1h(),
2388            anchor_at(at, wat),
2389            at,
2390            wat,
2391            Some(&carry),
2392        )
2393        .unwrap();
2394        assert_eq!(q.pings(), 1);
2395        assert!((q.ledger_usd() - first.cost_usd(OPUS)).abs() < 1e-12);
2396        let at2 = q.next_at();
2397        let wat2 = wat + (at2 - at);
2398        assert_eq!(q.fire(at2, wat2, 0.0, None), Fire::Ping);
2399        // Writing the reply again is now a partial miss, not ping 1.
2400        assert_eq!(
2401            q.ping_sent(&usage(OPUS, 250_000, 50_000, 40, 1), None, false),
2402            Err(PeriodEnd::PartialMiss {
2403                written: 50_000,
2404                prefix: P
2405            })
2406        );
2407    }
2408
2409    // ── auto's maximum period length (M-1) ──
2410
2411    /// Pure 1h reads per cache-read multiplier: the ski-rental bound alone
2412    /// gives 19 (0.1×), 39 (0.05×) and 79 (0.025×) pings; `auto`'s 24h cap
2413    /// stops the latter two at 26 (the 27th would be due at 24h45m).
2414    #[test]
2415    fn ping_counts_per_multiplier_and_the_auto_cap() {
2416        for (model, uncapped, capped) in [
2417            ("claude-opus-4-7", 19, 19),
2418            ("claude-opus-5-5", 39, 26),
2419            ("claude-fable-5-1", 79, 26),
2420        ] {
2421            let mut c = cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, model);
2422            let (pings, _, _, reason, elapsed) = run(c.clone(), &[usage(model, P, 0, 0, 0)]);
2423            assert_eq!(pings, uncapped, "{model}");
2424            assert!(
2425                matches!(reason, PeriodEnd::BoundReached { .. }),
2426                "{model}: {reason}"
2427            );
2428            assert_eq!(elapsed, uncapped * 55 * MIN, "{model}");
2429            c.auto_max_hours = 24;
2430            let (pings, _, _, reason, _) = run(c, &[usage(model, P, 0, 0, 0)]);
2431            assert_eq!(pings, capped, "{model}");
2432            if capped < uncapped {
2433                assert_eq!(reason, PeriodEnd::HoursReached(24), "{model}");
2434            }
2435        }
2436        // 5m: 12 (0.1×), 24 (0.05×), 49 (0.025×) pings, all well under 24h.
2437        for (model, want) in [
2438            ("claude-opus-4-7", 12),
2439            ("claude-opus-5-5", 24),
2440            ("claude-fable-5-1", 49),
2441        ] {
2442            let mut c = cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, model);
2443            c.auto_max_hours = 24;
2444            let (pings, ..) = run(c, &[usage(model, P, 0, 0, 0)]);
2445            assert_eq!(pings, want, "{model} 5m");
2446        }
2447        // An explicit <N>h window ignores the auto cap.
2448        let mut c = cfg(
2449            CacheKeepWarm::Hours(48),
2450            CacheTtl::OneHour,
2451            "claude-fable-5-1",
2452        );
2453        c.auto_max_hours = 24;
2454        let (pings, ..) = run(c, &[usage("claude-fable-5-1", P, 0, 0, 0)]);
2455        assert_eq!(pings, 52, "48h window: 52 pings at 55 min");
2456    }
2457
2458    // ── ping 1's write allowance (N-3) ──
2459
2460    #[test]
2461    fn first_write_counts_only_what_the_prefix_will_hold() {
2462        let t0 = Instant::now();
2463        let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(82_000);
2464        // The last request read 250k, sent 1k uncached and billed 40k
2465        // output — of which the reply text is ~2k; the rest was thinking,
2466        // which the API strips on resend.
2467        let last = usage(OPUS, 250_000, 0, 1_000, 40_000);
2468        let mut c = cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS);
2469        let billed = start(c.clone(), &last, t0, w0);
2470        assert_eq!(billed.prefix_tokens(), 291_000, "without the estimate");
2471        c.reply_tokens = Some(2_000);
2472        let p = start(c.clone(), &last, t0, w0);
2473        assert_eq!(p.prefix_tokens(), 253_000);
2474        // Never more than was billed.
2475        c.reply_tokens = Some(90_000);
2476        assert_eq!(start(c, &last, t0, w0).prefix_tokens(), 291_000);
2477    }
2478
2479    #[test]
2480    fn status_renders_spend_and_period() {
2481        let s = serde_json::json!({
2482            "mode": "auto",
2483            "active": true,
2484            "period": {"id": 3, "ttl_secs": 300, "pings": 2, "ledger_usd": 0.3,
2485                       "bound_usd": 1.875, "prefix_tokens": P, "next_ping_in_secs": 120,
2486                       "in_flight": false},
2487            "prefix_matches": true,
2488            "session_pings": 5,
2489            "session_keepwarm_usd": 0.75,
2490            "last_end": "prefix changed",
2491        });
2492        let text = render_status(&s);
2493        assert!(text.contains("auto"), "{text}");
2494        assert!(text.contains("5 pings, $0.7500"), "{text}");
2495        assert!(text.contains("ping 3 due in 120s"), "{text}");
2496        assert!(text.contains("$0.3000 spent of $1.8750 bound"), "{text}");
2497        assert!(text.contains("prefix unchanged"), "{text}");
2498        assert!(text.contains("last: prefix changed"), "{text}");
2499    }
2500}