Skip to main content

pitboard_core/warm/
mod.rs

1//! Warming up an account: starting its next five-hour period as soon as the last one resets,
2//! so that period resets as early as it can and no time between periods goes unused.
3//!
4//! A five-hour period starts with an account's first request after the last one reset, and
5//! resets five hours after the start of the ten minutes that request fell in (the register's
6//! `request_starts_the_five_hour_period`). An account nobody uses for a while after a
7//! reset starts its next period only when somebody does, and that period resets that much
8//! later. Where somebody asks for it, Pitboard starts the period at once instead, through
9//! Claude Code itself: one short request with the account's own login, made by the tool's
10//! own headless mode. Pitboard never sends a model request of its own.
11//!
12//! Asked for, account by account, and only within a span of each day somebody chose, 06:00
13//! to 22:00 unless they chose otherwise, so periods run back to back through the hours
14//! somebody works and none is started for a night nobody uses. The weekly limit is a fixed
15//! cycle that runs whether or not anything is sent (`weekly_period_is_a_fixed_cycle`),
16//! so there is nothing to start there. Claude Code only: when a Codex period starts has not
17//! been measured.
18//!
19//! Which accounts, and the span, are kept in `warm.json` in Pitboard's directory, read by
20//! every front end alike.
21
22pub(crate) mod decide;
23pub mod run;
24
25pub use decide::{Hold, Standing};
26
27use crate::context::Context;
28use crate::error::{Error, Result};
29use crate::host::Os;
30use crate::provider::ProviderId;
31use crate::service::Permit;
32use crate::state::State;
33use crate::{atomic, home};
34use jiff::Timestamp;
35use jiff::tz::TimeZone;
36use serde::{Deserialize, Serialize};
37use std::collections::BTreeSet;
38use std::path::PathBuf;
39
40const DAY: u16 = 24 * 60;
41
42/// A span of every day, in this machine's time zone, within which Pitboard warms up the
43/// accounts somebody turned it on for. From `from` up to `to`, in minutes from midnight; a
44/// span whose `from` is later than its `to` runs past midnight, and 00:00 to 24:00 is all day.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct Range {
47    from: u16,
48    to: u16,
49}
50
51impl Range {
52    pub const DEFAULT: Range = Range {
53        from: 6 * 60,
54        to: 22 * 60,
55    };
56
57    /// `None` where the span is empty, or either end is not a time of day. `to` may be 24:00,
58    /// and is where it is 00:00, as a clock shows the end of a day.
59    pub fn new(from: u16, to: u16) -> Option<Range> {
60        let to = if to == 0 { DAY } else { to };
61        (from < DAY && to <= DAY && from != to).then_some(Range { from, to })
62    }
63
64    /// From two times of day as a person writes them, `06:00` and `22:00`.
65    pub fn parse(from: &str, to: &str) -> std::result::Result<Range, String> {
66        let from = minutes(from).filter(|&m| m < DAY);
67        let to = minutes(to);
68        match (from, to) {
69            (Some(from), Some(to)) => Range::new(from, to)
70                .ok_or_else(|| "the span starts and ends at the same time".to_owned()),
71            _ => Err("give each time as hours and minutes, such as 06:00 or 22:30".to_owned()),
72        }
73    }
74
75    pub fn from(self) -> u16 {
76        self.from
77    }
78
79    pub fn to(self) -> u16 {
80        self.to
81    }
82
83    /// `minute` of the day falls within it.
84    pub fn contains(self, minute: u16) -> bool {
85        if self.from < self.to {
86            (self.from..self.to).contains(&minute)
87        } else {
88            minute >= self.from || minute < self.to
89        }
90    }
91
92    /// Whether `now` falls within it, in `zone`.
93    pub(crate) fn holds(self, now: i64, zone: &TimeZone) -> bool {
94        local_minute(now, zone).is_some_and(|minute| self.contains(minute))
95    }
96
97    /// The next time it opens after `now`, in `zone`. On a day a clock change skips the
98    /// opening time, it opens as the clock comes back to it, later that hour.
99    pub(crate) fn opens_after(self, now: i64, zone: &TimeZone) -> Option<i64> {
100        let today = Timestamp::from_second(now)
101            .ok()?
102            .to_zoned(zone.clone())
103            .date();
104        let at = |date: jiff::civil::Date| {
105            date.at(
106                i8::try_from(self.from / 60).ok()?,
107                i8::try_from(self.from % 60).ok()?,
108                0,
109                0,
110            )
111            .to_zoned(zone.clone())
112            .ok()
113            .map(|zoned| zoned.timestamp().as_second())
114        };
115        at(today)
116            .filter(|&opens| opens > now)
117            .or_else(|| at(today.tomorrow().ok()?))
118    }
119}
120
121impl Default for Range {
122    fn default() -> Range {
123        Range::DEFAULT
124    }
125}
126
127impl std::fmt::Display for Range {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        write!(f, "{} to {}", clock(self.from), clock(self.to))
130    }
131}
132
133/// `06:30` as minutes from midnight. `24:00` is a day's end.
134fn minutes(text: &str) -> Option<u16> {
135    let (hours, minutes) = text.trim().split_once(':')?;
136    let all_digits = |part: &str| part.len() == 2 && part.bytes().all(|b| b.is_ascii_digit());
137    if !all_digits(hours) || !all_digits(minutes) {
138        return None;
139    }
140    let (hours, minutes): (u16, u16) = (hours.parse().ok()?, minutes.parse().ok()?);
141    let total = hours * 60 + minutes;
142    (minutes < 60 && total <= DAY).then_some(total)
143}
144
145/// Minutes from midnight as a clock shows them, `06:30`.
146pub fn clock(minutes: u16) -> String {
147    format!("{:02}:{:02}", minutes / 60, minutes % 60)
148}
149
150/// The minute of the day `now` falls in, in `zone`.
151fn local_minute(now: i64, zone: &TimeZone) -> Option<u16> {
152    let zoned = Timestamp::from_second(now).ok()?.to_zoned(zone.clone());
153    u16::try_from(i32::from(zoned.hour()) * 60 + i32::from(zoned.minute())).ok()
154}
155
156/// Why a warm-up that ran, or was about to, started no period.
157#[derive(Debug, Clone, PartialEq, Eq)]
158#[non_exhaustive]
159pub enum Failure {
160    /// A limit of the account is used up, and Anthropic refused the request.
161    LimitReached,
162    /// Anthropic refused the login's access token.
163    LoginRefused,
164    /// The request was answered, and Anthropic's next reading still has no period running.
165    NotStarted,
166    /// Anthropic could not be reached, or was overloaded.
167    Unreachable,
168    /// The tool's run did not finish in time.
169    TimedOut,
170    /// The tool's run failed, for this reason in its own words.
171    Failed(String),
172}
173
174impl Failure {
175    /// A stable code, for `--json` and for telling one apart from another.
176    pub fn code(&self) -> &'static str {
177        match self {
178            Failure::LimitReached => "warm_up_limit_reached",
179            Failure::LoginRefused => "warm_up_login_refused",
180            Failure::NotStarted => "warm_up_not_started",
181            Failure::Unreachable => "warm_up_unreachable",
182            Failure::TimedOut => "warm_up_timed_out",
183            Failure::Failed(_) => "warm_up_failed",
184        }
185    }
186
187    /// Whether waiting may mend it, so it is not counted against the day's attempts: a
188    /// service out of reach, a run cut short, or a limit used up until it resets.
189    pub(crate) fn mended_by_waiting(&self) -> bool {
190        matches!(
191            self,
192            Failure::LimitReached | Failure::Unreachable | Failure::TimedOut
193        )
194    }
195}
196
197/// Which accounts Pitboard warms up, by account id, and within which span of each day.
198#[derive(Debug, Clone, Default, PartialEq, Eq)]
199pub struct Setting {
200    pub range: Range,
201    accounts: BTreeSet<String>,
202}
203
204impl Setting {
205    /// Whether the account with `id` is warmed up.
206    pub fn is_on(&self, id: &str) -> bool {
207        self.accounts.contains(id)
208    }
209
210    /// Whether any account is.
211    pub fn any(&self) -> bool {
212        !self.accounts.is_empty()
213    }
214
215    /// Warm up the account with `id`, or stop. Whether that changed anything.
216    pub(crate) fn turn(&mut self, id: &str, on: bool) -> bool {
217        if on {
218            self.accounts.insert(id.to_owned())
219        } else {
220            self.accounts.remove(id)
221        }
222    }
223}
224
225/// Why no account is warmed up on this machine for now, whatever each one's period says.
226#[derive(Debug, Clone, PartialEq, Eq)]
227#[non_exhaustive]
228pub enum Halt {
229    /// Pitboard warms up nothing on this system yet.
230    Unbuilt,
231    /// A switch was interrupted, and the change that finishes it is somebody's to make.
232    SwitchInterrupted,
233    /// Claude Code keeps its login under another name, which Pitboard does not act on.
234    CustomOauth,
235    /// Claude Code's managed settings make every run of it sign in another way, set by these,
236    /// so a warm-up would send a request on that rather than on an account's login.
237    Overridden(Vec<String>),
238    /// Claude Code is not installed where Pitboard looks.
239    ProgramMissing,
240}
241
242impl Halt {
243    /// A stable code, for `--json` and for telling one apart from another.
244    pub fn code(&self) -> &'static str {
245        match self {
246            Halt::Unbuilt => "warm_up_unbuilt",
247            Halt::SwitchInterrupted => "switch_interrupted",
248            Halt::CustomOauth => "custom_oauth_endpoint",
249            Halt::Overridden(_) => "warm_up_auth_overridden",
250            Halt::ProgramMissing => "claude_not_found",
251        }
252    }
253}
254
255/// Why nothing is warmed up here now, where something says so, asked in the order a change
256/// would refuse over it.
257pub(crate) fn halted(ctx: &Context) -> Option<Halt> {
258    match crate::host::OS {
259        Os::Windows => return Some(Halt::Unbuilt),
260        Os::MacOs | Os::Linux => {}
261    }
262    if crate::switch::unfinished(ctx) == Some(crate::switch::Unfinished::Interrupted) {
263        return Some(Halt::SwitchInterrupted);
264    }
265    if crate::settings::custom_oauth(ctx) {
266        return Some(Halt::CustomOauth);
267    }
268    let overridden = overridden(ctx);
269    if !overridden.is_empty() {
270        return Some(Halt::Overridden(overridden));
271    }
272    crate::provider::of(ProviderId::Claude)
273        .program(ctx)
274        .is_none()
275        .then_some(Halt::ProgramMissing)
276}
277
278/// What would make a warm-up's run sign in with something other than the access token it is
279/// handed: the managed settings every run of Claude Code here reads, or anything in its own
280/// config directory. Not this process's environment, which the run is never given.
281pub(crate) fn overridden(ctx: &Context) -> Vec<String> {
282    let config = run::home(ctx, ProviderId::Claude).join("config");
283    crate::settings::overrides_of_a_run_in(&config)
284        .iter()
285        .map(ToString::to_string)
286        .collect()
287}
288
289/// Each Claude Code account, whether Pitboard warms it up and where it stands, and the span,
290/// for a front end to show, with why nothing is warmed up here where something says so.
291#[derive(Debug, Clone, PartialEq)]
292pub struct Overview {
293    pub range: Range,
294    pub accounts: Vec<Choice>,
295    pub halted: Option<Halt>,
296}
297
298/// One Claude Code account, as it is typed, whether Pitboard warms it up, and where it stands
299/// where it does. `now` is where it stands for a warm-up somebody asks for now, which the span
300/// of the day and today's attempts do not hold back: `Due` where one would start its period.
301#[derive(Debug, Clone, PartialEq)]
302pub struct Choice {
303    pub account: String,
304    /// Its label with its tool, `claude/work`, as a front end asks for it.
305    pub qualified: String,
306    pub on: bool,
307    pub standing: Option<Standing>,
308    pub now: Standing,
309}
310
311impl Overview {
312    /// The soonest moment a warm-up is due, of every account turned on, and that account:
313    /// now where one is due.
314    pub fn next(&self, now: i64) -> Option<(&str, i64)> {
315        self.accounts
316            .iter()
317            .filter_map(|choice| {
318                let at = match choice.standing.as_ref()? {
319                    Standing::Due => now,
320                    Standing::Running { next, .. } => (*next)?,
321                    Standing::Resting { at } => *at,
322                    Standing::Waiting { until } => *until,
323                    Standing::Held(_) => return None,
324                };
325                Some((choice.account.as_str(), at))
326            })
327            .min_by_key(|(_, at)| *at)
328    }
329}
330
331/// The Claude Code account whose login Claude Code has stored, as Pitboard last knew it.
332pub(crate) fn in_use(ctx: &Context, state: &State) -> Option<crate::state::Key> {
333    crate::in_use::known(ctx, state, ProviderId::Claude)
334        .owner()
335        .and_then(|owner| state.account_of(ProviderId::Claude, owner))
336        .map(crate::state::Account::key)
337}
338
339/// What `setting` says of each Claude Code account `state` has, in the order they were
340/// enrolled, and where each one turned on stands, from Pitboard's files alone.
341pub(crate) fn overview(ctx: &Context, state: &State, setting: &Setting) -> Overview {
342    let now = ctx.now();
343    let zone = ctx.zone();
344    let in_use = in_use(ctx, state);
345    let readings = crate::readings::load(ctx);
346    let ledger = decide::Ledger::load(ctx);
347    Overview {
348        range: setting.range,
349        halted: setting.any().then(|| halted(ctx)).flatten(),
350        accounts: state
351            .accounts
352            .iter()
353            .filter(|account| account.provider() == ProviderId::Claude)
354            .map(|account| {
355                let on = setting.is_on(&account.id);
356                let standing = |asked| {
357                    decide::decide(
358                        decide::Login::of(account, in_use.as_ref(), now),
359                        readings.get(&account.id),
360                        ledger.get(&account.id),
361                        setting.range,
362                        asked,
363                        now,
364                        &zone,
365                    )
366                };
367                Choice {
368                    account: state.typed(&account.key()),
369                    qualified: account.key().qualified(),
370                    on,
371                    standing: on.then(|| standing(decide::Asked::ByItself)),
372                    now: standing(decide::Asked::Now),
373                }
374            })
375            .collect(),
376    }
377}
378
379/// What a warm-up would come to now, from Pitboard's files alone: no lock, no keychain and no
380/// network, so a look that finds nothing due costs nobody anything. `Act` where an account
381/// turned on is due, which only the decision under the lock says for certain.
382pub(crate) fn look(ctx: &Context, state: &State) -> Result<crate::duty::Look<Overview>> {
383    let setting = load(ctx)?;
384    let overview = overview(ctx, state, &setting);
385    let due = overview.halted.is_none()
386        && overview
387            .accounts
388            .iter()
389            .any(|choice| choice.standing == Some(Standing::Due));
390    Ok(if due {
391        crate::duty::Look::Act
392    } else {
393        crate::duty::Look::Stands(Box::new(overview))
394    })
395}
396
397/// The format of `warm.json` this Pitboard writes.
398const VERSION: u32 = 1;
399
400/// `warm.json` as written.
401#[derive(Debug, Serialize, Deserialize)]
402struct Kept {
403    version: u32,
404    range: KeptRange,
405    #[serde(default)]
406    accounts: Vec<String>,
407}
408
409#[derive(Debug, Serialize, Deserialize)]
410struct KeptRange {
411    from: String,
412    to: String,
413}
414
415pub(crate) fn path(ctx: &Context) -> PathBuf {
416    home::dir(ctx).join("warm.json")
417}
418
419/// What `warm.json` says: nobody, and the default span, where it is not there.
420pub fn load(ctx: &Context) -> Result<Setting> {
421    let path = path(ctx);
422    let raw = match std::fs::read_to_string(&path) {
423        Ok(raw) => raw,
424        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Setting::default()),
425        Err(source) => return Err(Error::WarmSettingUnreadable { path, source }),
426    };
427    let corrupt = |detail: String| Error::WarmSettingCorrupt {
428        path: path.clone(),
429        detail,
430    };
431    let version = serde_json::from_str::<serde_json::Value>(&raw)
432        .map_err(|e| corrupt(e.to_string()))?
433        .get("version")
434        .and_then(serde_json::Value::as_u64)
435        .ok_or_else(|| corrupt("it names no version".into()))?;
436    if version > u64::from(VERSION) {
437        return Err(Error::StateFromNewerVersion {
438            path,
439            found: u32::try_from(version).unwrap_or(u32::MAX),
440            expected: VERSION,
441        });
442    }
443    let kept: Kept = serde_json::from_str(&raw).map_err(|e| corrupt(e.to_string()))?;
444    let range = Range::parse(&kept.range.from, &kept.range.to).map_err(corrupt)?;
445    Ok(Setting {
446        range,
447        accounts: kept.accounts.into_iter().collect(),
448    })
449}
450
451/// `setting`, written whole to `warm.json`. Only under the lock every change takes.
452pub(crate) fn save(ctx: &Context, permit: Permit, setting: &Setting) -> Result<()> {
453    let path = path(ctx);
454    let kept = Kept {
455        version: VERSION,
456        range: KeptRange {
457            from: clock(setting.range.from),
458            to: clock(setting.range.to),
459        },
460        accounts: setting.accounts.iter().cloned().collect(),
461    };
462    let failed = |source| Error::StateWriteFailed {
463        path: path.clone(),
464        source,
465    };
466    let body = serde_json::to_vec(&kept).map_err(|e| failed(std::io::Error::other(e)))?;
467    home::ensure(ctx, permit).map_err(failed)?;
468    atomic::write(permit, &path, &body, atomic::Perms::Secret).map_err(failed)
469}
470
471/// Stop warming up an account nobody is enrolled as any more, and drop what was tried. A file
472/// that cannot be read or written keeps the id, which names no account and so warms up
473/// nothing.
474pub(crate) fn forget(ctx: &Context, permit: Permit, id: &str) {
475    if let Ok(mut setting) = load(ctx)
476        && setting.turn(id, false)
477    {
478        let _ = save(ctx, permit, &setting);
479    }
480    let mut ledger = decide::Ledger::load(ctx);
481    if ledger.forget(id) {
482        let _ = ledger.save(ctx, permit);
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    const HOUR: i64 = 3_600;
491
492    /// 2026-10-12 00:00 at UTC+7.
493    const MIDNIGHT: i64 = 1_791_738_000;
494
495    fn saigon() -> TimeZone {
496        TimeZone::fixed(jiff::tz::offset(7))
497    }
498
499    #[test]
500    fn a_span_reads_as_a_person_writes_it() {
501        let range = Range::parse("06:00", "22:00").expect("a span");
502        assert_eq!(range, Range::DEFAULT);
503        assert_eq!(range.to_string(), "06:00 to 22:00");
504        assert_eq!(
505            Range::parse("00:00", "24:00").map(|r| (r.from(), r.to())),
506            Ok((0, 1440))
507        );
508        assert!(Range::parse("22:00", "06:00").is_ok(), "past midnight");
509        assert_eq!(
510            Range::parse("00:00", "00:00"),
511            Range::parse("00:00", "24:00")
512        );
513        assert_eq!(
514            Range::parse("22:00", "00:00").map(|r| r.to_string()),
515            Ok("22:00 to 24:00".to_owned())
516        );
517        for (from, to) in [
518            ("06:00", "06:00"),
519            ("24:00", "06:00"),
520            ("6:00", "22:00"),
521            ("06:60", "22:00"),
522            ("06:00", "24:01"),
523            ("noon", "22:00"),
524        ] {
525            assert!(Range::parse(from, to).is_err(), "{from} to {to}");
526        }
527    }
528
529    #[test]
530    fn a_span_holds_from_its_start_up_to_its_end() {
531        let day = Range::DEFAULT;
532        assert!(!day.contains(359));
533        assert!(day.contains(360));
534        assert!(day.contains(1319));
535        assert!(!day.contains(1320));
536        let night = Range::parse("22:00", "06:00").expect("a span");
537        assert!(night.contains(1320));
538        assert!(night.contains(0));
539        assert!(night.contains(359));
540        assert!(!night.contains(360));
541        let all = Range::parse("00:00", "24:00").expect("a span");
542        assert!(all.contains(0) && all.contains(1439));
543    }
544
545    #[test]
546    fn a_span_opens_today_or_else_tomorrow_in_the_zone_given() {
547        let zone = saigon();
548        let day = Range::DEFAULT;
549        assert!(!day.holds(MIDNIGHT + 5 * HOUR, &zone));
550        assert!(day.holds(MIDNIGHT + 6 * HOUR, &zone));
551        assert_eq!(
552            day.opens_after(MIDNIGHT + 5 * HOUR, &zone),
553            Some(MIDNIGHT + 6 * HOUR)
554        );
555        assert_eq!(
556            day.opens_after(MIDNIGHT + 6 * HOUR, &zone),
557            Some(MIDNIGHT + 30 * HOUR),
558            "once open, the next opening is tomorrow's"
559        );
560        assert_eq!(
561            day.opens_after(MIDNIGHT + 23 * HOUR, &zone),
562            Some(MIDNIGHT + 30 * HOUR)
563        );
564    }
565
566    #[test]
567    fn a_span_skipped_by_a_clock_change_opens_as_the_clock_comes_back_to_it() {
568        // 2026-03-08 in New York's rules: 02:00 does not happen; clocks go from 01:59 to 03:00.
569        let zone = TimeZone::posix("EST5EDT,M3.2.0,M11.1.0").expect("a rule jiff reads");
570        let range = Range::parse("02:30", "05:00").expect("a span");
571        let before = jiff::civil::date(2026, 3, 8)
572            .at(1, 0, 0, 0)
573            .to_zoned(zone.clone())
574            .expect("a time")
575            .timestamp()
576            .as_second();
577        let opens = range.opens_after(before, &zone).expect("it opens");
578        assert_eq!(opens - before, HOUR + 30 * 60, "03:30 by the clock");
579    }
580}