From 0bf1eb3b11065b5933619663740365511773f204 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 16 Jun 2026 14:33:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(session-limits):=20LS1=20=E2=80=94=20couch?= =?UTF-8?q?e=20domaine=20(d=C3=A9tection=20+=20plan=20de=20reprise)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/domain/src/events.rs | 150 +++++++++++++++++++ crates/domain/src/lib.rs | 5 +- crates/domain/src/ports.rs | 20 +++ crates/domain/src/profile.rs | 175 ++++++++++++++++++++++ crates/domain/src/readiness.rs | 71 +++++++++ crates/domain/src/session_limit.rs | 227 +++++++++++++++++++++++++++++ 6 files changed, 647 insertions(+), 1 deletion(-) create mode 100644 crates/domain/src/session_limit.rs diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 215634e..7c344e2 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -209,6 +209,55 @@ pub enum DomainEvent { /// Target profile's submit delay in ms. `None` ⇒ front default (~60 ms). submit_delay_ms: Option, }, + /// Un agent vient d'entrer en **limite de session/débit** (ARCHITECTURE §21). + /// Publié quand le service de limite enregistre une nouvelle `SessionLimit` (niveau + /// 1 structuré ou niveau 2 motif). Balise discrète, basse fréquence, relayée au + /// front pour afficher le badge « limité jusqu'à HH:MM ». Model-agnostique : ne + /// porte que le fait neutre « limité, reset à T (peut-être) ». + AgentRateLimited { + /// L'agent entré en limite. + agent_id: AgentId, + /// Instant de reset en **époche-millisecondes**. `None` ⇒ heure inconnue + /// (pas de reprise auto, filet humain). + resets_at_ms: Option, + }, + /// Une **reprise automatique** a été armée pour un agent limité (ARCHITECTURE §21). + /// Publié après que le service a calculé le plan ([`crate::session_limit::plan_resume`]) + /// et armé le `Scheduler`. Relayé au front pour afficher le compte à rebours + le + /// bouton « Annuler la reprise » (fenêtre annulable). + AgentResumeScheduled { + /// L'agent dont la reprise est programmée. + agent_id: AgentId, + /// Échéance du réveil en **époche-millisecondes**. + fire_at_ms: i64, + }, + /// La **reprise automatique** d'un agent a été **annulée** (ARCHITECTURE §21) : + /// l'utilisateur a cliqué « Annuler la reprise » dans la fenêtre annulable. Relayé + /// au front pour retirer le compte à rebours. + AgentResumeCancelled { + /// L'agent dont la reprise a été annulée. + agent_id: AgentId, + }, + /// Un agent a effectivement été **relancé** après une limite (ARCHITECTURE §21) : + /// le réveil a tiré (ou reprise immédiate), l'agent a redémarré via + /// [`crate::ports::SessionPlan::Resume`] avec un prompt de reprise court. Relayé au + /// front pour effacer l'état « limité ». + AgentResumed { + /// L'agent relancé. + agent_id: AgentId, + }, + /// **Filet humain (niveau 3)** : une limite de session est **suspectée** sans + /// qu'aucune heure de reset fiable ne soit connue (ARCHITECTURE §21.1 niveau 3) — + /// typiquement un agent passé `Stalled` (lot 2) sans `SessionLimit` connue. IdeA ne + /// reprend **jamais** à l'aveugle : ce signal demande au front de **solliciter + /// l'utilisateur** (« limite détectée mais heure inconnue — reprendre à ? »). + AgentRateLimitSuspected { + /// L'agent dont la limite est suspectée. + agent_id: AgentId, + /// Instant de reset en **époche-millisecondes** si une estimation existe, + /// sinon `None` (l'utilisateur fournira l'heure). + resets_at_ms: Option, + }, /// Raw PTY output (usually routed to a dedicated channel, not this bus). PtyOutput { /// The session. @@ -217,3 +266,104 @@ pub enum DomainEvent { bytes: Vec, }, } + +#[cfg(test)] +mod tests { + use super::*; + + fn agent(n: u128) -> AgentId { + AgentId::from_uuid(uuid::Uuid::from_u128(n)) + } + + // -- §21 : constructibilité + égalité PartialEq des 5 variantes -------------- + + #[test] + fn agent_rate_limited_constructs_and_compares() { + let ev = DomainEvent::AgentRateLimited { + agent_id: agent(1), + resets_at_ms: Some(1_700_000_000_000), + }; + assert_eq!( + ev, + DomainEvent::AgentRateLimited { + agent_id: agent(1), + resets_at_ms: Some(1_700_000_000_000), + } + ); + // Une heure différente ⇒ inégaux. + assert_ne!( + ev, + DomainEvent::AgentRateLimited { + agent_id: agent(1), + resets_at_ms: None, + } + ); + } + + #[test] + fn agent_resume_scheduled_constructs_and_compares() { + let ev = DomainEvent::AgentResumeScheduled { + agent_id: agent(2), + fire_at_ms: 1_700_000_000_000, + }; + assert_eq!( + ev, + DomainEvent::AgentResumeScheduled { + agent_id: agent(2), + fire_at_ms: 1_700_000_000_000, + } + ); + assert_ne!( + ev, + DomainEvent::AgentResumeScheduled { + agent_id: agent(2), + fire_at_ms: 0, + } + ); + } + + #[test] + fn agent_resume_cancelled_constructs_and_compares() { + let ev = DomainEvent::AgentResumeCancelled { agent_id: agent(3) }; + assert_eq!(ev, DomainEvent::AgentResumeCancelled { agent_id: agent(3) }); + assert_ne!(ev, DomainEvent::AgentResumeCancelled { agent_id: agent(4) }); + } + + #[test] + fn agent_resumed_constructs_and_compares() { + let ev = DomainEvent::AgentResumed { agent_id: agent(5) }; + assert_eq!(ev, DomainEvent::AgentResumed { agent_id: agent(5) }); + assert_ne!(ev, DomainEvent::AgentResumed { agent_id: agent(6) }); + } + + #[test] + fn agent_rate_limit_suspected_constructs_and_compares() { + let ev = DomainEvent::AgentRateLimitSuspected { + agent_id: agent(7), + resets_at_ms: None, + }; + assert_eq!( + ev, + DomainEvent::AgentRateLimitSuspected { + agent_id: agent(7), + resets_at_ms: None, + } + ); + assert_ne!( + ev, + DomainEvent::AgentRateLimitSuspected { + agent_id: agent(7), + resets_at_ms: Some(42), + } + ); + } + + #[test] + fn distinct_session_limit_variants_are_not_equal() { + // Les variantes ne se confondent pas entre elles malgré des champs proches. + assert_ne!( + DomainEvent::AgentResumeCancelled { agent_id: agent(8) }, + DomainEvent::AgentResumed { agent_id: agent(8) } + ); + } +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index da35044..a1c619c 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -50,6 +50,7 @@ pub mod profile; pub mod project; pub mod readiness; pub mod sandbox; +pub mod session_limit; pub mod remote; pub mod skill; pub mod template; @@ -78,7 +79,7 @@ pub use template::{AgentTemplate, TemplateVersion}; pub use profile::{ AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, LivenessStrategy, - McpServerWiring, SessionStrategy, + McpServerWiring, RateLimitPattern, SessionStrategy, }; pub use mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId}; @@ -92,6 +93,8 @@ pub use input::{AgentBusyState, AgentLiveness, InputMediator, InputSource}; pub use readiness::{ReadinessPolicy, ReadinessSignal}; +pub use session_limit::{plan_resume, ResumePlan, RateLimitSource, SessionLimit}; + pub use conversation_log::{ ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer, ProviderSessionStore, TurnId, TurnRole, diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 5050088..de71833 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -225,6 +225,26 @@ pub enum ReplyEvent { /// jusqu'au [`ReplyEvent::Final`]. Un tour comporte ≥0 `Heartbeat`, jamais /// d'obligation d'en émettre. Heartbeat, + /// **Limite de session/débit atteinte** (model-agnostique, ARCHITECTURE §21) : + /// l'adapter structuré a observé que le moteur a suspendu l'agent pour cause de + /// quota (Claude `rate_limit_event` → `rate_limit_info.resetsAt`, ou équivalent). + /// Porte un fait neutre : « limité, reset à T (peut-être) ». Aucun détail propre + /// à une CLI ne franchit la frontière (forme du `rate_limit_event`, format de + /// l'heure…) — tout cela reste confiné à l'adapter (cf. §21.2-T2). + /// + /// **Jamais terminal** (cf. §21.2-T4) : exactement comme [`ReplyEvent::Heartbeat`], + /// un `RateLimited` **ne clôt pas** le flux — il s'intercale et le flux continue + /// jusqu'au [`ReplyEvent::Final`] **ou** jusqu'à une clôture du flux. Conséquence + /// pour les consommateurs : un tour clos **sans `Final`** parce que limité doit + /// être traité comme une **fin gracieuse limitée** (et non comme une erreur « flux + /// clos sans Final »), dès lors qu'un `RateLimited` a été vu dans le tour. + RateLimited { + /// Instant de réinitialisation de la limite, en **époche-millisecondes** + /// (homogène avec [`Clock::now_millis`]). `None` quand le moteur n'a pas + /// fourni d'heure de reset exploitable ⇒ pas de reprise auto possible (filet + /// humain, §21.1 niveau 3). Jamais une `Instant` monotone (cf. §21.2-T1). + resets_at_ms: Option, + }, /// **Événement terminal déterministe** d'un tour : l'adapter l'émet quand il a /// lu le message `result` documenté de la CLI. Porte le contenu final agrégé. /// Après `Final`, le flux se termine (plus aucun événement). diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index dc8aa8e..bd597b2 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -174,6 +174,64 @@ impl LivenessStrategy { } } +/// Motif déclaratif de détection d'une **limite de session/débit** pour un agent +/// **PTY/TUI sans adapter structuré** (ARCHITECTURE §21, niveau 2 de détection). +/// +/// Donnée **pure** (pas de code par CLI — Open/Closed, §9), calquée sur la +/// philosophie de [`AgentProfile::prompt_ready_pattern`] mais **plus riche** : là où +/// le retour-de-prompt est une simple sous-chaîne littérale, la limite de session a +/// besoin d'**extraire une heure de reset** dans la sortie. Le domaine **ne stocke +/// que la donnée** (chaînes) ; le **moteur regex et le parsing d'heure vivent en +/// infrastructure** (composant `RateLimitParser`, dépendance `regex` ajoutée au seul +/// `Cargo.toml` d'`infrastructure`, cf. §21.2-T2). **Aucune** regex ni heure parsée +/// ne franchit la frontière domaine : l'infra émet un +/// [`crate::ports::ReplyEvent::RateLimited`] avec une époche-ms normalisée. +/// +/// Invariant (garanti par le constructeur) : `pattern` est non vide. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RateLimitPattern { + /// Le **motif brut** (interprété comme une regex par l'infra) recherché dans la + /// sortie PTY pour reconnaître l'épisode de limite. Le domaine ne le compile + /// jamais — il le transporte tel quel jusqu'à l'adapter (§21.2-T2). + pub pattern: String, + /// Nom du **groupe de capture** (ou indication équivalente) d'où l'infra extrait + /// l'heure de reset. `None` ⇒ le motif détecte la limite **sans** heure de reset + /// ⇒ [`crate::ports::ReplyEvent::RateLimited`]`{ resets_at_ms: None }` (filet + /// humain). Donnée opaque au domaine. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reset_capture: Option, + /// **Format d'heure** (ex. style `strftime`) que l'infra utilise pour parser la + /// chaîne capturée par `reset_capture` en une heure murale, qu'elle compose + /// ensuite avec la date du jour + le fuseau via `Clock` pour obtenir une + /// époche-ms (§21.10-2). `None` ⇒ l'infra applique sa stratégie de parsing par + /// défaut. Donnée opaque au domaine. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub time_format: Option, +} + +impl RateLimitPattern { + /// Construit un motif validé (parse-don't-validate, comme + /// [`SessionStrategy::new`] / [`LivenessStrategy::new`]). + /// + /// # Errors + /// Renvoie [`DomainError::EmptyField`] (`"rateLimitPattern.pattern"`) si + /// `pattern` est vide. + pub fn new( + pattern: impl Into, + reset_capture: Option, + time_format: Option, + ) -> Result { + let pattern = pattern.into(); + crate::validation::non_empty(&pattern, "rateLimitPattern.pattern")?; + Ok(Self { + pattern, + reset_capture, + time_format, + }) + } +} + /// Adapter d'**exécution structurée** qui pilote un profil IA (ARCHITECTURE §17). /// /// Déclaratif, Open/Closed (comme [`EmbedderStrategy`]) : un profil déclare quel @@ -553,6 +611,21 @@ pub struct AgentProfile { /// un profil sans cette clé sérialise exactement comme avant. #[serde(default, skip_serializing_if = "Option::is_none")] pub liveness: Option, + /// Motif déclaratif de détection d'une **limite de session/débit** (ARCHITECTURE + /// §21, niveau 2). `None` (défaut, et valeur des profils existants) ⇒ **aucune** + /// détection par motif : seuls les agents structurés (niveau 1) ou le filet + /// humain (niveau 3) couvrent la limite. `Some(_)` ⇒ pour un agent **PTY/TUI sans + /// adapter structuré**, IdeA observe la sortie et émet un + /// [`crate::ports::ReplyEvent::RateLimited`] sur match. + /// + /// Le domaine ne porte que la **donnée** ([`RateLimitPattern`]) ; le moteur regex + /// + le parsing d'heure vivent en infra (§21.2-T2) — domaine dépendance-zéro + /// préservé. + /// + /// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation : + /// un profil sans cette clé sérialise exactement comme avant. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate_limit_pattern: Option, /// Séquence de soumission écrite **après** le texte d'une délégation pour la /// faire valider par la CLI (§20.3, fix Bug 1). Le portail d'écriture (front) /// écrit d'abord le texte (sans `\n`, pour esquiver la détection de paste de @@ -726,6 +799,7 @@ impl AgentProfile { mcp: None, prompt_ready_pattern: None, liveness: None, + rate_limit_pattern: None, submit_sequence: None, submit_delay_ms: None, projector: None, @@ -768,6 +842,15 @@ impl AgentProfile { self } + /// Builder : fixe le [`RateLimitPattern`] de détection de limite (§21, niveau 2) + /// et renvoie le profil. Laisse [`AgentProfile::new`] stable (zéro régression + /// d'appel) : les profils sans détection par motif ne l'appellent simplement pas. + #[must_use] + pub fn with_rate_limit_pattern(mut self, pattern: RateLimitPattern) -> Self { + self.rate_limit_pattern = Some(pattern); + self + } + /// Builder : fixe la [`Self::submit_sequence`] (§20.3, fix Bug 1) et renvoie le /// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les /// profils qui s'en remettent au défaut `"\r"` ne l'appellent simplement pas. @@ -1366,4 +1449,96 @@ mod mcp_tests { "transport expected; got: {toml}" ); } + + // -- §21 : rate_limit_pattern (détection de limite par motif, niveau 2) ------ + + #[test] + fn rate_limit_pattern_new_rejects_empty_pattern() { + let err = RateLimitPattern::new("", None, None).unwrap_err(); + assert!( + matches!(err, DomainError::EmptyField { field } if field == "rateLimitPattern.pattern"), + "un motif vide doit être rejeté; got: {err:?}" + ); + } + + #[test] + fn rate_limit_pattern_new_accepts_non_empty_pattern() { + let p = RateLimitPattern::new( + "rate limit.*resets at (?P.+)", + Some("reset".to_owned()), + Some("%H:%M".to_owned()), + ) + .expect("valid pattern"); + assert_eq!(p.pattern, "rate limit.*resets at (?P.+)"); + assert_eq!(p.reset_capture.as_deref(), Some("reset")); + assert_eq!(p.time_format.as_deref(), Some("%H:%M")); + } + + #[test] + fn profile_default_has_no_rate_limit_pattern() { + // Profils existants (via `new`) : aucun motif de limite. + assert!(profile_without_mcp().rate_limit_pattern.is_none()); + } + + #[test] + fn profile_without_rate_limit_pattern_omits_key_in_json() { + let json = serde_json::to_string(&profile_without_mcp()).expect("serialise"); + assert!( + !json.contains("rateLimitPattern"), + "a profile without a rate-limit pattern must NOT serialise the key (zero regression); got: {json}" + ); + } + + #[test] + fn legacy_json_without_rate_limit_pattern_deserialises_to_none() { + // JSON produit avant l'existence du champ : aucune clé `rateLimitPattern`. + let legacy = r#"{ + "id": "00000000-0000-0000-0000-000000000000", + "name": "Dev", + "command": "claude", + "args": [], + "contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" }, + "detect": null, + "cwdTemplate": "{agentRunDir}" + }"#; + let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise"); + assert!(profile.rate_limit_pattern.is_none()); + } + + #[test] + fn with_rate_limit_pattern_sets_and_round_trips_camel_case() { + let pattern = RateLimitPattern::new( + "limit reached, resets (?P.+)", + Some("resetAt".to_owned()), + Some("%-I%p".to_owned()), + ) + .expect("valid pattern"); + let profile = profile_without_mcp().with_rate_limit_pattern(pattern.clone()); + assert_eq!(profile.rate_limit_pattern, Some(pattern)); + + let json = serde_json::to_string(&profile).expect("serialise"); + assert!(json.contains("rateLimitPattern"), "key present: {json}"); + // camelCase respecté sur les champs de RateLimitPattern. + assert!(json.contains("resetCapture"), "camelCase field resetCapture: {json}"); + assert!(json.contains("timeFormat"), "camelCase field timeFormat: {json}"); + + let back: AgentProfile = serde_json::from_str(&json).expect("deserialise"); + assert_eq!(profile, back); + } + + #[test] + fn rate_limit_pattern_omits_unset_optional_fields_in_json() { + // reset_capture / time_format à None ⇒ leurs clés sont omises. + let pattern = RateLimitPattern::new("rate limited", None, None).expect("valid pattern"); + let json = serde_json::to_string(&pattern).expect("serialise"); + assert!(json.contains("\"pattern\""), "pattern field present: {json}"); + assert!( + !json.contains("resetCapture"), + "an unset resetCapture must be omitted; got: {json}" + ); + assert!( + !json.contains("timeFormat"), + "an unset timeFormat must be omitted; got: {json}" + ); + } } diff --git a/crates/domain/src/readiness.rs b/crates/domain/src/readiness.rs index 71b6559..459d993 100644 --- a/crates/domain/src/readiness.rs +++ b/crates/domain/src/readiness.rs @@ -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` 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, + }, } /// 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 { 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); + } } diff --git a/crates/domain/src/session_limit.rs b/crates/domain/src/session_limit.rs new file mode 100644 index 0000000..0caaf17 --- /dev/null +++ b/crates/domain/src/session_limit.rs @@ -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, + /// 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()); + } +}