Files
IdeA/crates/domain/src/session_limit.rs
Blomios 0bf1eb3b11 feat(session-limits): LS1 — couche domaine (détection + plan de reprise)
Pose les briques pures du domaine pour la gestion des limites de session
des agents (état en mémoire, aucun schéma de persistance modifié) :
- session_limit.rs (nouveau) : SessionLimit, ResumePlan, RateLimitSource,
  plan_resume (calcul du plan de reprise annulable).
- ports.rs : variante ReplyEvent::RateLimited.
- readiness.rs : variante ReadinessSignal::RateLimited + classify.
- profile.rs : RateLimitPattern + champ + builder.
- events.rs : 5 variantes DomainEvent pour le cycle de vie limite/reprise.
- lib.rs : module + re-exports.

Tests QA inline (#[cfg(test)]) : 24 tests dédiés.
`cargo test -p domain` = 165 passed / 0 failed, zéro régression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:33:57 +02:00

228 lines
8.8 KiB
Rust

//! 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<i64>,
/// 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<i64>,
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<String>,
},
/// 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<String>,
) -> 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<i64>) -> 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());
}
}