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

View File

@ -11,12 +11,14 @@
//! unknown agent (never a panic). //! unknown agent (never a panic).
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use app_tauri_lib::state::AppState; use app_tauri_lib::state::AppState;
use async_trait::async_trait;
use domain::events::DomainEvent; use domain::events::DomainEvent;
use domain::ids::NodeId; use domain::ids::{NodeId, SessionId};
use domain::ports::IdGenerator; use domain::ports::{AgentSession, AgentSessionError, IdGenerator, ReplyStream};
use domain::AgentId; use domain::AgentId;
use infrastructure::UuidGenerator; use infrastructure::UuidGenerator;
@ -33,6 +35,34 @@ fn node(n: u128) -> NodeId {
NodeId::from_uuid(uuid::Uuid::from_u128(n)) NodeId::from_uuid(uuid::Uuid::from_u128(n))
} }
fn session(n: u128) -> SessionId {
SessionId::from_uuid(uuid::Uuid::from_u128(n))
}
struct LiveStructuredSession {
id: SessionId,
conversation_id: Option<String>,
}
#[async_trait]
impl AgentSession for LiveStructuredSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
self.conversation_id.clone()
}
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
Ok(Box::new(std::iter::empty()))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
/// `cancel_resume` on an agent with no armed resume returns `false` (and never /// `cancel_resume` on an agent with no armed resume returns `false` (and never
/// panics): the service is wired and behaves as a clean no-op. /// panics): the service is wired and behaves as a clean no-op.
#[tokio::test] #[tokio::test]
@ -97,6 +127,134 @@ async fn on_rate_limited_arms_a_cancellable_resume_over_the_real_bus() {
assert!(saw_cancelled, "AgentResumeCancelled relayed on the bus"); assert!(saw_cancelled, "AgentResumeCancelled relayed on the bus");
} }
/// B1/B2 direct : le tap structuré doit résoudre `agent_id` + `node_id` +
/// `conversation_id` depuis le registre vivant avant d'appeler `on_rate_limited`.
/// Avec un reset connu et une conversation moteur connue, le service publie
/// `AgentRateLimited` puis `AgentResumeScheduled`.
#[tokio::test]
async fn direct_structured_rate_limit_uses_live_meta_and_arms_resume() {
let state = AppState::build(temp_path("appdata"));
let mut rx = state.event_bus.raw_receiver();
let sid = session(707);
state.structured_sessions.insert(
Arc::new(LiveStructuredSession {
id: sid,
conversation_id: Some("engine-main".to_owned()),
}),
agent(7),
node(70),
);
let (agent_id, node_id, conversation_id) = state
.structured_sessions
.meta_for_session(&sid)
.expect("live structured session metadata");
assert_eq!(agent_id, agent(7));
assert_eq!(node_id, node(70));
assert_eq!(conversation_id.as_deref(), Some("engine-main"));
let resets_at_ms = i64::MAX;
state.session_limit_service.on_rate_limited(
agent_id,
node_id,
conversation_id,
Some(resets_at_ms),
);
let mut saw_rate_limited = false;
let mut saw_scheduled = false;
for _ in 0..32 {
match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
Ok(Ok(DomainEvent::AgentRateLimited { agent_id, .. })) if agent_id == agent(7) => {
saw_rate_limited = true;
}
Ok(Ok(DomainEvent::AgentResumeScheduled { agent_id, .. })) if agent_id == agent(7) => {
saw_scheduled = true;
break;
}
Ok(Ok(_)) => continue,
_ => break,
}
}
assert!(
saw_rate_limited,
"AgentRateLimited emitted for direct agent"
);
assert!(
saw_scheduled,
"AgentResumeScheduled emitted for direct agent"
);
assert!(state.session_limit_service.cancel_resume(agent(7)));
}
/// B1/B2 direct : si le moteur donne une heure de reset mais que la session vivante
/// ne porte pas de `conversation_id`, la commande doit normaliser vers le fallback
/// humain plutôt que d'armer une reprise impossible à reprendre.
#[tokio::test]
async fn direct_structured_rate_limit_without_conversation_uses_human_fallback() {
let state = AppState::build(temp_path("appdata"));
let mut rx = state.event_bus.raw_receiver();
let sid = session(708);
state.structured_sessions.insert(
Arc::new(LiveStructuredSession {
id: sid,
conversation_id: None,
}),
agent(7),
node(70),
);
let (agent_id, node_id, conversation_id) = state
.structured_sessions
.meta_for_session(&sid)
.expect("live structured session metadata");
assert_eq!(conversation_id, None);
let resets_at_ms = Some(i64::MAX);
let (conversation_id, resets_at_ms) = match (conversation_id, resets_at_ms) {
(Some(conversation_id), resets_at_ms) => (Some(conversation_id), resets_at_ms),
(None, None) => (None, None),
(None, Some(_)) => (None, None),
};
state
.session_limit_service
.on_rate_limited(agent_id, node_id, conversation_id, resets_at_ms);
let mut saw_rate_limited_none = false;
let mut saw_suspected = false;
let mut saw_scheduled = false;
for _ in 0..32 {
match tokio::time::timeout(Duration::from_millis(50), rx.recv()).await {
Ok(Ok(DomainEvent::AgentRateLimited {
agent_id,
resets_at_ms: None,
})) if agent_id == agent(7) => saw_rate_limited_none = true,
Ok(Ok(DomainEvent::AgentRateLimitSuspected { agent_id, .. }))
if agent_id == agent(7) =>
{
saw_suspected = true;
}
Ok(Ok(DomainEvent::AgentResumeScheduled { agent_id, .. })) if agent_id == agent(7) => {
saw_scheduled = true;
}
Ok(Ok(_)) => continue,
_ => break,
}
}
assert!(
saw_rate_limited_none,
"fallback exposes AgentRateLimited(None)"
);
assert!(saw_suspected, "fallback exposes human input state");
assert!(
!saw_scheduled,
"no AgentResumeScheduled without engine conversation_id"
);
assert!(!state.session_limit_service.cancel_resume(agent(7)));
}
/// `set_resume_at` (filet humain niveau 3) résout l'agent→cellule dans les registries /// `set_resume_at` (filet humain niveau 3) résout l'agent→cellule dans les registries
/// des sessions vivantes ; **sans cellule vivante**, la résolution échoue ⇒ la commande /// des sessions vivantes ; **sans cellule vivante**, la résolution échoue ⇒ la commande
/// renvoie `NOT_FOUND` sans armer quoi que ce soit (pas d'armement orphelin). /// renvoie `NOT_FOUND` sans armer quoi que ce soit (pas d'armement orphelin).

