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:
@ -49,6 +49,20 @@ pub enum ReadinessSignal {
|
||||
/// Le garde-fou de durée de tour a expiré. Place réservée au **lot 2** — non
|
||||
/// produit dans ce lot.
|
||||
TimedOut,
|
||||
/// L'agent est en **limite de session/débit** (ARCHITECTURE §21) : le moteur l'a
|
||||
/// suspendu pour cause de quota. Déduit de [`ReplyEvent::RateLimited`] par
|
||||
/// [`ReadinessPolicy::classify`]. **Pas terminal** au sens « fin de tour » : il ne
|
||||
/// marque pas le tour comme `TurnEnded` (l'agent n'a pas rendu son `Final`) — il
|
||||
/// signale un 3ᵉ axe d'état orthogonal (« limité jusqu'à T »), que l'application
|
||||
/// traduit en planification de reprise.
|
||||
///
|
||||
/// `Copy` préservé : `Option<i64>` est `Copy`, donc l'enum le reste (cf. §21.3).
|
||||
RateLimited {
|
||||
/// Instant de reset en **époche-millisecondes** (cf.
|
||||
/// [`ReplyEvent::RateLimited::resets_at_ms`]). `None` ⇒ pas d'heure connue
|
||||
/// (filet humain, pas de reprise auto).
|
||||
resets_at_ms: Option<i64>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Politique **pure** de classification d'un événement de tour en
|
||||
@ -63,6 +77,11 @@ impl ReadinessPolicy {
|
||||
///
|
||||
/// - [`ReplyEvent::Final`] ⇒ `Some(`[`ReadinessSignal::TurnEnded`]`)` : seul
|
||||
/// événement terminal, il signe la fin de tour déterministe.
|
||||
/// - [`ReplyEvent::RateLimited`] ⇒
|
||||
/// `Some(`[`ReadinessSignal::RateLimited`]`{..})` : **non terminal** (l'agent
|
||||
/// n'a pas rendu son `Final`), mais porteur d'un signal exploitable par
|
||||
/// l'application (planifier la reprise à `resets_at_ms`). L'heure de reset est
|
||||
/// propagée telle quelle.
|
||||
/// - [`ReplyEvent::TextDelta`] / [`ReplyEvent::ToolActivity`] /
|
||||
/// [`ReplyEvent::Heartbeat`] ⇒ `None` : tous **non terminaux** (le flux
|
||||
/// continue). Un heartbeat prouve la vivacité mais ne termine pas le tour.
|
||||
@ -70,6 +89,11 @@ impl ReadinessPolicy {
|
||||
pub const fn classify(event: &ReplyEvent) -> Option<ReadinessSignal> {
|
||||
match event {
|
||||
ReplyEvent::Final { .. } => Some(ReadinessSignal::TurnEnded),
|
||||
ReplyEvent::RateLimited { resets_at_ms } => {
|
||||
Some(ReadinessSignal::RateLimited {
|
||||
resets_at_ms: *resets_at_ms,
|
||||
})
|
||||
}
|
||||
ReplyEvent::TextDelta { .. }
|
||||
| ReplyEvent::ToolActivity { .. }
|
||||
| ReplyEvent::Heartbeat => None,
|
||||
@ -108,4 +132,51 @@ mod tests {
|
||||
"un heartbeat prouve la vivacité mais ne termine JAMAIS le tour"
|
||||
);
|
||||
}
|
||||
|
||||
// -- §21 : RateLimited ⇒ ReadinessSignal::RateLimited (heure propagée) -------
|
||||
|
||||
#[test]
|
||||
fn rate_limited_with_known_reset_classifies_and_propagates_time() {
|
||||
let ev = ReplyEvent::RateLimited {
|
||||
resets_at_ms: Some(1_700_000_000_000),
|
||||
};
|
||||
assert_eq!(
|
||||
ReadinessPolicy::classify(&ev),
|
||||
Some(ReadinessSignal::RateLimited {
|
||||
resets_at_ms: Some(1_700_000_000_000),
|
||||
}),
|
||||
"l'heure de reset doit être propagée telle quelle"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_without_reset_classifies_with_none() {
|
||||
let ev = ReplyEvent::RateLimited { resets_at_ms: None };
|
||||
assert_eq!(
|
||||
ReadinessPolicy::classify(&ev),
|
||||
Some(ReadinessSignal::RateLimited { resets_at_ms: None }),
|
||||
"heure inconnue ⇒ None propagé (filet humain en aval)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_is_not_classified_as_turn_ended() {
|
||||
// Non terminal : un RateLimited ne marque JAMAIS le tour comme fini.
|
||||
let signal = ReadinessPolicy::classify(&ReplyEvent::RateLimited {
|
||||
resets_at_ms: Some(42),
|
||||
});
|
||||
assert_ne!(signal, Some(ReadinessSignal::TurnEnded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_signal_is_copy() {
|
||||
// Test de compilation : `ReadinessSignal` reste `Copy` (cf. §21.3). Si le
|
||||
// type cessait d'être `Copy`, l'usage après le `copy` ci-dessous (move
|
||||
// implicite) ne compilerait plus.
|
||||
let sig = ReadinessSignal::RateLimited {
|
||||
resets_at_ms: Some(7),
|
||||
};
|
||||
let copy = sig; // copie implicite, pas un move
|
||||
assert_eq!(sig, copy);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user