fix(session-limit): brancher le handle de limite sur le chemin direct (#30)

Le handle de limite de session ne se déclenchait pas quand un agent
directement adressé (dont Main) touchait sa propre limite : le ReplyEvent
::RateLimited du chemin structuré direct n'était pas relayé au service de
limite.

On tap désormais ReplyEvent::RateLimited dans le registre terminal vers
SessionLimitService::on_rate_limited, qui émet AgentRateLimited puis
AgentResumeScheduled et arme la reprise auto annulable, exactement comme le
chemin délégué.

Couverture QA (sortie réelle) : structured_registry_d1 12 passed,
session_limit_wiring 6 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 13:19:11 +02:00
parent 631dd4eccf
commit 9430c65050
4 changed files with 200 additions and 20 deletions

View File

@ -1652,11 +1652,10 @@ pub async fn agent_send(
// `Final` event (or stream end / superseded channel), then detaches *only its
// own* generation so a concurrent re-attach is never torn down.
let bridge = std::sync::Arc::clone(&state.chat_bridge);
// Level-1 session-limit tap (§21, niveau 1 — structuré). Resolve the agent + host
// cell of this session once; a `RateLimited` turn event then feeds the service
// (détecter→planifier). DORMANT en composition B-2 (aucune session structurée
// vivante) mais câblé pour forward-compat. `conversation_id` non disponible ici ⇒
// `None` (acceptable LS7).
// Level-1 session-limit tap (§21, niveau 1 — structuré). Resolve the agent, host
// cell and engine conversation once from the live registry; a `RateLimited` turn
// event then feeds the service (détecter→planifier). Without a conversation id the
// service must not pretend an automatic resume is armed.
let service = std::sync::Arc::clone(&state.session_limit_service);
let meta = state.structured_sessions.meta_for_session(&sid);
std::thread::spawn(move || {
@ -1665,8 +1664,17 @@ pub async fn agent_send(
// (le badge UI vient du bus `AgentRateLimited`, pas du flux chat) puis on
// continue à drainer comme pour un battement.
if let domain::ports::ReplyEvent::RateLimited { resets_at_ms } = &event {
if let Some((agent_id, node_id)) = meta {
service.on_rate_limited(agent_id, node_id, None, *resets_at_ms);
if let Some((agent_id, node_id, conversation_id)) = &meta {
let (conversation_id, resets_at_ms) = match (conversation_id, resets_at_ms) {
(Some(conversation_id), resets_at_ms) => {
(Some(conversation_id.clone()), *resets_at_ms)
}
(None, None) => (None, None),
// Reset connu mais conversation moteur absente : ne pas armer
// une reprise non reprenable ; surfacer le fallback humain.
(None, Some(_)) => (None, None),
};
service.on_rate_limited(*agent_id, *node_id, conversation_id, resets_at_ms);
}
}
// Heartbeats carry no chat content (readiness/heartbeat lot 1) ⇒ no wire