Skip to main content

pitboard_core/
autoswitch.rs

1//! Switching Claude Code by itself, before the account in use runs out.
2//!
3//! An agent working through a task stops when its account reaches a limit, while another of
4//! the same person's accounts may sit there with room. So, where somebody asks for it,
5//! Pitboard switches Claude Code to another of their accounts once any limit of the one in
6//! use reaches a share they chose, 95% unless they chose otherwise. Before the limit and not
7//! at it: a session already running follows a switch within about half a minute
8//! ([`crate::switch::ADOPTION_CEILING_SECONDS`]), and one that has reached its limit has
9//! already stopped.
10//!
11//! Asked for, and never by default: the menu bar app's setting, or `pitboard watch` running
12//! in a terminal. This is the one part of Pitboard that picks an account without anybody
13//! choosing it, so it is something a person turns on knowing what it is.
14//!
15//! Claude Code only. A running `codex` never follows a switch, so switching it would leave
16//! the agent it was meant to keep going on the account that ran out.
17//!
18//! It decides from what Pitboard already knows, the readings every front end and every
19//! status line record, and asks nobody anything to decide. What it never does:
20//!
21//! - switch to an account without room below the share in every limit it reports;
22//! - switch back by itself. The next time the account in use reaches the share, the best
23//!   account there is is chosen again, which may be the one it left;
24//! - switch away from one limit of an account twice before that limit resets. Somebody who
25//!   put it back in use meant to;
26//! - switch away from an account within [`SETTLING_SECONDS`] of its being put in use, by
27//!   anyone;
28//! - try more than [`ATTEMPTS`] times for one limit before it resets, counting only the
29//!   attempts that failed for a reason trying again will not mend. One that might, such as
30//!   Anthropic out of reach, is tried again later each time, up to [`RETRY_MOST_SECONDS`].
31
32use crate::context::Context;
33use crate::provider::ProviderId;
34use crate::service::Permit;
35use crate::state::{Key, State};
36use crate::status::Row;
37use crate::usage::{Snapshot, Window, same_reset};
38use crate::{atomic, home};
39use serde::{Deserialize, Serialize};
40use std::cmp::Ordering;
41use std::collections::BTreeMap;
42use std::path::PathBuf;
43
44/// The share of a limit at which Pitboard switches, as a whole percentage.
45///
46/// Never 100: an account at its limit has already stopped whatever was working in it, and
47/// the app already says so then. Never below 50, where half of every account would be left
48/// unused.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct Threshold(u8);
51
52impl Threshold {
53    pub const LOWEST: u8 = 50;
54    pub const HIGHEST: u8 = 99;
55    pub const DEFAULT: Threshold = Threshold(95);
56
57    /// `None` outside [`Threshold::LOWEST`] to [`Threshold::HIGHEST`].
58    pub fn new(percent: u8) -> Option<Threshold> {
59        (Threshold::LOWEST..=Threshold::HIGHEST)
60            .contains(&percent)
61            .then_some(Threshold(percent))
62    }
63
64    /// The nearest share there can be, for one kept by an earlier or a later version.
65    pub fn clamped(percent: i64) -> Threshold {
66        let lowest = i64::from(Threshold::LOWEST);
67        let highest = i64::from(Threshold::HIGHEST);
68        Threshold(u8::try_from(percent.clamp(lowest, highest)).unwrap_or(Threshold::DEFAULT.0))
69    }
70
71    pub const fn percent(self) -> u8 {
72        self.0
73    }
74
75    fn reached_by(self, window: &Window, now: i64) -> bool {
76        window.used(now) >= f64::from(self.0)
77    }
78}
79
80impl Default for Threshold {
81    fn default() -> Threshold {
82        Threshold::DEFAULT
83    }
84}
85
86/// How long an account put in use is left alone, by anyone's switch: as long as the app
87/// waits between reads of every account, so the account switched to has been asked about
88/// once before anything could switch away from it again. A switch that turned out to land
89/// on an account fuller than its last reading said is then corrected, and two switches are
90/// never made from one look at the numbers.
91pub const SETTLING_SECONDS: i64 = 300;
92
93/// How long after an attempt that came to nothing Pitboard tries again: long enough for a
94/// service that could not be reached, or a lock Claude Code held, to have come back. Twice
95/// as long after each attempt in a row that failed for a reason trying again may mend.
96pub const RETRY_SECONDS: i64 = 60;
97
98/// The longest it waits between attempts, however long the outage: as long as a usage check
99/// waits on a service that cannot be reached at most (`budget`).
100pub const RETRY_MOST_SECONDS: i64 = 900;
101
102/// Attempts at switching away from one limit of one account before it resets, of those that
103/// failed for a reason trying again will not mend: a switch refused this often for such a
104/// reason is not going to be made.
105pub const ATTEMPTS: u32 = 3;
106
107/// How long to wait after an attempt, `waits` attempts in a row having failed for a reason
108/// trying again may mend.
109fn retry_after(waits: u32) -> i64 {
110    (RETRY_SECONDS << waits.saturating_sub(1).min(4)).min(RETRY_MOST_SECONDS)
111}
112
113/// What Pitboard switches by itself, as it decided from what it knows.
114#[derive(Debug, Clone, PartialEq)]
115pub struct Plan {
116    /// The account in use, and its id, which the switch checks is still the one signed in.
117    pub from: Key,
118    pub from_id: String,
119    /// The account to switch to.
120    pub to: Key,
121    /// The limit of `from` that reached the share, as last read.
122    pub limit: Window,
123}
124
125impl Plan {
126    /// Whether `other` is this switch: the same accounts, for the same window of the same
127    /// limit, however far that limit has moved since.
128    pub(crate) fn same(&self, other: &Plan) -> bool {
129        self.from == other.from
130            && self.from_id == other.from_id
131            && self.to == other.to
132            && self.limit.same_limit(&other.limit)
133            && same_reset(
134                self.limit.resets_at.unwrap_or(0),
135                other.limit.resets_at.unwrap_or(0),
136            )
137    }
138}
139
140/// What Pitboard would do about the Claude Code account in use, from what it knows now.
141#[derive(Debug, Clone, PartialEq)]
142pub(crate) enum Decision {
143    /// Nothing: no limit has reached the share, or Pitboard has already acted on it, or is
144    /// waiting to.
145    Stay,
146    Switch(Plan),
147    /// A limit reached the share, and no other account has room.
148    NoRoom {
149        from: Key,
150        limit: Window,
151    },
152}
153
154/// What a look came to, for a front end to say.
155#[derive(Debug)]
156pub enum Auto {
157    /// Nothing to do now.
158    Idle,
159    /// Claude Code switched from `from` to `to`, as each is typed, because `limit` of
160    /// `from` reached the share.
161    Switched {
162        from: String,
163        to: String,
164        limit: Window,
165        adoption: crate::provider::Adoption,
166    },
167    /// `limit` of `from` reached the share, and no other account has room below it in every
168    /// limit.
169    NoRoom { from: String, limit: Window },
170    /// `limit` of `from` reached the share, and Pitboard will not switch, for `why`.
171    Skipped {
172        from: String,
173        limit: Window,
174        why: Skip,
175    },
176}
177
178/// Why Pitboard does not switch, where it would have.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub enum Skip {
181    /// A switch was interrupted, and the change that finishes it is somebody's to make.
182    SwitchInterrupted,
183    /// Claude Code keeps its login under another name, which Pitboard does not act on.
184    CustomOauth,
185    /// Claude Code authenticates some other way, set by these, so a switch would change
186    /// nothing its sessions use.
187    Overridden(Vec<String>),
188}
189
190impl Skip {
191    /// A stable code, for `--json` and for telling one apart from another.
192    pub fn code(&self) -> &'static str {
193        match self {
194            Skip::SwitchInterrupted => "switch_interrupted",
195            Skip::CustomOauth => "custom_oauth_endpoint",
196            Skip::Overridden(_) => "auth_overridden",
197        }
198    }
199}
200
201/// The limit of `reading` furthest past the share, or the one resetting sooner of two as far.
202fn reached(reading: &Snapshot, threshold: Threshold, now: i64) -> Option<&Window> {
203    reading
204        .windows
205        .iter()
206        .filter(|window| threshold.reached_by(window, now))
207        .min_by(|a, b| {
208            b.used(now)
209                .total_cmp(&a.used(now))
210                .then_with(|| sooner(a.resets_at, b.resets_at))
211        })
212}
213
214/// A known reset before an unknown one.
215fn sooner(a: Option<i64>, b: Option<i64>) -> Ordering {
216    match (a, b) {
217        (Some(a), Some(b)) => a.cmp(&b),
218        (Some(_), None) => Ordering::Less,
219        (None, Some(_)) => Ordering::Greater,
220        (None, None) => Ordering::Equal,
221    }
222}
223
224/// How full an account is that has room for the switch: of the limit that sent Pitboard
225/// looking, and at the fullest of its others. `None` where it has no room.
226///
227/// Room is below the share in every limit it reports, and a reading for each limit the
228/// account in use has: a limit it does not report counts as spent, as one with no reading
229/// at all does. Except a limit scoped to one model, other than the one that reached the
230/// share, where the account reports its five-hour and weekly limits: plans differ in the
231/// models they limit apart, and an account on another plan would otherwise never be one to
232/// go to.
233fn room(
234    candidate: &Row,
235    mine: &Snapshot,
236    limit: &Window,
237    threshold: Threshold,
238    now: i64,
239) -> Option<(f64, f64)> {
240    let theirs = &candidate.usage.as_ref()?.windows;
241    if theirs
242        .iter()
243        .any(|window| threshold.reached_by(window, now))
244    {
245        return None;
246    }
247    let reports = |kind: &str| {
248        theirs
249            .iter()
250            .any(|window| window.scope.is_none() && crate::usage::limit_name(&window.kind) == kind)
251    };
252    let overall = reports("session") && reports("weekly_all");
253    for had in &mine.windows {
254        if theirs.iter().any(|window| window.same_limit(had)) {
255            continue;
256        }
257        if !(overall && had.scope.is_some() && !had.same_limit(limit)) {
258            return None;
259        }
260    }
261    let used = theirs
262        .iter()
263        .find(|window| window.same_limit(limit))?
264        .used(now);
265    let fullest = theirs
266        .iter()
267        .filter(|window| !window.same_limit(limit))
268        .map(|window| window.used(now))
269        .fold(0.0, f64::max);
270    Some((used, fullest))
271}
272
273/// What Pitboard would do about the Claude Code account in use, from `rows`, as `state`
274/// and `ledger` stand at `now`.
275pub(crate) fn decide(
276    state: &State,
277    rows: &[Row],
278    ledger: &Ledger,
279    threshold: Threshold,
280    now: i64,
281) -> Decision {
282    let claude = |row: &&Row| row.provider == ProviderId::Claude && row.label.is_some();
283    let Some(current) = rows.iter().filter(claude).find(|row| row.signed_in) else {
284        return Decision::Stay;
285    };
286    let (Some(from), Some(reading)) = (current.key(), current.usage.as_ref()) else {
287        return Decision::Stay;
288    };
289    let Some(limit) = reached(reading, threshold, now) else {
290        return Decision::Stay;
291    };
292    let settling = state
293        .get(&from)
294        .and_then(|account| account.last_used_at)
295        .is_some_and(|at| (0..SETTLING_SECONDS).contains(&(now - at)));
296    let tried = ledger.tried(&current.id, limit);
297    let waiting = tried.is_some_and(|tried| {
298        tried.switched || tried.attempts >= ATTEMPTS || now - tried.at < retry_after(tried.waits)
299    });
300    if settling || waiting {
301        return Decision::Stay;
302    }
303    let passed_over = tried.map_or(&[][..], |tried| tried.passed_over.as_slice());
304    let best = rows
305        .iter()
306        .filter(claude)
307        .filter(|row| row.switchable(now))
308        .filter(|row| !passed_over.contains(&row.id))
309        .filter_map(|row| Some((row, room(row, reading, limit, threshold, now)?)))
310        .min_by(|(_, (a, a_most)), (_, (b, b_most))| {
311            a.total_cmp(b).then_with(|| a_most.total_cmp(b_most))
312        });
313    match best.and_then(|(row, _)| row.key()) {
314        Some(to) => Decision::Switch(Plan {
315            from,
316            from_id: current.id.clone(),
317            to,
318            limit: limit.clone(),
319        }),
320        None => Decision::NoRoom {
321            from,
322            limit: limit.clone(),
323        },
324    }
325}
326
327/// What Pitboard has tried for each limit of each account that reached the share, kept in
328/// Pitboard's directory between runs and shared by every front end that switches by
329/// itself. Written only under the lock every change takes, so two of them never act on one
330/// limit twice. Nothing here is secret: account ids, names of limits, times and counts.
331#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
332pub(crate) struct Ledger {
333    #[serde(default)]
334    limits: BTreeMap<String, Tried>,
335}
336
337/// One limit of one account, in the window that reset at `resets_at`.
338#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
339struct Tried {
340    /// When the limit resets, in epoch seconds, or 0 where that is not known.
341    resets_at: i64,
342    /// Switches away from it begun in this window, but for those that failed for a reason
343    /// trying again may mend.
344    attempts: u32,
345    /// Attempts in a row that failed for a reason trying again may mend, which each make the
346    /// wait before the next longer.
347    #[serde(default)]
348    waits: u32,
349    /// When the last began.
350    at: i64,
351    /// Whether one of them switched.
352    #[serde(default)]
353    switched: bool,
354    /// Accounts, by id, a switch to was refused for a reason of their own, such as a parked
355    /// login that no longer works, so the next attempt goes elsewhere.
356    #[serde(default)]
357    passed_over: Vec<String>,
358}
359
360fn path(ctx: &Context) -> PathBuf {
361    home::dir(ctx).join("autoswitch.json")
362}
363
364/// A limit of an account, by the names a limit goes by.
365fn entry(id: &str, limit: &Window) -> String {
366    [
367        id,
368        crate::usage::limit_name(&limit.kind),
369        limit.scope.as_deref().unwrap_or_default(),
370    ]
371    .join("/")
372}
373
374impl Ledger {
375    /// What is kept. Nothing kept, and a record that cannot be read, are nothing tried: the
376    /// record is written whole the next time anything is tried, and at worst one limit is
377    /// tried once more.
378    pub(crate) fn load(ctx: &Context) -> Ledger {
379        std::fs::read_to_string(path(ctx))
380            .ok()
381            .and_then(|raw| serde_json::from_str(&raw).ok())
382            .unwrap_or_default()
383    }
384
385    /// Kept whole, without the windows that have reset.
386    pub(crate) fn save(&mut self, ctx: &Context, permit: Permit) -> crate::error::Result<()> {
387        let now = ctx.now();
388        self.limits.retain(|_, tried| {
389            tried.resets_at > now || (tried.resets_at == 0 && now - tried.at < 7 * 86_400)
390        });
391        let body = serde_json::to_vec(self).map_err(|e| crate::error::Error::StateWriteFailed {
392            path: path(ctx),
393            source: std::io::Error::other(e),
394        })?;
395        atomic::write(permit, &path(ctx), &body, atomic::Perms::Secret).map_err(|source| {
396            crate::error::Error::StateWriteFailed {
397                path: path(ctx),
398                source,
399            }
400        })
401    }
402
403    /// What was tried for this window of `limit`, where anything was.
404    fn tried(&self, id: &str, limit: &Window) -> Option<&Tried> {
405        self.limits
406            .get(&entry(id, limit))
407            .filter(|tried| same_reset(tried.resets_at, limit.resets_at.unwrap_or(0)))
408    }
409
410    fn trying(&mut self, plan: &Plan) -> &mut Tried {
411        let resets_at = plan.limit.resets_at.unwrap_or(0);
412        let tried = self
413            .limits
414            .entry(entry(&plan.from_id, &plan.limit))
415            .or_default();
416        if !same_reset(tried.resets_at, resets_at) {
417            *tried = Tried {
418                resets_at,
419                ..Tried::default()
420            };
421        }
422        tried
423    }
424
425    /// A switch for `plan` begins at `now`.
426    pub(crate) fn attempt(&mut self, plan: &Plan, now: i64) {
427        let tried = self.trying(plan);
428        tried.attempts += 1;
429        tried.at = now;
430    }
431
432    /// The switch for `plan` failed for a reason trying again may mend: not one of the
433    /// attempts, and the next waits longer.
434    pub(crate) fn waited(&mut self, plan: &Plan) {
435        let tried = self.trying(plan);
436        tried.attempts = tried.attempts.saturating_sub(1);
437        tried.waits = tried.waits.saturating_add(1);
438    }
439
440    /// The switch for `plan` was made.
441    pub(crate) fn switched(&mut self, plan: &Plan) {
442        self.trying(plan).switched = true;
443    }
444
445    /// The switch for `plan` was refused over the account it would have switched to, and
446    /// the next goes to another.
447    pub(crate) fn pass_over(&mut self, plan: &Plan, to_id: &str) {
448        let tried = self.trying(plan);
449        tried.waits = 0;
450        if !tried.passed_over.iter().any(|id| id == to_id) {
451            tried.passed_over.push(to_id.to_owned());
452        }
453    }
454}
455
456/// What to do after a look: switch for this plan, or say this.
457#[derive(Debug)]
458pub(crate) enum Next {
459    Switch(Plan),
460    Say(Auto),
461}
462
463/// What Pitboard would do now, from its files alone: no lock, no keychain and no network, so
464/// a look that finds nothing to do costs nobody anything. What it decides is decided again
465/// under the lock before anything moves.
466pub(crate) fn look(ctx: &Context, state: &State, threshold: Threshold) -> Next {
467    let rows = crate::status::gather_offline(ctx, state).rows;
468    let plan = match decide(state, &rows, &Ledger::load(ctx), threshold, ctx.now()) {
469        Decision::Stay => return Next::Say(Auto::Idle),
470        Decision::NoRoom { from, limit } => {
471            return Next::Say(Auto::NoRoom {
472                from: state.typed(&from),
473                limit,
474            });
475        }
476        Decision::Switch(plan) => plan,
477    };
478    let overridden = crate::provider::of(ProviderId::Claude).overridden_by(ctx);
479    let why = if crate::switch::interrupted(ctx) {
480        Skip::SwitchInterrupted
481    } else if crate::settings::custom_oauth(ctx) {
482        Skip::CustomOauth
483    } else if !overridden.is_empty() {
484        Skip::Overridden(overridden)
485    } else {
486        return Next::Switch(plan);
487    };
488    Next::Say(Auto::Skipped {
489        from: state.typed(&plan.from),
490        limit: plan.limit,
491        why,
492    })
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use crate::state::Park;
499    use crate::switch::harness::{NOW, account, window};
500    use crate::usage::Source;
501
502    fn scoped(kind: &str, model: &str, percent: f64) -> Window {
503        Window {
504            scope: Some(model.into()),
505            ..window(kind, percent)
506        }
507    }
508
509    fn row(label: &str, signed_in: bool, windows: Vec<Window>) -> Row {
510        Row {
511            provider: ProviderId::Claude,
512            label: Some(label.into()),
513            email: format!("{label}@example.com"),
514            id: label.into(),
515            account_uuid: label.into(),
516            organization_uuid: Some("org".into()),
517            signed_in,
518            parked: (!signed_in).then(|| Park {
519                service: format!("pitboard-park-{label}-1"),
520                parked_at: NOW,
521                refresh_fingerprint: label.into(),
522                access_expires_at: Some(NOW + 3600),
523                refresh_expires_at: Some(NOW + 30 * 86_400),
524            }),
525            usage: Some(Snapshot {
526                windows,
527                observed_at: Some(NOW),
528                account_uuid: Some(label.into()),
529                source: Source::Remembered,
530            }),
531            stale: None,
532        }
533    }
534
535    fn state_of(rows: &[Row]) -> State {
536        let mut state = State::default();
537        for row in rows {
538            state.accounts.push(account(
539                row.label.as_deref().unwrap(),
540                &row.account_uuid,
541                row.parked.clone(),
542            ));
543        }
544        state
545    }
546
547    fn decided(rows: &[Row]) -> Decision {
548        decide(
549            &state_of(rows),
550            rows,
551            &Ledger::default(),
552            Threshold::DEFAULT,
553            NOW,
554        )
555    }
556
557    fn to(decision: &Decision) -> Option<&str> {
558        match decision {
559            Decision::Switch(plan) => Some(plan.to.label.as_str()),
560            _ => None,
561        }
562    }
563
564    fn work_at(percent: f64) -> Row {
565        row(
566            "work",
567            true,
568            vec![window("session", percent), window("weekly_all", 20.0)],
569        )
570    }
571
572    fn spare() -> Row {
573        row(
574            "spare",
575            false,
576            vec![window("session", 10.0), window("weekly_all", 30.0)],
577        )
578    }
579
580    #[test]
581    fn a_limit_below_the_share_is_left_alone_and_one_at_it_switches() {
582        assert_eq!(decided(&[work_at(94.9), spare()]), Decision::Stay);
583        let decision = decided(&[work_at(95.0), spare()]);
584        assert_eq!(to(&decision), Some("spare"));
585        let Decision::Switch(plan) = decision else {
586            unreachable!()
587        };
588        assert_eq!(
589            (plan.from.label.as_str(), plan.from_id.as_str()),
590            ("work", "work")
591        );
592        assert_eq!(plan.limit.kind, "session");
593    }
594
595    #[test]
596    fn every_limit_the_account_reports_can_reach_the_share() {
597        let weekly = row(
598            "work",
599            true,
600            vec![window("session", 10.0), window("weekly_all", 97.0)],
601        );
602        let opus = row(
603            "work",
604            true,
605            vec![
606                window("session", 10.0),
607                window("weekly_all", 10.0),
608                scoped("weekly_scoped", "Opus", 96.0),
609            ],
610        );
611        let named_as_a_session_names_them = row(
612            "work",
613            true,
614            vec![window("five_hour", 99.0), window("seven_day", 10.0)],
615        );
616        for (case, work) in [
617            ("weekly", weekly),
618            ("opus", opus),
619            ("five_hour", named_as_a_session_names_them),
620        ] {
621            let spare = row(
622                "spare",
623                false,
624                vec![
625                    window("session", 0.0),
626                    window("weekly_all", 0.0),
627                    scoped("weekly_scoped", "Opus", 0.0),
628                ],
629            );
630            assert_eq!(to(&decided(&[work, spare])), Some("spare"), "{case}");
631        }
632    }
633
634    #[test]
635    fn the_limit_furthest_past_the_share_is_the_one_acted_on() {
636        let work = row(
637            "work",
638            true,
639            vec![window("session", 96.0), window("weekly_all", 98.0)],
640        );
641        let Decision::Switch(plan) = decided(&[work, spare()]) else {
642            panic!("a switch")
643        };
644        assert_eq!(plan.limit.kind, "weekly_all");
645    }
646
647    #[test]
648    fn a_limit_whose_reset_has_passed_counts_as_reset() {
649        let mut work = work_at(100.0);
650        work.usage.as_mut().unwrap().windows[0].resets_at = Some(NOW);
651        assert_eq!(decided(&[work, spare()]), Decision::Stay);
652    }
653
654    #[test]
655    fn an_account_in_use_with_no_reading_is_never_switched_from() {
656        let mut work = work_at(99.0);
657        work.usage = None;
658        assert_eq!(decided(&[work, spare()]), Decision::Stay);
659    }
660
661    /// A running `codex` never follows a switch, so Codex is never switched by itself.
662    #[test]
663    fn codex_is_never_switched() {
664        let mut work = work_at(99.0);
665        let mut other = spare();
666        work.provider = ProviderId::Codex;
667        other.provider = ProviderId::Codex;
668        assert_eq!(decided(&[work, other]), Decision::Stay);
669        assert_eq!(
670            decided(&[work_at(99.0), {
671                let mut codex = spare();
672                codex.provider = ProviderId::Codex;
673                codex
674            }]),
675            Decision::NoRoom {
676                from: Key::new(ProviderId::Claude, "work"),
677                limit: window("session", 99.0)
678            }
679        );
680    }
681
682    #[test]
683    fn an_account_with_any_limit_at_the_share_is_no_place_to_go() {
684        let full_weekly = || {
685            row(
686                "spare",
687                false,
688                vec![window("session", 0.0), window("weekly_all", 95.0)],
689            )
690        };
691        assert!(matches!(
692            decided(&[work_at(96.0), full_weekly()]),
693            Decision::NoRoom { .. }
694        ));
695        let roomy = row(
696            "roomy",
697            false,
698            vec![window("session", 60.0), window("weekly_all", 10.0)],
699        );
700        assert_eq!(
701            to(&decided(&[work_at(96.0), full_weekly(), roomy])),
702            Some("roomy")
703        );
704    }
705
706    #[test]
707    fn an_account_that_cannot_be_switched_to_is_passed_over() {
708        let mut expired = spare();
709        expired.parked.as_mut().unwrap().refresh_expires_at = Some(NOW);
710        assert!(matches!(
711            decided(&[work_at(96.0), expired]),
712            Decision::NoRoom { .. }
713        ));
714    }
715
716    #[test]
717    fn a_limit_the_other_account_does_not_report_counts_as_spent() {
718        let unknown_weekly = row("spare", false, vec![window("session", 0.0)]);
719        assert!(matches!(
720            decided(&[work_at(96.0), unknown_weekly]),
721            Decision::NoRoom { .. }
722        ));
723        let mut unread = spare();
724        unread.usage = None;
725        assert!(matches!(
726            decided(&[work_at(96.0), unread]),
727            Decision::NoRoom { .. }
728        ));
729    }
730
731    /// Plans limit different models apart, so an account on another plan reports no limit
732    /// for a model the account in use has one for. It is one to go to where its five-hour
733    /// and weekly limits have room, unless that model's limit is the one that ran out.
734    #[test]
735    fn a_limit_for_a_model_the_other_plan_does_not_have_is_not_counted_against_it() {
736        let work = row(
737            "work",
738            true,
739            vec![
740                window("session", 97.0),
741                window("weekly_all", 20.0),
742                scoped("weekly_scoped", "Opus", 40.0),
743            ],
744        );
745        assert_eq!(to(&decided(&[work, spare()])), Some("spare"));
746
747        let opus_ran_out = row(
748            "work",
749            true,
750            vec![
751                window("session", 10.0),
752                window("weekly_all", 20.0),
753                scoped("weekly_scoped", "Opus", 97.0),
754            ],
755        );
756        assert!(matches!(
757            decided(&[opus_ran_out, spare()]),
758            Decision::NoRoom { .. }
759        ));
760
761        let session_only = row("spare", false, vec![window("session", 10.0)]);
762        let work = row(
763            "work",
764            true,
765            vec![
766                window("session", 97.0),
767                scoped("weekly_scoped", "Opus", 40.0),
768            ],
769        );
770        assert!(matches!(
771            decided(&[work, session_only]),
772            Decision::NoRoom { .. }
773        ));
774    }
775
776    #[test]
777    fn the_account_with_most_room_in_that_limit_is_chosen_then_the_least_full() {
778        let less = || {
779            row(
780                "less",
781                false,
782                vec![window("session", 50.0), window("weekly_all", 10.0)],
783            )
784        };
785        let more = row(
786            "more",
787            false,
788            vec![window("session", 20.0), window("weekly_all", 80.0)],
789        );
790        assert_eq!(to(&decided(&[work_at(96.0), less(), more])), Some("more"));
791        let tie = row(
792            "tie",
793            false,
794            vec![window("session", 50.0), window("weekly_all", 5.0)],
795        );
796        assert_eq!(to(&decided(&[work_at(96.0), less(), tie])), Some("tie"));
797        let same = row(
798            "same",
799            false,
800            vec![window("session", 50.0), window("weekly_all", 10.0)],
801        );
802        assert_eq!(
803            to(&decided(&[work_at(96.0), less(), same])),
804            Some("less"),
805            "the first of two alike"
806        );
807    }
808
809    #[test]
810    fn an_account_just_put_in_use_is_left_to_settle() {
811        let rows = [work_at(96.0), spare()];
812        let mut state = state_of(&rows);
813        let work = Key::new(ProviderId::Claude, "work");
814        state.used(&work, NOW - SETTLING_SECONDS + 1);
815        let ledger = Ledger::default();
816        assert_eq!(
817            decide(&state, &rows, &ledger, Threshold::DEFAULT, NOW),
818            Decision::Stay
819        );
820        state.used(&work, NOW - SETTLING_SECONDS);
821        assert_eq!(
822            to(&decide(&state, &rows, &ledger, Threshold::DEFAULT, NOW)),
823            Some("spare")
824        );
825    }
826
827    fn plan(rows: &[Row]) -> Plan {
828        match decided(rows) {
829            Decision::Switch(plan) => plan,
830            other => panic!("a switch, not {other:?}"),
831        }
832    }
833
834    #[test]
835    fn a_limit_switched_away_from_is_not_switched_away_from_again_before_it_resets() {
836        let rows = [work_at(96.0), spare()];
837        let state = state_of(&rows);
838        let mut ledger = Ledger::default();
839        let plan = plan(&rows);
840        ledger.attempt(&plan, NOW - 3600);
841        ledger.switched(&plan);
842        assert_eq!(
843            decide(&state, &rows, &ledger, Threshold::DEFAULT, NOW),
844            Decision::Stay
845        );
846
847        let mut next_window = work_at(96.0);
848        next_window.usage.as_mut().unwrap().windows[0].resets_at = Some(NOW + 3 * 3600);
849        let rows = [next_window, spare()];
850        assert_eq!(
851            to(&decide(&state, &rows, &ledger, Threshold::DEFAULT, NOW)),
852            Some("spare")
853        );
854    }
855
856    #[test]
857    fn a_failed_attempt_is_tried_again_after_a_minute_and_at_most_three_times() {
858        let rows = [work_at(96.0), spare()];
859        let state = state_of(&rows);
860        let mut ledger = Ledger::default();
861        let plan = plan(&rows);
862        ledger.attempt(&plan, NOW);
863        let at = |now| decide(&state, &rows, &ledger, Threshold::DEFAULT, now);
864        assert_eq!(at(NOW + RETRY_SECONDS - 1), Decision::Stay);
865        assert_eq!(to(&at(NOW + RETRY_SECONDS)), Some("spare"));
866        assert_eq!(at(NOW - 1), Decision::Stay, "a clock gone backwards waits");
867
868        ledger.attempt(&plan, NOW);
869        ledger.attempt(&plan, NOW);
870        let at = |now| decide(&state, &rows, &ledger, Threshold::DEFAULT, now);
871        assert_eq!(at(NOW + 3600), Decision::Stay);
872    }
873
874    /// A failure trying again may mend, such as Anthropic out of reach while the switch
875    /// asks whose a login is, is not one of the three: an outage of a few minutes must not
876    /// leave the account in use to run out. It waits longer after each, up to a quarter of an
877    /// hour, so an outage of hours is not asked about every minute.
878    #[test]
879    fn a_failure_trying_again_may_mend_waits_longer_each_time_and_is_not_counted() {
880        let rows = [work_at(96.0), spare()];
881        let state = state_of(&rows);
882        let mut ledger = Ledger::default();
883        let plan = plan(&rows);
884        let mut at = NOW;
885        for wait in [60, 120, 240, 480, 900, 900] {
886            ledger.attempt(&plan, at);
887            ledger.waited(&plan);
888            let decided = |now| decide(&state, &rows, &ledger, Threshold::DEFAULT, now);
889            assert_eq!(decided(at + wait - 1), Decision::Stay, "within {wait} s");
890            assert_eq!(to(&decided(at + wait)), Some("spare"), "after {wait} s");
891            at += wait;
892        }
893    }
894
895    #[test]
896    fn an_account_a_switch_was_refused_over_is_passed_over_for_the_next() {
897        let other = row(
898            "other",
899            false,
900            vec![window("session", 30.0), window("weekly_all", 30.0)],
901        );
902        let rows = [work_at(96.0), spare(), other];
903        let state = state_of(&rows);
904        let mut ledger = Ledger::default();
905        let plan = plan(&rows);
906        assert_eq!(plan.to.label, "spare");
907        ledger.attempt(&plan, NOW);
908        ledger.pass_over(&plan, "spare");
909        assert_eq!(
910            to(&decide(
911                &state,
912                &rows,
913                &ledger,
914                Threshold::DEFAULT,
915                NOW + RETRY_SECONDS
916            )),
917            Some("other")
918        );
919    }
920
921    #[test]
922    fn a_reset_a_second_off_is_the_same_window_and_a_minute_off_is_not() {
923        let rows = [work_at(96.0), spare()];
924        let mut ledger = Ledger::default();
925        let mut plan = plan(&rows);
926        let resets = plan.limit.resets_at.unwrap();
927        plan.limit.resets_at = Some(resets + 59);
928        ledger.attempt(&plan, NOW - 3600);
929        ledger.switched(&plan);
930        let state = state_of(&rows);
931        assert_eq!(
932            decide(&state, &rows, &ledger, Threshold::DEFAULT, NOW),
933            Decision::Stay
934        );
935        plan.limit.resets_at = Some(resets + 61);
936        let mut ledger = Ledger::default();
937        ledger.attempt(&plan, NOW - 3600);
938        ledger.switched(&plan);
939        assert_eq!(
940            to(&decide(&state, &rows, &ledger, Threshold::DEFAULT, NOW)),
941            Some("spare")
942        );
943    }
944
945    #[test]
946    fn a_share_is_between_fifty_and_ninety_nine() {
947        assert_eq!(Threshold::new(49), None);
948        assert_eq!(Threshold::new(100), None);
949        assert_eq!(Threshold::new(50).map(Threshold::percent), Some(50));
950        assert_eq!(Threshold::clamped(120).percent(), 99);
951        assert_eq!(Threshold::clamped(-3).percent(), 50);
952        assert_eq!(Threshold::default().percent(), 95);
953        let lower = Threshold::new(80).unwrap();
954        let rows = [work_at(85.0), spare()];
955        assert_eq!(decided(&rows), Decision::Stay);
956        assert_eq!(
957            to(&decide(
958                &state_of(&rows),
959                &rows,
960                &Ledger::default(),
961                lower,
962                NOW
963            )),
964            Some("spare")
965        );
966    }
967}