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:
2026-06-16 14:33:57 +02:00
parent fa5b826df5
commit 0bf1eb3b11
6 changed files with 647 additions and 1 deletions

View File

@ -209,6 +209,55 @@ pub enum DomainEvent {
/// Target profile's submit delay in ms. `None` ⇒ front default (~60 ms). /// Target profile's submit delay in ms. `None` ⇒ front default (~60 ms).
submit_delay_ms: Option<u32>, submit_delay_ms: Option<u32>,
}, },
/// 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<i64>,
},
/// 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<i64>,
},
/// Raw PTY output (usually routed to a dedicated channel, not this bus). /// Raw PTY output (usually routed to a dedicated channel, not this bus).
PtyOutput { PtyOutput {
/// The session. /// The session.
@ -217,3 +266,104 @@ pub enum DomainEvent {
bytes: Vec<u8>, bytes: Vec<u8>,
}, },
} }
#[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) }
);
}
}

View File

@ -50,6 +50,7 @@ pub mod profile;
pub mod project; pub mod project;
pub mod readiness; pub mod readiness;
pub mod sandbox; pub mod sandbox;
pub mod session_limit;
pub mod remote; pub mod remote;
pub mod skill; pub mod skill;
pub mod template; pub mod template;
@ -78,7 +79,7 @@ pub use template::{AgentTemplate, TemplateVersion};
pub use profile::{ pub use profile::{
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, LivenessStrategy, AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, LivenessStrategy,
McpServerWiring, SessionStrategy, McpServerWiring, RateLimitPattern, SessionStrategy,
}; };
pub use mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId}; 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 readiness::{ReadinessPolicy, ReadinessSignal};
pub use session_limit::{plan_resume, ResumePlan, RateLimitSource, SessionLimit};
pub use conversation_log::{ pub use conversation_log::{
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer, ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
ProviderSessionStore, TurnId, TurnRole, ProviderSessionStore, TurnId, TurnRole,

View File

@ -225,6 +225,26 @@ pub enum ReplyEvent {
/// jusqu'au [`ReplyEvent::Final`]. Un tour comporte ≥0 `Heartbeat`, jamais /// jusqu'au [`ReplyEvent::Final`]. Un tour comporte ≥0 `Heartbeat`, jamais
/// d'obligation d'en émettre. /// d'obligation d'en émettre.
Heartbeat, 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<i64>,
},
/// **Événement terminal déterministe** d'un tour : l'adapter l'émet quand il a /// **É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é. /// 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). /// Après `Final`, le flux se termine (plus aucun événement).

View File

@ -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<String>,
/// **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<String>,
}
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<String>,
reset_capture: Option<String>,
time_format: Option<String>,
) -> Result<Self, DomainError> {
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). /// Adapter d'**exécution structurée** qui pilote un profil IA (ARCHITECTURE §17).
/// ///
/// Déclaratif, Open/Closed (comme [`EmbedderStrategy`]) : un profil déclare quel /// 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. /// un profil sans cette clé sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub liveness: Option<LivenessStrategy>, pub liveness: Option<LivenessStrategy>,
/// 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<RateLimitPattern>,
/// Séquence de soumission écrite **après** le texte d'une délégation pour la /// 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) /// 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 /// écrit d'abord le texte (sans `\n`, pour esquiver la détection de paste de
@ -726,6 +799,7 @@ impl AgentProfile {
mcp: None, mcp: None,
prompt_ready_pattern: None, prompt_ready_pattern: None,
liveness: None, liveness: None,
rate_limit_pattern: None,
submit_sequence: None, submit_sequence: None,
submit_delay_ms: None, submit_delay_ms: None,
projector: None, projector: None,
@ -768,6 +842,15 @@ impl AgentProfile {
self 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 /// 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 /// 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. /// 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}" "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<reset>.+)",
Some("reset".to_owned()),
Some("%H:%M".to_owned()),
)
.expect("valid pattern");
assert_eq!(p.pattern, "rate limit.*resets at (?P<reset>.+)");
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<resetAt>.+)",
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}"
);
}
} }

View File

@ -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 /// Le garde-fou de durée de tour a expiré. Place réservée au **lot 2** — non
/// produit dans ce lot. /// produit dans ce lot.
TimedOut, 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 /// Politique **pure** de classification d'un événement de tour en
@ -63,6 +77,11 @@ impl ReadinessPolicy {
/// ///
/// - [`ReplyEvent::Final`] ⇒ `Some(`[`ReadinessSignal::TurnEnded`]`)` : seul /// - [`ReplyEvent::Final`] ⇒ `Some(`[`ReadinessSignal::TurnEnded`]`)` : seul
/// événement terminal, il signe la fin de tour déterministe. /// é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::TextDelta`] / [`ReplyEvent::ToolActivity`] /
/// [`ReplyEvent::Heartbeat`] ⇒ `None` : tous **non terminaux** (le flux /// [`ReplyEvent::Heartbeat`] ⇒ `None` : tous **non terminaux** (le flux
/// continue). Un heartbeat prouve la vivacité mais ne termine pas le tour. /// 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> { pub const fn classify(event: &ReplyEvent) -> Option<ReadinessSignal> {
match event { match event {
ReplyEvent::Final { .. } => Some(ReadinessSignal::TurnEnded), ReplyEvent::Final { .. } => Some(ReadinessSignal::TurnEnded),
ReplyEvent::RateLimited { resets_at_ms } => {
Some(ReadinessSignal::RateLimited {
resets_at_ms: *resets_at_ms,
})
}
ReplyEvent::TextDelta { .. } ReplyEvent::TextDelta { .. }
| ReplyEvent::ToolActivity { .. } | ReplyEvent::ToolActivity { .. }
| ReplyEvent::Heartbeat => None, | ReplyEvent::Heartbeat => None,
@ -108,4 +132,51 @@ mod tests {
"un heartbeat prouve la vivacité mais ne termine JAMAIS le tour" "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);
}
} }

View 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());
}
}