merge(session-limits): intégration de la feature limites de session (3 niveaux)
Intègre feature/agent-session-limits dans develop. Surface complète : - Niveau 1 : tap de détection sur agent_send (limites remontées par la CLI). - Niveau 2 : détecteur déclaratif au lancement (parser regex confiné) + reprise automatique planifiée (TokioScheduler) et annulable. - Niveau 3 : filet humain (set_resume_at / saisie d'heure) quand l'heure n'est pas exploitable automatiquement. Backend (domain/application/app-tauri) + front (badge, compte à rebours, formulaire de reprise) ; suites Rust et front vertes. Commits : LS2..LS8 (a1755e5→3f3504e). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -5,3 +5,4 @@
|
||||
- [remaining-work-idea-agent-control-ide](remaining-work-idea-agent-control-ide.md) — Etat des lieux des acquis et des chantiers restants pour aligner IdeA avec la cible d'IDE de controle d'agents IA.
|
||||
- [mcp-bridge-and-delegation-runtime-notes](mcp-bridge-and-delegation-runtime-notes.md) — Pieges runtime du pont MCP/delegation et regle de rebuild de l'AppImage (binaire qui tourne = AppImage, pas les sources).
|
||||
- [permissions-sandbox-system-state](permissions-sandbox-system-state.md) — Systeme de permissions/sandbox complet (Landlock sur PTY + structure) et le risque residuel $HOME/resume du chemin structure.
|
||||
- [session-limit-handling-design](session-limit-handling-design.md) — Design valide (detecteur hierarchique + reprise auto annulable) pour les limites de session des agents.
|
||||
|
||||
26
.ideai/memory/session-limit-handling-design.md
Normal file
26
.ideai/memory/session-limit-handling-design.md
Normal file
@ -0,0 +1,26 @@
|
||||
---
|
||||
name: session-limit-handling-design
|
||||
description: Design valide pour la detection des limites de session des agents et la reprise auto a la levee.
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
Feature : detecter quand un agent IA est en limite de session (et jusqu'a quelle heure), puis reprendre ou il en etait une fois la limite levee. Cadree le 2026-06-16.
|
||||
|
||||
Constat dur : l'heure exacte de reset n'existe nulle part de facon universelle (ni OS, ni code de sortie, ni API inter-modeles) — elle est fabriquee par le fournisseur et seulement exposee dans le flux de sa CLI. Donc « 100% fiable + zero dependance modele + heure exacte » sont incompatibles simultanement ; on vise le meilleur compromis via un detecteur hierarchique.
|
||||
|
||||
Solution retenue — detecteur hierarchique calque sur la hierarchie de readiness existante ([[remaining-work-idea-agent-control-ide]]) :
|
||||
- Niveau 1 (solide) : l'adapter structure extrait limite + reset du flux machine. Pour Claude, `rate_limit_event.rate_limit_info` est DEJA parse dans `infrastructure/session/claude.rs` mais jete (reduit a Heartbeat) — il suffit de lire `resetsAt`.
|
||||
- Niveau 2 (configurable) : champ de profil `rate_limit_pattern` (regex + capture heure) pour agents PTY/TUI sans adapter structure, dans la lignee des profils declaratifs §9.
|
||||
- Niveau 3 (filet humain) : si rien ne matche mais agent `Stalled` (deja prevu dans `domain/readiness.rs`), IdeA DEMANDE a l'utilisateur. Garantit le « 100% meme pour un novice » : jamais d'inaction silencieuse.
|
||||
|
||||
Model-agnostic tenu AU DOMAINE : le domaine ne connait que `RateLimited { until: Option<Instant> }`. Le savoir specifique modele est confine aux adapters/profils (philosophie §9).
|
||||
|
||||
Reprise = partie facile et deja model-agnostique : pivot sur `conversation_id` du moteur + `--resume` natif (deja cable dans `session/claude.rs` + `agent/resume.rs`) qui portent tout l'historique. Pas de reconstruction manuelle fragile.
|
||||
|
||||
Decisions produit verrouillees (2026-06-16) :
|
||||
- Reprise : AUTOMATIQUE a l'heure de reset, ANNULABLE (fenetre + notification).
|
||||
- Couverture : les TROIS niveaux d'emblee (incl. repli regex niveau 2).
|
||||
- Etat : EN MEMOIRE uniquement (pas de persistance de SessionLimit). Consequence assumee : le reveil auto ne joue que tant qu'IdeA reste ouvert ; si l'IDE est ferme/rouvert apres le reset, le chemin existant `ListResumableAgents` (agent_was_running/conversation_id) prend le relais.
|
||||
|
||||
Decoupage (cycle dev/test §3) : 1) domaine (variante `ReplyEvent::RateLimited`, `ReadinessSignal::RateLimited`, etat `SessionLimit`) ; 2) adapter Claude (extraire resetsAt) ; 3) profil (`rate_limit_pattern`) ; 4) application (planificateur de reprise sur le port Clock) ; 5) UI (badge « limite jusqu'a HH:MM » + filet humain).
|
||||
185
ARCHITECTURE.md
185
ARCHITECTURE.md
@ -2160,4 +2160,189 @@ La décision frontière l'évite : **la frontière propre est jugée côté fron
|
||||
|
||||
---
|
||||
|
||||
## 21. Gestion des limites de session des agents — détection hiérarchique + reprise auto annulable (cadrage 2026-06-16)
|
||||
|
||||
> Cadrage produit verrouillé avec l'utilisateur le 2026-06-16. **Étape 1 du cycle §3 — AUCUN code.**
|
||||
> Besoin : IdeA doit savoir **quand** un agent est en limite de session **et jusqu'à quand**, puis lui
|
||||
> demander de **reprendre où il en était** une fois la limite levée. Priorités : (1) **SOLIDE** (marche
|
||||
> même pour un novice, ~100 % des cas) ; (2) **sans dépendance au modèle** autant que possible.
|
||||
|
||||
### 21.0 État du terrain (lu dans le code, pas présumé)
|
||||
|
||||
- `domain/src/ports.rs` — `ReplyEvent` = `{ TextDelta, ToolActivity, Heartbeat, Final }`. **Aujourd'hui un
|
||||
`rate_limit_event` Claude est réduit à `Heartbeat`** (`infrastructure/src/session/claude.rs:90`,
|
||||
commentaire « fenêtre de limite de débit ») : l'info `rate_limit_info.resetsAt` est **lue puis jetée**.
|
||||
- `domain/src/readiness.rs` — `ReadinessSignal` = `{ TurnEnded, ExplicitReply, PromptReady, Stalled, TimedOut }`.
|
||||
`Stalled`/`TimedOut` sont **réservés au lot 2** (vocabulaire présent, **non produits** par `classify`).
|
||||
- `domain/src/input.rs` — deux axes d'état **orthogonaux** déjà posés : `AgentBusyState{Idle,Busy}` et
|
||||
`AgentLiveness{Alive,Stalled}`. La limite de session sera un **3ᵉ axe orthogonal**.
|
||||
- Reprise déjà câblée et **model-agnostique** : pivot `conversation_id` du moteur (`LeafCell.conversation_id`)
|
||||
+ `SessionPlan::{None,Assign,Resume}` (`ports.rs`) + `build_spawn_line` (`session/claude.rs:200`, `--resume`)
|
||||
+ `ListResumableAgents` (`application/agent/resume.rs`) + `SessionInspector` (`inspector/claude.rs`, « dernier sujet »).
|
||||
- `domain/src/ports.rs` — `Clock::now_millis() -> i64` (**épochе-millis**). **Il n'existe AUCUN port de
|
||||
minuterie** (pas de « réveille-moi à T »). C'est le seul vrai manque.
|
||||
|
||||
### 21.1 Décisions d'architecture (tranchées)
|
||||
|
||||
1. **Détecteur HIÉRARCHIQUE** calqué sur la hiérarchie de readiness existante. Trois niveaux, *premier
|
||||
qui matche gagne*, jamais d'inaction silencieuse :
|
||||
- **Niveau 1 — structuré (solide)** : l'adapter structuré extrait limite + reset du flux machine. Pour
|
||||
Claude, lire `rate_limit_info.resetsAt` **au lieu de le jeter**.
|
||||
- **Niveau 2 — déclaratif (configurable)** : champ de profil `rate_limit_pattern` (motif + capture de
|
||||
l'heure) pour agents **PTY/TUI sans adapter structuré**, dans la lignée des profils déclaratifs §9.
|
||||
- **Niveau 3 — filet humain** : rien ne matche **mais** l'agent est `Stalled` (lot 2) ⇒ IdeA **demande**
|
||||
à l'utilisateur. Garantit le « 100 % même pour un novice » : jamais d'inaction silencieuse.
|
||||
2. **Model-agnostic tenu AU DOMAINE.** Le domaine ne connaît qu'un fait neutre : « limité, reset à T (peut-
|
||||
être) ». Tout savoir spécifique modèle (forme du `rate_limit_event`, regex d'une TUI, parsing d'une heure
|
||||
locale) reste **confiné aux adapters/profils** (philosophie §9).
|
||||
3. **État EN MÉMOIRE uniquement** (décidé). **Aucune persistance** de `SessionLimit`, **aucun nouveau store**,
|
||||
**aucun schéma `.ideai/` modifié**. Conséquence assumée : le réveil auto ne joue que tant qu'IdeA reste
|
||||
ouvert ; IDE fermé/rouvert après le reset ⇒ le chemin **existant** `ListResumableAgents`
|
||||
(`agent_was_running`/`conversation_id`) prend le relais (popup de reprise, pas d'auto).
|
||||
4. **Reprise AUTOMATIQUE à l'heure de reset, ANNULABLE** (fenêtre + notification UI). Réutilise
|
||||
`SessionPlan::Resume` + un **prompt de reprise court** ; `--resume` porte tout l'historique (zéro
|
||||
reconstruction manuelle).
|
||||
5. **Un seul nouveau port** : `Scheduler` (minuterie one-shot annulable). Tout le reste **réutilise**
|
||||
l'existant (`Clock`, `EventBus`, `AgentSession`/`Factory`, `LaunchAgent`, `InputMediator`).
|
||||
|
||||
### 21.2 Tensions avec l'archi hexagonale existante — et leur résolution (à lire avant de coder)
|
||||
|
||||
| # | Tension (proposition initiale) | Résolution retenue |
|
||||
|---|---|---|
|
||||
| **T1** | `RateLimited { until: Option<Instant> }`. `std::time::Instant` est **monotone, non sérialisable, non horloge murale**, et **absent du domaine** (qui parle `i64` époche-millis via `Clock`). | **Abandonner `Instant`** → `resets_at_ms: Option<i64>` (époche-millis), homogène avec `Clock::now_millis` et `AgentBusyState.since_ms`, serde-friendly. **Déviation assumée de la proposition.** |
|
||||
| **T2** | `rate_limit_pattern` = **regex + capture**. Or le domaine est **dépendance-zéro** ; `prompt_ready_pattern` a été délibérément choisi **littéral** pour éviter la dépendance `regex`. | Le **domaine ne stocke que de la donnée** : `RateLimitPattern { pattern, reset_capture, time_format }` (chaînes). Le **moteur regex + le parsing d'heure vivent en infra** (un composant `RateLimitParser` / le watcher PTY). `regex` est ajouté au `Cargo.toml` de **`infrastructure` uniquement**. Domaine pur préservé (§1.4 / §9). |
|
||||
| **T3** | « armer un réveil sur le port `Clock` ». `Clock` ne sait que **donner l'heure**, pas **réveiller**. | Nouveau port **`Scheduler`** (ISP : minuterie fine, une responsabilité). `Clock` reste pour « maintenant ». |
|
||||
| **T4** | `RateLimited` comme événement de tour. Le contrat `ReplyStream` dit **« seul `Final` est terminal »** et `claude.rs` **rompt** la boucle au `Final`. | `ReplyEvent::RateLimited` est **non terminal** (comme `Heartbeat`) : il s'intercale, le flux continue jusqu'à `Final` **ou** clôture. **Point d'intégration** : un tour clos **sans `Final`** *parce que* limité ne doit **pas** devenir `AgentSessionError::Io` (« flux clos sans Final ») — `drain_bounded` doit traiter « clos + `RateLimited` vu » comme une **fin gracieuse limitée**, pas une erreur. |
|
||||
| **T5** | Niveau 3 « si agent `Stalled` ». Or `Stalled` est **réservé** au lot 2 (non produit aujourd'hui). | Le **niveau 3 dépend du lot 2** (détection de stagnation). À livrer **après** ; d'ici là, niveaux 1+2 couvrent le structuré et le PTY configuré. **Dépendance explicitée** dans le découpage. |
|
||||
|
||||
### 21.3 Modèle de domaine (ajouts purs, I/O-free)
|
||||
|
||||
- **`ReplyEvent::RateLimited { resets_at_ms: Option<i64> }`** (`ports.rs`) — non terminal, model-agnostique.
|
||||
- **`ReadinessSignal::RateLimited { resets_at_ms: Option<i64> }`** (`readiness.rs`) — l'enum **reste `Copy`**
|
||||
(`Option<i64>` est `Copy`). `ReadinessPolicy::classify(ReplyEvent::RateLimited{..})` ⇒
|
||||
`Some(ReadinessSignal::RateLimited{..})` (seul ajout au `match`).
|
||||
- **`domain/src/session_limit.rs` (NOUVEAU)** :
|
||||
- `SessionLimit { resets_at_ms: Option<i64>, detected_at_ms: i64, source: RateLimitSource }` (VO).
|
||||
- `RateLimitSource { Structured, Pattern, Human }` (traçabilité du niveau, pour l'UI).
|
||||
- **fonction pure** `plan_resume(now_ms, &SessionLimit) -> ResumePlan` avec
|
||||
`ResumePlan { fire_at_ms: i64 }` (si `resets_at_ms` absent ⇒ pas de plan auto ⇒ filet humain).
|
||||
Toute la logique de calendrier est **pure et testable sans I/O** (cf. `LayoutTree`, §7.2).
|
||||
- **`AgentProfile.rate_limit_pattern: Option<RateLimitPattern>`** + `with_rate_limit_pattern` (builder),
|
||||
`#[serde(default, skip_serializing_if = "Option::is_none")]` ⇒ **zéro régression** de sérialisation
|
||||
(mêmes tests que `liveness`/`prompt_ready_pattern`). `RateLimitPattern` = **donnée** (cf. T2), validée a
|
||||
minima (motif non vide) par un constructeur *parse-don't-validate*.
|
||||
- **`events.rs`** : `AgentRateLimited { agent_id, resets_at_ms: Option<i64> }`,
|
||||
`AgentResumeScheduled { agent_id, fire_at_ms }`, `AgentResumeCancelled { agent_id }`,
|
||||
`AgentResumed { agent_id }`, `AgentRateLimitSuspected { agent_id }` (niveau 3). Discrets, basse fréquence.
|
||||
|
||||
### 21.4 Le port `Scheduler` (frontière domaine — le seul nouveau port)
|
||||
|
||||
```rust
|
||||
/// Minuterie one-shot **annulable** (ARCHITECTURE §21). `Clock` dit l'heure ; ce
|
||||
/// port *réveille* à une échéance absolue. In-memory (aucune persistance, §21.1-3).
|
||||
pub trait Scheduler: Send + Sync {
|
||||
/// Arme un réveil à `deadline_ms` (époche-ms) qui, à échéance, **pousse** `task`
|
||||
/// vers le drain applicatif. Renvoie un id annulable.
|
||||
fn arm(&self, deadline_ms: i64, task: ScheduledTask) -> ScheduleId;
|
||||
/// Annule un réveil armé (idempotent ; `false` si déjà tiré/inconnu).
|
||||
fn cancel(&self, id: ScheduleId) -> bool;
|
||||
}
|
||||
|
||||
/// Intention model-agnostique exécutée à l'échéance (donnée pure, pas de closure
|
||||
/// traversant la frontière). Calqué sur l'esprit du dispatch orchestrateur §14.3.
|
||||
pub enum ScheduledTask {
|
||||
ResumeAgent { agent_id: AgentId, node_id: NodeId, conversation_id: Option<String> },
|
||||
}
|
||||
```
|
||||
|
||||
- **Pourquoi un port plutôt qu'un `tokio::sleep` direct** : garder l'**application testable sans temps réel**
|
||||
(un `Scheduler` fake déclenche à la demande) et l'inversion de dépendance (§1.2-D). `Clock` **reste** le
|
||||
port d'« heure courante ».
|
||||
- **Adapter** : `TokioScheduler` (`infrastructure/src/scheduler/`) — `tokio::time::sleep_until` + table de
|
||||
`JoinHandle` (abort = `cancel`) ; à l'échéance il **pousse `task`** dans un `mpsc` fourni à la construction
|
||||
(le drain est côté application). **Direction des dépendances respectée** : infra → canal → application
|
||||
(exactement le motif du watcher orchestrateur §14.3, qui draine un dispatch unique).
|
||||
|
||||
### 21.5 Application — service de limite & reprise
|
||||
|
||||
- **`application/src/agent/session_limit.rs` (NOUVEAU)** : `SessionLimitService`, qui compose **uniquement
|
||||
des ports/use-cases existants** + `Scheduler` :
|
||||
- `on_rate_limited(agent, SessionLimit)` : enregistre une entrée **en mémoire**, `plan_resume`, `Scheduler::
|
||||
arm(fire_at_ms, ResumeAgent{..})`, publie `AgentRateLimited` + `AgentResumeScheduled`.
|
||||
- `cancel_resume(agent)` : `Scheduler::cancel`, publie `AgentResumeCancelled` (la fenêtre annulable UI).
|
||||
- `resume_now(agent)` / drain de `ScheduledTask::ResumeAgent` : **compose `LaunchAgent` / `AgentSessionFactory::
|
||||
start` avec `SessionPlan::Resume{conversation_id}`** + **prompt de reprise court** ; publie `AgentResumed`.
|
||||
- `confirm_manual(agent, resets_at_ms)` (niveau 3) : même chemin que niveau 1 avec `source = Human`.
|
||||
- **`application/src/agent/structured.rs`** : dans `drain_with_readiness`, réagir à
|
||||
`ReadinessSignal::RateLimited{resets_at_ms}` ⇒ `SessionLimitService::on_rate_limited`. **Réconcilier T4** :
|
||||
un flux clos **sans `Final`** alors qu'un `RateLimited` a été vu ⇒ **pas** d'`Io`, mais une issue « limité »
|
||||
(l'agent reste vivant ; le service arme la reprise). Le `mark_idle`/FIFO reste piloté par `Final`/timeout.
|
||||
- **Niveau 3 (filet humain)** : à brancher **après le lot 2** — quand le détecteur de stagnation passe
|
||||
`Alive→Stalled` et qu'**aucune** `SessionLimit` n'est connue pour l'agent, le service publie
|
||||
`AgentRateLimitSuspected` ⇒ l'UI demande (jamais d'auto sans heure).
|
||||
|
||||
### 21.6 Infrastructure — où vit chaque détail modèle
|
||||
|
||||
- **Niveau 1** — `infrastructure/src/session/claude.rs` `parse_event` : `rate_limit_event` ⇒ lire
|
||||
`rate_limit_info.resetsAt`, le **normaliser en époche-ms** (ISO-8601/époch → `i64`) et émettre
|
||||
`ReplyEvent::RateLimited{resets_at_ms}` (non terminal) **au lieu** du `Heartbeat` actuel. Ajuster la boucle
|
||||
`send` (le `break 'lines` reste sur `Final` ; `RateLimited` ne rompt pas). `session/codex.rs` : mapper
|
||||
l'équivalent Codex **s'il existe**, sinon s'en remettre au niveau 2.
|
||||
- **Niveau 2** — `infrastructure/src/ratelimit/` (NOUVEAU) `RateLimitParser` : compile le
|
||||
`rate_limit_pattern` du profil (**`regex`, dépendance infra seulement**, cf. T2), observe le flux PTY du
|
||||
handle lié (réutilise l'armement du watcher de prompt, `MediatedInbox`), et à un match calcule
|
||||
`resets_at_ms` (capture d'heure + `Clock` + date du jour) puis émet `ReplyEvent::RateLimited` /
|
||||
notifie le service. **Aucune** regex ne franchit la frontière domaine.
|
||||
- **Port `Scheduler`** — `infrastructure/src/scheduler/` `TokioScheduler` (cf. §21.4).
|
||||
- `infrastructure/src/clock/` — **inchangé** (réutilisé pour « maintenant » et le calcul d'heure de reset).
|
||||
|
||||
### 21.7 Présentation (app-tauri + frontend)
|
||||
|
||||
- **`app-tauri`** (composition root) : instancier `TokioScheduler` + `SessionLimitService`, démarrer le
|
||||
**drain des `ScheduledTask`** (boucle de fond, jumelle du watcher orchestrateur). Commandes :
|
||||
`cancel_agent_resume`, `resume_agent_now`, `confirm_agent_rate_limit{resets_at_ms}`. Relais des nouveaux
|
||||
`DomainEvent` en events IPC **camelCase** (`agentRateLimited`, `agentResumeScheduled`, …).
|
||||
- **`frontend`** (`features/agents` + `features/terminals`) : **badge « limité jusqu'à HH:MM »** + compte à
|
||||
rebours + bouton **« Annuler la reprise »** (fenêtre annulable) ; **dialogue du filet humain** (niveau 3 :
|
||||
« limite détectée mais heure inconnue — reprendre à ? »). Étendre un `gateway` UI (port TS) + son adapter
|
||||
Tauri + les mocks (§1.3). Le « dernier sujet » du `SessionInspector` enrichit la notification.
|
||||
|
||||
### 21.8 Conformité hexagonale & SOLID
|
||||
|
||||
- **Domaine pur** : `SessionLimit`/`plan_resume`/`classify` testables **sans I/O ni temps réel** (T1 époche-ms,
|
||||
T2 regex hors domaine). **S** : `SessionLimitService` = une intention (détecter→planifier→reprendre) ;
|
||||
`Scheduler` = une responsabilité (minuter). **O** : ajouter un moteur niveau 1 = un adapter ; ajouter une
|
||||
TUI niveau 2 = **de la donnée** (`rate_limit_pattern`), pas de code. **L** : tout `Scheduler` (réel/fake)
|
||||
substituable. **I** : `Scheduler` minimal (`arm`/`cancel`), distinct de `Clock`. **D** : l'application
|
||||
reçoit `Arc<dyn Scheduler>` injecté au composition root.
|
||||
|
||||
### 21.9 Découpage en LOTS testables (cycle dev↔QA §3) — ordonné
|
||||
|
||||
| Lot | Couche | Contenu | Vert quand |
|
||||
|---|---|---|---|
|
||||
| **LS1** | domaine | `ReplyEvent::RateLimited` ; `ReadinessSignal::RateLimited` + `classify` ; `session_limit.rs` (VO + `plan_resume`) ; `events.rs` (5 variantes) ; `profile.rate_limit_pattern` + builder | tests purs : `classify` mappe RateLimited ; `plan_resume` (avec/sans `resets_at`) ; round-trip serde profil (clé omise si `None`, legacy→`None`) ; `ReadinessSignal` reste `Copy` |
|
||||
| **LS2** | infra (niv. 1) | `claude.rs` `parse_event` extrait `resetsAt`→époche-ms→`RateLimited` ; boucle `send` (pas de rupture sur RateLimited) ; idem Codex si applicable | conformance : une ligne `rate_limit_event` ⇒ `RateLimited{Some(ms)}` ; un tour `rate_limit_event`+`result` ⇒ `[…,RateLimited,Final]` ; sans `resetsAt` ⇒ `RateLimited{None}` |
|
||||
| **LS3** | domaine+infra | port `Scheduler` + `ScheduledTask` ; `TokioScheduler` (arm/cancel + push mpsc) | `arm` tire la tâche à l'échéance (deadline courte) ; `cancel` empêche le tir ; `cancel` post-tir = `false` |
|
||||
| **LS4** | application | `SessionLimitService` (on_rate_limited/cancel/resume_now/drain) ; `structured.rs` réconcilie T4 | mocks `Scheduler`+`AgentSession` : `on_rate_limited` arme + publie ; `cancel` annule ; drain compose `SessionPlan::Resume{id}` + prompt ; « clos sans Final + RateLimited » ⇒ **pas** d'`Io` |
|
||||
| **LS5** | infra (niv. 2) | `ratelimit/RateLimitParser` (regex, dép. infra) + armement sur le watcher PTY ; conso `rate_limit_pattern` | sur une sortie TUI échantillon : match ⇒ `resets_at_ms` calculé ; profil sans motif ⇒ aucun faux positif |
|
||||
| **LS6** | app+front | **niveau 3** (après lot 2) : `Stalled` sans limite connue ⇒ `AgentRateLimitSuspected` + `confirm_agent_rate_limit` | mock : transition `Stalled` sans `SessionLimit` ⇒ 1 `AgentRateLimitSuspected` ; `confirm_manual` arme comme niv. 1 |
|
||||
| **LS7** | app-tauri | wiring scheduler+service+drain ; commandes `cancel`/`resume_now`/`confirm` ; relais events camelCase | `cargo build`/tests commands ; events mappés ; drain démarré à open/create_project |
|
||||
| **LS8** | frontend | badge « limité jusqu'à HH:MM » + countdown + Annuler ; dialogue filet humain ; gateway+adapter+mocks | Vitest avec gateway mock : badge sur `agentRateLimited` ; Annuler appelle `cancel_agent_resume` ; dialogue sur `agentRateLimitSuspected` |
|
||||
|
||||
**Ordre** : LS1 → (LS2 ∥ LS3) → LS4 → LS5 → **LS6 (gate : lot 2 stagnation livré)** → LS7 → LS8.
|
||||
**LS1+LS2+LS4** donnent déjà le niveau 1 de bout en bout (Claude) : le cœur de valeur est atteint tôt.
|
||||
|
||||
### 21.10 Points ouverts (spikes)
|
||||
|
||||
1. **Format de `resetsAt`** (Claude) : époch vs ISO-8601 vs durée relative — à vérifier sur un vrai
|
||||
`rate_limit_event` (spike LS2). Le domaine ne voit que des **époche-ms** quoi qu'il arrive.
|
||||
2. **Heure locale → époche** (niveau 2) : une capture « 15:00 » est une **heure murale locale** ⇒ composer
|
||||
avec la date du jour + fuseau via `Clock` ; gérer le passage de minuit (reset « demain »). Confiné infra.
|
||||
3. **Codex** : existe-t-il un signal structuré de limite dans `codex exec --json` ? Sinon niveau 2 obligatoire
|
||||
pour Codex (spike LS2).
|
||||
4. **Double détection** : niveau 1 **et** niveau 2 pourraient matcher le même épisode ⇒ le service
|
||||
**dédoublonne par agent** (une `SessionLimit` vivante par agent ; le second signal rafraîchit, n'empile pas).
|
||||
|
||||
---
|
||||
|
||||
*Document maintenu par l'Agent Architecture — base du jalon « cadrage architecture » avant tout code applicatif.*
|
||||
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -1946,6 +1946,7 @@ dependencies = [
|
||||
"landlock",
|
||||
"notify",
|
||||
"portable-pty",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@ -32,6 +32,8 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
# `AppAgentResumer` implements the application's async `AgentResumer` port (LS7).
|
||||
async-trait = { workspace = true }
|
||||
# Cross-OS local IPC for the MCP loopback transport (M5a): Unix domain socket
|
||||
# (Linux/macOS) + Windows named pipe behind one async API, no network port. Pulled
|
||||
# in only here (the transport is an app-tauri/infra concern); its `tokio` feature
|
||||
|
||||
@ -178,8 +178,10 @@ impl ChatBridge {
|
||||
///
|
||||
/// Returns `None` for [`ReplyEvent::Heartbeat`]: a heartbeat is a non-terminal
|
||||
/// liveness proof (readiness/heartbeat lot 1) with **no chat content**, so it maps
|
||||
/// to no wire chunk — the pump simply skips it. Every content-bearing event still
|
||||
/// maps to exactly one chunk.
|
||||
/// to no wire chunk — the pump simply skips it. Likewise [`ReplyEvent::RateLimited`]
|
||||
/// is non-terminal and content-free (ports §21.2-T4): the UI badge comes from the
|
||||
/// `DomainEvent::AgentRateLimited` bus, not the chat stream, so it maps to `None`
|
||||
/// too. Every content-bearing event still maps to exactly one chunk.
|
||||
#[must_use]
|
||||
pub fn chunk_from_event(event: ReplyEvent) -> Option<ReplyChunk> {
|
||||
match event {
|
||||
@ -187,5 +189,6 @@ pub fn chunk_from_event(event: ReplyEvent) -> Option<ReplyChunk> {
|
||||
ReplyEvent::ToolActivity { label } => Some(ReplyChunk::ToolActivity { label }),
|
||||
ReplyEvent::Final { content } => Some(ReplyChunk::Final { content }),
|
||||
ReplyEvent::Heartbeat => None,
|
||||
ReplyEvent::RateLimited { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1137,6 +1137,11 @@ pub async fn launch_agent(
|
||||
// artefacts from previous AppImages do not survive into this activation.
|
||||
state.reconcile_claude_run_dirs(&project).await;
|
||||
|
||||
// Session-limit resume context (LS7, §21.5): record the Project + cell size keyed by
|
||||
// agent so an auto-resume (which only carries agent/node/conversation) can recompose a
|
||||
// full `LaunchAgentInput`. Cloned here — `project` is moved into the launch below.
|
||||
let resume_project = project.clone();
|
||||
|
||||
let output = state
|
||||
.launch_agent
|
||||
.execute(LaunchAgentInput {
|
||||
@ -1153,7 +1158,21 @@ pub async fn launch_agent(
|
||||
.await
|
||||
.map_err(ErrorDto::from)?;
|
||||
|
||||
if let Ok(mut contexts) = state.resume_contexts.lock() {
|
||||
contexts.insert(
|
||||
agent_id,
|
||||
crate::state::ResumeContext {
|
||||
project: resume_project,
|
||||
rows: request.rows,
|
||||
cols: request.cols,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let session_id = output.session.id;
|
||||
// Host cell of the freshly (re)launched session — the pivot the level-2 tap reports
|
||||
// to the service alongside the agent.
|
||||
let host_node_id = output.session.node_id;
|
||||
|
||||
// §17.4/§17.6: a structured launch routes to an `AgentSession`, registered
|
||||
// **only** in `StructuredSessions` — its id is never a live PTY. Such a cell is
|
||||
@ -1166,6 +1185,18 @@ pub async fn launch_agent(
|
||||
// Register the xterm output channel for this session.
|
||||
let gen = state.pty_bridge.register(session_id, on_output);
|
||||
|
||||
// Level-2 session-limit detector (§21, niveau 2 — PTY/TUI sans adapter
|
||||
// structuré). Selection rule (§21.10-4) is the single infra source `applies`:
|
||||
// arm a parser **only** for a profile without a structured adapter that declares
|
||||
// a `rate_limit_pattern` (anti-double-détection avec le niveau 1). A `None`
|
||||
// profile (reattach/idempotent) or an invalid regex ⇒ no parser, jamais de panique.
|
||||
let rate_limit_parser = output
|
||||
.profile
|
||||
.as_ref()
|
||||
.filter(|p| infrastructure::ratelimit::applies(p))
|
||||
.and_then(|p| p.rate_limit_pattern.as_ref())
|
||||
.and_then(infrastructure::RateLimitParser::new);
|
||||
|
||||
// Subscribe to the PTY's byte stream and pump it to the channel.
|
||||
// The stream is a blocking iterator; it runs on a dedicated OS thread and
|
||||
// ends when the PTY hits EOF or this attach is superseded.
|
||||
@ -1173,8 +1204,30 @@ pub async fn launch_agent(
|
||||
match state.pty_port.subscribe_output(&handle) {
|
||||
Ok(stream) => {
|
||||
let bridge: std::sync::Arc<PtyBridge> = std::sync::Arc::clone(&state.pty_bridge);
|
||||
// Captured for the level-2 tap (moved into the pump thread).
|
||||
let service = std::sync::Arc::clone(&state.session_limit_service);
|
||||
let detect_clock = std::sync::Arc::new(infrastructure::SystemClock::new());
|
||||
let conversation_id = request.conversation_id.clone();
|
||||
std::thread::spawn(move || {
|
||||
for chunk in stream {
|
||||
// §21.10-4 tap (best-effort par fragment) : avant de consommer le
|
||||
// fragment, on cherche le motif de limite. Une détection arme la
|
||||
// reprise via le service (détecter→planifier). Dormant si pas de
|
||||
// parser. La fragmentation PTV (motif coupé entre deux fragments)
|
||||
// est un raté best-effort connu pour LS7.
|
||||
if let Some(parser) = &rate_limit_parser {
|
||||
let text = String::from_utf8_lossy(&chunk);
|
||||
if let Some(limit) =
|
||||
parser.detect(&text, domain::ports::Clock::now_millis(&*detect_clock))
|
||||
{
|
||||
service.on_rate_limited(
|
||||
agent_id,
|
||||
host_node_id,
|
||||
conversation_id.clone(),
|
||||
limit.resets_at_ms,
|
||||
);
|
||||
}
|
||||
}
|
||||
if !bridge.send_output(&session_id, chunk) {
|
||||
break;
|
||||
}
|
||||
@ -1279,8 +1332,23 @@ 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).
|
||||
let service = std::sync::Arc::clone(&state.session_limit_service);
|
||||
let meta = state.structured_sessions.meta_for_session(&sid);
|
||||
std::thread::spawn(move || {
|
||||
for event in stream {
|
||||
// Non-terminal, sans contenu chat : un signal de limite alimente le service
|
||||
// (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);
|
||||
}
|
||||
}
|
||||
// Heartbeats carry no chat content (readiness/heartbeat lot 1) ⇒ no wire
|
||||
// chunk; skip them while still draining so the turn runs to its `Final`.
|
||||
let Some(chunk) = crate::chat::chunk_from_event(event) else {
|
||||
@ -1298,6 +1366,79 @@ pub async fn agent_send(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `cancel_resume` — annule la **reprise automatique** armée pour un agent limité
|
||||
/// (ARCHITECTURE §21.1-4, fenêtre annulable).
|
||||
///
|
||||
/// Délègue à [`SessionLimitService::cancel_resume`](application::SessionLimitService::cancel_resume) :
|
||||
/// désarme le réveil et, **seulement si** l'annulation a réussi (le réveil n'avait pas
|
||||
/// encore tiré), publie `AgentResumeCancelled`. Renvoie `true` ssi une reprise a
|
||||
/// effectivement été annulée (`false` si aucune n'était armée, ou si elle venait de
|
||||
/// tirer — auquel cas la reprise suit son cours).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] (`INVALID` for a malformed agent id).
|
||||
#[tauri::command]
|
||||
pub async fn cancel_resume(
|
||||
agent_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<bool, ErrorDto> {
|
||||
let id = parse_agent_id(&agent_id)?;
|
||||
Ok(state.session_limit_service.cancel_resume(id))
|
||||
}
|
||||
|
||||
/// `set_resume_at` — **filet humain niveau 3** (ARCHITECTURE §21.1) : l'utilisateur a
|
||||
/// saisi l'heure de reset d'un agent en limite **suspectée** (rien n'a matché
|
||||
/// automatiquement). Arme la **même** reprise annulable que les niveaux 1/2.
|
||||
///
|
||||
/// Le front ne dispose que de l'`agent_id` ; on résout côté backend :
|
||||
/// - `node_id` : la cellule vivante hébergeant l'agent, cherchée dans la registry
|
||||
/// structurée puis dans la registry terminal ([`StructuredSessions::node_for_agent`]
|
||||
/// / [`TerminalSessions::node_for_agent`]). Sans cellule vivante, la saisie n'a pas de
|
||||
/// cible ⇒ `NOT_FOUND`.
|
||||
/// - `conversation_id` : best-effort via la session structurée de l'agent
|
||||
/// ([`AgentSession::conversation_id`]) ; `None` toléré (reprise en mode dégradé).
|
||||
///
|
||||
/// Délègue ensuite à
|
||||
/// [`SessionLimitService::confirm_human_resume`](application::SessionLimitService::confirm_human_resume),
|
||||
/// qui réémet la paire `AgentRateLimited{Some}` + `AgentResumeScheduled` déjà relayée au
|
||||
/// front. Aucun nouvel événement.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] (`INVALID` for a malformed agent id, `NOT_FOUND` if no live
|
||||
/// cell hosts the agent).
|
||||
#[tauri::command]
|
||||
pub async fn set_resume_at(
|
||||
agent_id: String,
|
||||
resets_at_ms: i64,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
let id = parse_agent_id(&agent_id)?;
|
||||
|
||||
// Résolution agent→cellule : la registry des sessions vivantes est la source de
|
||||
// vérité. On regarde d'abord le structuré (qui porte aussi le `conversation_id`),
|
||||
// puis le terminal (PTY).
|
||||
let node_id = state
|
||||
.structured_sessions
|
||||
.node_for_agent(&id)
|
||||
.or_else(|| state.terminal_sessions.node_for_agent(&id))
|
||||
.ok_or_else(|| {
|
||||
ErrorDto::from(AppError::NotFound(format!(
|
||||
"aucune cellule vivante pour l'agent {id}"
|
||||
)))
|
||||
})?;
|
||||
|
||||
// `conversation_id` best-effort : seule une session structurée vivante l'expose.
|
||||
let conversation_id = state
|
||||
.structured_sessions
|
||||
.session_for_agent(&id)
|
||||
.and_then(|s| s.conversation_id());
|
||||
|
||||
state
|
||||
.session_limit_service
|
||||
.confirm_human_resume(id, node_id, conversation_id, resets_at_ms);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `interrupt_agent` — the **Interrompre** path (cadrage C4 §4.2).
|
||||
///
|
||||
/// Routes to [`OrchestratorService::interrupt_agent`], which `preempt`s the agent's
|
||||
|
||||
@ -205,6 +205,52 @@ pub enum DomainEventDto {
|
||||
/// Whether the in-process ONNX capability is compiled in.
|
||||
vector_onnx_enabled: bool,
|
||||
},
|
||||
/// An agent entered a **session/rate limit** (ARCHITECTURE §21). Low-frequency,
|
||||
/// model-agnostic badge: carries only the neutral "limited, maybe resets at T"
|
||||
/// fact. The frontend badges "limité jusqu'à HH:MM".
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentRateLimited {
|
||||
/// Agent id (UUID string).
|
||||
agent_id: String,
|
||||
/// Reset instant in **epoch-milliseconds**; `null` ⇒ unknown (no auto-resume).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
resets_at_ms: Option<i64>,
|
||||
},
|
||||
/// An **auto-resume** was armed for a rate-limited agent (ARCHITECTURE §21). The
|
||||
/// frontend shows the countdown + the "Annuler la reprise" button (cancellable
|
||||
/// window).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentResumeScheduled {
|
||||
/// Agent id (UUID string).
|
||||
agent_id: String,
|
||||
/// Wake-up deadline in **epoch-milliseconds**.
|
||||
fire_at_ms: i64,
|
||||
},
|
||||
/// An agent's **auto-resume** was **cancelled** (ARCHITECTURE §21): the user
|
||||
/// clicked "Annuler la reprise". The frontend removes the countdown.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentResumeCancelled {
|
||||
/// Agent id (UUID string).
|
||||
agent_id: String,
|
||||
},
|
||||
/// An agent was effectively **resumed** after a limit (ARCHITECTURE §21): the
|
||||
/// wake-up fired (or immediate resume). The frontend clears the "limited" state.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentResumed {
|
||||
/// Agent id (UUID string).
|
||||
agent_id: String,
|
||||
},
|
||||
/// **Human net (level 3)**: a session limit is **suspected** without any reliable
|
||||
/// reset time (ARCHITECTURE §21.1). IdeA never resumes blind: this asks the front
|
||||
/// to **prompt the user** ("limit detected but time unknown — resume at?").
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentRateLimitSuspected {
|
||||
/// Agent id (UUID string).
|
||||
agent_id: String,
|
||||
/// Reset instant in **epoch-milliseconds** if an estimate exists, else `null`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
resets_at_ms: Option<i64>,
|
||||
},
|
||||
/// Raw PTY output (normally routed to a per-session channel, not here).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PtyOutput {
|
||||
@ -377,6 +423,33 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
vector_http_enabled: *vector_http_enabled,
|
||||
vector_onnx_enabled: *vector_onnx_enabled,
|
||||
},
|
||||
DomainEvent::AgentRateLimited {
|
||||
agent_id,
|
||||
resets_at_ms,
|
||||
} => Self::AgentRateLimited {
|
||||
agent_id: agent_id.to_string(),
|
||||
resets_at_ms: *resets_at_ms,
|
||||
},
|
||||
DomainEvent::AgentResumeScheduled {
|
||||
agent_id,
|
||||
fire_at_ms,
|
||||
} => Self::AgentResumeScheduled {
|
||||
agent_id: agent_id.to_string(),
|
||||
fire_at_ms: *fire_at_ms,
|
||||
},
|
||||
DomainEvent::AgentResumeCancelled { agent_id } => Self::AgentResumeCancelled {
|
||||
agent_id: agent_id.to_string(),
|
||||
},
|
||||
DomainEvent::AgentResumed { agent_id } => Self::AgentResumed {
|
||||
agent_id: agent_id.to_string(),
|
||||
},
|
||||
DomainEvent::AgentRateLimitSuspected {
|
||||
agent_id,
|
||||
resets_at_ms,
|
||||
} => Self::AgentRateLimitSuspected {
|
||||
agent_id: agent_id.to_string(),
|
||||
resets_at_ms: *resets_at_ms,
|
||||
},
|
||||
DomainEvent::PtyOutput { session_id, bytes } => Self::PtyOutput {
|
||||
session_id: session_id.to_string(),
|
||||
bytes: bytes.clone(),
|
||||
@ -450,4 +523,19 @@ mod tests {
|
||||
assert_eq!(json["type"], "agentLivenessChanged");
|
||||
assert_eq!(json["liveness"], "alive");
|
||||
}
|
||||
|
||||
/// LS6 : un `AgentRateLimited` du domaine se relaie en DTO portant le même agent
|
||||
/// et l'heure de reset (époche-ms), et se sérialise en `"agentRateLimited"` avec
|
||||
/// `resetsAtMs` — le fait neutre que le front badge « limité jusqu'à HH:MM ».
|
||||
#[test]
|
||||
fn rate_limited_relays_to_dto_and_wire() {
|
||||
let dto = DomainEventDto::from(&DomainEvent::AgentRateLimited {
|
||||
agent_id: agent(11),
|
||||
resets_at_ms: Some(1_700_000_000_000),
|
||||
});
|
||||
let json = serde_json::to_value(&dto).expect("serialisable");
|
||||
assert_eq!(json["type"], "agentRateLimited");
|
||||
assert_eq!(json["agentId"], agent(11).to_string());
|
||||
assert_eq!(json["resetsAtMs"], 1_700_000_000_000_i64);
|
||||
}
|
||||
}
|
||||
|
||||
@ -165,6 +165,8 @@ pub fn run() {
|
||||
commands::launch_agent,
|
||||
commands::change_agent_profile,
|
||||
commands::agent_send,
|
||||
commands::cancel_resume,
|
||||
commands::set_resume_at,
|
||||
commands::interrupt_agent,
|
||||
commands::delegation_delivered,
|
||||
commands::set_front_attached,
|
||||
|
||||
@ -12,9 +12,11 @@ use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::{
|
||||
AssignSkillToAgent, ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab,
|
||||
AgentResumer, AppError, AssignSkillToAgent, ChangeAgentProfile, CheckEmbedderSuggestion,
|
||||
CloseProject, CloseTab,
|
||||
CloseTerminal, ConfigureProfiles, CreateAgentFromScratch, CreateAgentFromTemplate,
|
||||
CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent,
|
||||
LaunchAgentInput, SessionLimitService,
|
||||
DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate,
|
||||
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
||||
FirstRunState, GetMemory, GetProjectPermissions, GitBranches, GitCheckout, GitCommit, GitGraph,
|
||||
@ -35,7 +37,7 @@ use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector,
|
||||
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
|
||||
MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore, ProjectStore,
|
||||
PtyPort, SkillStore, TemplateStore,
|
||||
PtyPort, ScheduledTask, Scheduler, SkillStore, TemplateStore,
|
||||
};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||
@ -54,7 +56,7 @@ use infrastructure::{
|
||||
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||
OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory, SystemClock,
|
||||
SystemMillisClock, TokioBroadcastEventBus, UuidGenerator, VectorMemoryRecall,
|
||||
SystemMillisClock, TokioBroadcastEventBus, TokioScheduler, UuidGenerator, VectorMemoryRecall,
|
||||
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
|
||||
VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
@ -127,6 +129,108 @@ impl application::ProviderSessionProvider for AppProviderSessionProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// Contexte minimal de **relance** d'un agent (LS7, ARCHITECTURE §21.5).
|
||||
///
|
||||
/// [`AgentResumer::resume`] et [`ScheduledTask::ResumeAgent`] ne portent **pas** le
|
||||
/// `Project` ni la taille de la cellule, alors que [`LaunchAgentInput`] les exige.
|
||||
/// La commande `launch_agent` (seul endroit où ces faits sont en main) alimente ce
|
||||
/// contexte par `agent_id` ; [`AppAgentResumer`] le relit à l'échéance pour
|
||||
/// recomposer un lancement complet.
|
||||
#[derive(Clone)]
|
||||
pub struct ResumeContext {
|
||||
/// Le projet hôte de l'agent (pour recomposer `LaunchAgentInput`).
|
||||
pub project: Project,
|
||||
/// Hauteur de la cellule au dernier lancement (lignes PTY).
|
||||
pub rows: u16,
|
||||
/// Largeur de la cellule au dernier lancement (colonnes PTY).
|
||||
pub cols: u16,
|
||||
}
|
||||
|
||||
/// Registre partagé `agent_id → ResumeContext` (composition root ↔ commande
|
||||
/// `launch_agent`). Le **même** `Arc` est injecté dans [`AppAgentResumer`] et conservé
|
||||
/// sur [`AppState`] pour que la commande l'alimente à chaque lancement.
|
||||
pub type ResumeContexts = Arc<Mutex<HashMap<AgentId, ResumeContext>>>;
|
||||
|
||||
/// Implémente le port applicatif [`AgentResumer`] (LS7) **par-dessus** le mécanisme de
|
||||
/// lancement existant ([`LaunchAgent`]).
|
||||
///
|
||||
/// À l'échéance d'une limite de session, [`SessionLimitService::execute_resume`]
|
||||
/// délègue ici : on relit le [`ResumeContext`] alimenté par `launch_agent`, on
|
||||
/// recompose un [`LaunchAgentInput`] (avec le `conversation_id` de la cellule ⇒
|
||||
/// `LaunchAgent` applique [`domain::ports::SessionPlan::Resume`]), puis on transmet le
|
||||
/// `resume_prompt` comme **premier tour** via le portail d'entrée unique
|
||||
/// ([`InputMediator`](domain::input::InputMediator)) — jamais un write PTY brut
|
||||
/// (ARCHITECTURE §20). Sans contexte connu (resume « à l'aveugle »), on échoue
|
||||
/// proprement (l'erreur empêche `AgentResumed` d'être publié), jamais de panique.
|
||||
struct AppAgentResumer {
|
||||
/// Le **même** `Arc<LaunchAgent>` que la commande `launch_agent` (relance/réattache).
|
||||
launch_agent: Arc<LaunchAgent>,
|
||||
/// Portail d'entrée unique : injecte le prompt de reprise comme premier tour.
|
||||
input_mediator: Arc<dyn domain::input::InputMediator>,
|
||||
/// Contexte de relance alimenté par la commande `launch_agent`.
|
||||
contexts: ResumeContexts,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AgentResumer for AppAgentResumer {
|
||||
async fn resume(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
node_id: domain::NodeId,
|
||||
conversation_id: Option<String>,
|
||||
resume_prompt: &str,
|
||||
) -> Result<(), AppError> {
|
||||
// Repli propre (jamais de panique) : sans contexte de relance connu, on ne
|
||||
// reprend pas à l'aveugle. L'erreur remonte ⇒ `AgentResumed` n'est pas publié.
|
||||
let ctx = self
|
||||
.contexts
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|m| m.get(&agent_id).cloned());
|
||||
let Some(ctx) = ctx else {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"resume context for agent {agent_id}"
|
||||
)));
|
||||
};
|
||||
|
||||
// Recompose la déclaration MCP réelle (même recette que la commande
|
||||
// `launch_agent`) pour que l'agent repris retrouve ses outils `idea_*`.
|
||||
let mcp_runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime {
|
||||
exe,
|
||||
endpoint: crate::mcp_endpoint::mcp_endpoint(&ctx.project.id)
|
||||
.as_cli_arg()
|
||||
.to_owned(),
|
||||
project_id: ctx.project.id.as_uuid().simple().to_string(),
|
||||
requester: agent_id.to_string(),
|
||||
});
|
||||
|
||||
self.launch_agent
|
||||
.execute(LaunchAgentInput {
|
||||
project: ctx.project,
|
||||
agent_id,
|
||||
rows: ctx.rows,
|
||||
cols: ctx.cols,
|
||||
node_id: Some(node_id),
|
||||
conversation_id,
|
||||
mcp_runtime,
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Premier tour de reprise par le **portail d'entrée** (pas de write brut, §20) :
|
||||
// l'enqueue publie `DelegationReady`, la cellule l'écrit quand le prompt est prêt.
|
||||
// On ne corrèle aucune réponse (reprise, pas une délégation) ⇒ on lâche le
|
||||
// `PendingReply`.
|
||||
let ticket = domain::mailbox::Ticket::new(
|
||||
domain::mailbox::TicketId::new_random(),
|
||||
"IdeA",
|
||||
resume_prompt,
|
||||
);
|
||||
let _ = self.input_mediator.enqueue(agent_id, ticket);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the IPC layer needs at runtime, managed by Tauri.
|
||||
///
|
||||
/// Use cases are stored behind `Arc` so handlers clone cheaply. The concrete
|
||||
@ -339,6 +443,15 @@ pub struct AppState {
|
||||
/// stopped on close, exactly like the watcher, via the same `Mutex`-guarded
|
||||
/// per-project registry.
|
||||
pub mcp_servers: Mutex<HashMap<ProjectId, McpServerHandle>>,
|
||||
/// Service de gestion des **limites de session** des agents (ARCHITECTURE §21) :
|
||||
/// détecte → planifie → reprend, et annule. Alimenté par les taps niveau 1
|
||||
/// (structuré, `agent_send`) et niveau 2 (PTY, `launch_agent`) ; sa reprise auto est
|
||||
/// annulable via la commande `cancel_resume`.
|
||||
pub session_limit_service: Arc<SessionLimitService>,
|
||||
/// Registre `agent_id → ResumeContext` (LS7) partagé avec [`AppAgentResumer`] :
|
||||
/// la commande `launch_agent` y dépose le `Project`/taille du dernier lancement pour
|
||||
/// que la reprise auto puisse recomposer un `LaunchAgentInput` complet.
|
||||
pub resume_contexts: ResumeContexts,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@ -910,6 +1023,45 @@ impl AppState {
|
||||
});
|
||||
}
|
||||
let input_mediator = Arc::clone(&mediated_inbox) as Arc<dyn domain::input::InputMediator>;
|
||||
|
||||
// --- Limites de session des agents (ARCHITECTURE §21, LS7) ---
|
||||
// Service pur-ports « détecter → planifier → reprendre » câblé sur l'existant :
|
||||
// l'horloge système, le bus partagé, un `TokioScheduler` (minuterie one-shot
|
||||
// annulable) dont les tâches échues sont drainées plus bas, et un `AppAgentResumer`
|
||||
// qui relance via le *même* `LaunchAgent` que la commande `launch_agent`.
|
||||
let resume_contexts: ResumeContexts = Arc::new(Mutex::new(HashMap::new()));
|
||||
let (resume_tx, mut resume_rx) = tokio::sync::mpsc::unbounded_channel::<ScheduledTask>();
|
||||
let scheduler = Arc::new(TokioScheduler::new(
|
||||
resume_tx,
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
)) as Arc<dyn Scheduler>;
|
||||
let resumer = Arc::new(AppAgentResumer {
|
||||
launch_agent: Arc::clone(&launch_agent),
|
||||
input_mediator: Arc::clone(&input_mediator),
|
||||
contexts: Arc::clone(&resume_contexts),
|
||||
}) as Arc<dyn AgentResumer>;
|
||||
let session_limit_service = Arc::new(SessionLimitService::new(
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
scheduler,
|
||||
Arc::clone(&events_port),
|
||||
resumer,
|
||||
));
|
||||
// Drain du scheduler (§21.5-b) : à chaque réveil tiré, `TokioScheduler` pousse une
|
||||
// `ScheduledTask::ResumeAgent` ; on l'exécute via le service (relance + AgentResumed).
|
||||
// Patron `sweep_stalled` : `tauri::async_runtime::spawn` (pas `tokio::spawn` — `build`
|
||||
// tourne dans le hook `setup` sans runtime ambiant). Détaché ⇒ vit autant que l'app.
|
||||
{
|
||||
let service = Arc::clone(&session_limit_service);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
while let Some(task) = resume_rx.recv().await {
|
||||
if let Err(e) = service.execute_resume(task).await {
|
||||
// Best-effort : une relance qui échoue ne fige pas le drain.
|
||||
eprintln!("[session-limit] reprise auto échouée : {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Registre des conversations par paire (cadrage C3) : un fil par paire, session
|
||||
// vivante keyée par conversation (lève l'ambiguïté session/agent).
|
||||
let conversation_registry = Arc::new(InMemoryConversationRegistry::new())
|
||||
@ -1051,6 +1203,8 @@ impl AppState {
|
||||
orchestrator_service,
|
||||
orchestrator_watchers: Mutex::new(HashMap::new()),
|
||||
mcp_servers: Mutex::new(HashMap::new()),
|
||||
session_limit_service,
|
||||
resume_contexts,
|
||||
move_tab,
|
||||
}
|
||||
}
|
||||
|
||||
@ -309,6 +309,7 @@ fn launch_agent_output_maps_to_terminal_session_dto() {
|
||||
assigned_conversation_id: None,
|
||||
engine_session_id: None,
|
||||
structured: None,
|
||||
profile: None,
|
||||
};
|
||||
let dto = TerminalSessionDto::from(out);
|
||||
|
||||
@ -354,6 +355,7 @@ fn launch_agent_output_propagates_assigned_conversation_id() {
|
||||
assigned_conversation_id: Some("conv-xyz".to_owned()),
|
||||
engine_session_id: None,
|
||||
structured: None,
|
||||
profile: None,
|
||||
};
|
||||
let dto = TerminalSessionDto::from(out);
|
||||
|
||||
|
||||
@ -157,6 +157,7 @@ fn launch_output_with_structured_descriptor_derives_chat_cell_kind() {
|
||||
assigned_conversation_id: None,
|
||||
engine_session_id: None,
|
||||
structured: Some(descriptor),
|
||||
profile: None,
|
||||
};
|
||||
let dto = TerminalSessionDto::from(out);
|
||||
assert_eq!(dto.cell_kind, CellKind::Chat);
|
||||
@ -173,6 +174,7 @@ fn launch_output_without_structured_descriptor_derives_pty_cell_kind() {
|
||||
assigned_conversation_id: None,
|
||||
engine_session_id: None,
|
||||
structured: None,
|
||||
profile: None,
|
||||
};
|
||||
let dto = TerminalSessionDto::from(out);
|
||||
assert_eq!(dto.cell_kind, CellKind::Pty, "no descriptor ⇒ pty");
|
||||
@ -207,6 +209,7 @@ fn pty_launch_output_serialises_cellkind_pty_without_breaking_existing_keys() {
|
||||
assigned_conversation_id: None,
|
||||
engine_session_id: None,
|
||||
structured: None,
|
||||
profile: None,
|
||||
};
|
||||
let v = serde_json::to_value(TerminalSessionDto::from(out)).unwrap();
|
||||
assert_eq!(v["sessionId"], sid.to_string());
|
||||
|
||||
167
crates/app-tauri/tests/session_limit_wiring.rs
Normal file
167
crates/app-tauri/tests/session_limit_wiring.rs
Normal file
@ -0,0 +1,167 @@
|
||||
//! Integration test for the **session-limit** wiring in the composition root
|
||||
//! (ARCHITECTURE §21, LS7).
|
||||
//!
|
||||
//! Proves that [`AppState`] actually *constructs and wires* the
|
||||
//! `SessionLimitService` (the gap LS7 closes: the LS1–LS4 domain/application
|
||||
//! machinery existed but was never instantiated at runtime). The detect→plan→
|
||||
//! resume→cancel logic itself is covered by the application/infrastructure unit
|
||||
//! tests (LS3/LS4); here we assert the **composition** the commands rely on:
|
||||
//! the service is present, `on_rate_limited` arms a cancellable resume that emits
|
||||
//! the domain events on the real bus, and `cancel_resume` is a clean no-op for an
|
||||
//! unknown agent (never a panic).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use app_tauri_lib::state::AppState;
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ids::NodeId;
|
||||
use domain::AgentId;
|
||||
use infrastructure::UuidGenerator;
|
||||
use domain::ports::IdGenerator;
|
||||
|
||||
fn temp_path(tag: &str) -> PathBuf {
|
||||
let ids = UuidGenerator::new();
|
||||
std::env::temp_dir().join(format!("idea-sl-test-{tag}-{}", ids.new_uuid()))
|
||||
}
|
||||
|
||||
fn agent(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn node(n: u128) -> NodeId {
|
||||
NodeId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
/// `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.
|
||||
#[tokio::test]
|
||||
async fn cancel_resume_is_a_clean_noop_for_unknown_agent() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
assert!(
|
||||
!state.session_limit_service.cancel_resume(agent(1)),
|
||||
"no armed resume ⇒ cancel returns false"
|
||||
);
|
||||
}
|
||||
|
||||
/// A structured rate-limit signal with a **future** reset arms a cancellable
|
||||
/// resume: the wired service publishes `AgentRateLimited` then
|
||||
/// `AgentResumeScheduled` on the **real** event bus, and `cancel_resume` then
|
||||
/// publishes `AgentResumeCancelled` and returns `true`.
|
||||
#[tokio::test]
|
||||
async fn on_rate_limited_arms_a_cancellable_resume_over_the_real_bus() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
let mut rx = state.event_bus.raw_receiver();
|
||||
|
||||
// Reset far in the future so the scheduler does NOT fire before we cancel.
|
||||
let resets_at_ms = i64::MAX;
|
||||
state
|
||||
.session_limit_service
|
||||
.on_rate_limited(agent(7), node(70), None, Some(resets_at_ms));
|
||||
|
||||
// The two arming events, in order, on the bus.
|
||||
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 relayed on the bus");
|
||||
assert!(saw_scheduled, "AgentResumeScheduled relayed on the bus");
|
||||
|
||||
// The window is cancellable: cancel succeeds and emits AgentResumeCancelled.
|
||||
assert!(
|
||||
state.session_limit_service.cancel_resume(agent(7)),
|
||||
"an armed resume is cancellable"
|
||||
);
|
||||
let mut saw_cancelled = false;
|
||||
for _ in 0..32 {
|
||||
match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
|
||||
Ok(Ok(DomainEvent::AgentResumeCancelled { agent_id })) if agent_id == agent(7) => {
|
||||
saw_cancelled = true;
|
||||
break;
|
||||
}
|
||||
Ok(Ok(_)) => continue,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
assert!(saw_cancelled, "AgentResumeCancelled relayed on the bus");
|
||||
}
|
||||
|
||||
/// `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
|
||||
/// renvoie `NOT_FOUND` sans armer quoi que ce soit (pas d'armement orphelin).
|
||||
///
|
||||
/// La commande `#[tauri::command]` exige une `State<AppState>` non constructible hors
|
||||
/// runtime Tauri ; on couvre donc ici sa **précondition exacte** : sur un `AppState`
|
||||
/// neuf (aucune session vivante), aucune registry ne résout l'agent — c'est précisément
|
||||
/// le `None` qui produit le `NOT_FOUND` dans `set_resume_at` (cf. commands.rs:1420-1428).
|
||||
#[tokio::test]
|
||||
async fn set_resume_at_resolves_no_cell_for_an_agent_without_a_live_session() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
let unknown = agent(99);
|
||||
|
||||
assert!(
|
||||
state.structured_sessions.node_for_agent(&unknown).is_none(),
|
||||
"aucune session structurée vivante ⇒ pas de cellule"
|
||||
);
|
||||
assert!(
|
||||
state.terminal_sessions.node_for_agent(&unknown).is_none(),
|
||||
"aucune session terminal vivante ⇒ pas de cellule"
|
||||
);
|
||||
// Conjonction = la branche `ok_or_else(NotFound)` de set_resume_at est prise :
|
||||
// la saisie d'heure n'a pas de cible ⇒ aucun armement orphelin.
|
||||
let resolved = state
|
||||
.structured_sessions
|
||||
.node_for_agent(&unknown)
|
||||
.or_else(|| state.terminal_sessions.node_for_agent(&unknown));
|
||||
assert!(resolved.is_none(), "set_resume_at ⇒ NOT_FOUND (aucun armement orphelin)");
|
||||
}
|
||||
|
||||
/// Parité runtime du filet humain : `confirm_human_resume` (la délégation de
|
||||
/// `set_resume_at`) arme une reprise annulable sur le **vrai** bus — `AgentRateLimited`
|
||||
/// puis `AgentResumeScheduled` — exactement comme la branche auto `on_rate_limited`.
|
||||
#[tokio::test]
|
||||
async fn confirm_human_resume_arms_a_cancellable_resume_over_the_real_bus() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
let mut rx = state.event_bus.raw_receiver();
|
||||
|
||||
// Reset très loin dans le futur : le scheduler ne tire pas avant l'annulation.
|
||||
let resets_at_ms = i64::MAX;
|
||||
state
|
||||
.session_limit_service
|
||||
.confirm_human_resume(agent(8), node(80), None, 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(8) => {
|
||||
saw_rate_limited = true;
|
||||
}
|
||||
Ok(Ok(DomainEvent::AgentResumeScheduled { agent_id, .. })) if agent_id == agent(8) => {
|
||||
saw_scheduled = true;
|
||||
break;
|
||||
}
|
||||
Ok(Ok(_)) => continue,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
assert!(saw_rate_limited, "AgentRateLimited relayed on the bus (humain)");
|
||||
assert!(saw_scheduled, "AgentResumeScheduled relayed on the bus (humain)");
|
||||
|
||||
// Annulable par la même voie que l'auto.
|
||||
assert!(
|
||||
state.session_limit_service.cancel_resume(agent(8)),
|
||||
"an armed human resume is cancellable"
|
||||
);
|
||||
}
|
||||
@ -936,6 +936,15 @@ pub struct LaunchAgentOutput {
|
||||
/// avec les use cases A/B et le câblage qui ne lisent que `session` /
|
||||
/// `assigned_conversation_id`.
|
||||
pub structured: Option<StructuredSessionDescriptor>,
|
||||
/// Le **profil résolu** de l'agent lors d'un (re)lancement *effectif* (LS7,
|
||||
/// ARCHITECTURE §21.10-4). `LaunchAgent::execute` le résout déjà en interne (zéro
|
||||
/// I/O supplémentaire) ; on l'expose pour que le câblage app-tauri décide d'armer —
|
||||
/// ou non — le détecteur de limite **niveau 2** (PTY) via la règle de sélection
|
||||
/// `infrastructure::ratelimit::applies` (anti-double-détection N1/N2), sans
|
||||
/// reconstruire la frontière infra côté domaine. `None` sur une **réattache**
|
||||
/// (rebind/idempotent) où aucun profil n'est re-résolu — le câblage n'arme alors
|
||||
/// rien (best-effort, le tour est sans nouvelle session).
|
||||
pub profile: Option<AgentProfile>,
|
||||
}
|
||||
|
||||
/// Registry mapping each [`ProjectorKey`] to its concrete
|
||||
@ -1331,6 +1340,8 @@ impl LaunchAgent {
|
||||
assigned_conversation_id: None,
|
||||
engine_session_id: None,
|
||||
structured: None,
|
||||
// Réattache (rebind de vue) : aucun profil re-résolu.
|
||||
profile: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1350,6 +1361,8 @@ impl LaunchAgent {
|
||||
assigned_conversation_id: None,
|
||||
engine_session_id: None,
|
||||
structured: None,
|
||||
// Idempotent (pas de respawn) : aucun profil re-résolu.
|
||||
profile: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1388,6 +1401,8 @@ impl LaunchAgent {
|
||||
node_id,
|
||||
conversation_id: existing.conversation_id(),
|
||||
}),
|
||||
// Réattache structurée (rebind de vue) : aucun profil re-résolu.
|
||||
profile: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1605,6 +1620,9 @@ impl LaunchAgent {
|
||||
// Chemin PTY/terminal brut : pas de session moteur structurée à cacher.
|
||||
engine_session_id: None,
|
||||
structured: None,
|
||||
// Lancement effectif : profil résolu exposé pour la sélection du détecteur
|
||||
// de limite niveau 2 (§21.10-4) côté câblage.
|
||||
profile: Some(profile.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
@ -1690,6 +1708,9 @@ impl LaunchAgent {
|
||||
node_id,
|
||||
conversation_id: engine_session_id,
|
||||
}),
|
||||
// Lancement structuré effectif : profil résolu exposé (le tap niveau 2 PTY ne
|
||||
// l'arme pas — `applies` est faux pour un profil structuré, §21.10-4).
|
||||
profile: Some(profile.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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};
|
||||
|
||||
281
crates/application/src/agent/session_limit.rs
Normal file
281
crates/application/src/agent/session_limit.rs
Normal file
@ -0,0 +1,281 @@
|
||||
//! [`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.arm_scheduled(agent_id, fire_at_ms, node_id, conversation_id, resets_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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **(d) Filet humain (§21.1 niveau 3).** L'utilisateur a saisi l'heure de reset
|
||||
/// pour un agent en limite **suspectée** (rien n'a matché automatiquement). On
|
||||
/// construit une [`SessionLimit`] de source [`RateLimitSource::Human`], on calcule
|
||||
/// le plan via [`plan_resume`] et on **arme exactement la même reprise** que la
|
||||
/// branche auto : mêmes événements (`AgentRateLimited{Some}` + `AgentResumeScheduled`),
|
||||
/// même dédoublonnage, même annulabilité via [`Self::cancel_resume`].
|
||||
///
|
||||
/// L'heure saisie est traitée par le domaine sans privilège particulier : un reset
|
||||
/// déjà passé est clampé à `now` par [`plan_resume`] ⇒ reprise immédiate. Le cas
|
||||
/// [`ResumePlan::HumanFallback`] est ici inatteignable (`resets_at_ms` est toujours
|
||||
/// `Some`) ; on le traite en no-op défensif pour rester total.
|
||||
pub fn confirm_human_resume(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
node_id: NodeId,
|
||||
conversation_id: Option<String>,
|
||||
resets_at_ms: i64,
|
||||
) {
|
||||
let now = self.clock.now_millis();
|
||||
let limit = SessionLimit::new(Some(resets_at_ms), now, RateLimitSource::Human);
|
||||
|
||||
if let ResumePlan::Scheduled {
|
||||
fire_at_ms,
|
||||
conversation_id,
|
||||
} = plan_resume(now, &limit, conversation_id)
|
||||
{
|
||||
self.arm_scheduled(agent_id, fire_at_ms, node_id, conversation_id, Some(resets_at_ms));
|
||||
}
|
||||
}
|
||||
|
||||
/// Arme (ou ré-arme) une reprise **programmée** pour `agent_id`, fabrique commune aux
|
||||
/// deux entrées (auto §21.1 niveaux 1/2 et filet humain niveau 3). Séquence stricte,
|
||||
/// identique à l'origine — d'où **zéro régression** : publie `AgentRateLimited`
|
||||
/// (avec l'heure de reset connue), **dédoublonne** l'armement précédent via
|
||||
/// [`Self::disarm`] (interne, sans événement), arme le réveil via [`Scheduler::arm`],
|
||||
/// mémorise le [`ScheduleId`], puis publie `AgentResumeScheduled`.
|
||||
///
|
||||
/// `resets_at_ms` est l'heure de reset **annoncée à l'UI** (countdown) ; `fire_at_ms`
|
||||
/// est l'échéance effective (déjà clampée anti-passé par le domaine). Les deux ne
|
||||
/// coïncident que si le reset est futur — on conserve donc la sémantique d'origine en
|
||||
/// publiant l'heure de reset brute, pas l'échéance clampée.
|
||||
fn arm_scheduled(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
fire_at_ms: i64,
|
||||
node_id: NodeId,
|
||||
conversation_id: Option<String>,
|
||||
resets_at_ms: Option<i64>,
|
||||
) {
|
||||
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 });
|
||||
}
|
||||
|
||||
/// **(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)]
|
||||
|
||||
@ -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::{
|
||||
|
||||
@ -380,6 +380,22 @@ impl StructuredSessions {
|
||||
})
|
||||
}
|
||||
|
||||
/// Résout les coordonnées `(agent_id, node_id)` d'une session structurée par son
|
||||
/// [`SessionId`] (LS7, tap niveau 1 des limites de session, §21.10).
|
||||
///
|
||||
/// Jumeau « inverse » de [`Self::live_agents`] : là où `live_agents` énumère tout,
|
||||
/// celui-ci fait un lookup direct par id. Le pump structuré (`agent_send`) n'a en
|
||||
/// main que le `SessionId` du tour ; ce lookup lui rend l'agent et la cellule à
|
||||
/// passer à [`SessionLimitService::on_rate_limited`](crate::SessionLimitService) sur
|
||||
/// un signal `RateLimited`. `None` si l'id n'est pas (ou plus) une session vivante.
|
||||
#[must_use]
|
||||
pub fn meta_for_session(&self, id: &SessionId) -> Option<(AgentId, NodeId)> {
|
||||
self.entries
|
||||
.lock()
|
||||
.ok()
|
||||
.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.
|
||||
///
|
||||
/// Jumeau de [`TerminalSessions::live_agents`] : un tuple
|
||||
|
||||
641
crates/application/tests/session_limit_service.rs
Normal file
641
crates/application/tests/session_limit_service.rs
Normal file
@ -0,0 +1,641 @@
|
||||
//! LS4 — tests unitaires QA du `SessionLimitService` (ARCHITECTURE §21.5),
|
||||
//! **100 % fakes** des ports : `Clock` fixe, `Scheduler` enregistreur/contrôlable,
|
||||
//! `EventBus` espion, `AgentResumer` espion/contrôlable.
|
||||
//!
|
||||
//! Couvre les trois responsabilités du service :
|
||||
//! - (a) détection → planification (`on_rate_limited`) : armement + ordre des events ;
|
||||
//! - (b) exécution de la reprise (`execute_resume`) : prompt, event, propagation d'erreur ;
|
||||
//! - (c) annulation (`cancel_resume`) : contrat anti-course (cancel `false` ⇒ pas d'event).
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use application::{AgentResumer, AppError, SessionLimitService, RESUME_PROMPT};
|
||||
use domain::ids::{AgentId, NodeId, ScheduleId};
|
||||
use domain::ports::{Clock, EventBus, EventStream, ScheduledTask, Scheduler};
|
||||
use domain::DomainEvent;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn nid(n: u128) -> NodeId {
|
||||
NodeId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fakes des ports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Horloge fixe (déterminisme) : `now` injecté.
|
||||
struct FixedClock(i64);
|
||||
impl Clock for FixedClock {
|
||||
fn now_millis(&self) -> i64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// EventBus espion : journalise les events publiés, dans l'ordre.
|
||||
#[derive(Default, Clone)]
|
||||
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
|
||||
impl SpyBus {
|
||||
fn events(&self) -> Vec<DomainEvent> {
|
||||
self.0.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
impl EventBus for SpyBus {
|
||||
fn publish(&self, event: DomainEvent) {
|
||||
self.0.lock().unwrap().push(event);
|
||||
}
|
||||
fn subscribe(&self) -> EventStream {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// Scheduler fake : enregistre chaque `arm` (deadline + task) et chaque `cancel`,
|
||||
/// distribue des `ScheduleId` déterministes, et rend un résultat de `cancel`
|
||||
/// contrôlable (pour simuler le cas « déjà tiré »).
|
||||
#[derive(Clone)]
|
||||
struct FakeScheduler {
|
||||
armed: Arc<Mutex<Vec<(i64, ScheduledTask)>>>,
|
||||
issued: Arc<Mutex<Vec<ScheduleId>>>,
|
||||
cancels: Arc<Mutex<Vec<ScheduleId>>>,
|
||||
next_id: Arc<Mutex<u128>>,
|
||||
cancel_result: Arc<AtomicBool>,
|
||||
}
|
||||
impl FakeScheduler {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
armed: Arc::new(Mutex::new(Vec::new())),
|
||||
issued: Arc::new(Mutex::new(Vec::new())),
|
||||
cancels: Arc::new(Mutex::new(Vec::new())),
|
||||
next_id: Arc::new(Mutex::new(1)),
|
||||
cancel_result: Arc::new(AtomicBool::new(true)),
|
||||
}
|
||||
}
|
||||
fn set_cancel_result(&self, v: bool) {
|
||||
self.cancel_result.store(v, Ordering::SeqCst);
|
||||
}
|
||||
fn armed(&self) -> Vec<(i64, ScheduledTask)> {
|
||||
self.armed.lock().unwrap().clone()
|
||||
}
|
||||
fn issued(&self) -> Vec<ScheduleId> {
|
||||
self.issued.lock().unwrap().clone()
|
||||
}
|
||||
fn cancels(&self) -> Vec<ScheduleId> {
|
||||
self.cancels.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
impl Scheduler for FakeScheduler {
|
||||
fn arm(&self, deadline_ms: i64, task: ScheduledTask) -> ScheduleId {
|
||||
self.armed.lock().unwrap().push((deadline_ms, task));
|
||||
let mut n = self.next_id.lock().unwrap();
|
||||
let id = ScheduleId::from_uuid(Uuid::from_u128(*n));
|
||||
*n += 1;
|
||||
self.issued.lock().unwrap().push(id);
|
||||
id
|
||||
}
|
||||
fn cancel(&self, id: ScheduleId) -> bool {
|
||||
self.cancels.lock().unwrap().push(id);
|
||||
self.cancel_result.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
/// AgentResumer fake : enregistre l'appel `resume` (et le prompt reçu), et peut
|
||||
/// être configuré pour échouer (propagation d'erreur).
|
||||
#[derive(Clone)]
|
||||
struct FakeResumer {
|
||||
calls: Arc<Mutex<Vec<(AgentId, NodeId, Option<String>, String)>>>,
|
||||
fail: Arc<AtomicBool>,
|
||||
}
|
||||
impl FakeResumer {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
calls: Arc::new(Mutex::new(Vec::new())),
|
||||
fail: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
fn set_fail(&self, v: bool) {
|
||||
self.fail.store(v, Ordering::SeqCst);
|
||||
}
|
||||
fn calls(&self) -> Vec<(AgentId, NodeId, Option<String>, String)> {
|
||||
self.calls.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl AgentResumer for FakeResumer {
|
||||
async fn resume(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
node_id: NodeId,
|
||||
conversation_id: Option<String>,
|
||||
resume_prompt: &str,
|
||||
) -> Result<(), AppError> {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((agent_id, node_id, conversation_id, resume_prompt.to_owned()));
|
||||
if self.fail.load(Ordering::SeqCst) {
|
||||
Err(AppError::Internal("reprise échouée (fake)".to_owned()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Banc d'essai : assemble le service avec ses fakes (et garde les poignées).
|
||||
struct Env {
|
||||
service: SessionLimitService,
|
||||
scheduler: FakeScheduler,
|
||||
bus: SpyBus,
|
||||
resumer: FakeResumer,
|
||||
}
|
||||
fn env_at(now_ms: i64) -> Env {
|
||||
let scheduler = FakeScheduler::new();
|
||||
let bus = SpyBus::default();
|
||||
let resumer = FakeResumer::new();
|
||||
let service = SessionLimitService::new(
|
||||
Arc::new(FixedClock(now_ms)),
|
||||
Arc::new(scheduler.clone()),
|
||||
Arc::new(bus.clone()),
|
||||
Arc::new(resumer.clone()),
|
||||
);
|
||||
Env {
|
||||
service,
|
||||
scheduler,
|
||||
bus,
|
||||
resumer,
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// (a) Détection → planification
|
||||
// ===========================================================================
|
||||
|
||||
const NOW: i64 = 1_700_000_000_000;
|
||||
|
||||
/// `on_rate_limited(Some(reset futur))` ⇒ EXACTEMENT un `arm(fire_at_ms, ResumeAgent{..})`
|
||||
/// avec `fire_at_ms == reset` (futur) ; events `AgentRateLimited` PUIS `AgentResumeScheduled`
|
||||
/// dans CET ordre.
|
||||
#[test]
|
||||
fn on_rate_limited_future_arms_and_emits_in_order() {
|
||||
let env = env_at(NOW);
|
||||
let reset = NOW + 60_000;
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(reset));
|
||||
|
||||
// Exactement un arm, avec la bonne échéance et la bonne tâche.
|
||||
let armed = env.scheduler.armed();
|
||||
assert_eq!(armed.len(), 1, "exactement un arm");
|
||||
assert_eq!(armed[0].0, reset, "fire_at_ms == reset (futur)");
|
||||
assert_eq!(
|
||||
armed[0].1,
|
||||
ScheduledTask::ResumeAgent {
|
||||
agent_id: aid(1),
|
||||
node_id: nid(2),
|
||||
conversation_id: Some("conv-1".to_owned()),
|
||||
}
|
||||
);
|
||||
|
||||
// Ordre des events : RateLimited puis ResumeScheduled.
|
||||
assert_eq!(
|
||||
env.bus.events(),
|
||||
vec![
|
||||
DomainEvent::AgentRateLimited {
|
||||
agent_id: aid(1),
|
||||
resets_at_ms: Some(reset),
|
||||
},
|
||||
DomainEvent::AgentResumeScheduled {
|
||||
agent_id: aid(1),
|
||||
fire_at_ms: reset,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Reset DÉJÀ PASSÉ ⇒ `plan_resume` clampe à `now` ⇒ `fire_at_ms == now` (jamais dans
|
||||
/// le passé) ; le `AgentRateLimited` garde l'heure brute (passée), le `ResumeScheduled`
|
||||
/// porte le `now` clampé.
|
||||
#[test]
|
||||
fn on_rate_limited_past_reset_clamps_fire_at_to_now() {
|
||||
let env = env_at(NOW);
|
||||
let past = NOW - 60_000;
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), None, Some(past));
|
||||
|
||||
let armed = env.scheduler.armed();
|
||||
assert_eq!(armed.len(), 1);
|
||||
assert_eq!(armed[0].0, NOW, "fire_at_ms clampé à now (jamais le passé)");
|
||||
|
||||
assert_eq!(
|
||||
env.bus.events(),
|
||||
vec![
|
||||
DomainEvent::AgentRateLimited {
|
||||
agent_id: aid(1),
|
||||
resets_at_ms: Some(past), // l'heure brute (passée) est conservée dans l'event
|
||||
},
|
||||
DomainEvent::AgentResumeScheduled {
|
||||
agent_id: aid(1),
|
||||
fire_at_ms: NOW, // clampé
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// `on_rate_limited(None)` ⇒ AUCUN arm ; events `AgentRateLimited{None}` puis
|
||||
/// `AgentRateLimitSuspected{None}` (filet humain).
|
||||
#[test]
|
||||
fn on_rate_limited_without_reset_is_human_fallback_no_arm() {
|
||||
let env = env_at(NOW);
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), None);
|
||||
|
||||
assert!(env.scheduler.armed().is_empty(), "aucun arm sans heure de reset");
|
||||
assert!(env.scheduler.cancels().is_empty(), "aucun cancel non plus");
|
||||
assert_eq!(
|
||||
env.bus.events(),
|
||||
vec![
|
||||
DomainEvent::AgentRateLimited {
|
||||
agent_id: aid(1),
|
||||
resets_at_ms: None,
|
||||
},
|
||||
DomainEvent::AgentRateLimitSuspected {
|
||||
agent_id: aid(1),
|
||||
resets_at_ms: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Dédoublonnage (§21.10-4) : deux `on_rate_limited` successifs pour le MÊME agent ⇒
|
||||
/// l'ancien `ScheduleId` est annulé sur le Scheduler (cancel interne), un nouveau réveil
|
||||
/// est armé, et AUCUN `AgentResumeCancelled` n'est émis (le dédoublonnage est interne).
|
||||
#[test]
|
||||
fn on_rate_limited_twice_same_agent_dedups_cancelling_previous() {
|
||||
let env = env_at(NOW);
|
||||
let reset1 = NOW + 60_000;
|
||||
let reset2 = NOW + 120_000;
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(reset1));
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(reset2));
|
||||
|
||||
// Deux arms (un par signal), ids distincts.
|
||||
let issued = env.scheduler.issued();
|
||||
assert_eq!(issued.len(), 2, "deux arms (rafraîchissement, pas empilement)");
|
||||
// Le premier id émis a été annulé par le dédoublonnage du 2ᵉ signal.
|
||||
assert_eq!(
|
||||
env.scheduler.cancels(),
|
||||
vec![issued[0]],
|
||||
"l'ancien ScheduleId est cancel-é avant de réarmer"
|
||||
);
|
||||
|
||||
// Aucun AgentResumeCancelled : le dédoublonnage est silencieux.
|
||||
let cancelled: Vec<_> = env
|
||||
.bus
|
||||
.events()
|
||||
.into_iter()
|
||||
.filter(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. }))
|
||||
.collect();
|
||||
assert!(
|
||||
cancelled.is_empty(),
|
||||
"le dédoublonnage interne n'émet PAS AgentResumeCancelled"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// (b) Exécution de la reprise
|
||||
// ===========================================================================
|
||||
|
||||
/// `execute_resume(ResumeAgent{..})` ⇒ `AgentResumer::resume` appelé avec
|
||||
/// (agent, node, conv, RESUME_PROMPT) ; `AgentResumed` publié ; l'entrée armée est
|
||||
/// retirée (un `cancel_resume` ultérieur ⇒ false).
|
||||
#[tokio::test]
|
||||
async fn execute_resume_calls_resumer_with_prompt_and_emits_resumed() {
|
||||
let env = env_at(NOW);
|
||||
// Arme d'abord (pour prouver que l'entrée est ensuite retirée).
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
|
||||
|
||||
let task = ScheduledTask::ResumeAgent {
|
||||
agent_id: aid(1),
|
||||
node_id: nid(2),
|
||||
conversation_id: Some("conv-1".to_owned()),
|
||||
};
|
||||
env.service.execute_resume(task).await.expect("resume ok");
|
||||
|
||||
// Le resumer a vu exactement l'appel attendu, avec le prompt constant.
|
||||
assert_eq!(
|
||||
env.resumer.calls(),
|
||||
vec![(aid(1), nid(2), Some("conv-1".to_owned()), RESUME_PROMPT.to_owned())]
|
||||
);
|
||||
|
||||
// AgentResumed publié.
|
||||
assert!(
|
||||
env.bus
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| *e == DomainEvent::AgentResumed { agent_id: aid(1) }),
|
||||
"AgentResumed doit être publié"
|
||||
);
|
||||
|
||||
// L'entrée armée a été retirée ⇒ cancel_resume ultérieur = false.
|
||||
assert!(
|
||||
!env.service.cancel_resume(aid(1)),
|
||||
"après execute_resume, plus rien à annuler"
|
||||
);
|
||||
}
|
||||
|
||||
/// `AgentResumer` qui échoue ⇒ l'erreur est propagée ET `AgentResumed` n'est PAS publié.
|
||||
#[tokio::test]
|
||||
async fn execute_resume_propagates_error_without_emitting_resumed() {
|
||||
let env = env_at(NOW);
|
||||
env.resumer.set_fail(true);
|
||||
|
||||
let task = ScheduledTask::ResumeAgent {
|
||||
agent_id: aid(1),
|
||||
node_id: nid(2),
|
||||
conversation_id: None,
|
||||
};
|
||||
let err = env
|
||||
.service
|
||||
.execute_resume(task)
|
||||
.await
|
||||
.expect_err("la reprise doit échouer");
|
||||
assert!(matches!(err, AppError::Internal(_)), "erreur propagée: {err:?}");
|
||||
|
||||
assert!(
|
||||
!env
|
||||
.bus
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::AgentResumed { .. })),
|
||||
"AgentResumed ne doit PAS être publié si la reprise a échoué"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// (c) Annulation
|
||||
// ===========================================================================
|
||||
|
||||
/// `cancel_resume` après un armement, Scheduler renvoyant `true` ⇒ renvoie `true` et
|
||||
/// publie `AgentResumeCancelled`.
|
||||
#[test]
|
||||
fn cancel_resume_after_arm_returns_true_and_emits_cancelled() {
|
||||
let env = env_at(NOW);
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
|
||||
let issued = env.scheduler.issued();
|
||||
|
||||
assert!(env.service.cancel_resume(aid(1)), "cancel d'un réveil armé ⇒ true");
|
||||
// Le bon ScheduleId a été passé au Scheduler.
|
||||
assert_eq!(env.scheduler.cancels(), vec![issued[0]]);
|
||||
// AgentResumeCancelled publié.
|
||||
assert!(
|
||||
env.bus
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| *e == DomainEvent::AgentResumeCancelled { agent_id: aid(1) }),
|
||||
"AgentResumeCancelled doit être publié"
|
||||
);
|
||||
}
|
||||
|
||||
/// `cancel_resume` sans armement préalable ⇒ `false`, aucun event, et le Scheduler
|
||||
/// n'est même pas sollicité.
|
||||
#[test]
|
||||
fn cancel_resume_without_arm_is_false_no_event() {
|
||||
let env = env_at(NOW);
|
||||
assert!(!env.service.cancel_resume(aid(1)));
|
||||
assert!(env.scheduler.cancels().is_empty(), "Scheduler non sollicité");
|
||||
assert!(env.bus.events().is_empty(), "aucun event");
|
||||
}
|
||||
|
||||
/// Contrat ANTI-COURSE : Scheduler renvoyant `false` (« déjà tiré ») ⇒ `cancel_resume`
|
||||
/// renvoie `false` et n'émet PAS `AgentResumeCancelled` (la reprise suit son cours).
|
||||
#[test]
|
||||
fn cancel_resume_when_scheduler_already_fired_is_false_no_event() {
|
||||
let env = env_at(NOW);
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
|
||||
// Simule un réveil déjà tiré : cancel renvoie false.
|
||||
env.scheduler.set_cancel_result(false);
|
||||
|
||||
assert!(
|
||||
!env.service.cancel_resume(aid(1)),
|
||||
"cancel pile au tir ⇒ false (la reprise suit son cours)"
|
||||
);
|
||||
// Le Scheduler a bien été sollicité (mais a répondu false).
|
||||
assert_eq!(env.scheduler.cancels().len(), 1);
|
||||
// AUCUN AgentResumeCancelled (pas d'event trompeur).
|
||||
assert!(
|
||||
!env
|
||||
.bus
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
|
||||
"pas d'AgentResumeCancelled quand le réveil a déjà tiré"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// (d/LS8) Filet humain niveau 3 — `confirm_human_resume`
|
||||
// ===========================================================================
|
||||
|
||||
/// (a) Heure FUTURE saisie par l'humain ⇒ EXACTEMENT un `arm(fire_at_ms, ResumeAgent{..})`
|
||||
/// avec `fire_at_ms == resets_at_ms` (futur), un `ScheduleId` armé, et les events
|
||||
/// `AgentRateLimited{Some}` PUIS `AgentResumeScheduled` dans cet ordre — parité auto.
|
||||
#[test]
|
||||
fn confirm_human_resume_future_arms_and_emits_in_order() {
|
||||
let env = env_at(NOW);
|
||||
let reset = NOW + 90_000;
|
||||
env.service
|
||||
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), reset);
|
||||
|
||||
// Exactement un arm, bonne échéance, bonne tâche.
|
||||
let armed = env.scheduler.armed();
|
||||
assert_eq!(armed.len(), 1, "exactement un arm");
|
||||
assert_eq!(armed[0].0, reset, "fire_at_ms == reset (futur)");
|
||||
assert_eq!(
|
||||
armed[0].1,
|
||||
ScheduledTask::ResumeAgent {
|
||||
agent_id: aid(1),
|
||||
node_id: nid(2),
|
||||
conversation_id: Some("conv-1".to_owned()),
|
||||
}
|
||||
);
|
||||
// Un ScheduleId a bien été émis (armement actif).
|
||||
assert_eq!(env.scheduler.issued().len(), 1, "un ScheduleId armé");
|
||||
|
||||
// Ordre des events : RateLimited puis ResumeScheduled.
|
||||
assert_eq!(
|
||||
env.bus.events(),
|
||||
vec![
|
||||
DomainEvent::AgentRateLimited {
|
||||
agent_id: aid(1),
|
||||
resets_at_ms: Some(reset),
|
||||
},
|
||||
DomainEvent::AgentResumeScheduled {
|
||||
agent_id: aid(1),
|
||||
fire_at_ms: reset,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// (b) Heure PASSÉE saisie par l'humain (`resets_at_ms < now`) ⇒ clamp anti-passé :
|
||||
/// `fire_at_ms == now` (reprise quasi-immédiate). L'event `AgentRateLimited` conserve
|
||||
/// l'heure brute (passée) ; `AgentResumeScheduled` porte le `now` clampé.
|
||||
#[test]
|
||||
fn confirm_human_resume_past_reset_clamps_fire_at_to_now() {
|
||||
let env = env_at(NOW);
|
||||
let past = NOW - 30_000;
|
||||
env.service
|
||||
.confirm_human_resume(aid(1), nid(2), None, past);
|
||||
|
||||
let armed = env.scheduler.armed();
|
||||
assert_eq!(armed.len(), 1);
|
||||
assert_eq!(armed[0].0, NOW, "fire_at_ms clampé à now (jamais le passé)");
|
||||
|
||||
assert_eq!(
|
||||
env.bus.events(),
|
||||
vec![
|
||||
DomainEvent::AgentRateLimited {
|
||||
agent_id: aid(1),
|
||||
resets_at_ms: Some(past), // heure brute (passée) conservée dans l'event
|
||||
},
|
||||
DomainEvent::AgentResumeScheduled {
|
||||
agent_id: aid(1),
|
||||
fire_at_ms: NOW, // clampé
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// (c) DÉDOUBLONNAGE CROISÉ — `confirm_human_resume` APRÈS un `on_rate_limited` déjà
|
||||
/// armé pour le même agent ⇒ l'armement auto précédent est annulé (cancel interne du
|
||||
/// 1er ScheduleId), un seul réveil reste actif, et AUCUN `AgentResumeCancelled` n'est
|
||||
/// émis (dédoublonnage silencieux). On prouve l'unicité de l'armement actif : un
|
||||
/// `cancel_resume` réussit une fois (true), un second échoue (false).
|
||||
#[test]
|
||||
fn confirm_human_resume_after_auto_dedups_single_active_arm() {
|
||||
let env = env_at(NOW);
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
|
||||
env.service
|
||||
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 120_000);
|
||||
|
||||
let issued = env.scheduler.issued();
|
||||
assert_eq!(issued.len(), 2, "deux arms (auto puis humain), pas d'empilement");
|
||||
assert_eq!(
|
||||
env.scheduler.cancels(),
|
||||
vec![issued[0]],
|
||||
"le ScheduleId auto précédent est cancel-é avant de réarmer (humain)"
|
||||
);
|
||||
|
||||
// Aucun AgentResumeCancelled (dédoublonnage interne, silencieux).
|
||||
assert!(
|
||||
!env
|
||||
.bus
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
|
||||
"le dédoublonnage croisé n'émet PAS AgentResumeCancelled"
|
||||
);
|
||||
|
||||
// Unicité de l'armement actif : un seul cancel_resume aboutit.
|
||||
assert!(env.service.cancel_resume(aid(1)), "un armement actif unique ⇒ true");
|
||||
assert!(
|
||||
!env.service.cancel_resume(aid(1)),
|
||||
"plus aucun armement après le premier cancel ⇒ false (une seule entrée)"
|
||||
);
|
||||
}
|
||||
|
||||
/// (c-inverse) `on_rate_limited` APRÈS un `confirm_human_resume` déjà armé pour le même
|
||||
/// agent ⇒ symétrique : l'armement humain précédent est annulé (cancel interne), un seul
|
||||
/// réveil reste actif, pas d'`AgentResumeCancelled`.
|
||||
#[test]
|
||||
fn auto_after_confirm_human_resume_dedups_single_active_arm() {
|
||||
let env = env_at(NOW);
|
||||
env.service
|
||||
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 60_000);
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 120_000));
|
||||
|
||||
let issued = env.scheduler.issued();
|
||||
assert_eq!(issued.len(), 2, "deux arms (humain puis auto), pas d'empilement");
|
||||
assert_eq!(
|
||||
env.scheduler.cancels(),
|
||||
vec![issued[0]],
|
||||
"le ScheduleId humain précédent est cancel-é avant de réarmer (auto)"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!env
|
||||
.bus
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
|
||||
"le dédoublonnage croisé n'émet PAS AgentResumeCancelled"
|
||||
);
|
||||
|
||||
assert!(env.service.cancel_resume(aid(1)), "un armement actif unique ⇒ true");
|
||||
assert!(
|
||||
!env.service.cancel_resume(aid(1)),
|
||||
"plus aucun armement après le premier cancel ⇒ false (une seule entrée)"
|
||||
);
|
||||
}
|
||||
|
||||
/// (d) ANNULABILITÉ — `cancel_resume` après `confirm_human_resume` ⇒ renvoie `true` et
|
||||
/// publie `AgentResumeCancelled` (l'armement humain s'annule par la MÊME voie que l'auto).
|
||||
#[test]
|
||||
fn cancel_resume_after_confirm_human_resume_returns_true_and_emits_cancelled() {
|
||||
let env = env_at(NOW);
|
||||
env.service
|
||||
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 60_000);
|
||||
let issued = env.scheduler.issued();
|
||||
|
||||
assert!(
|
||||
env.service.cancel_resume(aid(1)),
|
||||
"cancel d'un réveil humain armé ⇒ true"
|
||||
);
|
||||
assert_eq!(env.scheduler.cancels(), vec![issued[0]]);
|
||||
assert!(
|
||||
env.bus
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| *e == DomainEvent::AgentResumeCancelled { agent_id: aid(1) }),
|
||||
"AgentResumeCancelled doit être publié"
|
||||
);
|
||||
}
|
||||
|
||||
/// (e) PARITÉ auto/humain — à reset (futur) IDENTIQUE, `confirm_human_resume` produit
|
||||
/// EXACTEMENT la même séquence d'events et le même `arm` (échéance + tâche) qu'`on_rate_limited`
|
||||
/// dans son cas Scheduled. La source (Human vs Structured) n'a aucun effet observable.
|
||||
#[test]
|
||||
fn confirm_human_resume_is_event_for_event_identical_to_auto_scheduled() {
|
||||
let reset = NOW + 60_000;
|
||||
|
||||
let auto = env_at(NOW);
|
||||
auto.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(reset));
|
||||
|
||||
let human = env_at(NOW);
|
||||
human
|
||||
.service
|
||||
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), reset);
|
||||
|
||||
// Même séquence d'events.
|
||||
assert_eq!(
|
||||
human.bus.events(),
|
||||
auto.bus.events(),
|
||||
"parité auto/humain : même séquence d'events à reset identique"
|
||||
);
|
||||
// Même armement (échéance + tâche).
|
||||
assert_eq!(
|
||||
human.scheduler.armed(),
|
||||
auto.scheduler.armed(),
|
||||
"parité auto/humain : même arm (fire_at_ms + ResumeAgent{{..}})"
|
||||
);
|
||||
}
|
||||
205
crates/application/tests/session_limit_t4.rs
Normal file
205
crates/application/tests/session_limit_t4.rs
Normal file
@ -0,0 +1,205 @@
|
||||
//! LS4 — réconciliation §21.2-T4 au niveau applicatif : `drain_with_readiness_outcome`
|
||||
//! traduit un tour clos par une **limite de session** (un `RateLimited` vu, pas de
|
||||
//! `Final`) en `Ok(TurnOutcome::RateLimited{..})` — une **fin gracieuse**, pas une
|
||||
//! erreur. **100 % fakes** (fake `AgentSession` scriptable + fake `InputMediator`).
|
||||
//!
|
||||
//! On vérifie AUSSI la NON-RÉGRESSION des signatures historiques `Result<String>` :
|
||||
//! - `drain_with_readiness` (et `send_blocking`) ⇒ un tour limité reste une `Io`
|
||||
//! (zéro régression pour l'appelant orchestrateur) ;
|
||||
//! - un flux clos SANS `Final` ET SANS `RateLimited` reste une `Io` (tour tronqué).
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use application::{drain_with_readiness, drain_with_readiness_outcome, send_blocking, TurnOutcome};
|
||||
use domain::ids::AgentId;
|
||||
use domain::input::{AgentBusyState, InputMediator};
|
||||
use domain::mailbox::{PendingReply, Ticket};
|
||||
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
|
||||
use domain::SessionId;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake AgentSession : `send` rejoue une liste fixe d'événements.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct FakeSession {
|
||||
events: Vec<ReplyEvent>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl AgentSession for FakeSession {
|
||||
fn id(&self) -> SessionId {
|
||||
SessionId::from_uuid(Uuid::from_u128(1))
|
||||
}
|
||||
fn conversation_id(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||
Ok(Box::new(self.events.clone().into_iter()))
|
||||
}
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake InputMediator : journalise mark_idle / mark_alive (pour prouver que la
|
||||
// readiness reste pilotée par le Final, pas par un RateLimited).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingMediator {
|
||||
calls: Mutex<Vec<&'static str>>,
|
||||
}
|
||||
impl InputMediator for RecordingMediator {
|
||||
fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply {
|
||||
PendingReply::new(Box::pin(std::future::pending()))
|
||||
}
|
||||
fn preempt(&self, _agent: AgentId) {}
|
||||
fn mark_idle(&self, _agent: AgentId) {
|
||||
self.calls.lock().unwrap().push("idle");
|
||||
}
|
||||
fn mark_alive(&self, _agent: AgentId) {
|
||||
self.calls.lock().unwrap().push("alive");
|
||||
}
|
||||
fn busy_state(&self, _agent: AgentId) -> AgentBusyState {
|
||||
AgentBusyState::Idle
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// drain_with_readiness_outcome — issue riche T4
|
||||
// ===========================================================================
|
||||
|
||||
/// `[RateLimited{Some(t)}]` sans Final ⇒ `Ok(TurnOutcome::RateLimited{Some(t)})`
|
||||
/// (fin gracieuse, PAS d'Err).
|
||||
#[tokio::test]
|
||||
async fn outcome_rate_limited_some_without_final_is_graceful() {
|
||||
let session = FakeSession {
|
||||
events: vec![ReplyEvent::RateLimited {
|
||||
resets_at_ms: Some(1_700_000_000_000),
|
||||
}],
|
||||
};
|
||||
let mediator = RecordingMediator::default();
|
||||
let out = drain_with_readiness_outcome(&session, "go", None, &mediator, aid(1))
|
||||
.await
|
||||
.expect("un tour limité est une fin gracieuse, pas une erreur");
|
||||
assert_eq!(
|
||||
out,
|
||||
TurnOutcome::RateLimited {
|
||||
resets_at_ms: Some(1_700_000_000_000)
|
||||
}
|
||||
);
|
||||
// Readiness : un RateLimited ne marque PAS Idle (piloté par Final uniquement).
|
||||
assert!(
|
||||
!mediator.calls.lock().unwrap().contains(&"idle"),
|
||||
"un RateLimited ne doit pas marquer l'agent Idle"
|
||||
);
|
||||
}
|
||||
|
||||
/// `[RateLimited{None}]` sans Final ⇒ `Ok(TurnOutcome::RateLimited{None})`.
|
||||
#[tokio::test]
|
||||
async fn outcome_rate_limited_none_without_final_is_graceful() {
|
||||
let session = FakeSession {
|
||||
events: vec![ReplyEvent::RateLimited { resets_at_ms: None }],
|
||||
};
|
||||
let mediator = RecordingMediator::default();
|
||||
let out = drain_with_readiness_outcome(&session, "go", None, &mediator, aid(1))
|
||||
.await
|
||||
.expect("fin gracieuse");
|
||||
assert_eq!(out, TurnOutcome::RateLimited { resets_at_ms: None });
|
||||
}
|
||||
|
||||
/// `[.., RateLimited, Final]` ⇒ `Ok(TurnOutcome::Completed(contenu))` : le Final
|
||||
/// l'emporte toujours (le tour a réellement abouti).
|
||||
#[tokio::test]
|
||||
async fn outcome_rate_limited_then_final_is_completed() {
|
||||
let session = FakeSession {
|
||||
events: vec![
|
||||
ReplyEvent::TextDelta { text: "a".into() },
|
||||
ReplyEvent::RateLimited {
|
||||
resets_at_ms: Some(42),
|
||||
},
|
||||
ReplyEvent::Final {
|
||||
content: "fini".into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
let mediator = RecordingMediator::default();
|
||||
let out = drain_with_readiness_outcome(&session, "go", None, &mediator, aid(1))
|
||||
.await
|
||||
.expect("ok");
|
||||
assert_eq!(out, TurnOutcome::Completed("fini".to_owned()));
|
||||
// Le Final marque bien Idle.
|
||||
assert!(mediator.calls.lock().unwrap().contains(&"idle"));
|
||||
}
|
||||
|
||||
/// `[TextDelta]` seul (ni Final ni RateLimited) ⇒ `Err(Io)` INCHANGÉ : un vrai flux
|
||||
/// tronqué reste une erreur (non-régression critique).
|
||||
#[tokio::test]
|
||||
async fn outcome_truncated_stream_without_final_or_ratelimit_is_io_error() {
|
||||
let session = FakeSession {
|
||||
events: vec![ReplyEvent::TextDelta { text: "a".into() }],
|
||||
};
|
||||
let mediator = RecordingMediator::default();
|
||||
let err = drain_with_readiness_outcome(&session, "go", None, &mediator, aid(1))
|
||||
.await
|
||||
.expect_err("flux tronqué sans limite ⇒ erreur");
|
||||
assert!(matches!(err, AgentSessionError::Io(_)), "vu: {err:?}");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Non-régression des signatures historiques Result<String>
|
||||
// ===========================================================================
|
||||
|
||||
/// `drain_with_readiness` (signature historique) : un tour limité reste `Err(Io)`
|
||||
/// (zéro régression pour l'appelant orchestrateur).
|
||||
#[tokio::test]
|
||||
async fn drain_with_readiness_rate_limited_is_io_error() {
|
||||
let session = FakeSession {
|
||||
events: vec![ReplyEvent::RateLimited {
|
||||
resets_at_ms: Some(1),
|
||||
}],
|
||||
};
|
||||
let mediator = RecordingMediator::default();
|
||||
let err = drain_with_readiness(&session, "go", None, &mediator, aid(1))
|
||||
.await
|
||||
.expect_err("limite ⇒ Io sur la signature historique");
|
||||
assert!(matches!(err, AgentSessionError::Io(_)), "vu: {err:?}");
|
||||
}
|
||||
|
||||
/// `send_blocking` (signature historique) : un tour limité reste `Err(Io)`.
|
||||
#[tokio::test]
|
||||
async fn send_blocking_rate_limited_is_io_error() {
|
||||
let session = FakeSession {
|
||||
events: vec![ReplyEvent::RateLimited { resets_at_ms: None }],
|
||||
};
|
||||
let err = send_blocking(&session, "go", None)
|
||||
.await
|
||||
.expect_err("limite ⇒ Io");
|
||||
assert!(matches!(err, AgentSessionError::Io(_)), "vu: {err:?}");
|
||||
}
|
||||
|
||||
/// `drain_with_readiness` nominal : `[.., Final]` ⇒ le contenu, et `mark_idle` au Final.
|
||||
#[tokio::test]
|
||||
async fn drain_with_readiness_nominal_still_completes() {
|
||||
let session = FakeSession {
|
||||
events: vec![
|
||||
ReplyEvent::TextDelta { text: "a".into() },
|
||||
ReplyEvent::Final {
|
||||
content: "fini".into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
let mediator = RecordingMediator::default();
|
||||
let content = drain_with_readiness(&session, "go", None, &mediator, aid(1))
|
||||
.await
|
||||
.expect("ok");
|
||||
assert_eq!(content, "fini");
|
||||
assert!(mediator.calls.lock().unwrap().contains(&"idle"));
|
||||
}
|
||||
@ -106,6 +106,24 @@ fn structured_insert_resolve_and_remove() {
|
||||
assert!(reg.session_for_agent(&a).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_meta_for_session_resolves_agent_and_node() {
|
||||
// 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.
|
||||
let reg = StructuredSessions::new();
|
||||
let s = sid(1);
|
||||
let a = aid(10);
|
||||
let n = nid(100);
|
||||
reg.insert(fake(s), a, n);
|
||||
|
||||
assert_eq!(reg.meta_for_session(&s), Some((a, n)));
|
||||
|
||||
// Id inconnu (ou retiré) ⇒ None (jamais de panique sur une session morte).
|
||||
assert_eq!(reg.meta_for_session(&sid(999)), None);
|
||||
reg.remove(&s);
|
||||
assert_eq!(reg.meta_for_session(&s), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_live_agents_lists_triples() {
|
||||
let reg = StructuredSessions::new();
|
||||
|
||||
@ -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) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,3 +92,10 @@ typed_id!(
|
||||
/// Identifies a node in a [`crate::layout::LayoutTree`].
|
||||
NodeId
|
||||
);
|
||||
typed_id!(
|
||||
/// Identifies one armed one-shot wake-up of a [`crate::ports::Scheduler`]
|
||||
/// (ARCHITECTURE §21.4). Opaque, cancellable handle returned by
|
||||
/// [`crate::ports::Scheduler::arm`] and consumed by
|
||||
/// [`crate::ports::Scheduler::cancel`].
|
||||
ScheduleId
|
||||
);
|
||||
|
||||
@ -50,6 +50,7 @@ pub mod profile;
|
||||
pub mod project;
|
||||
pub mod readiness;
|
||||
pub mod sandbox;
|
||||
pub mod session_limit;
|
||||
pub mod remote;
|
||||
pub mod skill;
|
||||
pub mod template;
|
||||
@ -64,8 +65,8 @@ mod validation;
|
||||
pub use error::DomainError;
|
||||
|
||||
pub use ids::{
|
||||
AgentId, LayoutId, NodeId, ProfileId, ProjectId, SessionId, SkillId, TabId, TemplateId,
|
||||
WindowId,
|
||||
AgentId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId, TabId,
|
||||
TemplateId, WindowId,
|
||||
};
|
||||
|
||||
pub use project::{Project, ProjectPath};
|
||||
@ -78,7 +79,7 @@ pub use template::{AgentTemplate, TemplateVersion};
|
||||
|
||||
pub use profile::{
|
||||
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, LivenessStrategy,
|
||||
McpServerWiring, SessionStrategy,
|
||||
McpServerWiring, RateLimitPattern, SessionStrategy,
|
||||
};
|
||||
|
||||
pub use mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId};
|
||||
@ -92,6 +93,8 @@ pub use input::{AgentBusyState, AgentLiveness, InputMediator, InputSource};
|
||||
|
||||
pub use readiness::{ReadinessPolicy, ReadinessSignal};
|
||||
|
||||
pub use session_limit::{plan_resume, ResumePlan, RateLimitSource, SessionLimit};
|
||||
|
||||
pub use conversation_log::{
|
||||
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
|
||||
ProviderSessionStore, TurnId, TurnRole,
|
||||
@ -142,6 +145,6 @@ pub use ports::{
|
||||
FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator,
|
||||
MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PermissionStore,
|
||||
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle,
|
||||
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, SpawnSpec, StoreError,
|
||||
TemplateStore,
|
||||
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, SpawnSpec,
|
||||
StoreError, TemplateStore,
|
||||
};
|
||||
|
||||
@ -29,7 +29,7 @@ use thiserror::Error;
|
||||
|
||||
use crate::agent::AgentManifest;
|
||||
use crate::events::DomainEvent;
|
||||
use crate::ids::{AgentId, SessionId};
|
||||
use crate::ids::{AgentId, NodeId, ScheduleId, SessionId};
|
||||
use crate::markdown::MarkdownDoc;
|
||||
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
||||
use crate::permission::ProjectPermissions;
|
||||
@ -225,6 +225,26 @@ pub enum ReplyEvent {
|
||||
/// jusqu'au [`ReplyEvent::Final`]. Un tour comporte ≥0 `Heartbeat`, jamais
|
||||
/// d'obligation d'en émettre.
|
||||
Heartbeat,
|
||||
/// **Limite de session/débit atteinte** (model-agnostique, ARCHITECTURE §21) :
|
||||
/// l'adapter structuré a observé que le moteur a suspendu l'agent pour cause de
|
||||
/// quota (Claude `rate_limit_event` → `rate_limit_info.resetsAt`, ou équivalent).
|
||||
/// Porte un fait neutre : « limité, reset à T (peut-être) ». Aucun détail propre
|
||||
/// à une CLI ne franchit la frontière (forme du `rate_limit_event`, format de
|
||||
/// l'heure…) — tout cela reste confiné à l'adapter (cf. §21.2-T2).
|
||||
///
|
||||
/// **Jamais terminal** (cf. §21.2-T4) : exactement comme [`ReplyEvent::Heartbeat`],
|
||||
/// un `RateLimited` **ne clôt pas** le flux — il s'intercale et le flux continue
|
||||
/// jusqu'au [`ReplyEvent::Final`] **ou** jusqu'à une clôture du flux. Conséquence
|
||||
/// pour les consommateurs : un tour clos **sans `Final`** parce que limité doit
|
||||
/// être traité comme une **fin gracieuse limitée** (et non comme une erreur « flux
|
||||
/// clos sans Final »), dès lors qu'un `RateLimited` a été vu dans le tour.
|
||||
RateLimited {
|
||||
/// Instant de réinitialisation de la limite, en **époche-millisecondes**
|
||||
/// (homogène avec [`Clock::now_millis`]). `None` quand le moteur n'a pas
|
||||
/// fourni d'heure de reset exploitable ⇒ pas de reprise auto possible (filet
|
||||
/// humain, §21.1 niveau 3). Jamais une `Instant` monotone (cf. §21.2-T1).
|
||||
resets_at_ms: Option<i64>,
|
||||
},
|
||||
/// **Événement terminal déterministe** d'un tour : l'adapter l'émet quand il a
|
||||
/// lu le message `result` documenté de la CLI. Porte le contenu final agrégé.
|
||||
/// Après `Final`, le flux se termine (plus aucun événement).
|
||||
@ -242,6 +262,34 @@ pub enum ReplyEvent {
|
||||
/// clôt le flux ; deltas, activités et heartbeats sont tous non terminaux.
|
||||
pub type ReplyStream = Box<dyn Iterator<Item = ReplyEvent> + Send>;
|
||||
|
||||
/// Intention **model-agnostique** exécutée à l'échéance d'un réveil de
|
||||
/// [`Scheduler`] (ARCHITECTURE §21.4).
|
||||
///
|
||||
/// **Donnée pure, pas de closure** : à l'image du dispatch de l'orchestrateur
|
||||
/// (§14.3, où une requête est une *donnée* validée puis dispatchée), une tâche
|
||||
/// programmée est une **valeur** qui franchit la frontière, jamais un effet ni une
|
||||
/// fonction. C'est ce qui garde le port [`Scheduler`] sans aucune dépendance vers
|
||||
/// l'application : l'adapter ne fait que **remettre** cette donnée à un drain
|
||||
/// applicatif, qui seul l'exécute.
|
||||
///
|
||||
/// Enum **extensible** : d'autres intentions programmées pourront s'ajouter sans
|
||||
/// toucher au port (Open/Closed).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ScheduledTask {
|
||||
/// Reprendre un agent à l'heure de reset de sa limite de session (§21) : relancer
|
||||
/// via [`SessionPlan::Resume`] avec un prompt de reprise court (logique côté
|
||||
/// application, lot LS4). Porte exactement le pivot de reprise model-agnostique.
|
||||
ResumeAgent {
|
||||
/// L'agent à reprendre.
|
||||
agent_id: AgentId,
|
||||
/// La cellule (nœud du layout) qui héberge sa session.
|
||||
node_id: NodeId,
|
||||
/// Id de conversation du moteur à reprendre (`None` ⇒ reprise dégradée sans
|
||||
/// id, comme le reste du chemin de reprise §15).
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-port error types
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -1246,3 +1294,50 @@ pub trait IdGenerator: Send + Sync {
|
||||
/// Returns a fresh UUID.
|
||||
fn new_uuid(&self) -> uuid::Uuid;
|
||||
}
|
||||
|
||||
/// Minuterie **one-shot annulable** (ARCHITECTURE §21.4) — le **seul** port neuf de
|
||||
/// la feature « limites de session ». Là où [`Clock`] dit *quelle heure il est*, ce
|
||||
/// port *réveille* à une échéance absolue donnée.
|
||||
///
|
||||
/// # Interface Segregation (§21.8-I)
|
||||
/// Volontairement minimal — `arm` / `cancel`, rien d'autre — et **distinct** de
|
||||
/// [`Clock`] : un consommateur qui n'a besoin que de « réveille-moi à T » ne dépend
|
||||
/// pas d'un store ni d'une horloge.
|
||||
///
|
||||
/// # Synchrone (pas d'`async_trait`)
|
||||
/// Comme [`Clock`], [`IdGenerator`] et [`EventBus`], ce port est **non bloquant** :
|
||||
/// `arm` ne fait qu'enregistrer une minuterie (l'attente réelle se passe en tâche de
|
||||
/// fond dans l'adapter) et `cancel` ne fait qu'annuler — aucun `await` au point
|
||||
/// d'appel. On le garde donc en `fn` simple, sans payer le boxing d'`async_trait`.
|
||||
///
|
||||
/// # Remise de la tâche échue — dispatch par DONNÉE (§14.3, §21.4)
|
||||
/// Le port **n'exécute jamais** la reprise (aucune dépendance vers l'application).
|
||||
/// À l'échéance, l'adapter **pousse la [`ScheduledTask`] (une valeur)** dans un canal
|
||||
/// fourni à sa construction, que l'application **draine** et exécute — exactement le
|
||||
/// patron du watcher d'orchestrateur, qui dispatche une requête-donnée vers
|
||||
/// l'`OrchestratorService`. Aucune closure ne franchit la frontière domaine.
|
||||
///
|
||||
/// # État en mémoire (§21.1-3)
|
||||
/// Aucune persistance : un réveil armé ne survit pas à un redémarrage d'IdeA (le
|
||||
/// chemin `ListResumableAgents` existant prend alors le relais).
|
||||
///
|
||||
/// # Substituabilité (Liskov)
|
||||
/// Tout adapter (le `TokioScheduler` réel comme un fake de test qui tire à la
|
||||
/// demande) respecte le même contrat : `arm` rend un id annulable ; `cancel` renvoie
|
||||
/// `true` ssi il a effectivement désarmé un réveil non encore tiré.
|
||||
pub trait Scheduler: Send + Sync {
|
||||
/// Arme une minuterie one-shot à `deadline_ms` (**époche-millisecondes absolues**,
|
||||
/// cohérent avec [`Clock::now_millis`] et `ResumePlan::Scheduled.fire_at_ms`). À
|
||||
/// l'échéance, l'adapter remet `task` au drain applicatif (cf. doc du trait).
|
||||
/// Renvoie un [`ScheduleId`] annulable.
|
||||
///
|
||||
/// `deadline_ms` **≤ now** ⇒ déclenchement **au plus tôt** (immédiat). Le clamp
|
||||
/// anti-passé est déjà assuré en amont par `plan_resume` (domaine) ; ce contrat ne
|
||||
/// fait que garantir qu'une échéance passée ne « se perd » pas.
|
||||
fn arm(&self, deadline_ms: i64, task: ScheduledTask) -> ScheduleId;
|
||||
|
||||
/// Annule un réveil armé **non encore tiré**. Idempotent et **sans erreur** :
|
||||
/// renvoie `true` s'il a effectivement été désarmé, `false` s'il était inconnu ou
|
||||
/// déjà tiré. C'est ce qui sous-tend la « reprise auto **annulable** » (§21.1-4).
|
||||
fn cancel(&self, id: ScheduleId) -> bool;
|
||||
}
|
||||
|
||||
@ -174,6 +174,64 @@ impl LivenessStrategy {
|
||||
}
|
||||
}
|
||||
|
||||
/// Motif déclaratif de détection d'une **limite de session/débit** pour un agent
|
||||
/// **PTY/TUI sans adapter structuré** (ARCHITECTURE §21, niveau 2 de détection).
|
||||
///
|
||||
/// Donnée **pure** (pas de code par CLI — Open/Closed, §9), calquée sur la
|
||||
/// philosophie de [`AgentProfile::prompt_ready_pattern`] mais **plus riche** : là où
|
||||
/// le retour-de-prompt est une simple sous-chaîne littérale, la limite de session a
|
||||
/// besoin d'**extraire une heure de reset** dans la sortie. Le domaine **ne stocke
|
||||
/// que la donnée** (chaînes) ; le **moteur regex et le parsing d'heure vivent en
|
||||
/// infrastructure** (composant `RateLimitParser`, dépendance `regex` ajoutée au seul
|
||||
/// `Cargo.toml` d'`infrastructure`, cf. §21.2-T2). **Aucune** regex ni heure parsée
|
||||
/// ne franchit la frontière domaine : l'infra émet un
|
||||
/// [`crate::ports::ReplyEvent::RateLimited`] avec une époche-ms normalisée.
|
||||
///
|
||||
/// Invariant (garanti par le constructeur) : `pattern` est non vide.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RateLimitPattern {
|
||||
/// Le **motif brut** (interprété comme une regex par l'infra) recherché dans la
|
||||
/// sortie PTY pour reconnaître l'épisode de limite. Le domaine ne le compile
|
||||
/// jamais — il le transporte tel quel jusqu'à l'adapter (§21.2-T2).
|
||||
pub pattern: String,
|
||||
/// Nom du **groupe de capture** (ou indication équivalente) d'où l'infra extrait
|
||||
/// l'heure de reset. `None` ⇒ le motif détecte la limite **sans** heure de reset
|
||||
/// ⇒ [`crate::ports::ReplyEvent::RateLimited`]`{ resets_at_ms: None }` (filet
|
||||
/// humain). Donnée opaque au domaine.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reset_capture: Option<String>,
|
||||
/// **Format d'heure** (ex. style `strftime`) que l'infra utilise pour parser la
|
||||
/// chaîne capturée par `reset_capture` en une heure murale, qu'elle compose
|
||||
/// ensuite avec la date du jour + le fuseau via `Clock` pour obtenir une
|
||||
/// époche-ms (§21.10-2). `None` ⇒ l'infra applique sa stratégie de parsing par
|
||||
/// défaut. Donnée opaque au domaine.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub time_format: Option<String>,
|
||||
}
|
||||
|
||||
impl RateLimitPattern {
|
||||
/// Construit un motif validé (parse-don't-validate, comme
|
||||
/// [`SessionStrategy::new`] / [`LivenessStrategy::new`]).
|
||||
///
|
||||
/// # Errors
|
||||
/// Renvoie [`DomainError::EmptyField`] (`"rateLimitPattern.pattern"`) si
|
||||
/// `pattern` est vide.
|
||||
pub fn new(
|
||||
pattern: impl Into<String>,
|
||||
reset_capture: Option<String>,
|
||||
time_format: Option<String>,
|
||||
) -> Result<Self, DomainError> {
|
||||
let pattern = pattern.into();
|
||||
crate::validation::non_empty(&pattern, "rateLimitPattern.pattern")?;
|
||||
Ok(Self {
|
||||
pattern,
|
||||
reset_capture,
|
||||
time_format,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapter d'**exécution structurée** qui pilote un profil IA (ARCHITECTURE §17).
|
||||
///
|
||||
/// Déclaratif, Open/Closed (comme [`EmbedderStrategy`]) : un profil déclare quel
|
||||
@ -553,6 +611,21 @@ pub struct AgentProfile {
|
||||
/// un profil sans cette clé sérialise exactement comme avant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub liveness: Option<LivenessStrategy>,
|
||||
/// Motif déclaratif de détection d'une **limite de session/débit** (ARCHITECTURE
|
||||
/// §21, niveau 2). `None` (défaut, et valeur des profils existants) ⇒ **aucune**
|
||||
/// détection par motif : seuls les agents structurés (niveau 1) ou le filet
|
||||
/// humain (niveau 3) couvrent la limite. `Some(_)` ⇒ pour un agent **PTY/TUI sans
|
||||
/// adapter structuré**, IdeA observe la sortie et émet un
|
||||
/// [`crate::ports::ReplyEvent::RateLimited`] sur match.
|
||||
///
|
||||
/// Le domaine ne porte que la **donnée** ([`RateLimitPattern`]) ; le moteur regex
|
||||
/// + le parsing d'heure vivent en infra (§21.2-T2) — domaine dépendance-zéro
|
||||
/// préservé.
|
||||
///
|
||||
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation :
|
||||
/// un profil sans cette clé sérialise exactement comme avant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rate_limit_pattern: Option<RateLimitPattern>,
|
||||
/// Séquence de soumission écrite **après** le texte d'une délégation pour la
|
||||
/// faire valider par la CLI (§20.3, fix Bug 1). Le portail d'écriture (front)
|
||||
/// écrit d'abord le texte (sans `\n`, pour esquiver la détection de paste de
|
||||
@ -726,6 +799,7 @@ impl AgentProfile {
|
||||
mcp: None,
|
||||
prompt_ready_pattern: None,
|
||||
liveness: None,
|
||||
rate_limit_pattern: None,
|
||||
submit_sequence: None,
|
||||
submit_delay_ms: None,
|
||||
projector: None,
|
||||
@ -768,6 +842,15 @@ impl AgentProfile {
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe le [`RateLimitPattern`] de détection de limite (§21, niveau 2)
|
||||
/// et renvoie le profil. Laisse [`AgentProfile::new`] stable (zéro régression
|
||||
/// d'appel) : les profils sans détection par motif ne l'appellent simplement pas.
|
||||
#[must_use]
|
||||
pub fn with_rate_limit_pattern(mut self, pattern: RateLimitPattern) -> Self {
|
||||
self.rate_limit_pattern = Some(pattern);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe la [`Self::submit_sequence`] (§20.3, fix Bug 1) et renvoie le
|
||||
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
||||
/// profils qui s'en remettent au défaut `"\r"` ne l'appellent simplement pas.
|
||||
@ -1366,4 +1449,96 @@ mod mcp_tests {
|
||||
"transport expected; got: {toml}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- §21 : rate_limit_pattern (détection de limite par motif, niveau 2) ------
|
||||
|
||||
#[test]
|
||||
fn rate_limit_pattern_new_rejects_empty_pattern() {
|
||||
let err = RateLimitPattern::new("", None, None).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, DomainError::EmptyField { field } if field == "rateLimitPattern.pattern"),
|
||||
"un motif vide doit être rejeté; got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_pattern_new_accepts_non_empty_pattern() {
|
||||
let p = RateLimitPattern::new(
|
||||
"rate limit.*resets at (?P<reset>.+)",
|
||||
Some("reset".to_owned()),
|
||||
Some("%H:%M".to_owned()),
|
||||
)
|
||||
.expect("valid pattern");
|
||||
assert_eq!(p.pattern, "rate limit.*resets at (?P<reset>.+)");
|
||||
assert_eq!(p.reset_capture.as_deref(), Some("reset"));
|
||||
assert_eq!(p.time_format.as_deref(), Some("%H:%M"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_default_has_no_rate_limit_pattern() {
|
||||
// Profils existants (via `new`) : aucun motif de limite.
|
||||
assert!(profile_without_mcp().rate_limit_pattern.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_without_rate_limit_pattern_omits_key_in_json() {
|
||||
let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
|
||||
assert!(
|
||||
!json.contains("rateLimitPattern"),
|
||||
"a profile without a rate-limit pattern must NOT serialise the key (zero regression); got: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_json_without_rate_limit_pattern_deserialises_to_none() {
|
||||
// JSON produit avant l'existence du champ : aucune clé `rateLimitPattern`.
|
||||
let legacy = r#"{
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"name": "Dev",
|
||||
"command": "claude",
|
||||
"args": [],
|
||||
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
|
||||
"detect": null,
|
||||
"cwdTemplate": "{agentRunDir}"
|
||||
}"#;
|
||||
let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
|
||||
assert!(profile.rate_limit_pattern.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_rate_limit_pattern_sets_and_round_trips_camel_case() {
|
||||
let pattern = RateLimitPattern::new(
|
||||
"limit reached, resets (?P<resetAt>.+)",
|
||||
Some("resetAt".to_owned()),
|
||||
Some("%-I%p".to_owned()),
|
||||
)
|
||||
.expect("valid pattern");
|
||||
let profile = profile_without_mcp().with_rate_limit_pattern(pattern.clone());
|
||||
assert_eq!(profile.rate_limit_pattern, Some(pattern));
|
||||
|
||||
let json = serde_json::to_string(&profile).expect("serialise");
|
||||
assert!(json.contains("rateLimitPattern"), "key present: {json}");
|
||||
// camelCase respecté sur les champs de RateLimitPattern.
|
||||
assert!(json.contains("resetCapture"), "camelCase field resetCapture: {json}");
|
||||
assert!(json.contains("timeFormat"), "camelCase field timeFormat: {json}");
|
||||
|
||||
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
|
||||
assert_eq!(profile, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_pattern_omits_unset_optional_fields_in_json() {
|
||||
// reset_capture / time_format à None ⇒ leurs clés sont omises.
|
||||
let pattern = RateLimitPattern::new("rate limited", None, None).expect("valid pattern");
|
||||
let json = serde_json::to_string(&pattern).expect("serialise");
|
||||
assert!(json.contains("\"pattern\""), "pattern field present: {json}");
|
||||
assert!(
|
||||
!json.contains("resetCapture"),
|
||||
"an unset resetCapture must be omitted; got: {json}"
|
||||
);
|
||||
assert!(
|
||||
!json.contains("timeFormat"),
|
||||
"an unset timeFormat must be omitted; got: {json}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,6 +49,20 @@ pub enum ReadinessSignal {
|
||||
/// Le garde-fou de durée de tour a expiré. Place réservée au **lot 2** — non
|
||||
/// produit dans ce lot.
|
||||
TimedOut,
|
||||
/// L'agent est en **limite de session/débit** (ARCHITECTURE §21) : le moteur l'a
|
||||
/// suspendu pour cause de quota. Déduit de [`ReplyEvent::RateLimited`] par
|
||||
/// [`ReadinessPolicy::classify`]. **Pas terminal** au sens « fin de tour » : il ne
|
||||
/// marque pas le tour comme `TurnEnded` (l'agent n'a pas rendu son `Final`) — il
|
||||
/// signale un 3ᵉ axe d'état orthogonal (« limité jusqu'à T »), que l'application
|
||||
/// traduit en planification de reprise.
|
||||
///
|
||||
/// `Copy` préservé : `Option<i64>` est `Copy`, donc l'enum le reste (cf. §21.3).
|
||||
RateLimited {
|
||||
/// Instant de reset en **époche-millisecondes** (cf.
|
||||
/// [`ReplyEvent::RateLimited::resets_at_ms`]). `None` ⇒ pas d'heure connue
|
||||
/// (filet humain, pas de reprise auto).
|
||||
resets_at_ms: Option<i64>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Politique **pure** de classification d'un événement de tour en
|
||||
@ -63,6 +77,11 @@ impl ReadinessPolicy {
|
||||
///
|
||||
/// - [`ReplyEvent::Final`] ⇒ `Some(`[`ReadinessSignal::TurnEnded`]`)` : seul
|
||||
/// événement terminal, il signe la fin de tour déterministe.
|
||||
/// - [`ReplyEvent::RateLimited`] ⇒
|
||||
/// `Some(`[`ReadinessSignal::RateLimited`]`{..})` : **non terminal** (l'agent
|
||||
/// n'a pas rendu son `Final`), mais porteur d'un signal exploitable par
|
||||
/// l'application (planifier la reprise à `resets_at_ms`). L'heure de reset est
|
||||
/// propagée telle quelle.
|
||||
/// - [`ReplyEvent::TextDelta`] / [`ReplyEvent::ToolActivity`] /
|
||||
/// [`ReplyEvent::Heartbeat`] ⇒ `None` : tous **non terminaux** (le flux
|
||||
/// continue). Un heartbeat prouve la vivacité mais ne termine pas le tour.
|
||||
@ -70,6 +89,11 @@ impl ReadinessPolicy {
|
||||
pub const fn classify(event: &ReplyEvent) -> Option<ReadinessSignal> {
|
||||
match event {
|
||||
ReplyEvent::Final { .. } => Some(ReadinessSignal::TurnEnded),
|
||||
ReplyEvent::RateLimited { resets_at_ms } => {
|
||||
Some(ReadinessSignal::RateLimited {
|
||||
resets_at_ms: *resets_at_ms,
|
||||
})
|
||||
}
|
||||
ReplyEvent::TextDelta { .. }
|
||||
| ReplyEvent::ToolActivity { .. }
|
||||
| ReplyEvent::Heartbeat => None,
|
||||
@ -108,4 +132,51 @@ mod tests {
|
||||
"un heartbeat prouve la vivacité mais ne termine JAMAIS le tour"
|
||||
);
|
||||
}
|
||||
|
||||
// -- §21 : RateLimited ⇒ ReadinessSignal::RateLimited (heure propagée) -------
|
||||
|
||||
#[test]
|
||||
fn rate_limited_with_known_reset_classifies_and_propagates_time() {
|
||||
let ev = ReplyEvent::RateLimited {
|
||||
resets_at_ms: Some(1_700_000_000_000),
|
||||
};
|
||||
assert_eq!(
|
||||
ReadinessPolicy::classify(&ev),
|
||||
Some(ReadinessSignal::RateLimited {
|
||||
resets_at_ms: Some(1_700_000_000_000),
|
||||
}),
|
||||
"l'heure de reset doit être propagée telle quelle"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_without_reset_classifies_with_none() {
|
||||
let ev = ReplyEvent::RateLimited { resets_at_ms: None };
|
||||
assert_eq!(
|
||||
ReadinessPolicy::classify(&ev),
|
||||
Some(ReadinessSignal::RateLimited { resets_at_ms: None }),
|
||||
"heure inconnue ⇒ None propagé (filet humain en aval)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_is_not_classified_as_turn_ended() {
|
||||
// Non terminal : un RateLimited ne marque JAMAIS le tour comme fini.
|
||||
let signal = ReadinessPolicy::classify(&ReplyEvent::RateLimited {
|
||||
resets_at_ms: Some(42),
|
||||
});
|
||||
assert_ne!(signal, Some(ReadinessSignal::TurnEnded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_signal_is_copy() {
|
||||
// Test de compilation : `ReadinessSignal` reste `Copy` (cf. §21.3). Si le
|
||||
// type cessait d'être `Copy`, l'usage après le `copy` ci-dessous (move
|
||||
// implicite) ne compilerait plus.
|
||||
let sig = ReadinessSignal::RateLimited {
|
||||
resets_at_ms: Some(7),
|
||||
};
|
||||
let copy = sig; // copie implicite, pas un move
|
||||
assert_eq!(sig, copy);
|
||||
}
|
||||
}
|
||||
|
||||
227
crates/domain/src/session_limit.rs
Normal file
227
crates/domain/src/session_limit.rs
Normal file
@ -0,0 +1,227 @@
|
||||
//! Limite de session/débit d'un agent — value object pur + calcul de reprise
|
||||
//! (ARCHITECTURE §21).
|
||||
//!
|
||||
//! Objet **pur** (aucune I/O, aucun temps réel, aucune dépendance externe) : il
|
||||
//! capture le **fait neutre** « cet agent est limité, reset à T (peut-être) » et
|
||||
//! calcule, à partir de l'heure courante, le **plan de reprise** correspondant.
|
||||
//! Tout savoir spécifique modèle (forme du `rate_limit_event` Claude, regex d'une
|
||||
//! TUI, parsing d'une heure locale) reste **confiné aux adapters/profils** (§21.2).
|
||||
//!
|
||||
//! La limite de session est un **3ᵉ axe d'état orthogonal** aux deux axes déjà
|
||||
//! posés dans [`crate::input`] ([`crate::input::AgentBusyState`] et
|
||||
//! [`crate::input::AgentLiveness`]) : un agent peut être `Idle`/`Busy`,
|
||||
//! `Alive`/`Stalled`, **et** limité jusqu'à une certaine heure.
|
||||
//!
|
||||
//! # Pourquoi des époche-millisecondes ?
|
||||
//!
|
||||
//! Toutes les heures manipulées ici sont des **i64 époche-millisecondes**,
|
||||
//! homogènes avec [`crate::ports::Clock::now_millis`] et
|
||||
//! [`crate::input::AgentBusyState`] : sérialisables, comparables, sans dépendance
|
||||
//! à `std::time::Instant` (monotone, non sérialisable — cf. §21.2-T1).
|
||||
|
||||
/// D'où provient la détection de la limite — utile pour la traçabilité et l'UI
|
||||
/// (afficher « limite structurée » vs « confirmée par l'utilisateur »).
|
||||
///
|
||||
/// Calqué sur la hiérarchie de détection à trois niveaux de §21.1.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RateLimitSource {
|
||||
/// **Niveau 1** : extraite du flux structuré de l'adapter (le plus solide ;
|
||||
/// Claude `rate_limit_info.resetsAt`, équivalent Codex…).
|
||||
Structured,
|
||||
/// **Niveau 2** : détectée par un motif déclaratif de profil
|
||||
/// ([`crate::profile::RateLimitPattern`]) sur la sortie PTY d'un agent TUI.
|
||||
Pattern,
|
||||
/// **Niveau 3** : confirmée par l'utilisateur (filet humain) quand rien n'a
|
||||
/// matché automatiquement mais que l'agent semble bloqué.
|
||||
Human,
|
||||
}
|
||||
|
||||
/// Value object **pur** : une limite de session/débit détectée pour un agent
|
||||
/// (ARCHITECTURE §21.3).
|
||||
///
|
||||
/// Immuable, sans I/O, trivialement testable. Vit **en mémoire uniquement**
|
||||
/// (§21.1-3) : aucune persistance, aucun store. Un agent n'a qu'**une** limite
|
||||
/// vivante à la fois ; un second signal la **rafraîchit** plutôt que de l'empiler
|
||||
/// (dédoublonnage assuré côté application, §21.10-4).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SessionLimit {
|
||||
/// Instant de reset en **époche-millisecondes**. `None` quand aucune heure de
|
||||
/// reset exploitable n'a été obtenue ⇒ pas de reprise auto possible (filet
|
||||
/// humain, §21.1 niveau 3).
|
||||
pub resets_at_ms: Option<i64>,
|
||||
/// Instant de **détection** de la limite, en époche-millisecondes (utile à l'UI
|
||||
/// et au diagnostic ; non requis pour le calcul de reprise).
|
||||
pub detected_at_ms: i64,
|
||||
/// Niveau de détection ayant produit cette limite.
|
||||
pub source: RateLimitSource,
|
||||
}
|
||||
|
||||
impl SessionLimit {
|
||||
/// Construit une limite de session. Pur, sans validation : tous les états
|
||||
/// (`resets_at_ms` présent ou absent, n'importe quelle source) sont légitimes.
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
resets_at_ms: Option<i64>,
|
||||
detected_at_ms: i64,
|
||||
source: RateLimitSource,
|
||||
) -> Self {
|
||||
Self {
|
||||
resets_at_ms,
|
||||
detected_at_ms,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
/// Vrai si une heure de reset exploitable est connue (⇒ reprise auto possible).
|
||||
/// Faux ⇒ filet humain (l'application demande l'heure à l'utilisateur).
|
||||
#[must_use]
|
||||
pub const fn has_known_reset(&self) -> bool {
|
||||
self.resets_at_ms.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Plan de reprise calculé par [`plan_resume`] (ARCHITECTURE §21.3).
|
||||
///
|
||||
/// Donnée **pure** : ne porte que ce dont l'application a besoin pour armer la
|
||||
/// reprise, jamais de closure ni de port (cf. l'esprit du dispatch orchestrateur
|
||||
/// §14.3 — une **intention** model-agnostique, pas un effet).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ResumePlan {
|
||||
/// Reprise **automatique programmée** : armer un réveil à `fire_at_ms` qui
|
||||
/// relancera l'agent via [`crate::ports::SessionPlan::Resume`].
|
||||
Scheduled {
|
||||
/// Échéance du réveil en **époche-millisecondes**. Vaut l'heure de reset,
|
||||
/// **clampée à `now_ms`** si le reset est déjà passé (on ne programme jamais
|
||||
/// une échéance dans le passé : reprise immédiate).
|
||||
fire_at_ms: i64,
|
||||
/// Identifiant de conversation du moteur à reprendre (pivot model-agnostique,
|
||||
/// porté tel quel jusqu'à [`crate::ports::SessionPlan::Resume`]). `None` ⇒
|
||||
/// reprise en mode dégradé (sans id), comme le reste du chemin de reprise.
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// Aucune reprise automatique possible : l'heure de reset est inconnue
|
||||
/// (`SessionLimit::resets_at_ms == None`) ⇒ **filet humain** (§21.1 niveau 3).
|
||||
/// L'application demandera l'heure à l'utilisateur plutôt que d'agir à l'aveugle
|
||||
/// (jamais d'inaction silencieuse, jamais d'auto sans heure).
|
||||
HumanFallback,
|
||||
}
|
||||
|
||||
/// Calcule le **plan de reprise** d'un agent limité, à partir de l'heure courante
|
||||
/// (ARCHITECTURE §21.3). Fonction **pure** : aucune I/O, aucun temps réel — `now_ms`
|
||||
/// est injecté (cf. les fonctions pures de `LayoutTree`, §7.2), ce qui la rend
|
||||
/// trivialement testable.
|
||||
///
|
||||
/// - `limit.resets_at_ms == Some(t)` ⇒ [`ResumePlan::Scheduled`] avec
|
||||
/// `fire_at_ms = max(t, now_ms)` (on ne programme jamais dans le passé : un reset
|
||||
/// déjà écoulé ⇒ reprise immédiate) et l'`conversation_id` fourni propagé tel quel.
|
||||
/// - `limit.resets_at_ms == None` ⇒ [`ResumePlan::HumanFallback`] (filet humain).
|
||||
#[must_use]
|
||||
pub fn plan_resume(
|
||||
now_ms: i64,
|
||||
limit: &SessionLimit,
|
||||
conversation_id: Option<String>,
|
||||
) -> ResumePlan {
|
||||
match limit.resets_at_ms {
|
||||
Some(resets_at_ms) => ResumePlan::Scheduled {
|
||||
fire_at_ms: resets_at_ms.max(now_ms),
|
||||
conversation_id,
|
||||
},
|
||||
None => ResumePlan::HumanFallback,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const NOW: i64 = 1_700_000_000_000;
|
||||
|
||||
fn limit(resets_at_ms: Option<i64>) -> SessionLimit {
|
||||
SessionLimit::new(resets_at_ms, NOW, RateLimitSource::Structured)
|
||||
}
|
||||
|
||||
// -- plan_resume : reset futur ⇒ Scheduled à l'heure de reset ----------------
|
||||
|
||||
#[test]
|
||||
fn future_reset_schedules_at_reset_time_with_conversation_id() {
|
||||
let reset = NOW + 60_000;
|
||||
let plan = plan_resume(NOW, &limit(Some(reset)), Some("conv-42".to_owned()));
|
||||
assert_eq!(
|
||||
plan,
|
||||
ResumePlan::Scheduled {
|
||||
fire_at_ms: reset,
|
||||
conversation_id: Some("conv-42".to_owned()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// -- plan_resume : clamp anti-passé ------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn past_reset_is_clamped_to_now_never_in_the_past() {
|
||||
let past = NOW - 60_000;
|
||||
let plan = plan_resume(NOW, &limit(Some(past)), None);
|
||||
match plan {
|
||||
ResumePlan::Scheduled { fire_at_ms, .. } => {
|
||||
assert_eq!(fire_at_ms, NOW, "un reset déjà passé ⇒ reprise immédiate (now)");
|
||||
}
|
||||
other => panic!("attendu Scheduled, obtenu {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_exactly_now_fires_at_now() {
|
||||
let plan = plan_resume(NOW, &limit(Some(NOW)), None);
|
||||
assert_eq!(
|
||||
plan,
|
||||
ResumePlan::Scheduled {
|
||||
fire_at_ms: NOW,
|
||||
conversation_id: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// -- plan_resume : pas d'heure ⇒ filet humain --------------------------------
|
||||
|
||||
#[test]
|
||||
fn unknown_reset_falls_back_to_human() {
|
||||
let plan = plan_resume(NOW, &limit(None), Some("conv-42".to_owned()));
|
||||
assert_eq!(plan, ResumePlan::HumanFallback);
|
||||
}
|
||||
|
||||
// -- conversation_id propagé tel quel (Some et None) -------------------------
|
||||
|
||||
#[test]
|
||||
fn conversation_id_some_is_propagated_into_scheduled() {
|
||||
let reset = NOW + 1;
|
||||
let plan = plan_resume(NOW, &limit(Some(reset)), Some("c".to_owned()));
|
||||
assert_eq!(
|
||||
plan,
|
||||
ResumePlan::Scheduled {
|
||||
fire_at_ms: reset,
|
||||
conversation_id: Some("c".to_owned()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversation_id_none_is_propagated_into_scheduled() {
|
||||
let reset = NOW + 1;
|
||||
let plan = plan_resume(NOW, &limit(Some(reset)), None);
|
||||
assert_eq!(
|
||||
plan,
|
||||
ResumePlan::Scheduled {
|
||||
fire_at_ms: reset,
|
||||
conversation_id: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// -- SessionLimit::has_known_reset -------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn has_known_reset_is_true_with_some_and_false_with_none() {
|
||||
assert!(limit(Some(NOW)).has_known_reset());
|
||||
assert!(!limit(None).has_known_reset());
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,11 @@ async-trait = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
# Moteur regex du détecteur de limite de session niveau 2 (ARCHITECTURE §21.2-T2) :
|
||||
# le DOMAINE ne porte que la donnée du motif (`RateLimitPattern`) ; le moteur regex
|
||||
# vit ICI, jamais dans `domain` (qui reste dépendance-zéro). Version alignée sur
|
||||
# celle déjà présente dans le `Cargo.lock` (transitive).
|
||||
regex = "1"
|
||||
portable-pty = "0.9"
|
||||
git2 = { workspace = true }
|
||||
|
||||
|
||||
@ -27,11 +27,14 @@ pub mod orchestrator;
|
||||
pub mod permission;
|
||||
pub mod process;
|
||||
pub mod pty;
|
||||
pub mod ratelimit;
|
||||
pub mod remote;
|
||||
pub mod runtime;
|
||||
pub mod sandbox;
|
||||
pub mod scheduler;
|
||||
pub mod session;
|
||||
pub mod store;
|
||||
pub mod timeparse;
|
||||
|
||||
pub use clock::SystemClock;
|
||||
pub use conversation::InMemoryConversationRegistry;
|
||||
@ -58,7 +61,9 @@ pub use remote::{remote_host, LocalHost};
|
||||
pub use runtime::CliAgentRuntime;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use sandbox::LandlockSandbox;
|
||||
pub use ratelimit::RateLimitParser;
|
||||
pub use sandbox::{default_enforcer, NoopSandbox};
|
||||
pub use scheduler::TokioScheduler;
|
||||
pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory};
|
||||
#[cfg(feature = "vector-onnx")]
|
||||
pub use store::OnnxEmbedder;
|
||||
|
||||
384
crates/infrastructure/src/ratelimit/mod.rs
Normal file
384
crates/infrastructure/src/ratelimit/mod.rs
Normal file
@ -0,0 +1,384 @@
|
||||
//! Détecteur de limite de session **niveau 2 — déclaratif** (ARCHITECTURE §21,
|
||||
//! niveau 2 de la hiérarchie §21.1). Pour les agents **PTY/TUI sans adapter
|
||||
//! structuré**, on n'a pas de flux machine : on **observe la sortie texte** et on y
|
||||
//! cherche le motif déclaré par le profil ([`RateLimitPattern`], LS1).
|
||||
//!
|
||||
//! # Frontière T2 respectée
|
||||
//!
|
||||
//! Le **domaine** ne porte que la *donnée* du motif ; le **moteur regex** et le
|
||||
//! **parsing d'heure** vivent **ici** (§21.2-T2). Aucune `regex` ne franchit la
|
||||
//! frontière domaine : ce module produit un [`SessionLimit`] (valeur domaine pure).
|
||||
//!
|
||||
//! # Robustesse (« solide même pour un novice »)
|
||||
//!
|
||||
//! Une regex **invalide** dans un profil mal configuré ⇒ [`RateLimitParser::new`]
|
||||
//! renvoie `None` (pas de détecteur) : **jamais** de panique ni d'erreur fatale. Un
|
||||
//! profil pourri ne doit pas faire tomber IdeA.
|
||||
//!
|
||||
//! # Anti-double-détection niveau 1 / niveau 2 (§21.10-4)
|
||||
//!
|
||||
//! Le niveau 2 ne s'applique **qu'aux agents sans adapter structuré** : voir
|
||||
//! [`applies`]. Un agent qui a un `structured_adapter` détecte sa limite par le
|
||||
//! niveau 1 (flux machine, `session/claude.rs`) ; on ne lui arme **pas** de parser
|
||||
//! niveau 2 ⇒ pas de double détection. C'est la **règle de sélection** que le câblage
|
||||
//! (LS7) doit appliquer pour décider d'instancier — ou non — un [`RateLimitParser`].
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use domain::profile::{AgentProfile, RateLimitPattern};
|
||||
use domain::session_limit::{RateLimitSource, SessionLimit};
|
||||
|
||||
use crate::timeparse;
|
||||
|
||||
/// Règle de sélection (§21.10-4) : le détecteur niveau 2 ne s'applique qu'aux agents
|
||||
/// **sans adapter structuré** (sinon le niveau 1 détecte déjà) **et** dont le profil
|
||||
/// déclare un `rate_limit_pattern`. Source **unique** de la règle anti-double-
|
||||
/// détection, à appeler par le câblage PTY (LS7) avant d'instancier un parser.
|
||||
#[must_use]
|
||||
pub fn applies(profile: &AgentProfile) -> bool {
|
||||
profile.structured_adapter.is_none() && profile.rate_limit_pattern.is_some()
|
||||
}
|
||||
|
||||
/// Stratégie d'interprétation de l'heure capturée, déduite **une seule fois** du
|
||||
/// champ `time_format` du profil (donnée opaque au domaine, comprise ici).
|
||||
///
|
||||
/// Jeux de jetons reconnus (insensibles à la casse) ; tout le reste ⇒ [`Self::Auto`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ResetTimeFormat {
|
||||
/// `None`/inconnu : best-effort **absolu** (epoch entier/float, ou ISO-8601).
|
||||
Auto,
|
||||
/// `"epoch_s"` / `"epoch_seconds"` / `"unix_s"` : entier epoch en **secondes**.
|
||||
EpochSeconds,
|
||||
/// `"epoch_ms"` / `"epoch_millis"` / `"unix_ms"` : entier epoch en **ms**.
|
||||
EpochMillis,
|
||||
/// `"iso8601"` / `"rfc3339"` / `"iso"` : chaîne ISO-8601 absolue.
|
||||
Iso8601,
|
||||
/// `"relative_s"` / `"relative_seconds"` / `"duration_s"` / `"retry_after_s"` :
|
||||
/// **délai** en secondes ⇒ `now + delta`.
|
||||
RelativeSeconds,
|
||||
/// `"relative_ms"` / `"relative_millis"` : **délai** en ms ⇒ `now + delta`.
|
||||
RelativeMillis,
|
||||
/// `"wall"` / `"wall_clock"` / `"local"` / `"hh:mm"` : **heure murale locale**
|
||||
/// (« 3pm », « 15:00 ») ⇒ aujourd'hui à cette heure, demain si déjà passée.
|
||||
WallClock,
|
||||
}
|
||||
|
||||
impl ResetTimeFormat {
|
||||
/// Déduit la stratégie depuis le `time_format` déclaratif (ou `Auto` si absent).
|
||||
fn from_opt(time_format: Option<&str>) -> Self {
|
||||
let Some(raw) = time_format else {
|
||||
return Self::Auto;
|
||||
};
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"epoch_s" | "epoch_seconds" | "unix_s" => Self::EpochSeconds,
|
||||
"epoch_ms" | "epoch_millis" | "unix_ms" => Self::EpochMillis,
|
||||
"iso8601" | "rfc3339" | "iso" => Self::Iso8601,
|
||||
"relative_s" | "relative_seconds" | "duration_s" | "retry_after_s" => {
|
||||
Self::RelativeSeconds
|
||||
}
|
||||
"relative_ms" | "relative_millis" => Self::RelativeMillis,
|
||||
"wall" | "wall_clock" | "local" | "hh:mm" => Self::WallClock,
|
||||
_ => Self::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit la chaîne capturée en époche-ms selon la stratégie, en référence à
|
||||
/// `now_ms` pour les heures relatives/murales. `None` si la capture est
|
||||
/// inexploitable (⇒ détection sans heure ⇒ filet humain en aval).
|
||||
fn resolve(self, captured: &str, now_ms: i64) -> Option<i64> {
|
||||
let c = captured.trim();
|
||||
match self {
|
||||
Self::Auto => timeparse::parse_absolute_ms(c),
|
||||
Self::EpochSeconds => c.parse::<i64>().ok().map(|s| s.saturating_mul(1000)),
|
||||
Self::EpochMillis => c.parse::<i64>().ok(),
|
||||
Self::Iso8601 => timeparse::parse_rfc3339_to_ms(c),
|
||||
Self::RelativeSeconds => c
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.map(|d| now_ms + (d * 1000.0) as i64),
|
||||
Self::RelativeMillis => c.parse::<i64>().ok().map(|d| now_ms + d),
|
||||
Self::WallClock => {
|
||||
let (h, m, s) = timeparse::parse_wall_clock(c)?;
|
||||
Some(timeparse::wall_clock_to_ms(now_ms, h, m, s))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Détecteur niveau 2 : un **motif regex compilé une fois** + sa stratégie d'heure.
|
||||
///
|
||||
/// À instancier **par agent** (au câblage PTY, LS7) à partir de son
|
||||
/// `rate_limit_pattern`, puis à nourrir avec chaque fragment de sortie via
|
||||
/// [`detect`](Self::detect). La compilation (coûteuse) est faite **une seule fois**
|
||||
/// à la construction, pas à chaque fragment.
|
||||
pub struct RateLimitParser {
|
||||
/// Motif compilé recherché dans la sortie.
|
||||
regex: Regex,
|
||||
/// Nom (ou index décimal) du groupe de capture d'où extraire l'heure de reset.
|
||||
/// `None` ⇒ détection **sans** extraction d'heure (⇒ `resets_at_ms: None`).
|
||||
reset_capture: Option<String>,
|
||||
/// Stratégie d'interprétation de la capture, pré-calculée.
|
||||
time_format: ResetTimeFormat,
|
||||
}
|
||||
|
||||
impl RateLimitParser {
|
||||
/// Compile le motif du profil. `None` si la regex est **invalide** (profil mal
|
||||
/// configuré) — robustesse : jamais de panique, l'agent tourne juste sans
|
||||
/// détection niveau 2.
|
||||
#[must_use]
|
||||
pub fn new(pattern: &RateLimitPattern) -> Option<Self> {
|
||||
let regex = Regex::new(&pattern.pattern).ok()?;
|
||||
Some(Self {
|
||||
regex,
|
||||
reset_capture: pattern.reset_capture.clone(),
|
||||
time_format: ResetTimeFormat::from_opt(pattern.time_format.as_deref()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Analyse un fragment de sortie texte.
|
||||
///
|
||||
/// - **pas de match** ⇒ `None` (pas de limite) ;
|
||||
/// - **match** ⇒ `Some(SessionLimit)` avec `source = Pattern`,
|
||||
/// `detected_at_ms = now_ms`, et `resets_at_ms` extrait du groupe de capture
|
||||
/// `reset_capture` interprété selon `time_format` — `None` si pas de capture,
|
||||
/// groupe absent, ou heure inexploitable (détection **utile sans heure** ⇒ filet
|
||||
/// humain en aval, §21).
|
||||
#[must_use]
|
||||
pub fn detect(&self, text: &str, now_ms: i64) -> Option<SessionLimit> {
|
||||
let caps = self.regex.captures(text)?;
|
||||
let resets_at_ms = self.reset_capture.as_deref().and_then(|name| {
|
||||
// Groupe nommé en priorité ; repli sur un index décimal si `name` en est un.
|
||||
let raw = caps
|
||||
.name(name)
|
||||
.or_else(|| name.parse::<usize>().ok().and_then(|i| caps.get(i)))?;
|
||||
self.time_format.resolve(raw.as_str(), now_ms)
|
||||
});
|
||||
Some(SessionLimit::new(resets_at_ms, now_ms, RateLimitSource::Pattern))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
|
||||
|
||||
/// Instant de référence (epoch-ms) : 2023-11-14T22:13:20Z = 1_700_000_000 s.
|
||||
const NOW: i64 = 1_700_000_000_000;
|
||||
/// Début de journée UTC du 2023-11-14 (= 19675 × 86_400_000), pour les heures murales.
|
||||
const DAY_START: i64 = 1_699_920_000_000;
|
||||
|
||||
fn pattern(
|
||||
pat: &str,
|
||||
reset_capture: Option<&str>,
|
||||
time_format: Option<&str>,
|
||||
) -> RateLimitPattern {
|
||||
RateLimitPattern::new(
|
||||
pat.to_owned(),
|
||||
reset_capture.map(str::to_owned),
|
||||
time_format.map(str::to_owned),
|
||||
)
|
||||
.expect("motif valide (non vide)")
|
||||
}
|
||||
|
||||
// -- new : robustesse regex ----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn new_returns_none_on_invalid_regex() {
|
||||
// Parenthèse ouvrante non fermée ⇒ regex invalide ⇒ None (jamais de panique).
|
||||
let p = pattern("rate limit (", None, None);
|
||||
assert!(RateLimitParser::new(&p).is_none());
|
||||
}
|
||||
|
||||
// -- detect : pas de match -----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn detect_returns_none_when_pattern_does_not_match() {
|
||||
let parser = RateLimitParser::new(&pattern("rate limit reached", None, None)).unwrap();
|
||||
assert!(parser.detect("tout va bien", NOW).is_none());
|
||||
}
|
||||
|
||||
// -- detect : match sans reset_capture -----------------------------------
|
||||
|
||||
#[test]
|
||||
fn detect_match_without_reset_capture_has_no_time() {
|
||||
let parser = RateLimitParser::new(&pattern("rate limit reached", None, None)).unwrap();
|
||||
let limit = parser.detect("oops: rate limit reached!", NOW).expect("détecté");
|
||||
assert_eq!(limit.resets_at_ms, None);
|
||||
assert_eq!(limit.detected_at_ms, NOW);
|
||||
assert_eq!(limit.source, RateLimitSource::Pattern);
|
||||
}
|
||||
|
||||
// -- detect : formats ABSOLUS --------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn detect_epoch_seconds_format() {
|
||||
let parser = RateLimitParser::new(&pattern(
|
||||
r"resets at (?P<reset>\d+)",
|
||||
Some("reset"),
|
||||
Some("epoch_s"),
|
||||
))
|
||||
.unwrap();
|
||||
let limit = parser
|
||||
.detect("limit, resets at 1700000000 ok", NOW)
|
||||
.expect("détecté");
|
||||
assert_eq!(limit.resets_at_ms, Some(1_700_000_000_000));
|
||||
assert_eq!(limit.source, RateLimitSource::Pattern);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_epoch_millis_format() {
|
||||
let parser = RateLimitParser::new(&pattern(
|
||||
r"resets at (?P<reset>\d+)",
|
||||
Some("reset"),
|
||||
Some("epoch_ms"),
|
||||
))
|
||||
.unwrap();
|
||||
let limit = parser
|
||||
.detect("resets at 1700000000000", NOW)
|
||||
.expect("détecté");
|
||||
assert_eq!(limit.resets_at_ms, Some(1_700_000_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_iso8601_format() {
|
||||
let parser = RateLimitParser::new(&pattern(
|
||||
r"resets at (?P<reset>\S+)",
|
||||
Some("reset"),
|
||||
Some("iso8601"),
|
||||
))
|
||||
.unwrap();
|
||||
let limit = parser
|
||||
.detect("resets at 2023-11-14T22:13:20Z", NOW)
|
||||
.expect("détecté");
|
||||
assert_eq!(limit.resets_at_ms, Some(1_700_000_000_000));
|
||||
}
|
||||
|
||||
// -- detect : format RELATIF ---------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn detect_relative_seconds_format_uses_now() {
|
||||
let parser = RateLimitParser::new(&pattern(
|
||||
r"retry after (?P<reset>\d+)s",
|
||||
Some("reset"),
|
||||
Some("relative_s"),
|
||||
))
|
||||
.unwrap();
|
||||
let limit = parser.detect("retry after 600s", NOW).expect("détecté");
|
||||
assert_eq!(limit.resets_at_ms, Some(NOW + 600_000));
|
||||
assert_eq!(limit.source, RateLimitSource::Pattern);
|
||||
}
|
||||
|
||||
// -- detect : format MURAL (passage de minuit) ---------------------------
|
||||
|
||||
/// « 3pm » alors qu'il est 10 h ⇒ 15 h AUJOURD'HUI (même jour UTC).
|
||||
#[test]
|
||||
fn detect_wall_clock_same_day_when_future() {
|
||||
let parser = RateLimitParser::new(&pattern(
|
||||
r"resets at (?P<reset>.+)",
|
||||
Some("reset"),
|
||||
Some("wall"),
|
||||
))
|
||||
.unwrap();
|
||||
let now_10h = DAY_START + 10 * 3_600_000; // 10:00 UTC ce jour-là
|
||||
let limit = parser.detect("resets at 3pm", now_10h).expect("détecté");
|
||||
// 15:00 le même jour.
|
||||
assert_eq!(limit.resets_at_ms, Some(DAY_START + 15 * 3_600_000));
|
||||
}
|
||||
|
||||
/// « 3pm » alors qu'il est 16 h ⇒ 15 h DEMAIN (déjà passé ⇒ +24 h).
|
||||
#[test]
|
||||
fn detect_wall_clock_next_day_when_past() {
|
||||
let parser = RateLimitParser::new(&pattern(
|
||||
r"resets at (?P<reset>.+)",
|
||||
Some("reset"),
|
||||
Some("wall"),
|
||||
))
|
||||
.unwrap();
|
||||
let now_16h = DAY_START + 16 * 3_600_000; // 16:00 UTC
|
||||
let limit = parser.detect("resets at 3pm", now_16h).expect("détecté");
|
||||
// 15:00 le LENDEMAIN.
|
||||
assert_eq!(
|
||||
limit.resets_at_ms,
|
||||
Some(DAY_START + 15 * 3_600_000 + 86_400_000)
|
||||
);
|
||||
}
|
||||
|
||||
// -- detect : match mais capture inexploitable ⇒ détection sans heure -----
|
||||
|
||||
#[test]
|
||||
fn detect_match_with_missing_capture_group_has_no_time() {
|
||||
// reset_capture nommé "reset" mais le motif ne définit aucun groupe "reset".
|
||||
let parser =
|
||||
RateLimitParser::new(&pattern("rate limited", Some("reset"), Some("epoch_s"))).unwrap();
|
||||
let limit = parser.detect("rate limited", NOW).expect("détecté");
|
||||
assert_eq!(limit.resets_at_ms, None);
|
||||
assert_eq!(limit.source, RateLimitSource::Pattern);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_match_with_unparsable_value_has_no_time() {
|
||||
// Groupe présent mais valeur non numérique pour un format epoch_s ⇒ None.
|
||||
let parser = RateLimitParser::new(&pattern(
|
||||
r"reset (?P<reset>\w+)",
|
||||
Some("reset"),
|
||||
Some("epoch_s"),
|
||||
))
|
||||
.unwrap();
|
||||
let limit = parser.detect("reset abc", NOW).expect("détecté");
|
||||
assert_eq!(limit.resets_at_ms, None);
|
||||
}
|
||||
|
||||
// -- detect : appelable plusieurs fois (regex compilé une seule fois) -----
|
||||
|
||||
#[test]
|
||||
fn detect_can_be_called_multiple_times() {
|
||||
let parser = RateLimitParser::new(&pattern(
|
||||
r"resets at (?P<reset>\d+)",
|
||||
Some("reset"),
|
||||
Some("epoch_s"),
|
||||
))
|
||||
.unwrap();
|
||||
let a = parser.detect("resets at 1700000000", NOW).expect("1er détecté");
|
||||
assert!(parser.detect("rien ici", NOW).is_none());
|
||||
let b = parser.detect("resets at 1700000000", NOW).expect("2e détecté");
|
||||
assert_eq!(a.resets_at_ms, b.resets_at_ms);
|
||||
assert_eq!(a.resets_at_ms, Some(1_700_000_000_000));
|
||||
}
|
||||
|
||||
// -- applies(profile) : règle anti-double-détection (§21.10-4) ------------
|
||||
|
||||
fn base_profile() -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
ProfileId::from_uuid(uuid::Uuid::nil()),
|
||||
"Dev",
|
||||
"claude",
|
||||
Vec::new(),
|
||||
ContextInjection::convention_file("CLAUDE.md").expect("valide"),
|
||||
None,
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.expect("profil valide")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_false_for_structured_profile_even_with_pattern() {
|
||||
let profile = base_profile()
|
||||
.with_structured_adapter(StructuredAdapter::Claude)
|
||||
.with_rate_limit_pattern(pattern("rate limit", None, None));
|
||||
assert!(!applies(&profile), "un agent structuré détecte par le niveau 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_true_for_pty_profile_with_pattern() {
|
||||
let profile = base_profile().with_rate_limit_pattern(pattern("rate limit", None, None));
|
||||
assert!(applies(&profile));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_false_for_pty_profile_without_pattern() {
|
||||
assert!(!applies(&base_profile()));
|
||||
}
|
||||
}
|
||||
297
crates/infrastructure/src/scheduler/mod.rs
Normal file
297
crates/infrastructure/src/scheduler/mod.rs
Normal file
@ -0,0 +1,297 @@
|
||||
//! [`TokioScheduler`] — adapter du port [`domain::ports::Scheduler`] (ARCHITECTURE
|
||||
//! §21.4), minuterie **one-shot annulable** en mémoire.
|
||||
//!
|
||||
//! # Rôle
|
||||
//!
|
||||
//! Armer un réveil à une **échéance absolue** (époche-ms) qui, à expiration, **remet
|
||||
//! une [`ScheduledTask`] (donnée pure) au drain applicatif** via un canal `mpsc`
|
||||
//! fourni à la construction — exactement le patron du watcher d'orchestrateur
|
||||
//! (§14.3) : l'adapter dispatche une *donnée*, il **n'exécute jamais** la reprise
|
||||
//! lui-même (aucune dépendance vers l'application). `cancel` désarme un réveil non
|
||||
//! encore tiré (c'est ce qui rend la « reprise auto annulable », §21.1-4).
|
||||
//!
|
||||
//! # En mémoire uniquement (§21.1-3)
|
||||
//!
|
||||
//! Aucune persistance : les réveils armés ne survivent pas à un redémarrage d'IdeA.
|
||||
//! La table `id → JoinHandle` vit derrière un `Mutex` (thread-safe). L'attente réelle
|
||||
//! se fait dans une tâche tokio de fond ; `cancel` l'`abort()`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use domain::ids::ScheduleId;
|
||||
use domain::ports::{Clock, Scheduler, ScheduledTask};
|
||||
|
||||
/// Adapter tokio du port [`Scheduler`] (§21.4). Construire avec [`TokioScheduler::new`].
|
||||
///
|
||||
/// Thread-safe et clonable-en-`Arc` : injecté au composition root comme
|
||||
/// `Arc<dyn Scheduler>` (LS7, hors périmètre de ce lot).
|
||||
pub struct TokioScheduler {
|
||||
/// Canal de **remise** des tâches échues : à l'échéance, la tâche de fond y pousse
|
||||
/// la [`ScheduledTask`]. Le drain (application, LS4) en est l'autre bout. Non borné
|
||||
/// pour ne **jamais** perdre/retarder une reprise (événements rares, basse
|
||||
/// fréquence) ni bloquer la tâche de fond.
|
||||
tx: UnboundedSender<ScheduledTask>,
|
||||
/// Horloge injectée (déterminisme/testabilité) pour traduire l'échéance absolue en
|
||||
/// délai relatif (`deadline_ms - now`).
|
||||
clock: Arc<dyn Clock>,
|
||||
/// Table `id → handle` des réveils armés non encore tirés. `cancel` y retire +
|
||||
/// `abort()` le handle.
|
||||
handles: Arc<Mutex<HashMap<ScheduleId, JoinHandle<()>>>>,
|
||||
}
|
||||
|
||||
impl TokioScheduler {
|
||||
/// Construit l'adapter. `tx` est le bout **émetteur** du canal de remise (le
|
||||
/// drain applicatif détient le récepteur) ; `clock` fournit « maintenant » pour
|
||||
/// calculer le délai d'attente.
|
||||
#[must_use]
|
||||
pub fn new(tx: UnboundedSender<ScheduledTask>, clock: Arc<dyn Clock>) -> Self {
|
||||
Self {
|
||||
tx,
|
||||
clock,
|
||||
handles: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Scheduler for TokioScheduler {
|
||||
fn arm(&self, deadline_ms: i64, task: ScheduledTask) -> ScheduleId {
|
||||
let id = ScheduleId::new_random();
|
||||
|
||||
// Délai relatif : une échéance déjà passée (≤ now) ⇒ délai nul ⇒ déclenchement
|
||||
// au plus tôt (contrat du port). `saturating_sub` + `max(0)` évitent tout
|
||||
// débordement/négatif avant le cast non signé.
|
||||
let now = self.clock.now_millis();
|
||||
let delay = Duration::from_millis(deadline_ms.saturating_sub(now).max(0) as u64);
|
||||
|
||||
let tx = self.tx.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
tokio::time::sleep(delay).await;
|
||||
// Remet la DONNÉE au drain applicatif (jamais d'exécution ici). Une erreur
|
||||
// d'envoi signifie que le récepteur a été lâché (IdeA s'arrête) ⇒ ignorée.
|
||||
let _ = tx.send(task);
|
||||
});
|
||||
|
||||
let mut map = self.handles.lock().expect("scheduler mutex sain");
|
||||
// Élague au passage les réveils déjà tirés (handles terminés) pour borner la
|
||||
// table sans course : sous le même verrou, `is_finished()` est un fait stable.
|
||||
map.retain(|_, h| !h.is_finished());
|
||||
map.insert(id, handle);
|
||||
id
|
||||
}
|
||||
|
||||
fn cancel(&self, id: ScheduleId) -> bool {
|
||||
let mut map = self.handles.lock().expect("scheduler mutex sain");
|
||||
match map.remove(&id) {
|
||||
// Inconnu (jamais armé, ou déjà élagué après tir) ⇒ rien à annuler.
|
||||
None => false,
|
||||
// Déjà tiré mais encore présent ⇒ pas une annulation (la tâche est partie).
|
||||
Some(handle) if handle.is_finished() => false,
|
||||
// Armé et non tiré ⇒ on l'abort : la reprise n'aura pas lieu.
|
||||
Some(handle) => {
|
||||
handle.abort();
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::mpsc::error::TryRecvError;
|
||||
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver};
|
||||
use tokio::time::timeout;
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::ids::{AgentId, NodeId, ScheduleId};
|
||||
use domain::ports::{Clock, Scheduler, ScheduledTask};
|
||||
|
||||
use super::TokioScheduler;
|
||||
use crate::SystemClock;
|
||||
|
||||
/// Délai d'ARMEMENT court (les réveils doivent tomber vite pour ne pas ralentir
|
||||
/// la suite).
|
||||
const SHORT_MS: i64 = 50;
|
||||
/// Timeout de RÉCEPTION généreux (anti-flaky) : on attend bien plus longtemps que
|
||||
/// le délai d'armement pour absorber l'aléa d'ordonnancement sans jamais expirer
|
||||
/// à tort.
|
||||
const RECV_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Construit un scheduler + le bout récepteur du canal de remise + l'horloge réelle
|
||||
/// (partagée, pour calculer des échéances cohérentes avec celle qu'`arm` lira).
|
||||
fn make() -> (TokioScheduler, UnboundedReceiver<ScheduledTask>, Arc<dyn Clock>) {
|
||||
let (tx, rx) = unbounded_channel();
|
||||
let clock: Arc<dyn Clock> = Arc::new(SystemClock::new());
|
||||
(TokioScheduler::new(tx, clock.clone()), rx, clock)
|
||||
}
|
||||
|
||||
/// Une `ScheduledTask` identifiable par son `conversation_id` (pour distinguer les
|
||||
/// tâches concurrentes à l'arrivée).
|
||||
fn task_with(conv: &str) -> ScheduledTask {
|
||||
ScheduledTask::ResumeAgent {
|
||||
agent_id: AgentId::from_uuid(Uuid::from_u128(1)),
|
||||
node_id: NodeId::from_uuid(Uuid::from_u128(2)),
|
||||
conversation_id: Some(conv.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `arm` à `now + SHORT_MS` ⇒ la tâche EXACTE arrive (sous timeout de sécurité) ;
|
||||
/// et AVANT l'échéance, le canal est vide (try_recv juste après arm).
|
||||
#[tokio::test]
|
||||
async fn arm_fires_after_deadline_with_exact_task() {
|
||||
let (sched, mut rx, clock) = make();
|
||||
let t = task_with("c-fire");
|
||||
sched.arm(clock.now_millis() + SHORT_MS, t.clone());
|
||||
|
||||
// Juste après arm : rien n'est encore tiré (échéance dans le futur).
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Err(TryRecvError::Empty)),
|
||||
"aucune tâche ne doit arriver AVANT l'échéance"
|
||||
);
|
||||
|
||||
let got = timeout(RECV_TIMEOUT, rx.recv())
|
||||
.await
|
||||
.expect("le réveil doit tirer sous le timeout de sécurité")
|
||||
.expect("le canal de remise doit rester ouvert");
|
||||
assert_eq!(got, t, "la tâche reçue doit être exactement celle armée");
|
||||
}
|
||||
|
||||
/// `cancel` AVANT l'échéance empêche le tir : on arme un délai court, on annule
|
||||
/// aussitôt (large marge avant les 50 ms), puis on attend 4× l'échéance — la tâche
|
||||
/// NE doit JAMAIS arriver. Prouve une absence qui, SANS l'annulation, se serait
|
||||
/// produite (le délai court aurait tiré bien avant la fin de l'attente).
|
||||
#[tokio::test]
|
||||
async fn cancel_before_deadline_prevents_fire() {
|
||||
let (sched, mut rx, clock) = make();
|
||||
let id = sched.arm(clock.now_millis() + SHORT_MS, task_with("c-cancel"));
|
||||
assert!(sched.cancel(id), "cancel d'un réveil armé non tiré ⇒ true");
|
||||
|
||||
// Attend bien au-delà de l'échéance (4×) : si l'annulation avait échoué, la
|
||||
// tâche serait déjà là.
|
||||
tokio::time::sleep(Duration::from_millis((SHORT_MS as u64) * 4)).await;
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Err(TryRecvError::Empty)),
|
||||
"après cancel, aucune tâche ne doit être remise"
|
||||
);
|
||||
|
||||
// L'id a été retiré de la table : un second cancel ne trouve plus rien.
|
||||
assert!(!sched.cancel(id), "un id déjà annulé/retiré ⇒ false");
|
||||
}
|
||||
|
||||
/// `cancel` d'un id JAMAIS armé ⇒ false (rien à désarmer).
|
||||
#[tokio::test]
|
||||
async fn cancel_unknown_id_is_false() {
|
||||
let (sched, _rx, _clock) = make();
|
||||
assert!(!sched.cancel(ScheduleId::new_random()));
|
||||
}
|
||||
|
||||
/// `cancel` APRÈS le tir ⇒ false : on arme court, on attend la réception (donc le
|
||||
/// tir a eu lieu), puis on annule — la tâche est déjà partie, rien à désarmer.
|
||||
#[tokio::test]
|
||||
async fn cancel_after_fire_is_false() {
|
||||
let (sched, mut rx, clock) = make();
|
||||
let id = sched.arm(clock.now_millis() + SHORT_MS, task_with("c-after"));
|
||||
|
||||
let _ = timeout(RECV_TIMEOUT, rx.recv())
|
||||
.await
|
||||
.expect("doit tirer")
|
||||
.expect("canal ouvert");
|
||||
// Laisse l'exécuteur finaliser l'état du JoinHandle (is_finished) après l'envoi.
|
||||
for _ in 0..8 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
|
||||
assert!(
|
||||
!sched.cancel(id),
|
||||
"annuler un réveil déjà tiré ne désarme rien ⇒ false"
|
||||
);
|
||||
}
|
||||
|
||||
/// Échéance DÉJÀ PASSÉE (`now - 1000`) ⇒ tir quasi-immédiat (délai nul) : la tâche
|
||||
/// arrive sous un timeout court.
|
||||
#[tokio::test]
|
||||
async fn past_deadline_fires_immediately() {
|
||||
let (sched, mut rx, clock) = make();
|
||||
let t = task_with("c-past");
|
||||
sched.arm(clock.now_millis() - 1000, t.clone());
|
||||
|
||||
let got = timeout(Duration::from_millis(500), rx.recv())
|
||||
.await
|
||||
.expect("une échéance passée doit tirer au plus tôt")
|
||||
.expect("canal ouvert");
|
||||
assert_eq!(got, t);
|
||||
}
|
||||
|
||||
/// Plusieurs `arm` concurrents (même échéance) : chacun tire indépendamment, on
|
||||
/// reçoit les TROIS tâches (ensemble, sans dépendre de l'ordre d'arrivée).
|
||||
#[tokio::test]
|
||||
async fn multiple_concurrent_arms_all_fire() {
|
||||
let (sched, mut rx, clock) = make();
|
||||
let deadline = clock.now_millis() + SHORT_MS;
|
||||
let ids: Vec<_> = ["a", "b", "c"]
|
||||
.iter()
|
||||
.map(|c| sched.arm(deadline, task_with(c)))
|
||||
.collect();
|
||||
// Les ids armés sont distincts (pas de collision).
|
||||
assert_ne!(ids[0], ids[1]);
|
||||
assert_ne!(ids[1], ids[2]);
|
||||
assert_ne!(ids[0], ids[2]);
|
||||
|
||||
let mut got = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let task = timeout(RECV_TIMEOUT, rx.recv())
|
||||
.await
|
||||
.expect("chaque réveil doit tirer")
|
||||
.expect("canal ouvert");
|
||||
// `ScheduledTask` est mono-variante : déstructuration directe (pas de `if let`).
|
||||
let ScheduledTask::ResumeAgent {
|
||||
conversation_id, ..
|
||||
} = task;
|
||||
got.push(conversation_id.expect("conv présent"));
|
||||
}
|
||||
got.sort();
|
||||
assert_eq!(got, vec!["a".to_owned(), "b".to_owned(), "c".to_owned()]);
|
||||
}
|
||||
|
||||
/// `cancel` ciblé parmi plusieurs réveils concurrents : seul l'annulé ne tire pas ;
|
||||
/// les autres arrivent normalement. Prouve l'indépendance des handles (pas de fuite,
|
||||
/// pas d'annulation collatérale).
|
||||
#[tokio::test]
|
||||
async fn cancel_one_among_many_leaves_others_firing() {
|
||||
let (sched, mut rx, clock) = make();
|
||||
let deadline = clock.now_millis() + SHORT_MS;
|
||||
let keep1 = sched.arm(deadline, task_with("keep-1"));
|
||||
let drop_id = sched.arm(deadline, task_with("dropped"));
|
||||
let keep2 = sched.arm(deadline, task_with("keep-2"));
|
||||
let _ = (keep1, keep2);
|
||||
|
||||
assert!(sched.cancel(drop_id), "le réveil ciblé est annulé ⇒ true");
|
||||
|
||||
// Les deux survivants arrivent ; l'annulé jamais.
|
||||
let mut got = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let task = timeout(RECV_TIMEOUT, rx.recv())
|
||||
.await
|
||||
.expect("les survivants doivent tirer")
|
||||
.expect("canal ouvert");
|
||||
let ScheduledTask::ResumeAgent {
|
||||
conversation_id, ..
|
||||
} = task;
|
||||
got.push(conversation_id.expect("conv présent"));
|
||||
}
|
||||
got.sort();
|
||||
assert_eq!(got, vec!["keep-1".to_owned(), "keep-2".to_owned()]);
|
||||
// Plus rien derrière (l'annulé n'a pas tiré).
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Err(TryRecvError::Empty)),
|
||||
"le réveil annulé ne doit jamais être remis"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,70 @@ 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)
|
||||
}
|
||||
|
||||
/// Convertit une [`Value`] JSON (entier / float / chaîne) en époche-ms, défensivement.
|
||||
///
|
||||
/// La conversion **générique** (heuristique secondes-vs-ms, parseur ISO-8601, algo
|
||||
/// jour-civil) est factorisée dans [`crate::timeparse`] et **partagée** avec le
|
||||
/// détecteur de niveau 2 (`ratelimit`) — pas de duplication (§21.2-T2). Seule
|
||||
/// l'extraction depuis un `serde_json::Value` (typage int/float/str) reste ici.
|
||||
fn value_to_epoch_ms(v: &Value) -> Option<i64> {
|
||||
if let Some(i) = v.as_i64() {
|
||||
return Some(crate::timeparse::int_epoch_to_ms(i));
|
||||
}
|
||||
if let Some(u) = v.as_u64() {
|
||||
return Some(crate::timeparse::int_epoch_to_ms(i64::try_from(u).ok()?));
|
||||
}
|
||||
if let Some(f) = v.as_f64() {
|
||||
return Some(crate::timeparse::float_epoch_to_ms(f));
|
||||
}
|
||||
if let Some(s) = v.as_str() {
|
||||
return crate::timeparse::parse_absolute_ms(s);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Adapter de session structurée Claude.
|
||||
///
|
||||
/// Incarnation « un `claude -p` par tour » (§17.2 (b)) : chaque `send` relance la
|
||||
@ -241,9 +314,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);
|
||||
|
||||
@ -210,7 +210,8 @@ pub(crate) mod harness {
|
||||
other => panic!("le dernier événement doit être Final, vu: {other:?}"),
|
||||
}
|
||||
// Les événements avant le Final ne sont que des deltas / activités / heartbeats
|
||||
// (tous **non terminaux** ; le heartbeat est une preuve de vivacité, lot 1).
|
||||
// / rate-limited (tous **non terminaux** ; le heartbeat est une preuve de
|
||||
// vivacité, lot 1 ; le RateLimited s'intercale comme un heartbeat, §21-T4).
|
||||
for e in &events[..events.len() - 1] {
|
||||
assert!(
|
||||
matches!(
|
||||
@ -218,8 +219,9 @@ pub(crate) mod harness {
|
||||
ReplyEvent::TextDelta { .. }
|
||||
| ReplyEvent::ToolActivity { .. }
|
||||
| ReplyEvent::Heartbeat
|
||||
| ReplyEvent::RateLimited { .. }
|
||||
),
|
||||
"avant le Final, seuls deltas/activités/heartbeats sont permis, vu: {e:?}"
|
||||
"avant le Final, seuls deltas/activités/heartbeats/rate-limited sont permis, vu: {e:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -122,14 +122,20 @@ mod tests {
|
||||
assert_eq!(parsed.events, vec![ReplyEvent::Heartbeat]);
|
||||
}
|
||||
|
||||
/// §21 (LS2) : un `rate_limit_event` n'est PLUS un heartbeat — il porte désormais
|
||||
/// un [`ReplyEvent::RateLimited`] (niveau 1 structuré). Sans heure de reset
|
||||
/// exploitable dans `rate_limit_info` ⇒ `RateLimited{None}` (filet humain en aval).
|
||||
/// Le `session_id` reste capté.
|
||||
#[test]
|
||||
fn claude_parse_rate_limit_event_is_heartbeat() {
|
||||
fn claude_parse_rate_limit_event_without_reset_is_rate_limited_none() {
|
||||
let parsed = claude::parse_event(
|
||||
r#"{"type":"rate_limit_event","rate_limit_info":{"x":1},"session_id":"conv-123"}"#,
|
||||
)
|
||||
.expect("parse ok");
|
||||
// Plus ignoré : preuve de vivacité ⇒ heartbeat (mais session_id tout de même capté).
|
||||
assert_eq!(parsed.events, vec![ReplyEvent::Heartbeat]);
|
||||
assert_eq!(
|
||||
parsed.events,
|
||||
vec![ReplyEvent::RateLimited { resets_at_ms: None }]
|
||||
);
|
||||
assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
|
||||
}
|
||||
|
||||
@ -1286,4 +1292,351 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&cmd);
|
||||
let _ = std::fs::remove_file(&argv);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// LS2 — adapter Claude niveau 1 (§21) : `parse_reset_ms` (parseur ISO-8601
|
||||
// À LA MAIN + heuristique secondes/ms + days_from_civil) et le mapping
|
||||
// `parse_event` du `rate_limit_event` vers `ReplyEvent::RateLimited`, plus
|
||||
// la NON-TERMINALITÉ (T4). Tout passe par les fonctions pures (jamais de
|
||||
// process) sauf le test de séquence via `send()` (FakeCli).
|
||||
// =====================================================================
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::claude::parse_reset_ms;
|
||||
|
||||
// ---- parse_reset_ms : noms de champ reconnus + priorité ----------------
|
||||
|
||||
/// Les cinq noms de champ plausibles sont chacun reconnus (valeur en secondes
|
||||
/// ⇒ ×1000). Couvre `resetsAt`, `resets_at`, `reset_at`, `resetAt`, `reset`.
|
||||
#[test]
|
||||
fn parse_reset_ms_recognises_every_field_name() {
|
||||
for key in ["resetsAt", "resets_at", "reset_at", "resetAt", "reset"] {
|
||||
let info = json!({ key: 1_700_000_000_i64 });
|
||||
assert_eq!(
|
||||
parse_reset_ms(&info),
|
||||
Some(1_700_000_000_000),
|
||||
"le champ `{key}` doit être reconnu (epoch secondes ×1000)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Priorité : si plusieurs clés sont présentes, la PREMIÈRE de l'ordre
|
||||
/// (`resetsAt` avant `reset`) gagne.
|
||||
#[test]
|
||||
fn parse_reset_ms_first_known_key_wins() {
|
||||
// resetsAt (priorité 1) = 1_700_000_000 s ; reset (priorité 5) = 5 s.
|
||||
let info = json!({ "reset": 5, "resetsAt": 1_700_000_000_i64 });
|
||||
assert_eq!(
|
||||
parse_reset_ms(&info),
|
||||
Some(1_700_000_000_000),
|
||||
"resetsAt prime sur reset"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- parse_reset_ms : heuristique secondes vs millisecondes ------------
|
||||
|
||||
#[test]
|
||||
fn parse_reset_ms_integer_seconds_are_scaled_to_ms() {
|
||||
// < 10^12 ⇒ secondes ⇒ ×1000.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": 1_700_000_000_i64 })), Some(1_700_000_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_reset_ms_integer_millis_are_kept_as_is() {
|
||||
// ≥ 10^12 ⇒ déjà des millisecondes ⇒ tel quel.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": 1_700_000_000_000_i64 })), Some(1_700_000_000_000));
|
||||
}
|
||||
|
||||
/// Le SEUIL exact (10^12) : juste en-dessous ⇒ secondes (×1000) ; pile/au-dessus
|
||||
/// ⇒ millisecondes (tel quel).
|
||||
#[test]
|
||||
fn parse_reset_ms_threshold_boundary() {
|
||||
// 10^12 - 1 ⇒ secondes ⇒ ×1000.
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": 999_999_999_999_i64 })),
|
||||
Some(999_999_999_999_000)
|
||||
);
|
||||
// 10^12 pile ⇒ millisecondes ⇒ tel quel (la borne est inclusive côté ms).
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": 1_000_000_000_000_i64 })),
|
||||
Some(1_000_000_000_000)
|
||||
);
|
||||
}
|
||||
|
||||
// ---- parse_reset_ms : floats ------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_reset_ms_float_seconds_preserve_fraction() {
|
||||
// 1_700_000_000.5 s < 10^12 ⇒ ×1000 = 1_700_000_000_500 ms.
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": 1_700_000_000.5_f64 })),
|
||||
Some(1_700_000_000_500)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_reset_ms_float_millis_kept_as_is() {
|
||||
// 1.7e12 ≥ 10^12 ⇒ déjà ms ⇒ tronqué tel quel.
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": 1_700_000_000_000.0_f64 })),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
}
|
||||
|
||||
// ---- parse_reset_ms : chaînes numériques (même heuristique) ------------
|
||||
|
||||
#[test]
|
||||
fn parse_reset_ms_string_integer_uses_seconds_heuristic() {
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "1700000000" })), Some(1_700_000_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_reset_ms_string_float_uses_seconds_heuristic() {
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "1700000000.5" })), Some(1_700_000_000_500));
|
||||
}
|
||||
|
||||
// ---- parse_reset_ms : ISO-8601 / RFC3339 (parseur maison) --------------
|
||||
|
||||
/// `...Z` (UTC) : un instant rond connu. `2023-11-14T22:13:20Z` correspond à
|
||||
/// l'epoch 1_700_000_000 s ⇒ 1_700_000_000_000 ms. Recoupe le parseur ISO
|
||||
/// maison (days_from_civil + math d'heure) contre l'heuristique secondes.
|
||||
#[test]
|
||||
fn parse_reset_ms_iso_utc_z() {
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "2023-11-14T22:13:20Z" })),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
}
|
||||
|
||||
/// Offset `+hh:mm` : `2023-11-14T23:13:20+01:00` est le MÊME instant que
|
||||
/// `22:13:20Z` ⇒ doit donner exactement le même epoch-ms (offset soustrait).
|
||||
#[test]
|
||||
fn parse_reset_ms_iso_positive_offset_converts_to_utc() {
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "2023-11-14T23:13:20+01:00" })),
|
||||
Some(1_700_000_000_000),
|
||||
"+01:00 ⇒ on soustrait 1h pour revenir à l'UTC"
|
||||
);
|
||||
}
|
||||
|
||||
/// Offset `-hh:mm` : `2023-11-14T21:13:20-01:00` est aussi `22:13:20Z`.
|
||||
#[test]
|
||||
fn parse_reset_ms_iso_negative_offset_converts_to_utc() {
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "2023-11-14T21:13:20-01:00" })),
|
||||
Some(1_700_000_000_000),
|
||||
"-01:00 ⇒ on ajoute 1h pour revenir à l'UTC"
|
||||
);
|
||||
}
|
||||
|
||||
/// Offset compact `±hhmm` (sans `:`) supporté par `split_tz`.
|
||||
#[test]
|
||||
fn parse_reset_ms_iso_compact_offset() {
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "2023-11-14T23:13:20+0100" })),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
}
|
||||
|
||||
/// Fraction de seconde `.fff` : tronquée/complétée à 3 chiffres (précision ms).
|
||||
#[test]
|
||||
fn parse_reset_ms_iso_fraction_padded_and_truncated() {
|
||||
// `.5` ⇒ "500" ms.
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "1970-01-01T00:00:00.5Z" })),
|
||||
Some(500)
|
||||
);
|
||||
// `.123456` ⇒ tronqué à "123" ms.
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "1970-01-01T00:00:00.123456Z" })),
|
||||
Some(123)
|
||||
);
|
||||
// `.7` ⇒ complété à "700" ms.
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "1970-01-01T00:00:00.7Z" })),
|
||||
Some(700)
|
||||
);
|
||||
}
|
||||
|
||||
// ---- parse_reset_ms : robustesse (jamais de panic, jamais d'erreur) ----
|
||||
|
||||
#[test]
|
||||
fn parse_reset_ms_unknown_key_yields_none() {
|
||||
// Aucune clé connue ⇒ None.
|
||||
assert_eq!(parse_reset_ms(&json!({ "retryAfter": 60 })), None);
|
||||
assert_eq!(parse_reset_ms(&json!({})), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_reset_ms_non_numeric_garbage_yields_none() {
|
||||
// Valeurs inexploitables (booléen, null, tableau, objet, chaîne pourrie) ⇒ None.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": true })), None);
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": null })), None);
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": [1, 2, 3] })), None);
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": { "nested": 1 } })), None);
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "pas une date" })), None);
|
||||
}
|
||||
|
||||
/// Formes ISO **structurellement** malformées ⇒ None (pas de panic). NB : le
|
||||
/// parseur maison ne valide PAS les plages (un mois 13 / jour 99 calcule une
|
||||
/// valeur sans erreur) ; ce qui produit `None`, c'est l'ABSENCE de séparateur
|
||||
/// `T`, une composante non numérique, ou un nombre de composantes invalide.
|
||||
#[test]
|
||||
fn parse_reset_ms_invalid_iso_string_yields_none() {
|
||||
// Pas de séparateur de date/heure.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14" })), None);
|
||||
// Année non numérique.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "abcd-11-14T00:00:00Z" })), None);
|
||||
// Composante de date manquante (pas de jour).
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11T00:00:00Z" })), None);
|
||||
// Trop de composantes de date.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14-9T00:00:00Z" })), None);
|
||||
// Minute manquante dans l'heure.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14T22Z" })), None);
|
||||
}
|
||||
|
||||
// ---- days_from_civil & bissextiles (via le parseur ISO) ----------------
|
||||
|
||||
/// Référence absolue : l'époque Unix elle-même. `1970-01-01T00:00:00Z` ⇒ 0 ms
|
||||
/// (days_from_civil(1970,1,1) == 0).
|
||||
#[test]
|
||||
fn parse_reset_ms_unix_epoch_is_zero() {
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "1970-01-01T00:00:00Z" })),
|
||||
Some(0)
|
||||
);
|
||||
}
|
||||
|
||||
/// Année bissextile : le 29 février 2024 existe et donne l'epoch attendu.
|
||||
/// `2024-02-29T00:00:00Z` = 1_709_164_800 s = 1_709_164_800_000 ms (calculé à la
|
||||
/// main : 2024-01-01 = 1_704_067_200 ; +31j (janvier) ; +28j pour atteindre le 29).
|
||||
#[test]
|
||||
fn parse_reset_ms_leap_day_2024_02_29() {
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "2024-02-29T00:00:00Z" })),
|
||||
Some(1_709_164_800_000)
|
||||
);
|
||||
}
|
||||
|
||||
/// Date post-2001 connue, recoupée indépendamment : `2021-01-01T00:00:00Z`
|
||||
/// = 1_609_459_200 s = 1_609_459_200_000 ms.
|
||||
#[test]
|
||||
fn parse_reset_ms_known_post_2001_date() {
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "2021-01-01T00:00:00Z" })),
|
||||
Some(1_609_459_200_000)
|
||||
);
|
||||
}
|
||||
|
||||
// ---- parse_event : mapping rate_limit_event ----------------------------
|
||||
|
||||
/// `rate_limit_event` avec `rate_limit_info.resetsAt` exploitable ⇒
|
||||
/// `RateLimited{Some(...)}` (l'heure de reset est extraite et normalisée).
|
||||
#[test]
|
||||
fn parse_event_rate_limit_with_reset_yields_rate_limited_some() {
|
||||
let parsed = claude::parse_event(
|
||||
r#"{"type":"rate_limit_event","rate_limit_info":{"resetsAt":1700000000},"session_id":"c"}"#,
|
||||
)
|
||||
.expect("parse ok");
|
||||
assert_eq!(
|
||||
parsed.events,
|
||||
vec![ReplyEvent::RateLimited {
|
||||
resets_at_ms: Some(1_700_000_000_000)
|
||||
}]
|
||||
);
|
||||
assert_eq!(parsed.session_id.as_deref(), Some("c"));
|
||||
}
|
||||
|
||||
/// `rate_limit_event` SANS `rate_limit_info` exploitable ⇒ `RateLimited{None}`
|
||||
/// (et surtout PAS un `Heartbeat` : c'est le changement §21/LS2).
|
||||
#[test]
|
||||
fn parse_event_rate_limit_without_info_is_rate_limited_none_not_heartbeat() {
|
||||
// rate_limit_info absent.
|
||||
let absent = claude::parse_event(r#"{"type":"rate_limit_event","session_id":"c"}"#)
|
||||
.expect("parse ok");
|
||||
assert_eq!(
|
||||
absent.events,
|
||||
vec![ReplyEvent::RateLimited { resets_at_ms: None }]
|
||||
);
|
||||
assert_ne!(absent.events, vec![ReplyEvent::Heartbeat]);
|
||||
|
||||
// rate_limit_info présent mais sans clé de reset connue.
|
||||
let no_key = claude::parse_event(
|
||||
r#"{"type":"rate_limit_event","rate_limit_info":{"x":1},"session_id":"c"}"#,
|
||||
)
|
||||
.expect("parse ok");
|
||||
assert_eq!(
|
||||
no_key.events,
|
||||
vec![ReplyEvent::RateLimited { resets_at_ms: None }]
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Non-terminalité (T4) : RateLimited n'interrompt PAS ----------------
|
||||
|
||||
/// Au niveau séquence de `parse_event` : `rate_limit_event` puis `result` ⇒ la
|
||||
/// concaténation des events est `[RateLimited, Final]` — le RateLimited s'intercale
|
||||
/// et seul le Final clôt.
|
||||
#[test]
|
||||
fn parse_event_sequence_rate_limited_then_final_is_not_interrupted() {
|
||||
let rl = claude::parse_event(
|
||||
r#"{"type":"rate_limit_event","rate_limit_info":{"resetsAt":1700000000},"session_id":"c"}"#,
|
||||
)
|
||||
.expect("parse ok");
|
||||
let fin = claude::parse_event(
|
||||
r#"{"type":"result","subtype":"success","result":"fini","session_id":"c"}"#,
|
||||
)
|
||||
.expect("parse ok");
|
||||
let mut seq = rl.events;
|
||||
seq.extend(fin.events);
|
||||
assert_eq!(
|
||||
seq,
|
||||
vec![
|
||||
ReplyEvent::RateLimited {
|
||||
resets_at_ms: Some(1_700_000_000_000)
|
||||
},
|
||||
ReplyEvent::Final {
|
||||
content: "fini".to_owned()
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Au niveau `send()` (FakeCli) : init → rate_limit_event → assistant → result ⇒
|
||||
/// le flux émis est `[Heartbeat, RateLimited, TextDelta, Final]`. Le RateLimited
|
||||
/// NE rompt PAS la boucle d'émission (T4) ; seul le Final clôt — on le PROUVE
|
||||
/// bout-en-bout, pas seulement au niveau parse.
|
||||
#[tokio::test]
|
||||
async fn send_emits_rate_limited_intercalated_only_final_closes() {
|
||||
let fake = FakeCli::printing(&[
|
||||
r#"{"type":"system","subtype":"init","session_id":"rl-1","cwd":"/tmp","tools":[]}"#,
|
||||
r#"{"type":"rate_limit_event","rate_limit_info":{"resetsAt":1700000000},"session_id":"rl-1"}"#,
|
||||
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"ap"}]},"session_id":"rl-1","parent_tool_use_id":null}"#,
|
||||
r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"rl-1","num_turns":1}"#,
|
||||
]);
|
||||
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
|
||||
let events: Vec<ReplyEvent> = session.send("x").await.expect("send ok").collect();
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![
|
||||
ReplyEvent::Heartbeat,
|
||||
ReplyEvent::RateLimited {
|
||||
resets_at_ms: Some(1_700_000_000_000)
|
||||
},
|
||||
ReplyEvent::TextDelta { text: "ap".into() },
|
||||
ReplyEvent::Final {
|
||||
content: "ok".into()
|
||||
},
|
||||
],
|
||||
"RateLimited s'intercale (non terminal), seul Final clôt"
|
||||
);
|
||||
// Exactement un Final, en dernière position.
|
||||
assert_eq!(
|
||||
events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
315
crates/infrastructure/src/timeparse.rs
Normal file
315
crates/infrastructure/src/timeparse.rs
Normal file
@ -0,0 +1,315 @@
|
||||
//! Parsing d'heures **pur et dépendance-zéro**, partagé par les deux niveaux de
|
||||
//! détection de limite de session (ARCHITECTURE §21) :
|
||||
//!
|
||||
//! - **niveau 1 structuré** (`session/claude.rs`) : normaliser le `resetsAt` d'un
|
||||
//! `rate_limit_event` (entier epoch s/ms, ou chaîne ISO-8601) en époche-ms ;
|
||||
//! - **niveau 2 déclaratif** (`ratelimit`) : interpréter l'heure capturée par un
|
||||
//! `RateLimitPattern` selon son `time_format` (absolue, **murale locale**, ou
|
||||
//! **relative**).
|
||||
//!
|
||||
//! Tout est **pur** (aucune I/O, `now_ms` injecté) ⇒ testable sans process ni
|
||||
//! horloge réelle. Factorisé ici pour **ne pas dupliquer** le savoir de LS2
|
||||
//! (l'algorithme jour-civil de Howard Hinnant, l'heuristique secondes-vs-ms, le
|
||||
//! parseur RFC3339) entre les deux détecteurs (DRY, §21.2-T2).
|
||||
|
||||
/// 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.
|
||||
pub const EPOCH_MS_THRESHOLD: i64 = 1_000_000_000_000;
|
||||
|
||||
/// Millisecondes dans une journée (24 h), pour le calcul d'heure murale.
|
||||
pub const DAY_MS: i64 = 86_400_000;
|
||||
|
||||
/// Applique l'heuristique secondes-vs-ms à un epoch **entier**.
|
||||
#[must_use]
|
||||
pub 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).
|
||||
#[must_use]
|
||||
pub 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
|
||||
}
|
||||
}
|
||||
|
||||
/// Tente de lire une heure **absolue** depuis une chaîne : entier epoch, puis float
|
||||
/// epoch (heuristique s-vs-ms), puis **ISO-8601 / RFC3339**. `None` si rien n'est
|
||||
/// reconnu. C'est la branche « chaîne » réutilisée par le niveau 1
|
||||
/// (`claude::parse_reset_ms`) et le niveau 2 (`time_format` absolu).
|
||||
#[must_use]
|
||||
pub fn parse_absolute_ms(s: &str) -> Option<i64> {
|
||||
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));
|
||||
}
|
||||
parse_rfc3339_to_ms(t)
|
||||
}
|
||||
|
||||
/// **Parse une chaîne ISO-8601 / RFC3339** (`YYYY-MM-DDThh:mm:ss[.fff][Z|±hh:mm]`) en
|
||||
/// époche-ms. Pur, dépendance-zéro. Best-effort : `None` sur toute forme non
|
||||
/// reconnue. Sans désignateur de fuseau ⇒ traité **UTC**.
|
||||
#[must_use]
|
||||
pub 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)
|
||||
}
|
||||
|
||||
/// Convertit une **heure murale locale** `(h, m, s)` en époche-ms en référence à
|
||||
/// `now_ms`, avec **passage de minuit** (spike §21.10-2).
|
||||
///
|
||||
/// Règle : on calcule « **aujourd'hui** à `h:m:s` » sur la journée UTC contenant
|
||||
/// `now_ms` ; si cet instant est **déjà passé** (≤ `now_ms`), on prend **le
|
||||
/// lendemain** (`+ 24 h`). Ainsi « resets at 3pm » alors qu'il est 16 h vise 15 h
|
||||
/// **demain**, jamais une heure dans le passé.
|
||||
///
|
||||
/// **Limite assumée (UTC)** : faute de base de fuseaux dans le binaire (dépendance-
|
||||
/// zéro), la journée de référence est la **journée UTC**. Une heure murale d'un
|
||||
/// fuseau très décalé peut donc viser le mauvais jour de ±1 ; le passage de minuit
|
||||
/// borne l'erreur à « au plus tôt maintenant ». Un offset de fuseau explicite (champ
|
||||
/// futur du profil) lèverait cette limite — confiné infra, hors périmètre LS5.
|
||||
#[must_use]
|
||||
pub fn wall_clock_to_ms(now_ms: i64, hour: u32, minute: u32, second: u32) -> i64 {
|
||||
let day_start = now_ms - now_ms.rem_euclid(DAY_MS);
|
||||
let tod_ms = (i64::from(hour) * 3_600 + i64::from(minute) * 60 + i64::from(second)) * 1000;
|
||||
let target = day_start + tod_ms;
|
||||
if target <= now_ms {
|
||||
target + DAY_MS
|
||||
} else {
|
||||
target
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse une heure murale lâche : `"15:00"`, `"15:00:30"`, `"3pm"`, `"3:30 pm"`,
|
||||
/// `"3 PM"` → `(heure 0-23, minute, seconde)`. `None` si non reconnue. Utilisé par le
|
||||
/// niveau 2 quand `time_format` désigne une heure murale.
|
||||
#[must_use]
|
||||
pub fn parse_wall_clock(s: &str) -> Option<(u32, u32, u32)> {
|
||||
let lower = s.trim().to_ascii_lowercase();
|
||||
// Suffixe am/pm éventuel.
|
||||
let (body, meridiem) = if let Some(b) = lower.strip_suffix("am") {
|
||||
(b.trim(), Some(false))
|
||||
} else if let Some(b) = lower.strip_suffix("pm") {
|
||||
(b.trim(), Some(true))
|
||||
} else {
|
||||
(lower.as_str(), None)
|
||||
};
|
||||
|
||||
let mut parts = body.split(':');
|
||||
let mut hour: u32 = parts.next()?.trim().parse().ok()?;
|
||||
let minute: u32 = parts.next().map_or(Ok(0), |p| p.trim().parse()).ok()?;
|
||||
let second: u32 = parts.next().map_or(Ok(0), |p| p.trim().parse()).ok()?;
|
||||
if parts.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Conversion 12 h → 24 h si un méridien est présent.
|
||||
match meridiem {
|
||||
Some(true) => {
|
||||
// pm : 12pm reste 12, 1..=11pm ⇒ +12.
|
||||
if hour < 12 {
|
||||
hour += 12;
|
||||
}
|
||||
}
|
||||
Some(false) => {
|
||||
// am : 12am ⇒ 0, le reste inchangé.
|
||||
if hour == 12 {
|
||||
hour = 0;
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
if hour >= 24 || minute >= 60 || second >= 60 {
|
||||
return None;
|
||||
}
|
||||
Some((hour, minute, second))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[must_use]
|
||||
pub 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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Début de journée UTC du 2023-11-14 (= 19675 × 86_400_000).
|
||||
const DAY_START: i64 = 1_699_920_000_000;
|
||||
|
||||
// -- days_from_civil ------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn days_from_civil_epoch_is_zero() {
|
||||
assert_eq!(days_from_civil(1970, 1, 1), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn days_from_civil_leap_day_2024() {
|
||||
// 2024-02-29T00:00:00Z = 1_709_164_800_000 ms = 19782 jours pleins.
|
||||
assert_eq!(days_from_civil(2024, 2, 29), 19782);
|
||||
// Cohérence avec le jour suivant (1ᵉʳ mars), preuve que le 29 février existe.
|
||||
assert_eq!(
|
||||
days_from_civil(2024, 3, 1),
|
||||
days_from_civil(2024, 2, 29) + 1
|
||||
);
|
||||
}
|
||||
|
||||
// -- parse_wall_clock -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_wall_clock_variants() {
|
||||
assert_eq!(parse_wall_clock("3pm"), Some((15, 0, 0)));
|
||||
assert_eq!(parse_wall_clock("15:00:30"), Some((15, 0, 30)));
|
||||
assert_eq!(parse_wall_clock("3:30 pm"), Some((15, 30, 0)));
|
||||
assert_eq!(parse_wall_clock("12am"), Some((0, 0, 0))); // minuit
|
||||
assert_eq!(parse_wall_clock("12pm"), Some((12, 0, 0))); // midi
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_wall_clock_rejects_invalid() {
|
||||
assert_eq!(parse_wall_clock("pas une heure"), None);
|
||||
assert_eq!(parse_wall_clock("25:00"), None); // heure hors plage
|
||||
assert_eq!(parse_wall_clock("10:75"), None); // minute hors plage
|
||||
assert_eq!(parse_wall_clock("1:2:3:4"), None); // trop de composantes
|
||||
}
|
||||
|
||||
// -- wall_clock_to_ms (passage de minuit) --------------------------------
|
||||
|
||||
#[test]
|
||||
fn wall_clock_to_ms_same_day_when_future() {
|
||||
let now_10h = DAY_START + 10 * 3_600_000;
|
||||
// 15:00 est dans le futur ⇒ même jour.
|
||||
assert_eq!(wall_clock_to_ms(now_10h, 15, 0, 0), DAY_START + 15 * 3_600_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wall_clock_to_ms_next_day_when_past() {
|
||||
let now_16h = DAY_START + 16 * 3_600_000;
|
||||
// 15:00 est déjà passé ⇒ lendemain (+24 h).
|
||||
assert_eq!(
|
||||
wall_clock_to_ms(now_16h, 15, 0, 0),
|
||||
DAY_START + 15 * 3_600_000 + DAY_MS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wall_clock_to_ms_exactly_now_rolls_to_next_day() {
|
||||
// Cas-limite : la cible == now ⇒ considérée passée ⇒ lendemain (jamais le présent).
|
||||
let now_15h = DAY_START + 15 * 3_600_000;
|
||||
assert_eq!(wall_clock_to_ms(now_15h, 15, 0, 0), now_15h + DAY_MS);
|
||||
}
|
||||
|
||||
// -- parse_absolute_ms (recoupe LS2) -------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_absolute_ms_epoch_seconds_and_millis() {
|
||||
assert_eq!(parse_absolute_ms("1700000000"), Some(1_700_000_000_000)); // s ⇒ ×1000
|
||||
assert_eq!(parse_absolute_ms("1700000000000"), Some(1_700_000_000_000)); // ms tel quel
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_absolute_ms_iso8601() {
|
||||
assert_eq!(
|
||||
parse_absolute_ms("2023-11-14T22:13:20Z"),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_absolute_ms_garbage_is_none() {
|
||||
assert_eq!(parse_absolute_ms("pas une date"), None);
|
||||
}
|
||||
|
||||
// -- parse_rfc3339_to_ms : offsets signés (cohérence niveau 1/2) ----------
|
||||
|
||||
#[test]
|
||||
fn parse_rfc3339_offsets_convert_to_utc() {
|
||||
// +01:00 et -01:00 autour de 22:13:20Z ⇒ même instant.
|
||||
assert_eq!(
|
||||
parse_rfc3339_to_ms("2023-11-14T23:13:20+01:00"),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_rfc3339_to_ms("2023-11-14T21:13:20-01:00"),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -38,4 +38,16 @@ export class TauriInputGateway implements InputGateway {
|
||||
request: { agentId, attached },
|
||||
});
|
||||
}
|
||||
|
||||
async cancelResume(agentId: string): Promise<boolean> {
|
||||
// `cancel_resume` takes a bare `agent_id` (camelCase `agentId`), not a
|
||||
// `request` envelope, and returns whether a pending resume was disarmed.
|
||||
return invoke<boolean>("cancel_resume", { agentId });
|
||||
}
|
||||
|
||||
async setResumeAt(agentId: string, resetsAtMs: number): Promise<void> {
|
||||
// `set_resume_at` takes bare `agent_id` + `resets_at_ms` (camelCase), like
|
||||
// `cancel_resume`; arms the cancellable resume at the chosen instant.
|
||||
await invoke("set_resume_at", { agentId, resetsAtMs });
|
||||
}
|
||||
}
|
||||
|
||||
@ -1559,6 +1559,16 @@ export class MockInputGateway implements InputGateway {
|
||||
readonly delivered: MockInputCall[] = [];
|
||||
/** All recorded front-attached reports, in order. */
|
||||
readonly frontAttached: { agentId: string; attached: boolean }[] = [];
|
||||
/** All recorded cancel-resume calls, in order. */
|
||||
readonly cancelledResumes: string[] = [];
|
||||
/** All recorded set-resume-at armings (human net, level 3), in order. */
|
||||
readonly resumeArmings: { agentId: string; resetsAtMs: number }[] = [];
|
||||
/**
|
||||
* What {@link cancelResume} resolves to (mirrors the backend `bool`: whether a
|
||||
* pending resume was disarmed). Tests can flip it to exercise the "already
|
||||
* fired / none armed" path.
|
||||
*/
|
||||
cancelResumeResult = true;
|
||||
|
||||
async interrupt(projectId: string, agentId: string): Promise<void> {
|
||||
this.interrupts.push({ projectId, agentId });
|
||||
@ -1575,6 +1585,15 @@ export class MockInputGateway implements InputGateway {
|
||||
async setFrontAttached(agentId: string, attached: boolean): Promise<void> {
|
||||
this.frontAttached.push({ agentId, attached });
|
||||
}
|
||||
|
||||
async cancelResume(agentId: string): Promise<boolean> {
|
||||
this.cancelledResumes.push(agentId);
|
||||
return this.cancelResumeResult;
|
||||
}
|
||||
|
||||
async setResumeAt(agentId: string, resetsAtMs: number): Promise<void> {
|
||||
this.resumeArmings.push({ agentId, resetsAtMs });
|
||||
}
|
||||
}
|
||||
|
||||
/** In-memory permissions gateway. */
|
||||
|
||||
@ -12,7 +12,7 @@ import { createMockGateways, MockSystemGateway } from "./index";
|
||||
const gateways: Gateways = createMockGateways();
|
||||
|
||||
describe("createMockGateways", () => {
|
||||
it("exposes all thirteen gateways", () => {
|
||||
it("exposes all fourteen gateways", () => {
|
||||
expect(Object.keys(gateways).sort()).toEqual([
|
||||
"agent",
|
||||
"embedder",
|
||||
@ -20,6 +20,7 @@ describe("createMockGateways", () => {
|
||||
"input",
|
||||
"layout",
|
||||
"memory",
|
||||
"permission",
|
||||
"profile",
|
||||
"project",
|
||||
"remote",
|
||||
|
||||
@ -36,6 +36,55 @@ export type DomainEvent =
|
||||
submitSequence?: string;
|
||||
submitDelayMs?: number;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* An agent entered a session/rate limit (ARCHITECTURE §21). Low-frequency,
|
||||
* model-agnostic. `resetsAtMs` is the reset instant in epoch-milliseconds;
|
||||
* absent ⇒ unknown (no reliable time → no auto-resume). The frontend badges
|
||||
* "limité jusqu'à HH:MM" when known, "limité" otherwise.
|
||||
*/
|
||||
type: "agentRateLimited";
|
||||
agentId: string;
|
||||
resetsAtMs?: number;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* An auto-resume was armed for a rate-limited agent (ARCHITECTURE §21).
|
||||
* `fireAtMs` is the wake-up deadline in epoch-milliseconds. The frontend
|
||||
* shows the countdown + the "Annuler la reprise" button (cancellable window).
|
||||
*/
|
||||
type: "agentResumeScheduled";
|
||||
agentId: string;
|
||||
fireAtMs: number;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* An agent's auto-resume was cancelled (ARCHITECTURE §21): the user clicked
|
||||
* "Annuler la reprise". The frontend removes the countdown but keeps the
|
||||
* "limité" state (no auto-resume will fire).
|
||||
*/
|
||||
type: "agentResumeCancelled";
|
||||
agentId: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* An agent was effectively resumed after a limit (ARCHITECTURE §21): the
|
||||
* wake-up fired (or an immediate resume). The frontend clears all limit state.
|
||||
*/
|
||||
type: "agentResumed";
|
||||
agentId: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Human net (level 3): a session limit is suspected without any reliable
|
||||
* reset time (ARCHITECTURE §21.1). IdeA never resumes blind: this asks the
|
||||
* frontend to surface an "heure inconnue" state and prompt the user.
|
||||
* `resetsAtMs` carries an estimate when one exists, else absent.
|
||||
*/
|
||||
type: "agentRateLimitSuspected";
|
||||
agentId: string;
|
||||
resetsAtMs?: number;
|
||||
}
|
||||
| { type: "templateUpdated"; templateId: string; version: number }
|
||||
| { type: "agentDriftDetected"; agentId: string; from: number; to: number }
|
||||
| { type: "agentSynced"; agentId: string; to: number }
|
||||
|
||||
234
frontend/src/features/agents/AgentLimitBadge.test.tsx
Normal file
234
frontend/src/features/agents/AgentLimitBadge.test.tsx
Normal file
@ -0,0 +1,234 @@
|
||||
/**
|
||||
* LS7/LS8-front — {@link AgentLimitBadge} presentation + its pure helpers
|
||||
* (ARCHITECTURE §21).
|
||||
*
|
||||
* Three layers:
|
||||
* - the exported pure helpers `formatCountdown` / `formatResetTime` /
|
||||
* `timeInputToEpochMs` (edge cases: 0, negative, sub-minute, multi-minute,
|
||||
* midnight, seconds-stripped, malformed/empty time input);
|
||||
* - the rendered badge across its states (limité jusqu'à HH:MM / "limité"
|
||||
* without a time / the "heure inconnue" resume-time form for a suspected limit
|
||||
* without a reset), plus the "Annuler la reprise" button wiring when a resume
|
||||
* is armed;
|
||||
* - the human-net form (LS8): submitting a time arms a resume via onSetResumeAt.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
AgentLimitBadge,
|
||||
formatCountdown,
|
||||
formatResetTime,
|
||||
timeInputToEpochMs,
|
||||
} from "./AgentLimitBadge";
|
||||
|
||||
describe("formatCountdown", () => {
|
||||
it("renders 0s for exactly zero", () => {
|
||||
expect(formatCountdown(0)).toBe("0s");
|
||||
});
|
||||
|
||||
it("clamps a negative (past) deadline to 0s", () => {
|
||||
expect(formatCountdown(-5_000)).toBe("0s");
|
||||
});
|
||||
|
||||
it("renders sub-minute durations as just seconds", () => {
|
||||
expect(formatCountdown(5_000)).toBe("5s");
|
||||
expect(formatCountdown(59_000)).toBe("59s");
|
||||
});
|
||||
|
||||
it("rounds partial seconds up (ceil)", () => {
|
||||
expect(formatCountdown(4_200)).toBe("5s");
|
||||
expect(formatCountdown(1)).toBe("1s");
|
||||
});
|
||||
|
||||
it("renders minutes + seconds past a minute", () => {
|
||||
expect(formatCountdown(60_000)).toBe("1m 0s");
|
||||
expect(formatCountdown(90_000)).toBe("1m 30s");
|
||||
expect(formatCountdown(125_000)).toBe("2m 5s");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatResetTime", () => {
|
||||
it("produces an HH:MM wall-clock label (no seconds component)", () => {
|
||||
// Same minute, +30s apart → identical label (seconds are not shown).
|
||||
const base = new Date(2026, 0, 15, 9, 5, 0).getTime();
|
||||
const plus30s = new Date(2026, 0, 15, 9, 5, 30).getTime();
|
||||
expect(formatResetTime(base)).toBe(formatResetTime(plus30s));
|
||||
// Matches the documented HH:MM contract (delegates to toLocaleTimeString).
|
||||
expect(formatResetTime(base)).toBe(
|
||||
new Date(base).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders midnight as a stable two-digit label", () => {
|
||||
const midnight = new Date(2026, 0, 15, 0, 0, 0).getTime();
|
||||
const label = formatResetTime(midnight);
|
||||
expect(label).toBe(
|
||||
new Date(midnight).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
);
|
||||
// Minutes are "00" at midnight regardless of 12h/24h locale.
|
||||
expect(label).toContain("00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeInputToEpochMs", () => {
|
||||
it("maps HH:MM to the same calendar day as `now`", () => {
|
||||
const now = new Date(2026, 0, 15, 9, 0, 0).getTime();
|
||||
const ms = timeInputToEpochMs("14:30", now);
|
||||
expect(ms).not.toBeNull();
|
||||
const d = new Date(ms!);
|
||||
expect(d.getFullYear()).toBe(2026);
|
||||
expect(d.getMonth()).toBe(0);
|
||||
expect(d.getDate()).toBe(15);
|
||||
expect(d.getHours()).toBe(14);
|
||||
expect(d.getMinutes()).toBe(30);
|
||||
expect(d.getSeconds()).toBe(0);
|
||||
expect(d.getMilliseconds()).toBe(0);
|
||||
});
|
||||
|
||||
it("returns a past instant unchanged (backend clamps to now)", () => {
|
||||
const now = new Date(2026, 0, 15, 18, 0, 0).getTime();
|
||||
const ms = timeInputToEpochMs("08:00", now);
|
||||
expect(ms).not.toBeNull();
|
||||
expect(ms!).toBeLessThan(now);
|
||||
});
|
||||
|
||||
it("returns null for empty or malformed input", () => {
|
||||
const now = Date.now();
|
||||
expect(timeInputToEpochMs("", now)).toBeNull();
|
||||
expect(timeInputToEpochMs("nope", now)).toBeNull();
|
||||
expect(timeInputToEpochMs("25:00", now)).toBeNull();
|
||||
expect(timeInputToEpochMs("12:60", now)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentLimitBadge (render)", () => {
|
||||
const noop = () => {};
|
||||
|
||||
it("shows 'limité jusqu'à HH:MM' when the reset time is known", () => {
|
||||
const at = new Date(2026, 0, 15, 14, 30, 0).getTime();
|
||||
render(
|
||||
<AgentLimitBadge
|
||||
state={{ limitedUntil: at }}
|
||||
onCancelResume={noop}
|
||||
onSetResumeAt={noop}
|
||||
/>,
|
||||
);
|
||||
const expected = `limité jusqu'à ${formatResetTime(at)}`;
|
||||
expect(screen.getByText(expected)).toBeTruthy();
|
||||
// No countdown / cancel button without an armed resume.
|
||||
expect(screen.queryByLabelText("cancel resume")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows plain 'limité' when no reset time is known", () => {
|
||||
render(
|
||||
<AgentLimitBadge state={{}} onCancelResume={noop} onSetResumeAt={noop} />,
|
||||
);
|
||||
expect(screen.getByText("limité")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the 'heure inconnue' resume-time form for a suspected limit without a time", () => {
|
||||
render(
|
||||
<AgentLimitBadge
|
||||
state={{ suspected: true }}
|
||||
onCancelResume={noop}
|
||||
onSetResumeAt={noop}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("limité")).toBeTruthy();
|
||||
expect(screen.getByText(/heure inconnue/)).toBeTruthy();
|
||||
// The human-net form: a resume-time input + a "schedule resume" button.
|
||||
expect(screen.getByLabelText("resume time")).toBeTruthy();
|
||||
expect(screen.getByLabelText("schedule resume")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT show the resume-time form when a suspected limit has a time", () => {
|
||||
const at = new Date(2026, 0, 15, 14, 30, 0).getTime();
|
||||
render(
|
||||
<AgentLimitBadge
|
||||
state={{ suspected: true, limitedUntil: at }}
|
||||
onCancelResume={noop}
|
||||
onSetResumeAt={noop}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(`limité jusqu'à ${formatResetTime(at)}`)).toBeTruthy();
|
||||
expect(screen.queryByText(/heure inconnue/)).toBeNull();
|
||||
expect(screen.queryByLabelText("resume time")).toBeNull();
|
||||
});
|
||||
|
||||
it("submitting the resume-time form arms a resume at the chosen epoch-ms", () => {
|
||||
const onSetResumeAt = vi.fn();
|
||||
render(
|
||||
<AgentLimitBadge
|
||||
state={{ suspected: true }}
|
||||
onCancelResume={noop}
|
||||
onSetResumeAt={onSetResumeAt}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("resume time"), {
|
||||
target: { value: "23:45" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("schedule resume"));
|
||||
expect(onSetResumeAt).toHaveBeenCalledTimes(1);
|
||||
const ms = onSetResumeAt.mock.calls[0][0] as number;
|
||||
const d = new Date(ms);
|
||||
expect(d.getHours()).toBe(23);
|
||||
expect(d.getMinutes()).toBe(45);
|
||||
});
|
||||
|
||||
it("does not arm a resume when the time input is empty (button disabled)", () => {
|
||||
const onSetResumeAt = vi.fn();
|
||||
render(
|
||||
<AgentLimitBadge
|
||||
state={{ suspected: true }}
|
||||
onCancelResume={noop}
|
||||
onSetResumeAt={onSetResumeAt}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText("schedule resume") as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
expect(onSetResumeAt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the countdown + calls onCancelResume when a resume is armed", () => {
|
||||
const onCancelResume = vi.fn();
|
||||
const fireAt = Date.now() + 90_000;
|
||||
render(
|
||||
<AgentLimitBadge
|
||||
state={{ limitedUntil: Date.now(), resumeFireAt: fireAt }}
|
||||
onCancelResume={onCancelResume}
|
||||
onSetResumeAt={noop}
|
||||
/>,
|
||||
);
|
||||
// Countdown label present (≈ "reprise dans 1m 30s").
|
||||
expect(screen.getByLabelText("resume countdown").textContent).toMatch(
|
||||
/reprise dans/,
|
||||
);
|
||||
|
||||
const btn = screen.getByLabelText("cancel resume");
|
||||
fireEvent.click(btn);
|
||||
expect(onCancelResume).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disables the cancel button while busy", () => {
|
||||
render(
|
||||
<AgentLimitBadge
|
||||
state={{ resumeFireAt: Date.now() + 10_000 }}
|
||||
onCancelResume={noop}
|
||||
onSetResumeAt={noop}
|
||||
busy
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText("cancel resume") as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
180
frontend/src/features/agents/AgentLimitBadge.tsx
Normal file
180
frontend/src/features/agents/AgentLimitBadge.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
/**
|
||||
* `AgentLimitBadge` — presentational badge for an agent's session-limit state
|
||||
* (ARCHITECTURE §21). All limit *state* is computed in {@link useAgents}; this
|
||||
* component only renders it and owns the 1 s countdown clock (a pure UI concern).
|
||||
*
|
||||
* It shows:
|
||||
* - "limité jusqu'à HH:MM" when the reset time is known, "limité" otherwise;
|
||||
* - a live countdown + an "Annuler la reprise" button while an auto-resume is
|
||||
* armed (`resumeFireAt`);
|
||||
* - a "heure inconnue" human-net note (level 3) when the limit is only
|
||||
* *suspected* with no reliable time — IdeA never resumes blind.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button, Input, cn } from "@/shared";
|
||||
import type { AgentLimitState } from "./useAgents";
|
||||
|
||||
/**
|
||||
* Converts a `<input type="time">` value (`"HH:MM"`) into an epoch-ms instant on
|
||||
* the same calendar day as `now`. Returns `null` for an empty/malformed value so
|
||||
* the caller can ignore an incomplete entry.
|
||||
*
|
||||
* No future/past guard on purpose: an instant earlier than `now` is left as-is —
|
||||
* the backend clamps a past time to "now" (⇒ immediate resume), so the front
|
||||
* needs no strict validation.
|
||||
*/
|
||||
export function timeInputToEpochMs(value: string, now: number): number | null {
|
||||
const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
|
||||
if (!match) return null;
|
||||
const hours = Number(match[1]);
|
||||
const minutes = Number(match[2]);
|
||||
if (hours > 23 || minutes > 59) return null;
|
||||
const d = new Date(now);
|
||||
d.setHours(hours, minutes, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
/** Formats an epoch-ms instant as a local `HH:MM` wall-clock label. */
|
||||
export function formatResetTime(epochMs: number): string {
|
||||
return new Date(epochMs).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a remaining duration (ms) as a compact countdown: `Xm Ys` (or `Ys`
|
||||
* under a minute). Clamped at zero — a past deadline reads `0s`.
|
||||
*/
|
||||
export function formatCountdown(remainingMs: number): string {
|
||||
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
|
||||
}
|
||||
|
||||
export interface AgentLimitBadgeProps {
|
||||
/** The agent's limit state (from {@link useAgents.limitByAgent}). */
|
||||
state: AgentLimitState;
|
||||
/** Invoked when the user clicks "Annuler la reprise". */
|
||||
onCancelResume: () => void;
|
||||
/**
|
||||
* Human net (level 3): invoked with the chosen wake-up instant (epoch-ms) when
|
||||
* the user submits the resume-time form on a suspected-without-time limit.
|
||||
*/
|
||||
onSetResumeAt: (resetsAtMs: number) => void;
|
||||
/** Disables the actions (e.g. while another request is in flight). */
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
export function AgentLimitBadge({
|
||||
state,
|
||||
onCancelResume,
|
||||
onSetResumeAt,
|
||||
busy = false,
|
||||
}: AgentLimitBadgeProps) {
|
||||
const { limitedUntil, resumeFireAt, suspected } = state;
|
||||
|
||||
// Tick a local clock once per second only while a resume countdown is armed,
|
||||
// so the displayed remaining time stays fresh without a global timer.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (resumeFireAt === undefined) return;
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [resumeFireAt]);
|
||||
|
||||
// Local resume-time entry (human net form). Bound to a `time` input.
|
||||
const [resumeTime, setResumeTime] = useState("");
|
||||
|
||||
// The human-net form shows only when the limit is *suspected* with neither a
|
||||
// reliable reset time nor an armed resume — IdeA must ask before resuming.
|
||||
const needsResumeTime =
|
||||
suspected === true &&
|
||||
limitedUntil === undefined &&
|
||||
resumeFireAt === undefined;
|
||||
|
||||
function handleSubmitResumeTime(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const epochMs = timeInputToEpochMs(resumeTime, Date.now());
|
||||
if (epochMs === null) return;
|
||||
onSetResumeAt(epochMs);
|
||||
setResumeTime("");
|
||||
}
|
||||
|
||||
const limitLabel =
|
||||
limitedUntil !== undefined
|
||||
? `limité jusqu'à ${formatResetTime(limitedUntil)}`
|
||||
: "limité";
|
||||
|
||||
return (
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span
|
||||
aria-label="session limit"
|
||||
title={
|
||||
suspected
|
||||
? "Limite de session détectée sans heure fiable"
|
||||
: "Limite de session atteinte"
|
||||
}
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
"bg-danger/20 text-danger",
|
||||
)}
|
||||
>
|
||||
{limitLabel}
|
||||
</span>
|
||||
|
||||
{needsResumeTime && (
|
||||
// Human net (level 3, §21.1): the limit is suspected with no reliable
|
||||
// time. IdeA never resumes blind — ask the user for a resume time, then
|
||||
// arm it via `set_resume_at`. A past time is clamped to "now" by the
|
||||
// backend (⇒ immediate resume), so no strict validation is needed here.
|
||||
<form
|
||||
onSubmit={handleSubmitResumeTime}
|
||||
className="flex items-center gap-2"
|
||||
aria-label="resume time form"
|
||||
>
|
||||
<span className="text-xs text-muted" role="note">
|
||||
heure inconnue
|
||||
</span>
|
||||
<Input
|
||||
type="time"
|
||||
aria-label="resume time"
|
||||
value={resumeTime}
|
||||
disabled={busy}
|
||||
onChange={(e) => setResumeTime(e.target.value)}
|
||||
className="h-8 w-28 text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="primary"
|
||||
aria-label="schedule resume"
|
||||
disabled={busy || resumeTime.trim() === ""}
|
||||
>
|
||||
Programmer la reprise
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{resumeFireAt !== undefined && (
|
||||
<span className="flex items-center gap-2">
|
||||
<span aria-label="resume countdown" className="text-xs text-muted">
|
||||
reprise dans {formatCountdown(resumeFireAt - now)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="cancel resume"
|
||||
disabled={busy}
|
||||
onClick={onCancelResume}
|
||||
>
|
||||
Annuler la reprise
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -21,6 +21,7 @@ import { TerminalView } from "@/features/terminals/TerminalView";
|
||||
import { useDrift } from "@/features/templates/useDrift";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { useAgents } from "./useAgents";
|
||||
import { AgentLimitBadge } from "./AgentLimitBadge";
|
||||
|
||||
export interface AgentsPanelProps {
|
||||
/** The project whose agents to manage. */
|
||||
@ -327,6 +328,8 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
// Source of this agent's last orchestration delegation (mcp vs
|
||||
// file), if any has been observed. Absent ⇒ no badge.
|
||||
const delegationSource = vm.delegationSourceByRequester[a.id];
|
||||
// Session-limit state (ARCHITECTURE §21), if the agent is limited.
|
||||
const limitState = vm.limitByAgent[a.id];
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
@ -373,6 +376,17 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
)}
|
||||
</button>
|
||||
|
||||
{limitState && (
|
||||
<AgentLimitBadge
|
||||
state={limitState}
|
||||
busy={vm.busy}
|
||||
onCancelResume={() => void vm.cancelResume(a.id)}
|
||||
onSetResumeAt={(resetsAtMs) =>
|
||||
void vm.setResumeAt(a.id, resetsAtMs)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{/* Profile hot-swap selector (Chantier A). Changing the
|
||||
engine abandons the conversation history → confirmation. */}
|
||||
|
||||
@ -18,6 +18,29 @@ import type {
|
||||
import type { LiveAgent, OpenTerminalOptions, TerminalHandle } from "@/ports";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
/**
|
||||
* Per-agent session-limit state (ARCHITECTURE §21), populated from the limit
|
||||
* domain events. An agent absent from the map is in its normal (unlimited) state.
|
||||
*/
|
||||
export interface AgentLimitState {
|
||||
/**
|
||||
* Reset instant in epoch-milliseconds when known (from `agentRateLimited` /
|
||||
* `agentRateLimitSuspected`). Absent ⇒ "limité" without a reliable time.
|
||||
*/
|
||||
limitedUntil?: number;
|
||||
/**
|
||||
* Wake-up deadline in epoch-milliseconds of an armed auto-resume (from
|
||||
* `agentResumeScheduled`). Present ⇒ show the countdown + "Annuler la reprise".
|
||||
*/
|
||||
resumeFireAt?: number;
|
||||
/**
|
||||
* Human net (level 3): the limit was suspected without a reliable time
|
||||
* (`agentRateLimitSuspected`). The UI surfaces an "heure inconnue" state and
|
||||
* asks the user — IdeA never resumes blind.
|
||||
*/
|
||||
suspected?: boolean;
|
||||
}
|
||||
|
||||
/** What the agents UI needs from this hook. */
|
||||
export interface AgentsViewModel {
|
||||
/** All agents known to the project. */
|
||||
@ -37,6 +60,13 @@ export interface AgentsViewModel {
|
||||
* without `source`) yields no badge.
|
||||
*/
|
||||
delegationSourceByRequester: Record<string, "mcp" | "file">;
|
||||
/**
|
||||
* Per-agent session-limit state (ARCHITECTURE §21), keyed by agent id. Populated
|
||||
* from the limit domain events (`agentRateLimited`, `agentResumeScheduled`,
|
||||
* `agentResumeCancelled`, `agentResumed`, `agentRateLimitSuspected`). An agent
|
||||
* absent from the map is unlimited.
|
||||
*/
|
||||
limitByAgent: Record<string, AgentLimitState>;
|
||||
/** Last error message, or `null`. */
|
||||
error: string | null;
|
||||
/** Whether a request is in flight. */
|
||||
@ -83,6 +113,21 @@ export interface AgentsViewModel {
|
||||
) => Promise<TerminalHandle>;
|
||||
/** Stops an agent PTY explicitly by closing its live session id. */
|
||||
stopAgent: (agentId?: string) => Promise<void>;
|
||||
/**
|
||||
* Cancels the auto-resume armed for a rate-limited agent (ARCHITECTURE §21):
|
||||
* the "Annuler la reprise" action. Optimistically drops the countdown; the
|
||||
* backend's `agentResumeCancelled` event confirms it. The agent stays "limité"
|
||||
* (no auto-resume will fire). Resolves to the backend verdict (`false` if the
|
||||
* resume had already fired / none was armed).
|
||||
*/
|
||||
cancelResume: (agentId: string) => Promise<boolean>;
|
||||
/**
|
||||
* Human net (level 3, ARCHITECTURE §21.1): arms a resume at a user-chosen
|
||||
* `resetsAtMs` (epoch-ms) for an agent whose limit was suspected without a
|
||||
* reliable time. No optimistic mutation — the backend re-emits
|
||||
* `agentResumeScheduled`, which flips the badge to the nominal countdown.
|
||||
*/
|
||||
setResumeAt: (agentId: string, resetsAtMs: number) => Promise<void>;
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
@ -93,7 +138,7 @@ function describe(e: unknown): string {
|
||||
}
|
||||
|
||||
export function useAgents(projectId: string): AgentsViewModel {
|
||||
const { agent, profile, system, terminal } = useGateways();
|
||||
const { agent, profile, system, terminal, input } = useGateways();
|
||||
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
@ -106,6 +151,9 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
const [delegationSourceByRequester, setDelegationSourceByRequester] = useState<
|
||||
Record<string, "mcp" | "file">
|
||||
>({});
|
||||
const [limitByAgent, setLimitByAgent] = useState<
|
||||
Record<string, AgentLimitState>
|
||||
>({});
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true);
|
||||
@ -173,6 +221,57 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
: { ...prev, [requesterId]: source },
|
||||
);
|
||||
}
|
||||
// Session-limit lifecycle (ARCHITECTURE §21): fold each event into the
|
||||
// per-agent limit state. `agentResumed` clears the entry entirely.
|
||||
switch (event.type) {
|
||||
case "agentRateLimited":
|
||||
setLimitByAgent((prev) => ({
|
||||
...prev,
|
||||
[event.agentId]: {
|
||||
limitedUntil: event.resetsAtMs,
|
||||
resumeFireAt: prev[event.agentId]?.resumeFireAt,
|
||||
suspected: false,
|
||||
},
|
||||
}));
|
||||
break;
|
||||
case "agentResumeScheduled":
|
||||
setLimitByAgent((prev) => ({
|
||||
...prev,
|
||||
[event.agentId]: {
|
||||
...prev[event.agentId],
|
||||
resumeFireAt: event.fireAtMs,
|
||||
},
|
||||
}));
|
||||
break;
|
||||
case "agentResumeCancelled":
|
||||
setLimitByAgent((prev) => {
|
||||
const current = prev[event.agentId];
|
||||
if (!current) return prev;
|
||||
// Keep the agent "limité" but drop the armed resume countdown.
|
||||
const { resumeFireAt: _dropped, ...rest } = current;
|
||||
return { ...prev, [event.agentId]: rest };
|
||||
});
|
||||
break;
|
||||
case "agentResumed":
|
||||
setLimitByAgent((prev) => {
|
||||
if (!(event.agentId in prev)) return prev;
|
||||
const { [event.agentId]: _cleared, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
break;
|
||||
case "agentRateLimitSuspected":
|
||||
setLimitByAgent((prev) => ({
|
||||
...prev,
|
||||
[event.agentId]: {
|
||||
...prev[event.agentId],
|
||||
limitedUntil: event.resetsAtMs,
|
||||
suspected: true,
|
||||
},
|
||||
}));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
})
|
||||
.then((un) => {
|
||||
if (cancelled) un();
|
||||
@ -334,6 +433,40 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
[liveAgents, refreshLiveAgents, runningAgentId, terminal],
|
||||
);
|
||||
|
||||
const cancelResume = useCallback(
|
||||
async (agentId: string): Promise<boolean> => {
|
||||
// Optimistically drop the countdown so the button feels responsive; the
|
||||
// backend's `agentResumeCancelled` event confirms it (and a `false` verdict
|
||||
// — already fired — is reconciled by the eventual `agentResumed`).
|
||||
setLimitByAgent((prev) => {
|
||||
const current = prev[agentId];
|
||||
if (!current?.resumeFireAt) return prev;
|
||||
const { resumeFireAt: _dropped, ...rest } = current;
|
||||
return { ...prev, [agentId]: rest };
|
||||
});
|
||||
try {
|
||||
return (await input?.cancelResume(agentId)) ?? false;
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[input],
|
||||
);
|
||||
|
||||
const setResumeAt = useCallback(
|
||||
async (agentId: string, resetsAtMs: number): Promise<void> => {
|
||||
// No optimistic mutation: the backend re-emits `agentResumeScheduled`,
|
||||
// which the subscription folds into `limitByAgent` (badge → countdown).
|
||||
try {
|
||||
await input?.setResumeAt(agentId, resetsAtMs);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
}
|
||||
},
|
||||
[input],
|
||||
);
|
||||
|
||||
return {
|
||||
agents,
|
||||
selectedAgentId,
|
||||
@ -341,6 +474,7 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
profiles,
|
||||
liveAgents,
|
||||
delegationSourceByRequester,
|
||||
limitByAgent,
|
||||
error,
|
||||
busy,
|
||||
runningAgentId,
|
||||
@ -353,5 +487,7 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
deleteAgent,
|
||||
launchAgent,
|
||||
stopAgent,
|
||||
cancelResume,
|
||||
setResumeAt,
|
||||
};
|
||||
}
|
||||
|
||||
235
frontend/src/features/agents/useAgentsLimits.test.tsx
Normal file
235
frontend/src/features/agents/useAgentsLimits.test.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
/**
|
||||
* LS7-front — session-limit state in {@link useAgents} (ARCHITECTURE §21).
|
||||
*
|
||||
* Drives the hook behind the real {@link DIProvider} with an in-memory
|
||||
* {@link MockSystemGateway} (to emit the limit domain events) and a
|
||||
* {@link MockInputGateway} (to record `cancelResume`). Verifies that each of the
|
||||
* five limit events folds correctly into `limitByAgent`, plus the optimistic
|
||||
* `cancelResume` action and the backend verdict it returns.
|
||||
*
|
||||
* Cases:
|
||||
* - `agentRateLimited` → `{ limitedUntil, suspected: false }`
|
||||
* - `agentResumeScheduled` → arms `resumeFireAt`
|
||||
* - `agentResumeCancelled` → drops `resumeFireAt`, stays limité
|
||||
* - `agentResumed` → clears the entry entirely
|
||||
* - `agentRateLimitSuspected` (with & without `resetsAtMs`) → `suspected: true`
|
||||
* - realistic sequence rateLimited → scheduled → cancelResume (action)
|
||||
* - `cancelResume` action: optimistic drop + both backend verdicts
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
import type { DomainEvent } from "@/domain";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { MockInputGateway, MockSystemGateway } from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { useAgents } from "./useAgents";
|
||||
|
||||
const PROJECT_ID = "proj-limits-001";
|
||||
const AGENT = "agent-A";
|
||||
|
||||
function setup() {
|
||||
const system = new MockSystemGateway();
|
||||
const input = new MockInputGateway();
|
||||
// useAgents calls agent.listAgents / profile.listProfiles on mount; provide
|
||||
// minimal stubs so the mount effect resolves without a backend.
|
||||
const agent = {
|
||||
listAgents: async () => [],
|
||||
listLiveAgents: async () => [],
|
||||
};
|
||||
const profile = { listProfiles: async () => [] };
|
||||
const gateways = { system, input, agent, profile } as unknown as Gateways;
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<DIProvider gateways={gateways}>{children}</DIProvider>
|
||||
);
|
||||
const view = renderHook(() => useAgents(PROJECT_ID), { wrapper });
|
||||
return { system, input, view };
|
||||
}
|
||||
|
||||
/** Emits an event and flushes the async subscription/microtasks. */
|
||||
async function emit(system: MockSystemGateway, event: DomainEvent) {
|
||||
await act(async () => {
|
||||
system.emit(event);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("useAgents — session-limit state (§21)", () => {
|
||||
it("agentRateLimited with a reset time → { limitedUntil, suspected:false }", async () => {
|
||||
const { system, view } = setup();
|
||||
await emit(system, {
|
||||
type: "agentRateLimited",
|
||||
agentId: AGENT,
|
||||
resetsAtMs: 1_800_000_000_000,
|
||||
});
|
||||
expect(view.result.current.limitByAgent[AGENT]).toEqual({
|
||||
limitedUntil: 1_800_000_000_000,
|
||||
resumeFireAt: undefined,
|
||||
suspected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("agentResumeScheduled arms resumeFireAt on top of the limited state", async () => {
|
||||
const { system, view } = setup();
|
||||
await emit(system, {
|
||||
type: "agentRateLimited",
|
||||
agentId: AGENT,
|
||||
resetsAtMs: 1_800_000_000_000,
|
||||
});
|
||||
await emit(system, {
|
||||
type: "agentResumeScheduled",
|
||||
agentId: AGENT,
|
||||
fireAtMs: 1_800_000_300_000,
|
||||
});
|
||||
expect(view.result.current.limitByAgent[AGENT]).toMatchObject({
|
||||
limitedUntil: 1_800_000_000_000,
|
||||
resumeFireAt: 1_800_000_300_000,
|
||||
suspected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("agentResumeCancelled drops resumeFireAt but keeps the agent limité", async () => {
|
||||
const { system, view } = setup();
|
||||
await emit(system, {
|
||||
type: "agentRateLimited",
|
||||
agentId: AGENT,
|
||||
resetsAtMs: 1_800_000_000_000,
|
||||
});
|
||||
await emit(system, {
|
||||
type: "agentResumeScheduled",
|
||||
agentId: AGENT,
|
||||
fireAtMs: 1_800_000_300_000,
|
||||
});
|
||||
await emit(system, { type: "agentResumeCancelled", agentId: AGENT });
|
||||
|
||||
const state = view.result.current.limitByAgent[AGENT];
|
||||
expect(state).toBeDefined();
|
||||
expect(state.resumeFireAt).toBeUndefined();
|
||||
expect(state.limitedUntil).toBe(1_800_000_000_000);
|
||||
});
|
||||
|
||||
it("agentResumeCancelled on an unknown agent is a no-op (no entry created)", async () => {
|
||||
const { system, view } = setup();
|
||||
await emit(system, { type: "agentResumeCancelled", agentId: AGENT });
|
||||
expect(view.result.current.limitByAgent[AGENT]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("agentResumed clears the limit entry entirely", async () => {
|
||||
const { system, view } = setup();
|
||||
await emit(system, {
|
||||
type: "agentRateLimited",
|
||||
agentId: AGENT,
|
||||
resetsAtMs: 1_800_000_000_000,
|
||||
});
|
||||
await emit(system, { type: "agentResumed", agentId: AGENT });
|
||||
expect(view.result.current.limitByAgent[AGENT]).toBeUndefined();
|
||||
expect(AGENT in view.result.current.limitByAgent).toBe(false);
|
||||
});
|
||||
|
||||
it("agentRateLimitSuspected with a time → { limitedUntil, suspected:true }", async () => {
|
||||
const { system, view } = setup();
|
||||
await emit(system, {
|
||||
type: "agentRateLimitSuspected",
|
||||
agentId: AGENT,
|
||||
resetsAtMs: 1_800_000_500_000,
|
||||
});
|
||||
expect(view.result.current.limitByAgent[AGENT]).toMatchObject({
|
||||
limitedUntil: 1_800_000_500_000,
|
||||
suspected: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("agentRateLimitSuspected WITHOUT a time → suspected:true, no limitedUntil", async () => {
|
||||
const { system, view } = setup();
|
||||
await emit(system, { type: "agentRateLimitSuspected", agentId: AGENT });
|
||||
const state = view.result.current.limitByAgent[AGENT];
|
||||
expect(state.suspected).toBe(true);
|
||||
expect(state.limitedUntil).toBeUndefined();
|
||||
});
|
||||
|
||||
it("realistic sequence: rateLimited → scheduled → cancelResume (action)", async () => {
|
||||
const { system, input, view } = setup();
|
||||
await emit(system, {
|
||||
type: "agentRateLimited",
|
||||
agentId: AGENT,
|
||||
resetsAtMs: 1_800_000_000_000,
|
||||
});
|
||||
await emit(system, {
|
||||
type: "agentResumeScheduled",
|
||||
agentId: AGENT,
|
||||
fireAtMs: 1_800_000_300_000,
|
||||
});
|
||||
expect(view.result.current.limitByAgent[AGENT].resumeFireAt).toBe(
|
||||
1_800_000_300_000,
|
||||
);
|
||||
|
||||
// The user clicks "Annuler la reprise": optimistic drop + port call.
|
||||
let verdict: boolean | undefined;
|
||||
await act(async () => {
|
||||
verdict = await view.result.current.cancelResume(AGENT);
|
||||
});
|
||||
|
||||
// Optimistic: the countdown is gone immediately; the agent stays limité.
|
||||
expect(view.result.current.limitByAgent[AGENT].resumeFireAt).toBeUndefined();
|
||||
expect(view.result.current.limitByAgent[AGENT].limitedUntil).toBe(
|
||||
1_800_000_000_000,
|
||||
);
|
||||
// The action routed through the InputGateway and returned the verdict.
|
||||
expect(input.cancelledResumes).toEqual([AGENT]);
|
||||
expect(verdict).toBe(true);
|
||||
});
|
||||
|
||||
it("cancelResume relays a `false` backend verdict (already fired / none armed)", async () => {
|
||||
const { system, input, view } = setup();
|
||||
input.cancelResumeResult = false;
|
||||
await emit(system, {
|
||||
type: "agentResumeScheduled",
|
||||
agentId: AGENT,
|
||||
fireAtMs: 1_800_000_300_000,
|
||||
});
|
||||
|
||||
let verdict: boolean | undefined;
|
||||
await act(async () => {
|
||||
verdict = await view.result.current.cancelResume(AGENT);
|
||||
});
|
||||
expect(verdict).toBe(false);
|
||||
expect(input.cancelledResumes).toEqual([AGENT]);
|
||||
// Still optimistically dropped locally.
|
||||
expect(view.result.current.limitByAgent[AGENT]?.resumeFireAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("cancelResume with no armed resume still calls the port (no-op locally)", async () => {
|
||||
const { input, view } = setup();
|
||||
// No event emitted: the agent has no entry at all.
|
||||
await waitFor(() => expect(view.result.current).toBeTruthy());
|
||||
let verdict: boolean | undefined;
|
||||
await act(async () => {
|
||||
verdict = await view.result.current.cancelResume(AGENT);
|
||||
});
|
||||
expect(verdict).toBe(true);
|
||||
expect(input.cancelledResumes).toEqual([AGENT]);
|
||||
expect(view.result.current.limitByAgent[AGENT]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("folds limits for two agents independently", async () => {
|
||||
const { system, view } = setup();
|
||||
await emit(system, {
|
||||
type: "agentRateLimited",
|
||||
agentId: "agent-A",
|
||||
resetsAtMs: 111,
|
||||
});
|
||||
await emit(system, {
|
||||
type: "agentRateLimitSuspected",
|
||||
agentId: "agent-B",
|
||||
});
|
||||
expect(view.result.current.limitByAgent["agent-A"]).toMatchObject({
|
||||
limitedUntil: 111,
|
||||
suspected: false,
|
||||
});
|
||||
expect(view.result.current.limitByAgent["agent-B"]).toMatchObject({
|
||||
suspected: true,
|
||||
});
|
||||
expect(view.result.current.limitByAgent["agent-B"].limitedUntil).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@ -552,6 +552,24 @@ export interface InputGateway {
|
||||
* mounted cell keeps the write-portal path. Best-effort; never throws into the UI.
|
||||
*/
|
||||
setFrontAttached(agentId: string, attached: boolean): Promise<void>;
|
||||
/**
|
||||
* Cancels the **auto-resume** armed for a rate-limited agent (ARCHITECTURE §21):
|
||||
* the user clicked "Annuler la reprise" during the cancellable window. Disarms
|
||||
* the scheduled wake-up. Resolves to `true` iff a resume was effectively
|
||||
* cancelled (a wake-up was still pending); `false` if none was armed or it had
|
||||
* already fired (the resume then runs its course). The backend also emits
|
||||
* `agentResumeCancelled` on success, which clears the countdown in the UI.
|
||||
*/
|
||||
cancelResume(agentId: string): Promise<boolean>;
|
||||
/**
|
||||
* Human net (level 3, ARCHITECTURE §21.1): arms a resume at a user-chosen
|
||||
* instant for an agent whose limit was *suspected* without a reliable reset
|
||||
* time. `resetsAtMs` is the chosen wake-up in epoch-milliseconds; the backend
|
||||
* clamps a past instant to "now" (⇒ immediate resume). This arms the same
|
||||
* cancellable resume as the automatic path and re-emits `agentResumeScheduled`,
|
||||
* so the UI flips on its own from "heure inconnue" to the nominal countdown.
|
||||
*/
|
||||
setResumeAt(agentId: string, resetsAtMs: number): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user