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:
@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user