feat(session-limits): LS4 — service application + réconciliation T4

Orchestre la détection et la reprise au niveau application :
- session_limit.rs (nouveau) : SessionLimitService + port AgentResumer +
  const RESUME_PROMPT.
- structured.rs : réconciliation T4 — enum TurnOutcome +
  drain_with_readiness_outcome ; signatures historiques préservées.
- agent/mod.rs + lib.rs : modules et re-exports.

Tests QA (nouveaux) : tests/session_limit_service.rs (9) +
tests/session_limit_t4.rs (7).
`cargo test -p application` = tous binaires verts / 0 failed (16 nouveaux),
zéro régression (drain_with_readiness_lot1 7/7, send_blocking_d1 9/9) ;
builds domaine+infra+application 0 warning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 18:55:26 +02:00
parent 253310bb3e
commit 9000b4d09f
6 changed files with 987 additions and 20 deletions

View File

@ -10,13 +10,17 @@ mod catalogue;
mod inspect;
mod lifecycle;
mod resume;
mod session_limit;
mod structured;
mod usecases;
pub(crate) use lifecycle::unique_md_path;
pub(crate) use lifecycle::ReattachDecision;
pub use structured::{drain_with_readiness, send_blocking};
pub use session_limit::{AgentResumer, SessionLimitService, RESUME_PROMPT};
pub use structured::{
drain_with_readiness, drain_with_readiness_outcome, send_blocking, TurnOutcome,
};
pub use catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles};
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};

View File

