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