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>
This commit is contained in:
227
crates/domain/src/session_limit.rs
Normal file
227
crates/domain/src/session_limit.rs
Normal file
@ -0,0 +1,227 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user