@ -0,0 +1,229 @@
//! [`SessionLimitService`] — orchestration applicative des **limites de session**
//! des agents (ARCHITECTURE §21.5) : **détecter → planifier → reprendre**, et
//! **annuler**.
//!
//! Service **pur-ports** (SOLID/hexagonal) : il ne dépend que de traits du domaine
//! ([`Clock`], [`Scheduler`], [`EventBus`]) et d'un port applicatif de reprise
//! ([`AgentResumer`], implémenté au composition root en LS7 par-dessus `LaunchAgent`).
//! Aucune dépendance vers un adapter concret ⇒ entièrement testable avec des fakes.
//!
//! # État en mémoire uniquement (§21.1-3)
//!
//! La seule mémoire du service est une table `agent_id → ScheduleId` des reprises
//! **armées** (pour pouvoir les annuler). Aucune persistance : à un redémarrage
//! d'IdeA le chemin `ListResumableAgents` existant prend le relais.
//!
//! # Dédoublonnage par agent (§21.10-4)
//!
//! Un agent n'a qu'**une** reprise armée à la fois : un second signal de limite
//! **rafraîchit** l'armement (annule l'ancien, arme le nouveau) au lieu d'empiler.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::ids::{AgentId, NodeId, ScheduleId};
use domain::ports::{Clock, EventBus, ScheduledTask, Scheduler};
use domain::session_limit::{plan_resume, RateLimitSource, ResumePlan, SessionLimit};
use domain::DomainEvent;
use crate::error::AppError;
/// Prompt **court** envoyé à l'agent au moment de la reprise automatique (§21.5).
///
/// `--resume` (via [`domain::ports::SessionPlan::Resume`]) porte déjà tout
/// l'historique : ce prompt n'a qu'à **réamorcer** le tour, pas reconstruire le
/// contexte. Volontairement neutre et model-agnostique.
pub const RESUME_PROMPT: &str =
"La limite de session est levée. Reprends là où tu t'étais arrêté.";
/// Port applicatif de **reprise d'un agent** (frontière implémentée au composition
/// root, LS7). Calqué sur les autres traits-passerelles de l'application
/// ([`crate::agent::HandoffProvider`], [`crate::agent::ProviderSessionProvider`]) :
/// l'app-tauri le branche par-dessus le mécanisme de lancement existant
/// (`LaunchAgent` + `AgentSessionFactory`) avec [`domain::ports::SessionPlan::Resume`].
///
/// Le service ne sait **pas** relancer un agent lui-même (cela exige le `Project`, le
/// profil, le contexte préparé, le PTY… que seul `LaunchAgent` résout) ; il délègue
/// donc à ce port, en restant testable avec un fake.
#[async_trait]
pub trait AgentResumer: Send + Sync {
/// Relance/réattache l'agent `agent_id` dans sa cellule `node_id`, en reprenant la
/// conversation moteur `conversation_id` (`SessionPlan::Resume` côté lancement) et
/// en lui transmettant `resume_prompt` comme premier tour.
///
/// # Errors
/// [`AppError`] si la relance échoue (profil/contexte introuvable, échec de
/// démarrage de session…). Le service propage l'erreur sans publier `AgentResumed`.
async fn resume(
&self,
agent_id: AgentId,
node_id: NodeId,
conversation_id: Option<String>,
resume_prompt: &str,
) -> Result<(), AppError>;
}
/// Service d'orchestration des limites de session (§21.5).
pub struct SessionLimitService {
clock: Arc<dyn Clock>,
scheduler: Arc<dyn Scheduler>,
events: Arc<dyn EventBus>,
resumer: Arc<dyn AgentResumer>,
/// Reprises **armées** non encore tirées : `agent_id → ScheduleId` (en mémoire).
armed: Mutex<HashMap<AgentId, ScheduleId>>,
}
impl SessionLimitService {
/// Construit le service depuis ses ports injectés (composition root).
#[must_use]
pub fn new(
clock: Arc<dyn Clock>,
scheduler: Arc<dyn Scheduler>,
events: Arc<dyn EventBus>,
resumer: Arc<dyn AgentResumer>,
) -> Self {
Self {
clock,
scheduler,
events,
resumer,
armed: Mutex::new(HashMap::new()),
}
}
/// **(a) Détection → planification.** À partir d'un signal de limite
/// (`ReadinessSignal::RateLimited`/`TurnOutcome::RateLimited`) pour `agent_id` dans
/// la cellule `node_id`, construit un [`SessionLimit`] (source `Structured`),
/// calcule le plan via [`plan_resume`] et agit :
///
/// - [`ResumePlan::Scheduled`] ⇒ publie `AgentRateLimited`, **arme** la reprise via
/// [`Scheduler::arm`] (dédoublonnée : un éventuel armement antérieur du même agent
/// est annulé d'abord, §21.10-4), mémorise le [`ScheduleId`], puis publie
/// `AgentResumeScheduled`.
/// - [`ResumePlan::HumanFallback`] (heure de reset inconnue) ⇒ publie
/// `AgentRateLimited{None}` puis `AgentRateLimitSuspected{None}` (filet humain ;
/// la confirmation UI est LS6/LS8 — ici on émet seulement l'événement).
pub fn on_rate_limited(
&self,
agent_id: AgentId,
node_id: NodeId,
conversation_id: Option<String>,
resets_at_ms: Option<i64>,
) {
let now = self.clock.now_millis();
let limit = SessionLimit::new(resets_at_ms, now, RateLimitSource::Structured);
match plan_resume(now, &limit, conversation_id) {
ResumePlan::Scheduled {
fire_at_ms,
conversation_id,
} => {
self.events.publish(DomainEvent::AgentRateLimited {
agent_id,
resets_at_ms,
});
// Dédoublonnage (§21.10-4) : un signal de rafraîchissement annule
// l'armement précédent (sans événement d'annulation : c'est interne).
self.disarm(agent_id);
let id = self.scheduler.arm(
fire_at_ms,
ScheduledTask::ResumeAgent {
agent_id,
node_id,
conversation_id,
},
);
self.armed.lock().expect("session-limit mutex sain").insert(agent_id, id);
self.events
.publish(DomainEvent::AgentResumeScheduled { agent_id, fire_at_ms });
}
ResumePlan::HumanFallback => {
self.events.publish(DomainEvent::AgentRateLimited {
agent_id,
resets_at_ms: None,
});
self.events.publish(DomainEvent::AgentRateLimitSuspected {
agent_id,
resets_at_ms: None,
});
}
}
}
/// **(b) Exécution de la reprise.** Consomme une [`ScheduledTask::ResumeAgent`]
/// échue (celle que `TokioScheduler` pousse dans le canal de remise ; le câblage du
/// récepteur dans le runtime est LS7). Retire l'entrée armée (le réveil a tiré),
/// relance l'agent via [`AgentResumer`] avec [`RESUME_PROMPT`], puis publie
/// `AgentResumed`.
///
/// # Errors
/// [`AppError`] propagée par [`AgentResumer::resume`] (la relance a échoué) ; dans
/// ce cas `AgentResumed` n'est **pas** publié.
pub async fn execute_resume(&self, task: ScheduledTask) -> Result<(), AppError> {
let ScheduledTask::ResumeAgent {
agent_id,
node_id,
conversation_id,
} = task;
// Le réveil a tiré : l'entrée armée n'a plus lieu d'être (qu'on réussisse ou non).
self.disarm(agent_id);
self.resumer
.resume(agent_id, node_id, conversation_id, RESUME_PROMPT)
.await?;
self.events.publish(DomainEvent::AgentResumed { agent_id });
Ok(())
}
/// **(c) Annulation.** Désarme la reprise auto de `agent_id` (socle du « annulable »).
/// Retrouve le [`ScheduleId`], appelle [`Scheduler::cancel`] et, **seulement si**
/// l'annulation a réussi, retire l'entrée et publie `AgentResumeCancelled`.
///
/// Renvoie `true` ssi la reprise a effectivement été annulée.
///
/// # Course « cancel pile au tir » (vigilance QA, LS3)
/// Sous runtime multi-thread, le réveil peut tirer **pile** au moment de l'annulation :
/// [`Scheduler::cancel`] renvoie alors `false` (déjà tiré). Dans ce cas on **ne
/// publie pas** `AgentResumeCancelled` et on **laisse l'entrée** (l'`execute_resume`
/// en cours la retirera) : la reprise **suit son cours**, cohérent et sans
/// événement trompeur.
pub fn cancel_resume(&self, agent_id: AgentId) -> bool {
let id = self
.armed
.lock()
.expect("session-limit mutex sain")
.get(&agent_id)
.copied();
let Some(id) = id else {
return false; // aucune reprise armée pour cet agent.
};
if self.scheduler.cancel(id) {
self.armed.lock().expect("session-limit mutex sain").remove(&agent_id);
self.events
.publish(DomainEvent::AgentResumeCancelled { agent_id });
true
} else {
// Déjà tiré : la reprise suivra son cours, pas d'événement d'annulation.
false
}
}
/// Retire (best-effort) l'armement de `agent_id` et annule le réveil sous-jacent
/// s'il existe. Usage interne (rafraîchissement / nettoyage post-tir) — **ne publie
/// aucun événement** (contrairement à [`Self::cancel_resume`]).
fn disarm(&self, agent_id: AgentId) {
let previous = self
.armed
.lock()
.expect("session-limit mutex sain")
.remove(&agent_id);
if let Some(id) = previous {
self.scheduler.cancel(id);
}
}
}

