Files
IdeA/crates/infrastructure/src/ratelimit/mod.rs
Blomios 287681c198 feat(orchestrator): modèle de désignation d'orchestrateur + sink de diagnostic
Introduit le modèle AgentManifest { version, entries, orchestrator } et la
garde d'écriture directe may_write_directly(..., &OrchestratorDesignation) :
seul l'orchestrateur désigné peut écrire directement, les autres passent par
le rendez-vous médié. Câble la désignation à travers domain → application →
infrastructure → app-tauri (context_guard, service, lifecycle, ports).

Ajoute crates/application/src/diag.rs : sink de diagnostic best-effort, sans
dépendance, qui miroite les traces du rendez-vous inter-agents de
l'orchestrateur vers un fichier de log persistant (utile au lancement via
AppImage où stderr est jeté), avec la même discipline « zéro dépendance,
ne casse jamais le rendez-vous ».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 08:56:39 +02:00

395 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Détecteur de limite de session **niveau 2 — déclaratif** (ARCHITECTURE §21,
//! niveau 2 de la hiérarchie §21.1). Pour les agents **PTY/TUI sans adapter
//! structuré**, on n'a pas de flux machine : on **observe la sortie texte** et on y
//! cherche le motif déclaré par le profil ([`RateLimitPattern`], LS1).
//!
//! # Frontière T2 respectée
//!
//! Le **domaine** ne porte que la *donnée* du motif ; le **moteur regex** et le
//! **parsing d'heure** vivent **ici** (§21.2-T2). Aucune `regex` ne franchit la
//! frontière domaine : ce module produit un [`SessionLimit`] (valeur domaine pure).
//!
//! # Robustesse (« solide même pour un novice »)
//!
//! Une regex **invalide** dans un profil mal configuré ⇒ [`RateLimitParser::new`]
//! renvoie `None` (pas de détecteur) : **jamais** de panique ni d'erreur fatale. Un
//! profil pourri ne doit pas faire tomber IdeA.
//!
//! # Anti-double-détection niveau 1 / niveau 2 (§21.10-4)
//!
//! Le niveau 2 ne s'applique **qu'aux agents sans adapter structuré** : voir
//! [`applies`]. Un agent qui a un `structured_adapter` détecte sa limite par le
//! niveau 1 (flux machine, `session/claude.rs`) ; on ne lui arme **pas** de parser
//! niveau 2 ⇒ pas de double détection. C'est la **règle de sélection** que le câblage
//! (LS7) doit appliquer pour décider d'instancier — ou non — un [`RateLimitParser`].
use regex::Regex;
use domain::profile::{AgentProfile, RateLimitPattern};
use domain::session_limit::{RateLimitSource, SessionLimit};
use crate::timeparse;
/// Règle de sélection (§21.10-4) : le détecteur niveau 2 ne s'applique qu'aux agents
/// **sans adapter structuré** (sinon le niveau 1 détecte déjà) **et** dont le profil
/// déclare un `rate_limit_pattern`. Source **unique** de la règle anti-double-
/// détection, à appeler par le câblage PTY (LS7) avant d'instancier un parser.
#[must_use]
pub fn applies(profile: &AgentProfile) -> bool {
profile.structured_adapter.is_none() && profile.rate_limit_pattern.is_some()
}
/// Stratégie d'interprétation de l'heure capturée, déduite **une seule fois** du
/// champ `time_format` du profil (donnée opaque au domaine, comprise ici).
///
/// Jeux de jetons reconnus (insensibles à la casse) ; tout le reste ⇒ [`Self::Auto`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ResetTimeFormat {
/// `None`/inconnu : best-effort **absolu** (epoch entier/float, ou ISO-8601).
Auto,
/// `"epoch_s"` / `"epoch_seconds"` / `"unix_s"` : entier epoch en **secondes**.
EpochSeconds,
/// `"epoch_ms"` / `"epoch_millis"` / `"unix_ms"` : entier epoch en **ms**.
EpochMillis,
/// `"iso8601"` / `"rfc3339"` / `"iso"` : chaîne ISO-8601 absolue.
Iso8601,
/// `"relative_s"` / `"relative_seconds"` / `"duration_s"` / `"retry_after_s"` :
/// **délai** en secondes ⇒ `now + delta`.
RelativeSeconds,
/// `"relative_ms"` / `"relative_millis"` : **délai** en ms ⇒ `now + delta`.
RelativeMillis,
/// `"wall"` / `"wall_clock"` / `"local"` / `"hh:mm"` : **heure murale locale**
/// (« 3pm », « 15:00 ») ⇒ aujourd'hui à cette heure, demain si déjà passée.
WallClock,
}
impl ResetTimeFormat {
/// Déduit la stratégie depuis le `time_format` déclaratif (ou `Auto` si absent).
fn from_opt(time_format: Option<&str>) -> Self {
let Some(raw) = time_format else {
return Self::Auto;
};
match raw.trim().to_ascii_lowercase().as_str() {
"epoch_s" | "epoch_seconds" | "unix_s" => Self::EpochSeconds,
"epoch_ms" | "epoch_millis" | "unix_ms" => Self::EpochMillis,
"iso8601" | "rfc3339" | "iso" => Self::Iso8601,
"relative_s" | "relative_seconds" | "duration_s" | "retry_after_s" => {
Self::RelativeSeconds
}
"relative_ms" | "relative_millis" => Self::RelativeMillis,
"wall" | "wall_clock" | "local" | "hh:mm" => Self::WallClock,
_ => Self::Auto,
}
}
/// Convertit la chaîne capturée en époche-ms selon la stratégie, en référence à
/// `now_ms` pour les heures relatives/murales. `None` si la capture est
/// inexploitable (⇒ détection sans heure ⇒ filet humain en aval).
fn resolve(self, captured: &str, now_ms: i64) -> Option<i64> {
let c = captured.trim();
match self {
Self::Auto => timeparse::parse_absolute_ms(c),
Self::EpochSeconds => c.parse::<i64>().ok().map(|s| s.saturating_mul(1000)),
Self::EpochMillis => c.parse::<i64>().ok(),
Self::Iso8601 => timeparse::parse_rfc3339_to_ms(c),
Self::RelativeSeconds => c.parse::<f64>().ok().map(|d| now_ms + (d * 1000.0) as i64),
Self::RelativeMillis => c.parse::<i64>().ok().map(|d| now_ms + d),
Self::WallClock => {
let (h, m, s) = timeparse::parse_wall_clock(c)?;
Some(timeparse::wall_clock_to_ms(now_ms, h, m, s))
}
}
}
}
/// Détecteur niveau 2 : un **motif regex compilé une fois** + sa stratégie d'heure.
///
/// À instancier **par agent** (au câblage PTY, LS7) à partir de son
/// `rate_limit_pattern`, puis à nourrir avec chaque fragment de sortie via
/// [`detect`](Self::detect). La compilation (coûteuse) est faite **une seule fois**
/// à la construction, pas à chaque fragment.
pub struct RateLimitParser {
/// Motif compilé recherché dans la sortie.
regex: Regex,
/// Nom (ou index décimal) du groupe de capture d'où extraire l'heure de reset.
/// `None` ⇒ détection **sans** extraction d'heure (⇒ `resets_at_ms: None`).
reset_capture: Option<String>,
/// Stratégie d'interprétation de la capture, pré-calculée.
time_format: ResetTimeFormat,
}
impl RateLimitParser {
/// Compile le motif du profil. `None` si la regex est **invalide** (profil mal
/// configuré) — robustesse : jamais de panique, l'agent tourne juste sans
/// détection niveau 2.
#[must_use]
pub fn new(pattern: &RateLimitPattern) -> Option<Self> {
let regex = Regex::new(&pattern.pattern).ok()?;
Some(Self {
regex,
reset_capture: pattern.reset_capture.clone(),
time_format: ResetTimeFormat::from_opt(pattern.time_format.as_deref()),
})
}
/// Analyse un fragment de sortie texte.
///
/// - **pas de match** ⇒ `None` (pas de limite) ;
/// - **match** ⇒ `Some(SessionLimit)` avec `source = Pattern`,
/// `detected_at_ms = now_ms`, et `resets_at_ms` extrait du groupe de capture
/// `reset_capture` interprété selon `time_format` — `None` si pas de capture,
/// groupe absent, ou heure inexploitable (détection **utile sans heure** ⇒ filet
/// humain en aval, §21).
#[must_use]
pub fn detect(&self, text: &str, now_ms: i64) -> Option<SessionLimit> {
let caps = self.regex.captures(text)?;
let resets_at_ms = self.reset_capture.as_deref().and_then(|name| {
// Groupe nommé en priorité ; repli sur un index décimal si `name` en est un.
let raw = caps
.name(name)
.or_else(|| name.parse::<usize>().ok().and_then(|i| caps.get(i)))?;
self.time_format.resolve(raw.as_str(), now_ms)
});
Some(SessionLimit::new(
resets_at_ms,
now_ms,
RateLimitSource::Pattern,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::ids::ProfileId;
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
/// Instant de référence (epoch-ms) : 2023-11-14T22:13:20Z = 1_700_000_000 s.
const NOW: i64 = 1_700_000_000_000;
/// Début de journée UTC du 2023-11-14 (= 19675 × 86_400_000), pour les heures murales.
const DAY_START: i64 = 1_699_920_000_000;
fn pattern(
pat: &str,
reset_capture: Option<&str>,
time_format: Option<&str>,
) -> RateLimitPattern {
RateLimitPattern::new(
pat.to_owned(),
reset_capture.map(str::to_owned),
time_format.map(str::to_owned),
)
.expect("motif valide (non vide)")
}
// -- new : robustesse regex ----------------------------------------------
#[test]
fn new_returns_none_on_invalid_regex() {
// Parenthèse ouvrante non fermée ⇒ regex invalide ⇒ None (jamais de panique).
let p = pattern("rate limit (", None, None);
assert!(RateLimitParser::new(&p).is_none());
}
// -- detect : pas de match -----------------------------------------------
#[test]
fn detect_returns_none_when_pattern_does_not_match() {
let parser = RateLimitParser::new(&pattern("rate limit reached", None, None)).unwrap();
assert!(parser.detect("tout va bien", NOW).is_none());
}
// -- detect : match sans reset_capture -----------------------------------
#[test]
fn detect_match_without_reset_capture_has_no_time() {
let parser = RateLimitParser::new(&pattern("rate limit reached", None, None)).unwrap();
let limit = parser
.detect("oops: rate limit reached!", NOW)
.expect("détecté");
assert_eq!(limit.resets_at_ms, None);
assert_eq!(limit.detected_at_ms, NOW);
assert_eq!(limit.source, RateLimitSource::Pattern);
}
// -- detect : formats ABSOLUS --------------------------------------------
#[test]
fn detect_epoch_seconds_format() {
let parser = RateLimitParser::new(&pattern(
r"resets at (?P<reset>\d+)",
Some("reset"),
Some("epoch_s"),
))
.unwrap();
let limit = parser
.detect("limit, resets at 1700000000 ok", NOW)
.expect("détecté");
assert_eq!(limit.resets_at_ms, Some(1_700_000_000_000));
assert_eq!(limit.source, RateLimitSource::Pattern);
}
#[test]
fn detect_epoch_millis_format() {
let parser = RateLimitParser::new(&pattern(
r"resets at (?P<reset>\d+)",
Some("reset"),
Some("epoch_ms"),
))
.unwrap();
let limit = parser
.detect("resets at 1700000000000", NOW)
.expect("détecté");
assert_eq!(limit.resets_at_ms, Some(1_700_000_000_000));
}
#[test]
fn detect_iso8601_format() {
let parser = RateLimitParser::new(&pattern(
r"resets at (?P<reset>\S+)",
Some("reset"),
Some("iso8601"),
))
.unwrap();
let limit = parser
.detect("resets at 2023-11-14T22:13:20Z", NOW)
.expect("détecté");
assert_eq!(limit.resets_at_ms, Some(1_700_000_000_000));
}
// -- detect : format RELATIF ---------------------------------------------
#[test]
fn detect_relative_seconds_format_uses_now() {
let parser = RateLimitParser::new(&pattern(
r"retry after (?P<reset>\d+)s",
Some("reset"),
Some("relative_s"),
))
.unwrap();
let limit = parser.detect("retry after 600s", NOW).expect("détecté");
assert_eq!(limit.resets_at_ms, Some(NOW + 600_000));
assert_eq!(limit.source, RateLimitSource::Pattern);
}
// -- detect : format MURAL (passage de minuit) ---------------------------
/// « 3pm » alors qu'il est 10 h ⇒ 15 h AUJOURD'HUI (même jour UTC).
#[test]
fn detect_wall_clock_same_day_when_future() {
let parser = RateLimitParser::new(&pattern(
r"resets at (?P<reset>.+)",
Some("reset"),
Some("wall"),
))
.unwrap();
let now_10h = DAY_START + 10 * 3_600_000; // 10:00 UTC ce jour-là
let limit = parser.detect("resets at 3pm", now_10h).expect("détecté");
// 15:00 le même jour.
assert_eq!(limit.resets_at_ms, Some(DAY_START + 15 * 3_600_000));
}
/// « 3pm » alors qu'il est 16 h ⇒ 15 h DEMAIN (déjà passé ⇒ +24 h).
#[test]
fn detect_wall_clock_next_day_when_past() {
let parser = RateLimitParser::new(&pattern(
r"resets at (?P<reset>.+)",
Some("reset"),
Some("wall"),
))
.unwrap();
let now_16h = DAY_START + 16 * 3_600_000; // 16:00 UTC
let limit = parser.detect("resets at 3pm", now_16h).expect("détecté");
// 15:00 le LENDEMAIN.
assert_eq!(
limit.resets_at_ms,
Some(DAY_START + 15 * 3_600_000 + 86_400_000)
);
}
// -- detect : match mais capture inexploitable ⇒ détection sans heure -----
#[test]
fn detect_match_with_missing_capture_group_has_no_time() {
// reset_capture nommé "reset" mais le motif ne définit aucun groupe "reset".
let parser =
RateLimitParser::new(&pattern("rate limited", Some("reset"), Some("epoch_s"))).unwrap();
let limit = parser.detect("rate limited", NOW).expect("détecté");
assert_eq!(limit.resets_at_ms, None);
assert_eq!(limit.source, RateLimitSource::Pattern);
}
#[test]
fn detect_match_with_unparsable_value_has_no_time() {
// Groupe présent mais valeur non numérique pour un format epoch_s ⇒ None.
let parser = RateLimitParser::new(&pattern(
r"reset (?P<reset>\w+)",
Some("reset"),
Some("epoch_s"),
))
.unwrap();
let limit = parser.detect("reset abc", NOW).expect("détecté");
assert_eq!(limit.resets_at_ms, None);
}
// -- detect : appelable plusieurs fois (regex compilé une seule fois) -----
#[test]
fn detect_can_be_called_multiple_times() {
let parser = RateLimitParser::new(&pattern(
r"resets at (?P<reset>\d+)",
Some("reset"),
Some("epoch_s"),
))
.unwrap();
let a = parser
.detect("resets at 1700000000", NOW)
.expect("1er détecté");
assert!(parser.detect("rien ici", NOW).is_none());
let b = parser
.detect("resets at 1700000000", NOW)
.expect("2e détecté");
assert_eq!(a.resets_at_ms, b.resets_at_ms);
assert_eq!(a.resets_at_ms, Some(1_700_000_000_000));
}
// -- applies(profile) : règle anti-double-détection (§21.10-4) ------------
fn base_profile() -> AgentProfile {
AgentProfile::new(
ProfileId::from_uuid(uuid::Uuid::nil()),
"Dev",
"claude",
Vec::new(),
ContextInjection::convention_file("CLAUDE.md").expect("valide"),
None,
"{agentRunDir}",
None,
)
.expect("profil valide")
}
#[test]
fn applies_false_for_structured_profile_even_with_pattern() {
let profile = base_profile()
.with_structured_adapter(StructuredAdapter::Claude)
.with_rate_limit_pattern(pattern("rate limit", None, None));
assert!(
!applies(&profile),
"un agent structuré détecte par le niveau 1"
);
}
#[test]
fn applies_true_for_pty_profile_with_pattern() {
let profile = base_profile().with_rate_limit_pattern(pattern("rate limit", None, None));
assert!(applies(&profile));
}
#[test]
fn applies_false_for_pty_profile_without_pattern() {
assert!(!applies(&base_profile()));
}
}