View File

@ -464,7 +464,7 @@ impl StructuredSessions {
}) })
} }
/// Résout les coordonnées `(agent_id, node_id)` d'une session structurée par son /// Résout les coordonnées `(agent_id, node_id, conversation_id)` d'une session structurée par son
/// [`SessionId`] (LS7, tap niveau 1 des limites de session, §21.10). /// [`SessionId`] (LS7, tap niveau 1 des limites de session, §21.10).
/// ///
/// Jumeau « inverse » de [`Self::live_agents`] : là où `live_agents` énumère tout, /// Jumeau « inverse » de [`Self::live_agents`] : là où `live_agents` énumère tout,
@ -473,11 +473,11 @@ impl StructuredSessions {
/// passer à [`SessionLimitService::on_rate_limited`](crate::SessionLimitService) sur /// passer à [`SessionLimitService::on_rate_limited`](crate::SessionLimitService) sur
/// un signal `RateLimited`. `None` si l'id n'est pas (ou plus) une session vivante. /// un signal `RateLimited`. `None` si l'id n'est pas (ou plus) une session vivante.
#[must_use] #[must_use]
pub fn meta_for_session(&self, id: &SessionId) -> Option<(AgentId, NodeId)> { pub fn meta_for_session(&self, id: &SessionId) -> Option<(AgentId, NodeId, Option<String>)> {
self.entries self.entries.lock().ok().and_then(|m| {
.lock() m.get(id)
.ok() .map(|e| (e.agent_id, e.node_id, e.session.conversation_id()))
.and_then(|m| m.get(id).map(|e| (e.agent_id, e.node_id))) })
} }
/// Liste chaque agent IA vivant, sa cellule hôte et son id de session. /// Liste chaque agent IA vivant, sa cellule hôte et son id de session.

View File

@ -41,6 +41,7 @@ fn nid(n: u128) -> NodeId {
/// `send`/`shutdown` ne sont pas exercés ici. /// `send`/`shutdown` ne sont pas exercés ici.
struct FakeSession { struct FakeSession {
id: SessionId, id: SessionId,
conversation_id: Option<String>,
} }
#[async_trait] #[async_trait]
@ -49,7 +50,7 @@ impl AgentSession for FakeSession {
self.id self.id
} }
fn conversation_id(&self) -> Option<String> { fn conversation_id(&self) -> Option<String> {
None self.conversation_id.clone()
} }
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> { async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
let stream: ReplyStream = Box::new(std::iter::empty()); let stream: ReplyStream = Box::new(std::iter::empty());
@ -61,7 +62,17 @@ impl AgentSession for FakeSession {
} }
fn fake(id: SessionId) -> Arc<dyn AgentSession> { fn fake(id: SessionId) -> Arc<dyn AgentSession> {
Arc::new(FakeSession { id }) Arc::new(FakeSession {
id,
conversation_id: None,
})
}
fn fake_with_conversation(id: SessionId, conversation_id: &str) -> Arc<dyn AgentSession> {
Arc::new(FakeSession {
id,
conversation_id: Some(conversation_id.to_owned()),
})
} }
// =========================================================================== // ===========================================================================
@ -107,16 +118,19 @@ fn structured_insert_resolve_and_remove() {
} }
#[test] #[test]
fn structured_meta_for_session_resolves_agent_and_node() { fn structured_meta_for_session_resolves_agent_node_and_conversation() {
// LS7 (§21.10) : le tap niveau 1 n'a que le `SessionId` du tour et doit retrouver // LS7 (§21.10) : le tap niveau 1 n'a que le `SessionId` du tour et doit retrouver
// l'agent + la cellule hôte à passer au service de limite. Lookup direct par id. // l'agent + la cellule hôte à passer au service de limite. Lookup direct par id.
let reg = StructuredSessions::new(); let reg = StructuredSessions::new();
let s = sid(1); let s = sid(1);
let a = aid(10); let a = aid(10);
let n = nid(100); let n = nid(100);
reg.insert(fake(s), a, n); reg.insert(fake_with_conversation(s, "conv-live"), a, n);
assert_eq!(reg.meta_for_session(&s), Some((a, n))); assert_eq!(
reg.meta_for_session(&s),
Some((a, n, Some("conv-live".to_owned())))
);
// Id inconnu (ou retiré) ⇒ None (jamais de panique sur une session morte). // Id inconnu (ou retiré) ⇒ None (jamais de panique sur une session morte).
assert_eq!(reg.meta_for_session(&sid(999)), None); assert_eq!(reg.meta_for_session(&sid(999)), None);