View File

@ -19,6 +19,31 @@ use domain::ids::AgentId;
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent};
use domain::readiness::{ReadinessPolicy, ReadinessSignal};
/// Issue d'un tour drainé (ARCHITECTURE §21.2-T4, réconciliation de la limite de
/// session avec le contrat « seul `Final` est terminal »).
///
/// Un tour se termine de deux façons **gracieuses** :
/// - [`TurnOutcome::Completed`] : le flux a rendu son [`ReplyEvent::Final`] — fin
/// déterministe normale (cas historique, contenu agrégé) ;
/// - [`TurnOutcome::RateLimited`] : le flux s'est **clos sans `Final`** *parce que*
/// l'agent est entré en **limite de session** (un [`ReplyEvent::RateLimited`] a été
/// observé dans le tour). Ce n'est **pas** une erreur (§21.2-T4) : l'agent reste
/// vivant, le service de limite (lot LS4) arme la reprise.
///
/// Un flux clos **sans `Final` ET sans `RateLimited`** reste une **erreur**
/// [`AgentSessionError::Io`] (tour réellement interrompu) — comportement inchangé.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TurnOutcome {
/// Tour terminé normalement par un `Final` ; porte le contenu agrégé.
Completed(String),
/// Tour clos sur une **limite de session** (sans `Final`) ; porte l'heure de
/// reset éventuelle (époche-ms) telle que vue dans le dernier `RateLimited`.
RateLimited {
/// Instant de reset en époche-ms (`None` ⇒ heure inconnue ⇒ filet humain).
resets_at_ms: Option<i64>,
},
}
/// Envoie `prompt` à la session vivante puis **draine le flux de réponse jusqu'au
/// [`ReplyEvent::Final`]**, et retourne son contenu agrégé.
///
@ -36,14 +61,23 @@ use domain::readiness::{ReadinessPolicy, ReadinessSignal};
/// - [`AgentSessionError::Io`]/[`AgentSessionError::Decode`] remontées par `send`
/// (échec de communication / décodage de la sortie structurée) ;
/// - [`AgentSessionError::Io`] si le flux se termine **sans** `Final` (tour
/// interrompu) ;
/// interrompu) — y compris un tour clos sur une **limite de session** (le
/// rendez-vous synchrone n'a pas de contenu à rendre ; cf. [`TurnOutcome`] et la
/// variante riche [`drain_with_readiness_outcome`] pour exploiter la limite) ;
/// - [`AgentSessionError::Timeout`] si `timeout` expire avant le `Final`.
pub async fn send_blocking(
session: &dyn AgentSession,
prompt: &str,
timeout: Option<Duration>,
) -> Result<String, AgentSessionError> {
drain_bounded_events(session, prompt, timeout, |_event| {}, |_signal| {}).await
match drain_bounded_events(session, prompt, timeout, |_event| {}, |_signal| {}).await? {
TurnOutcome::Completed(content) => Ok(content),
// Le rendez-vous synchrone (ask) attend un contenu : un tour limité n'en a pas
// ⇒ on conserve le comportement historique (erreur), sans casser le contrat.
TurnOutcome::RateLimited { .. } => Err(AgentSessionError::Io(
"le tour s'est clos en limite de session, sans contenu Final".to_string(),
)),
}
}
/// Comme [`send_blocking`], mais **branche la readiness** : à chaque événement du
@ -61,7 +95,9 @@ pub async fn send_blocking(
///
/// # Errors
/// Identiques à [`send_blocking`] (échec `send`/décodage, flux clos sans `Final`,
/// timeout).
/// timeout). Un tour clos sur une **limite de session** ⇒ [`AgentSessionError::Io`]
/// **ici** (signature historique `Result<String>`, zéro régression pour l'appelant
/// orchestrateur) ; utilise [`drain_with_readiness_outcome`] pour exploiter la limite.
pub async fn drain_with_readiness(
session: &dyn AgentSession,
prompt: &str,
@ -69,6 +105,38 @@ pub async fn drain_with_readiness(
mediator: &dyn InputMediator,
agent: AgentId,
) -> Result<String, AgentSessionError> {
match drain_with_readiness_outcome(session, prompt, timeout, mediator, agent).await? {
TurnOutcome::Completed(content) => Ok(content),
TurnOutcome::RateLimited { .. } => Err(AgentSessionError::Io(
"le tour s'est clos en limite de session, sans contenu Final".to_string(),
)),
}
}
/// Variante **riche** de [`drain_with_readiness`] : même branchement readiness, mais
/// retourne le [`TurnOutcome`] complet au lieu de réduire la limite à une erreur.
///
/// C'est le point d'entrée du chemin **conscient de la limite** (réconciliation
/// §21.2-T4) : sur un tour clos sans `Final` mais ayant vu un
/// [`ReplyEvent::RateLimited`], il renvoie `Ok(`[`TurnOutcome::RateLimited`]`)` (fin
/// gracieuse) plutôt qu'une `Io`. Le drain applicatif / le `SessionLimitService`
/// (LS4) consomment cette issue pour armer la reprise. Le câblage de ce chemin sur
/// le tour délégué de l'orchestrateur est du ressort de LS7.
///
/// **Readiness inchangée** : `mark_idle` reste piloté **uniquement** par le `Final`
/// (`TurnEnded`) — un `RateLimited` ne marque **pas** l'agent `Idle` (§21.5 : « le
/// `mark_idle`/FIFO reste piloté par `Final`/timeout »).
///
/// # Errors
/// Comme [`drain_with_readiness`], **sauf** qu'un tour limité n'est plus une erreur
/// (il devient [`TurnOutcome::RateLimited`]).
pub async fn drain_with_readiness_outcome(
session: &dyn AgentSession,
prompt: &str,
timeout: Option<Duration>,
mediator: &dyn InputMediator,
agent: AgentId,
) -> Result<TurnOutcome, AgentSessionError> {
// `on_signal` ne reçoit QUE les événements terminaux (le `Final` ⇒ `TurnEnded`) :
// la readiness ne classe pas les non-terminaux. Pour le **battement** de vivacité
// (lot 2) on a besoin de notifier le médiateur à CHAQUE événement non terminal
@ -84,6 +152,8 @@ pub async fn drain_with_readiness(
}
},
|signal| {
// Seul le `Final` marque `Idle` : un `RateLimited` ne fait PAS avancer la
// FIFO (la reprise est gérée par le service de limite, §21.5).
if signal == ReadinessSignal::TurnEnded {
mediator.mark_idle(agent);
}
@ -106,7 +176,7 @@ async fn drain_bounded_events(
timeout: Option<Duration>,
on_event: impl FnMut(&ReplyEvent),
on_signal: impl FnMut(ReadinessSignal),
) -> Result<String, AgentSessionError> {
) -> Result<TurnOutcome, AgentSessionError> {
match timeout {
Some(dur) => match tokio::time::timeout(
dur,
@ -126,18 +196,27 @@ async fn drain_bounded_events(
///
/// Le flux ([`domain::ports::ReplyStream`]) est un itérateur synchrone et borné :
/// après le `Final` il ne produit plus rien. On le parcourt donc simplement
/// jusqu'à rencontrer le `Final` (et on retourne son contenu) ; si le flux
/// s'épuise avant, c'est un tour interrompu → erreur [`AgentSessionError::Io`].
/// jusqu'à rencontrer le `Final` (et on retourne son contenu) ; si le flux s'épuise
/// avant, l'issue dépend de ce qu'on a vu (réconciliation §21.2-T4) :
/// - un [`ReplyEvent::RateLimited`] a été observé ⇒ fin **gracieuse**
/// [`TurnOutcome::RateLimited`] (l'agent est limité, pas en erreur) ;
/// - sinon ⇒ tour réellement interrompu → erreur [`AgentSessionError::Io`]
/// (comportement **inchangé**).
///
/// Chaque événement est classé par [`ReadinessPolicy`] et le signal éventuel est
/// remonté à `on_signal` (le `Final` ⇒ [`ReadinessSignal::TurnEnded`]). Deltas,
/// activités et heartbeats sont non terminaux ⇒ ignorés par le rendez-vous synchrone.
/// remonté à `on_signal` (le `Final` ⇒ [`ReadinessSignal::TurnEnded`] ; un
/// `RateLimited` ⇒ [`ReadinessSignal::RateLimited`]). Deltas, activités et heartbeats
/// sont non terminaux ⇒ ignorés par le rendez-vous synchrone.
async fn drain_to_final(
session: &dyn AgentSession,
prompt: &str,
mut on_event: impl FnMut(&ReplyEvent),
mut on_signal: impl FnMut(ReadinessSignal),
) -> Result<String, AgentSessionError> {
) -> Result<TurnOutcome, AgentSessionError> {
// Mémorise la dernière limite vue (§21.2-T4) : `Some(resets_at_ms)` dès qu'un
// `RateLimited` traverse le flux. Sert UNIQUEMENT au cas « clos sans Final » —
// un `Final` ultérieur l'emporte toujours (le tour a réellement abouti).
let mut last_rate_limit: Option<Option<i64>> = None;
let stream = session.send(prompt).await?;
for event in stream {
// Battement de vivacité (lot 2) : notifié pour CHAQUE événement brut, avant le
@ -146,14 +225,23 @@ async fn drain_to_final(
if let Some(signal) = ReadinessPolicy::classify(&event) {
on_signal(signal);
}
if let ReplyEvent::Final { content } = event {
return Ok(content);
match event {
ReplyEvent::Final { content } => return Ok(TurnOutcome::Completed(content)),
ReplyEvent::RateLimited { resets_at_ms } => last_rate_limit = Some(resets_at_ms),
// TextDelta / ToolActivity / Heartbeat : non terminaux, ignorés ici.
ReplyEvent::TextDelta { .. }
| ReplyEvent::ToolActivity { .. }
| ReplyEvent::Heartbeat => {}
}
// TextDelta / ToolActivity / Heartbeat : non terminaux, ignorés ici.
}
Err(AgentSessionError::Io(
"le flux de réponse s'est terminé sans événement Final".to_string(),
))
// Flux clos sans `Final` : fin gracieuse SI une limite a été vue (§21.2-T4),
// sinon erreur comme avant.
match last_rate_limit {
Some(resets_at_ms) => Ok(TurnOutcome::RateLimited { resets_at_ms }),
None => Err(AgentSessionError::Io(
"le flux de réponse s'est terminé sans événement Final".to_string(),
)),
}
}
#[cfg(test)]

View File

@ -29,8 +29,9 @@ pub mod terminal;
pub mod window;
pub use agent::{
drain_with_readiness, reference_profile_id, reference_profiles, selectable_reference_profiles,
send_blocking, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput,
drain_with_readiness, drain_with_readiness_outcome, reference_profile_id, reference_profiles,
selectable_reference_profiles, send_blocking, AgentResumer, ChangeAgentProfile,
ChangeAgentProfileInput, ChangeAgentProfileOutput,
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch,
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile,
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
@ -41,8 +42,8 @@ pub use agent::{
ProfileAvailability,
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput,
SaveProfileOutput, StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
AGENT_MEMORY_RECALL_BUDGET,
SaveProfileOutput, SessionLimitService, StructuredSessionDescriptor, TurnOutcome,
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, RESUME_PROMPT,
};
pub use conversation::RecordTurn;
pub use embedder::{