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:
2026-06-16 14:33:57 +02:00
parent fa5b826df5
commit 0bf1eb3b11
6 changed files with 647 additions and 1 deletions

View File

@ -209,6 +209,55 @@ pub enum DomainEvent {
/// Target profile's submit delay in ms. `None` ⇒ front default (~60 ms).
submit_delay_ms: Option<u32>,
},
/// Un agent vient d'entrer en **limite de session/débit** (ARCHITECTURE §21).
/// Publié quand le service de limite enregistre une nouvelle `SessionLimit` (niveau
/// 1 structuré ou niveau 2 motif). Balise discrète, basse fréquence, relayée au
/// front pour afficher le badge « limité jusqu'à HH:MM ». Model-agnostique : ne
/// porte que le fait neutre « limité, reset à T (peut-être) ».
AgentRateLimited {
/// L'agent entré en limite.
agent_id: AgentId,
/// Instant de reset en **époche-millisecondes**. `None` ⇒ heure inconnue
/// (pas de reprise auto, filet humain).
resets_at_ms: Option<i64>,
},
/// Une **reprise automatique** a été armée pour un agent limité (ARCHITECTURE §21).
/// Publié après que le service a calculé le plan ([`crate::session_limit::plan_resume`])
/// et armé le `Scheduler`. Relayé au front pour afficher le compte à rebours + le
/// bouton « Annuler la reprise » (fenêtre annulable).
AgentResumeScheduled {
/// L'agent dont la reprise est programmée.
agent_id: AgentId,
/// Échéance du réveil en **époche-millisecondes**.
fire_at_ms: i64,
},
/// La **reprise automatique** d'un agent a été **annulée** (ARCHITECTURE §21) :
/// l'utilisateur a cliqué « Annuler la reprise » dans la fenêtre annulable. Relayé
/// au front pour retirer le compte à rebours.
AgentResumeCancelled {
/// L'agent dont la reprise a été annulée.
agent_id: AgentId,
},
/// Un agent a effectivement été **relancé** après une limite (ARCHITECTURE §21) :
/// le réveil a tiré (ou reprise immédiate), l'agent a redémarré via
/// [`crate::ports::SessionPlan::Resume`] avec un prompt de reprise court. Relayé au
/// front pour effacer l'état « limité ».
AgentResumed {
/// L'agent relancé.
agent_id: AgentId,
},
/// **Filet humain (niveau 3)** : une limite de session est **suspectée** sans
/// qu'aucune heure de reset fiable ne soit connue (ARCHITECTURE §21.1 niveau 3) —
/// typiquement un agent passé `Stalled` (lot 2) sans `SessionLimit` connue. IdeA ne
/// reprend **jamais** à l'aveugle : ce signal demande au front de **solliciter
/// l'utilisateur** (« limite détectée mais heure inconnue — reprendre à ? »).
AgentRateLimitSuspected {
/// L'agent dont la limite est suspectée.
agent_id: AgentId,
/// Instant de reset en **époche-millisecondes** si une estimation existe,
/// sinon `None` (l'utilisateur fournira l'heure).
resets_at_ms: Option<i64>,
},
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
PtyOutput {
/// The session.
@ -217,3 +266,104 @@ pub enum DomainEvent {
bytes: Vec<u8>,
},
}
#[cfg(test)]
mod tests {
use super::*;
fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
// -- §21 : constructibilité + égalité PartialEq des 5 variantes --------------
#[test]
fn agent_rate_limited_constructs_and_compares() {
let ev = DomainEvent::AgentRateLimited {
agent_id: agent(1),
resets_at_ms: Some(1_700_000_000_000),
};
assert_eq!(
ev,
DomainEvent::AgentRateLimited {
agent_id: agent(1),
resets_at_ms: Some(1_700_000_000_000),
}
);
// Une heure différente ⇒ inégaux.
assert_ne!(
ev,
DomainEvent::AgentRateLimited {
agent_id: agent(1),
resets_at_ms: None,
}
);
}
#[test]
fn agent_resume_scheduled_constructs_and_compares() {
let ev = DomainEvent::AgentResumeScheduled {
agent_id: agent(2),
fire_at_ms: 1_700_000_000_000,
};
assert_eq!(
ev,
DomainEvent::AgentResumeScheduled {
agent_id: agent(2),
fire_at_ms: 1_700_000_000_000,
}
);
assert_ne!(
ev,
DomainEvent::AgentResumeScheduled {
agent_id: agent(2),
fire_at_ms: 0,
}
);
}
#[test]
fn agent_resume_cancelled_constructs_and_compares() {
let ev = DomainEvent::AgentResumeCancelled { agent_id: agent(3) };
assert_eq!(ev, DomainEvent::AgentResumeCancelled { agent_id: agent(3) });
assert_ne!(ev, DomainEvent::AgentResumeCancelled { agent_id: agent(4) });
}
#[test]
fn agent_resumed_constructs_and_compares() {
let ev = DomainEvent::AgentResumed { agent_id: agent(5) };
assert_eq!(ev, DomainEvent::AgentResumed { agent_id: agent(5) });
assert_ne!(ev, DomainEvent::AgentResumed { agent_id: agent(6) });
}
#[test]
fn agent_rate_limit_suspected_constructs_and_compares() {
let ev = DomainEvent::AgentRateLimitSuspected {
agent_id: agent(7),
resets_at_ms: None,
};
assert_eq!(
ev,
DomainEvent::AgentRateLimitSuspected {
agent_id: agent(7),
resets_at_ms: None,
}
);
assert_ne!(
ev,
DomainEvent::AgentRateLimitSuspected {
agent_id: agent(7),
resets_at_ms: Some(42),
}
);
}
#[test]
fn distinct_session_limit_variants_are_not_equal() {
// Les variantes ne se confondent pas entre elles malgré des champs proches.
assert_ne!(
DomainEvent::AgentResumeCancelled { agent_id: agent(8) },
DomainEvent::AgentResumed { agent_id: agent(8) }
);
}
}