//! Limite de session/débit d'un agent — value object pur + calcul de reprise //! (ARCHITECTURE §21). //! //! Objet **pur** (aucune I/O, aucun temps réel, aucune dépendance externe) : il //! capture le **fait neutre** « cet agent est limité, reset à T (peut-être) » et //! calcule, à partir de l'heure courante, le **plan de reprise** correspondant. //! Tout savoir spécifique modèle (forme du `rate_limit_event` Claude, regex d'une //! TUI, parsing d'une heure locale) reste **confiné aux adapters/profils** (§21.2). //! //! La limite de session est un **3ᵉ axe d'état orthogonal** aux deux axes déjà //! posés dans [`crate::input`] ([`crate::input::AgentBusyState`] et //! [`crate::input::AgentLiveness`]) : un agent peut être `Idle`/`Busy`, //! `Alive`/`Stalled`, **et** limité jusqu'à une certaine heure. //! //! # Pourquoi des époche-millisecondes ? //! //! Toutes les heures manipulées ici sont des **i64 époche-millisecondes**, //! homogènes avec [`crate::ports::Clock::now_millis`] et //! [`crate::input::AgentBusyState`] : sérialisables, comparables, sans dépendance //! à `std::time::Instant` (monotone, non sérialisable — cf. §21.2-T1). /// D'où provient la détection de la limite — utile pour la traçabilité et l'UI /// (afficher « limite structurée » vs « confirmée par l'utilisateur »). /// /// Calqué sur la hiérarchie de détection à trois niveaux de §21.1. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RateLimitSource { /// **Niveau 1** : extraite du flux structuré de l'adapter (le plus solide ; /// Claude `rate_limit_info.resetsAt`, équivalent Codex…). Structured, /// **Niveau 2** : détectée par un motif déclaratif de profil /// ([`crate::profile::RateLimitPattern`]) sur la sortie PTY d'un agent TUI. Pattern, /// **Niveau 3** : confirmée par l'utilisateur (filet humain) quand rien n'a /// matché automatiquement mais que l'agent semble bloqué. Human, } /// Value object **pur** : une limite de session/débit détectée pour un agent /// (ARCHITECTURE §21.3). /// /// Immuable, sans I/O, trivialement testable. Vit **en mémoire uniquement** /// (§21.1-3) : aucune persistance, aucun store. Un agent n'a qu'**une** limite /// vivante à la fois ; un second signal la **rafraîchit** plutôt que de l'empiler /// (dédoublonnage assuré côté application, §21.10-4). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SessionLimit { /// Instant de reset en **époche-millisecondes**. `None` quand aucune heure de /// reset exploitable n'a été obtenue ⇒ pas de reprise auto possible (filet /// humain, §21.1 niveau 3). pub resets_at_ms: Option, /// Instant de **détection** de la limite, en époche-millisecondes (utile à l'UI /// et au diagnostic ; non requis pour le calcul de reprise). pub detected_at_ms: i64, /// Niveau de détection ayant produit cette limite. pub source: RateLimitSource, } impl SessionLimit { /// Construit une limite de session. Pur, sans validation : tous les états /// (`resets_at_ms` présent ou absent, n'importe quelle source) sont légitimes. #[must_use] pub const fn new( resets_at_ms: Option, detected_at_ms: i64, source: RateLimitSource, ) -> Self { Self { resets_at_ms, detected_at_ms, source, } } /// Vrai si une heure de reset exploitable est connue (⇒ reprise auto possible). /// Faux ⇒ filet humain (l'application demande l'heure à l'utilisateur). #[must_use] pub const fn has_known_reset(&self) -> bool { self.resets_at_ms.is_some() } } /// Plan de reprise calculé par [`plan_resume`] (ARCHITECTURE §21.3). /// /// Donnée **pure** : ne porte que ce dont l'application a besoin pour armer la /// reprise, jamais de closure ni de port (cf. l'esprit du dispatch orchestrateur /// §14.3 — une **intention** model-agnostique, pas un effet). #[derive(Debug, Clone, PartialEq, Eq)] pub enum ResumePlan { /// Reprise **automatique programmée** : armer un réveil à `fire_at_ms` qui /// relancera l'agent via [`crate::ports::SessionPlan::Resume`]. Scheduled { /// Échéance du réveil en **époche-millisecondes**. Vaut l'heure de reset, /// **clampée à `now_ms`** si le reset est déjà passé (on ne programme jamais /// une échéance dans le passé : reprise immédiate). fire_at_ms: i64, /// Identifiant de conversation du moteur à reprendre (pivot model-agnostique, /// porté tel quel jusqu'à [`crate::ports::SessionPlan::Resume`]). `None` ⇒ /// reprise en mode dégradé (sans id), comme le reste du chemin de reprise. conversation_id: Option, }, /// Aucune reprise automatique possible : l'heure de reset est inconnue /// (`SessionLimit::resets_at_ms == None`) ⇒ **filet humain** (§21.1 niveau 3). /// L'application demandera l'heure à l'utilisateur plutôt que d'agir à l'aveugle /// (jamais d'inaction silencieuse, jamais d'auto sans heure). HumanFallback, } /// Calcule le **plan de reprise** d'un agent limité, à partir de l'heure courante /// (ARCHITECTURE §21.3). Fonction **pure** : aucune I/O, aucun temps réel — `now_ms` /// est injecté (cf. les fonctions pures de `LayoutTree`, §7.2), ce qui la rend /// trivialement testable. /// /// - `limit.resets_at_ms == Some(t)` ⇒ [`ResumePlan::Scheduled`] avec /// `fire_at_ms = max(t, now_ms)` (on ne programme jamais dans le passé : un reset /// déjà écoulé ⇒ reprise immédiate) et l'`conversation_id` fourni propagé tel quel. /// - `limit.resets_at_ms == None` ⇒ [`ResumePlan::HumanFallback`] (filet humain). #[must_use] pub fn plan_resume( now_ms: i64, limit: &SessionLimit, conversation_id: Option, ) -> ResumePlan { match limit.resets_at_ms { Some(resets_at_ms) => ResumePlan::Scheduled { fire_at_ms: resets_at_ms.max(now_ms), conversation_id, }, None => ResumePlan::HumanFallback, } } #[cfg(test)] mod tests { use super::*; const NOW: i64 = 1_700_000_000_000; fn limit(resets_at_ms: Option) -> SessionLimit { SessionLimit::new(resets_at_ms, NOW, RateLimitSource::Structured) } // -- plan_resume : reset futur ⇒ Scheduled à l'heure de reset ---------------- #[test] fn future_reset_schedules_at_reset_time_with_conversation_id() { let reset = NOW + 60_000; let plan = plan_resume(NOW, &limit(Some(reset)), Some("conv-42".to_owned())); assert_eq!( plan, ResumePlan::Scheduled { fire_at_ms: reset, conversation_id: Some("conv-42".to_owned()), } ); } // -- plan_resume : clamp anti-passé ------------------------------------------ #[test] fn past_reset_is_clamped_to_now_never_in_the_past() { let past = NOW - 60_000; let plan = plan_resume(NOW, &limit(Some(past)), None); match plan { ResumePlan::Scheduled { fire_at_ms, .. } => { assert_eq!(fire_at_ms, NOW, "un reset déjà passé ⇒ reprise immédiate (now)"); } other => panic!("attendu Scheduled, obtenu {other:?}"), } } #[test] fn reset_exactly_now_fires_at_now() { let plan = plan_resume(NOW, &limit(Some(NOW)), None); assert_eq!( plan, ResumePlan::Scheduled { fire_at_ms: NOW, conversation_id: None, } ); } // -- plan_resume : pas d'heure ⇒ filet humain -------------------------------- #[test] fn unknown_reset_falls_back_to_human() { let plan = plan_resume(NOW, &limit(None), Some("conv-42".to_owned())); assert_eq!(plan, ResumePlan::HumanFallback); } // -- conversation_id propagé tel quel (Some et None) ------------------------- #[test] fn conversation_id_some_is_propagated_into_scheduled() { let reset = NOW + 1; let plan = plan_resume(NOW, &limit(Some(reset)), Some("c".to_owned())); assert_eq!( plan, ResumePlan::Scheduled { fire_at_ms: reset, conversation_id: Some("c".to_owned()), } ); } #[test] fn conversation_id_none_is_propagated_into_scheduled() { let reset = NOW + 1; let plan = plan_resume(NOW, &limit(Some(reset)), None); assert_eq!( plan, ResumePlan::Scheduled { fire_at_ms: reset, conversation_id: None, } ); } // -- SessionLimit::has_known_reset ------------------------------------------- #[test] fn has_known_reset_is_true_with_some_and_false_with_none() { assert!(limit(Some(NOW)).has_known_reset()); assert!(!limit(None).has_known_reset()); } }