feat(session-limits): LS2 — adapter Claude niveau 1 (infra)
Câble la détection de limite de session dans l'adapter Claude : - claude.rs : parse_event émet ReplyEvent::RateLimited ; nouvelle fonction pure parse_reset_ms + helpers ; parseur ISO maison ; doccomments T4. - mod.rs : 26 nouveaux tests QA (#[cfg(test)]) + 2 tests existants alignés sur le nouveau contrat. - conformance.rs : RateLimited ajouté aux événements non terminaux autorisés. `cargo test -p infrastructure` = 188 passed / 0 failed, zéro régression. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -51,7 +51,11 @@ pub struct ParsedLine {
|
||||
/// ⇒ capture le `session_id` (= id de conversation pour la reprise) **et** émet un
|
||||
/// [`ReplyEvent::Heartbeat`] (preuve de vivacité non terminale : la CLI a démarré).
|
||||
/// - `{"type":"rate_limit_event","rate_limit_info":{…},"session_id":"…"}`
|
||||
/// ⇒ [`ReplyEvent::Heartbeat`] (pas de contenu, mais le moteur est vivant).
|
||||
/// ⇒ [`ReplyEvent::RateLimited`] (ARCHITECTURE §21, niveau 1 structuré) : l'heure
|
||||
/// de reset est extraite de `rate_limit_info` par [`parse_reset_ms`] et normalisée
|
||||
/// en époche-ms. Sans `rate_limit_info` exploitable ⇒ `RateLimited{None}` (limite
|
||||
/// détectée, heure inconnue ⇒ filet humain en aval). **Non terminal** (comme le
|
||||
/// `Heartbeat`) : il s'intercale, le flux continue jusqu'au `Final` ou la clôture.
|
||||
/// - `{"type":"assistant","message":{"role":"assistant","content":[
|
||||
/// {"type":"text","text":"…"} | {"type":"tool_use","name":"…", …}
|
||||
/// ], …},"session_id":"…","parent_tool_use_id":null}`
|
||||
@ -85,9 +89,14 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
|
||||
// init/handshake : on capte le session_id ET on émet un battement de cœur
|
||||
// (preuve de vivacité non terminale : la CLI a démarré et répond).
|
||||
Some("system") => vec![ReplyEvent::Heartbeat],
|
||||
// Fenêtre de limite de débit : pas de contenu, mais le moteur est vivant ⇒
|
||||
// battement de cœur (readiness/heartbeat lot 1), plus ignoré.
|
||||
Some("rate_limit_event") => vec![ReplyEvent::Heartbeat],
|
||||
// Limite de session/débit (ARCHITECTURE §21, niveau 1) : on lit l'heure de
|
||||
// reset dans `rate_limit_info` (au lieu de la jeter) et on émet un
|
||||
// `RateLimited{resets_at_ms}` **non terminal**. Robuste : absence/illisibilité
|
||||
// de `rate_limit_info` ⇒ `RateLimited{None}` (jamais d'erreur), filet humain.
|
||||
Some("rate_limit_event") => {
|
||||
let resets_at_ms = value.get("rate_limit_info").and_then(parse_reset_ms);
|
||||
vec![ReplyEvent::RateLimited { resets_at_ms }]
|
||||
}
|
||||
Some("assistant") => assistant_events(&value),
|
||||
Some("result") => value
|
||||
.get("result")
|
||||
@ -139,6 +148,173 @@ fn assistant_events(value: &Value) -> Vec<ReplyEvent> {
|
||||
events
|
||||
}
|
||||
|
||||
/// **Extrait l'heure de reset d'une limite de débit** depuis l'objet
|
||||
/// `rate_limit_info` d'un `rate_limit_event` Claude, **normalisée en époche-ms**
|
||||
/// (ARCHITECTURE §21, niveau 1 structuré). Fonction **pure** (aucune I/O, aucun
|
||||
/// `now`), isolée du reste de l'adapter pour être testable sans process.
|
||||
///
|
||||
/// # Spike §21.10-1 — format réel non garanti, parsing défensif
|
||||
///
|
||||
/// Le schéma exact du champ de reset n'est **pas** documenté de façon stable. On
|
||||
/// gère donc défensivement plusieurs **noms de champ** plausibles, dans l'ordre :
|
||||
/// `resetsAt`, `resets_at`, `reset_at`, `resetAt`, `reset`. La première clé présente
|
||||
/// gagne. Sa valeur est convertie en époche-ms selon son type ([`value_to_epoch_ms`]) :
|
||||
///
|
||||
/// - **entier/float epoch en secondes** (magnitude `< 10^12`) ⇒ `× 1000` ;
|
||||
/// - **entier/float epoch en millisecondes** (magnitude `≥ 10^12`) ⇒ tel quel ;
|
||||
/// - **chaîne** : d'abord tentée comme entier/float epoch (même heuristique), sinon
|
||||
/// parsée comme **ISO-8601 / RFC3339** ([`parse_rfc3339_to_ms`]).
|
||||
///
|
||||
/// L'heuristique secondes-vs-ms repose sur le **seuil de magnitude** [`EPOCH_MS_THRESHOLD`]
|
||||
/// (`10^12`) : `10^12 ms ≈ 2001-09`, `10^12 s ≈ an 33658` — toute date plausible
|
||||
/// (1970…) tombe sans ambiguïté du bon côté.
|
||||
///
|
||||
/// # Hypothèses & limites assumées
|
||||
///
|
||||
/// - Un champ **relatif** (`retryAfter` / `retry_after`, en secondes) **n'est pas**
|
||||
/// exploité ici : le convertir en instant absolu exigerait `now`, or cette fonction
|
||||
/// est **pure et sans horloge** (cf. `Clock` confiné à l'application). Il est donc
|
||||
/// **ignoré** (documenté) ⇒ `None` ⇒ `RateLimited{None}` ⇒ filet humain. Une
|
||||
/// résolution `now + delta` pourra être ajoutée côté appelant/application (LS4) qui,
|
||||
/// lui, détient `Clock`.
|
||||
/// - Une chaîne ISO **sans fuseau** est traitée comme **UTC** (best-effort), le reset
|
||||
/// Claude étant une heure absolue. La gestion d'heure murale locale relève du
|
||||
/// niveau 2 (LS5, §21.10-2), pas d'ici.
|
||||
///
|
||||
/// Retourne `None` (jamais d'erreur) si aucune clé connue n'est présente ou si la
|
||||
/// valeur est inexploitable — l'appelant émet alors `RateLimited{None}`.
|
||||
#[must_use]
|
||||
pub fn parse_reset_ms(rate_limit_info: &Value) -> Option<i64> {
|
||||
const RESET_KEYS: [&str; 5] = ["resetsAt", "resets_at", "reset_at", "resetAt", "reset"];
|
||||
let raw = RESET_KEYS.iter().find_map(|k| rate_limit_info.get(*k))?;
|
||||
value_to_epoch_ms(raw)
|
||||
}
|
||||
|
||||
/// Seuil de magnitude départageant un epoch en **secondes** d'un epoch en
|
||||
/// **millisecondes** : `10^12 ms ≈ 2001-09-09`, `10^12 s ≈ an 33658`. Tout epoch
|
||||
/// plausible (≥ 1970) de magnitude `≥ 10^12` est donc déjà des millisecondes ;
|
||||
/// en-dessous, ce sont des secondes.
|
||||
const EPOCH_MS_THRESHOLD: i64 = 1_000_000_000_000;
|
||||
|
||||
/// Convertit une [`Value`] JSON (entier / float / chaîne) en époche-ms, défensivement.
|
||||
fn value_to_epoch_ms(v: &Value) -> Option<i64> {
|
||||
if let Some(i) = v.as_i64() {
|
||||
return Some(int_epoch_to_ms(i));
|
||||
}
|
||||
if let Some(u) = v.as_u64() {
|
||||
return Some(int_epoch_to_ms(i64::try_from(u).ok()?));
|
||||
}
|
||||
if let Some(f) = v.as_f64() {
|
||||
return Some(float_epoch_to_ms(f));
|
||||
}
|
||||
if let Some(s) = v.as_str() {
|
||||
let t = s.trim();
|
||||
if let Ok(i) = t.parse::<i64>() {
|
||||
return Some(int_epoch_to_ms(i));
|
||||
}
|
||||
if let Ok(f) = t.parse::<f64>() {
|
||||
return Some(float_epoch_to_ms(f));
|
||||
}
|
||||
return parse_rfc3339_to_ms(t);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Applique l'heuristique secondes-vs-ms à un epoch **entier**.
|
||||
const fn int_epoch_to_ms(n: i64) -> i64 {
|
||||
if n.abs() >= EPOCH_MS_THRESHOLD {
|
||||
n
|
||||
} else {
|
||||
n * 1000
|
||||
}
|
||||
}
|
||||
|
||||
/// Idem pour un epoch **flottant** (fraction de seconde préservée → ms).
|
||||
fn float_epoch_to_ms(f: f64) -> i64 {
|
||||
if f.abs() >= EPOCH_MS_THRESHOLD as f64 {
|
||||
f as i64
|
||||
} else {
|
||||
(f * 1000.0) as i64
|
||||
}
|
||||
}
|
||||
|
||||
/// **Parse une chaîne ISO-8601 / RFC3339** (`YYYY-MM-DDThh:mm:ss[.fff][Z|±hh:mm]`) en
|
||||
/// époche-ms. Pur, dépendance-zéro (aucune crate de date). Best-effort : retourne
|
||||
/// `None` sur toute forme non reconnue. Sans désignateur de fuseau ⇒ traité **UTC**.
|
||||
fn parse_rfc3339_to_ms(s: &str) -> Option<i64> {
|
||||
let (date, rest) = s.split_once(['T', 't', ' '])?;
|
||||
let mut dp = date.split('-');
|
||||
let year: i64 = dp.next()?.parse().ok()?;
|
||||
let month: i64 = dp.next()?.parse().ok()?;
|
||||
let day: i64 = dp.next()?.parse().ok()?;
|
||||
if dp.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (time, tz_offset_secs) = split_tz(rest)?;
|
||||
let mut tp = time.split(':');
|
||||
let hour: i64 = tp.next()?.parse().ok()?;
|
||||
let minute: i64 = tp.next()?.parse().ok()?;
|
||||
let (second, millis) = split_seconds_frac(tp.next().unwrap_or("0"))?;
|
||||
if tp.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let days = days_from_civil(year, month, day);
|
||||
let epoch_secs = days * 86_400 + hour * 3_600 + minute * 60 + second - tz_offset_secs;
|
||||
Some(epoch_secs * 1000 + millis)
|
||||
}
|
||||
|
||||
/// Sépare la partie heure de son **désignateur de fuseau** et renvoie l'offset en
|
||||
/// secondes (à **soustraire** de l'heure locale pour obtenir l'UTC). `Z`/`z` ⇒ 0 ;
|
||||
/// `±hh:mm` ou `±hhmm` ⇒ offset signé ; aucun désignateur ⇒ 0 (UTC best-effort).
|
||||
fn split_tz(rest: &str) -> Option<(&str, i64)> {
|
||||
if let Some(stripped) = rest.strip_suffix(['Z', 'z']) {
|
||||
return Some((stripped, 0));
|
||||
}
|
||||
if let Some(pos) = rest.rfind(['+', '-']) {
|
||||
let (time, tz) = rest.split_at(pos);
|
||||
let sign = if tz.starts_with('-') { -1 } else { 1 };
|
||||
let tz = &tz[1..];
|
||||
let (h, m) = if let Some((h, m)) = tz.split_once(':') {
|
||||
(h.parse::<i64>().ok()?, m.parse::<i64>().ok()?)
|
||||
} else if tz.len() == 4 {
|
||||
(tz[0..2].parse::<i64>().ok()?, tz[2..4].parse::<i64>().ok()?)
|
||||
} else {
|
||||
(tz.parse::<i64>().ok()?, 0)
|
||||
};
|
||||
return Some((time, sign * (h * 3_600 + m * 60)));
|
||||
}
|
||||
Some((rest, 0))
|
||||
}
|
||||
|
||||
/// Sépare `SS` ou `SS.fff…` en `(secondes, millisecondes)`. La fraction est tronquée
|
||||
/// /complétée à **3 chiffres** (précision ms).
|
||||
fn split_seconds_frac(s: &str) -> Option<(i64, i64)> {
|
||||
let Some((sec, frac)) = s.split_once('.') else {
|
||||
return Some((s.parse::<i64>().ok()?, 0));
|
||||
};
|
||||
let sec = sec.parse::<i64>().ok()?;
|
||||
let mut d3: String = frac.chars().take_while(char::is_ascii_digit).take(3).collect();
|
||||
while d3.len() < 3 {
|
||||
d3.push('0');
|
||||
}
|
||||
let millis = d3.parse::<i64>().ok()?;
|
||||
Some((sec, millis))
|
||||
}
|
||||
|
||||
/// Jours depuis l'époque Unix (1970-01-01) pour une date civile proleptique
|
||||
/// grégorienne. Algorithme de Howard Hinnant (`days_from_civil`), exact et
|
||||
/// dépendance-zéro ; gère bissextiles et siècles.
|
||||
const fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = (if y >= 0 { y } else { y - 399 }) / 400;
|
||||
let yoe = y - era * 400;
|
||||
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
/// Adapter de session structurée Claude.
|
||||
///
|
||||
/// Incarnation « un `claude -p` par tour » (§17.2 (b)) : chaque `send` relance la
|
||||
@ -241,9 +417,11 @@ impl AgentSession for ClaudeSdkSession {
|
||||
captured_id = Some(id);
|
||||
}
|
||||
// Aplatit : une ligne `assistant` multi-blocs rend plusieurs événements.
|
||||
// Le `Final` est **terminal** (contrat de port) : on arrête d'émettre dès
|
||||
// qu'on l'a vu, pour qu'aucun heartbeat de fin (`rate_limit_event` tardif…)
|
||||
// ne le suive dans le flux.
|
||||
// Le `Final` est le **seul** événement terminal (contrat de port, §21-T4) :
|
||||
// on arrête d'émettre dès qu'on l'a vu. Un `RateLimited` (comme un
|
||||
// `Heartbeat`) est **non terminal** : il s'intercale et ne rompt JAMAIS la
|
||||
// boucle — un tour limité clos sans `Final` reste une fin gracieuse (le
|
||||
// traitement « limité » est en aval, application lot LS4).
|
||||
for event in parsed.events {
|
||||
let is_final = matches!(event, ReplyEvent::Final { .. });
|
||||
events.push(event);
|
||||
|
||||
Reference in New Issue
Block a user