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

@ -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).
///
/// 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<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
/// 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<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}"
);
}
}