diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index 5cefda9..14764f4 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -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. diff --git a/.ideai/memory/session-limit-handling-design.md b/.ideai/memory/session-limit-handling-design.md new file mode 100644 index 0000000..60fb627 --- /dev/null +++ b/.ideai/memory/session-limit-handling-design.md @@ -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 }`. 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). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5ffdf76..992a141 100644 --- a/ARCHITECTURE.md +++ b/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 }`. `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` (é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 }`** (`ports.rs`) — non terminal, model-agnostique. +- **`ReadinessSignal::RateLimited { resets_at_ms: Option }`** (`readiness.rs`) — l'enum **reste `Copy`** + (`Option` est `Copy`). `ReadinessPolicy::classify(ReplyEvent::RateLimited{..})` ⇒ + `Some(ReadinessSignal::RateLimited{..})` (seul ajout au `match`). +- **`domain/src/session_limit.rs` (NOUVEAU)** : + - `SessionLimit { resets_at_ms: Option, 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`** + `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 }`, + `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 }, +} +``` + +- **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` 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.* diff --git a/Cargo.lock b/Cargo.lock index c0985bf..c6a6e82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1946,6 +1946,7 @@ dependencies = [ "landlock", "notify", "portable-pty", + "regex", "reqwest 0.12.28", "serde", "serde_json", diff --git a/crates/app-tauri/Cargo.toml b/crates/app-tauri/Cargo.toml index 96040c3..e3fa15d 100644 --- a/crates/app-tauri/Cargo.toml +++ b/crates/app-tauri/Cargo.toml @@ -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 diff --git a/crates/app-tauri/src/chat.rs b/crates/app-tauri/src/chat.rs index dfcdecd..daa7977 100644 --- a/crates/app-tauri/src/chat.rs +++ b/crates/app-tauri/src/chat.rs @@ -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 { match event { @@ -187,5 +189,6 @@ pub fn chunk_from_event(event: ReplyEvent) -> Option { ReplyEvent::ToolActivity { label } => Some(ReplyChunk::ToolActivity { label }), ReplyEvent::Final { content } => Some(ReplyChunk::Final { content }), ReplyEvent::Heartbeat => None, + ReplyEvent::RateLimited { .. } => None, } } diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 38c8b90..196f221 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -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 = 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 { + 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 diff --git a/crates/app-tauri/src/events.rs b/crates/app-tauri/src/events.rs index 26c512d..1dbce94 100644 --- a/crates/app-tauri/src/events.rs +++ b/crates/app-tauri/src/events.rs @@ -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, + }, + /// 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, + }, /// 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); + } } diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 59fd150..cb61d5e 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -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, diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 93ac7a2..fdf9831 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -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>>; + +/// 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` que la commande `launch_agent` (relance/réattache). + launch_agent: Arc, + /// Portail d'entrée unique : injecte le prompt de reprise comme premier tour. + input_mediator: Arc, + /// 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, + 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>, + /// 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, + /// 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; + + // --- 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::(); + let scheduler = Arc::new(TokioScheduler::new( + resume_tx, + Arc::clone(&clock) as Arc, + )) as Arc; + let resumer = Arc::new(AppAgentResumer { + launch_agent: Arc::clone(&launch_agent), + input_mediator: Arc::clone(&input_mediator), + contexts: Arc::clone(&resume_contexts), + }) as Arc; + let session_limit_service = Arc::new(SessionLimitService::new( + Arc::clone(&clock) as Arc, + 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, } } diff --git a/crates/app-tauri/tests/dto_agents.rs b/crates/app-tauri/tests/dto_agents.rs index 74bdb5d..fa48c98 100644 --- a/crates/app-tauri/tests/dto_agents.rs +++ b/crates/app-tauri/tests/dto_agents.rs @@ -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); diff --git a/crates/app-tauri/tests/dto_chat.rs b/crates/app-tauri/tests/dto_chat.rs index 4297333..b9fb1e0 100644 --- a/crates/app-tauri/tests/dto_chat.rs +++ b/crates/app-tauri/tests/dto_chat.rs @@ -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()); diff --git a/crates/app-tauri/tests/session_limit_wiring.rs b/crates/app-tauri/tests/session_limit_wiring.rs new file mode 100644 index 0000000..ae1e2d8 --- /dev/null +++ b/crates/app-tauri/tests/session_limit_wiring.rs @@ -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` 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" + ); +} diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 4e8bf29..f20c539 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -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, + /// 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, } /// 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()), }) } diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index e93f6e9..8f2389e 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -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}; diff --git a/crates/application/src/agent/session_limit.rs b/crates/application/src/agent/session_limit.rs new file mode 100644 index 0000000..09e35ea --- /dev/null +++ b/crates/application/src/agent/session_limit.rs @@ -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, + resume_prompt: &str, + ) -> Result<(), AppError>; +} + +/// Service d'orchestration des limites de session (§21.5). +pub struct SessionLimitService { + clock: Arc, + scheduler: Arc, + events: Arc, + resumer: Arc, + /// Reprises **armées** non encore tirées : `agent_id → ScheduleId` (en mémoire). + armed: Mutex>, +} + +impl SessionLimitService { + /// Construit le service depuis ses ports injectés (composition root). + #[must_use] + pub fn new( + clock: Arc, + scheduler: Arc, + events: Arc, + resumer: Arc, + ) -> 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, + resets_at_ms: Option, + ) { + 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, + 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, + resets_at_ms: Option, + ) { + 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); + } + } +} diff --git a/crates/application/src/agent/structured.rs b/crates/application/src/agent/structured.rs index b2a452a..8884118 100644 --- a/crates/application/src/agent/structured.rs +++ b/crates/application/src/agent/structured.rs @@ -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, + }, +} + /// 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, ) -> Result { - 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`, 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 { + 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, + mediator: &dyn InputMediator, + agent: AgentId, +) -> Result { // `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, on_event: impl FnMut(&ReplyEvent), on_signal: impl FnMut(ReadinessSignal), -) -> Result { +) -> Result { 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 { +) -> Result { + // 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> = 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)] diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 744699d..4a0a42c 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -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::{ diff --git a/crates/application/src/terminal/registry.rs b/crates/application/src/terminal/registry.rs index b86e632..6c14c6e 100644 --- a/crates/application/src/terminal/registry.rs +++ b/crates/application/src/terminal/registry.rs @@ -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 diff --git a/crates/application/tests/session_limit_service.rs b/crates/application/tests/session_limit_service.rs new file mode 100644 index 0000000..ef08e1b --- /dev/null +++ b/crates/application/tests/session_limit_service.rs @@ -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>>); +impl SpyBus { + fn events(&self) -> Vec { + 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>>, + issued: Arc>>, + cancels: Arc>>, + next_id: Arc>, + cancel_result: Arc, +} +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 { + self.issued.lock().unwrap().clone() + } + fn cancels(&self) -> Vec { + 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, String)>>>, + fail: Arc, +} +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)> { + self.calls.lock().unwrap().clone() + } +} +#[async_trait] +impl AgentResumer for FakeResumer { + async fn resume( + &self, + agent_id: AgentId, + node_id: NodeId, + conversation_id: Option, + 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{{..}})" + ); +} diff --git a/crates/application/tests/session_limit_t4.rs b/crates/application/tests/session_limit_t4.rs new file mode 100644 index 0000000..2a225b6 --- /dev/null +++ b/crates/application/tests/session_limit_t4.rs @@ -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` : +//! - `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, +} +#[async_trait] +impl AgentSession for FakeSession { + fn id(&self) -> SessionId { + SessionId::from_uuid(Uuid::from_u128(1)) + } + fn conversation_id(&self) -> Option { + None + } + async fn send(&self, _prompt: &str) -> Result { + 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>, +} +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 +// =========================================================================== + +/// `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")); +} diff --git a/crates/application/tests/structured_registry_d1.rs b/crates/application/tests/structured_registry_d1.rs index 0f74913..a35a3c1 100644 --- a/crates/application/tests/structured_registry_d1.rs +++ b/crates/application/tests/structured_registry_d1.rs @@ -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(); diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 215634e..7c344e2 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -209,6 +209,55 @@ pub enum DomainEvent { /// Target profile's submit delay in ms. `None` ⇒ front default (~60 ms). submit_delay_ms: Option, }, + /// 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, + }, + /// 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, + }, /// Raw PTY output (usually routed to a dedicated channel, not this bus). PtyOutput { /// The session. @@ -217,3 +266,104 @@ pub enum DomainEvent { bytes: Vec, }, } + +#[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) } + ); + } +} diff --git a/crates/domain/src/ids.rs b/crates/domain/src/ids.rs index 00fb82c..0e67a71 100644 --- a/crates/domain/src/ids.rs +++ b/crates/domain/src/ids.rs @@ -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 +); diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index da35044..8fca7d6 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -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, }; diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 5050088..6a8fd47 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -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, + }, /// **É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 + 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, + }, +} + // --------------------------------------------------------------------------- // 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; +} diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index dc8aa8e..bd597b2 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -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, + /// **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, +} + +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, + reset_capture: Option, + time_format: Option, + ) -> Result { + 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, + /// 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, /// 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.+)", + Some("reset".to_owned()), + Some("%H:%M".to_owned()), + ) + .expect("valid pattern"); + assert_eq!(p.pattern, "rate limit.*resets at (?P.+)"); + 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.+)", + 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}" + ); + } } diff --git a/crates/domain/src/readiness.rs b/crates/domain/src/readiness.rs index 71b6559..459d993 100644 --- a/crates/domain/src/readiness.rs +++ b/crates/domain/src/readiness.rs @@ -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` 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, + }, } /// 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 { 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); + } } diff --git a/crates/domain/src/session_limit.rs b/crates/domain/src/session_limit.rs new file mode 100644 index 0000000..0caaf17 --- /dev/null +++ b/crates/domain/src/session_limit.rs @@ -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, + /// 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, + 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, + }, + /// 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, +) -> 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) -> 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()); + } +} diff --git a/crates/infrastructure/Cargo.toml b/crates/infrastructure/Cargo.toml index bbfa886..85b3c71 100644 --- a/crates/infrastructure/Cargo.toml +++ b/crates/infrastructure/Cargo.toml @@ -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 } diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index df9e914..b05eb4a 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -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; diff --git a/crates/infrastructure/src/ratelimit/mod.rs b/crates/infrastructure/src/ratelimit/mod.rs new file mode 100644 index 0000000..efcdf96 --- /dev/null +++ b/crates/infrastructure/src/ratelimit/mod.rs @@ -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 { + let c = captured.trim(); + match self { + Self::Auto => timeparse::parse_absolute_ms(c), + Self::EpochSeconds => c.parse::().ok().map(|s| s.saturating_mul(1000)), + Self::EpochMillis => c.parse::().ok(), + Self::Iso8601 => timeparse::parse_rfc3339_to_ms(c), + Self::RelativeSeconds => c + .parse::() + .ok() + .map(|d| now_ms + (d * 1000.0) as i64), + Self::RelativeMillis => c.parse::().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, + /// 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 { + 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 { + 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::().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\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\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\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\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.+)", + 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.+)", + 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\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\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())); + } +} diff --git a/crates/infrastructure/src/scheduler/mod.rs b/crates/infrastructure/src/scheduler/mod.rs new file mode 100644 index 0000000..5e60bb9 --- /dev/null +++ b/crates/infrastructure/src/scheduler/mod.rs @@ -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` (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, + /// Horloge injectée (déterminisme/testabilité) pour traduire l'échéance absolue en + /// délai relatif (`deadline_ms - now`). + clock: Arc, + /// Table `id → handle` des réveils armés non encore tirés. `cancel` y retire + + /// `abort()` le handle. + handles: Arc>>>, +} + +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, clock: Arc) -> 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, Arc) { + let (tx, rx) = unbounded_channel(); + let clock: Arc = 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" + ); + } +} diff --git a/crates/infrastructure/src/session/claude.rs b/crates/infrastructure/src/session/claude.rs index 8b00329..470e9b7 100644 --- a/crates/infrastructure/src/session/claude.rs +++ b/crates/infrastructure/src/session/claude.rs @@ -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 { // 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 { 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 { + 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 { + 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); diff --git a/crates/infrastructure/src/session/conformance.rs b/crates/infrastructure/src/session/conformance.rs index 416beb4..5fe8d19 100644 --- a/crates/infrastructure/src/session/conformance.rs +++ b/crates/infrastructure/src/session/conformance.rs @@ -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:?}" ); } diff --git a/crates/infrastructure/src/session/mod.rs b/crates/infrastructure/src/session/mod.rs index 0d6a3b9..09ef248 100644 --- a/crates/infrastructure/src/session/mod.rs +++ b/crates/infrastructure/src/session/mod.rs @@ -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 = 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 + ); + } } diff --git a/crates/infrastructure/src/timeparse.rs b/crates/infrastructure/src/timeparse.rs new file mode 100644 index 0000000..a1eae6e --- /dev/null +++ b/crates/infrastructure/src/timeparse.rs @@ -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 { + let t = s.trim(); + if let Ok(i) = t.parse::() { + return Some(int_epoch_to_ms(i)); + } + if let Ok(f) = t.parse::() { + 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 { + 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::().ok()?, m.parse::().ok()?) + } else if tz.len() == 4 { + (tz[0..2].parse::().ok()?, tz[2..4].parse::().ok()?) + } else { + (tz.parse::().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::().ok()?, 0)); + }; + let sec = sec.parse::().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::().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) + ); + } +} diff --git a/frontend/src/adapters/input.ts b/frontend/src/adapters/input.ts index b25406b..e2ce146 100644 --- a/frontend/src/adapters/input.ts +++ b/frontend/src/adapters/input.ts @@ -38,4 +38,16 @@ export class TauriInputGateway implements InputGateway { request: { agentId, attached }, }); } + + async cancelResume(agentId: string): Promise { + // `cancel_resume` takes a bare `agent_id` (camelCase `agentId`), not a + // `request` envelope, and returns whether a pending resume was disarmed. + return invoke("cancel_resume", { agentId }); + } + + async setResumeAt(agentId: string, resetsAtMs: number): Promise { + // `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 }); + } } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index a4c0f70..7c04261 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -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 { this.interrupts.push({ projectId, agentId }); @@ -1575,6 +1585,15 @@ export class MockInputGateway implements InputGateway { async setFrontAttached(agentId: string, attached: boolean): Promise { this.frontAttached.push({ agentId, attached }); } + + async cancelResume(agentId: string): Promise { + this.cancelledResumes.push(agentId); + return this.cancelResumeResult; + } + + async setResumeAt(agentId: string, resetsAtMs: number): Promise { + this.resumeArmings.push({ agentId, resetsAtMs }); + } } /** In-memory permissions gateway. */ diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index 93c9ac9..bf071d1 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -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", diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 3b9078a..a3f06b6 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -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 } diff --git a/frontend/src/features/agents/AgentLimitBadge.test.tsx b/frontend/src/features/agents/AgentLimitBadge.test.tsx new file mode 100644 index 0000000..70bb544 --- /dev/null +++ b/frontend/src/features/agents/AgentLimitBadge.test.tsx @@ -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( + , + ); + 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( + , + ); + expect(screen.getByText("limité")).toBeTruthy(); + }); + + it("shows the 'heure inconnue' resume-time form for a suspected limit without a time", () => { + render( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + // 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( + , + ); + expect( + (screen.getByLabelText("cancel resume") as HTMLButtonElement).disabled, + ).toBe(true); + }); +}); diff --git a/frontend/src/features/agents/AgentLimitBadge.tsx b/frontend/src/features/agents/AgentLimitBadge.tsx new file mode 100644 index 0000000..16e9f52 --- /dev/null +++ b/frontend/src/features/agents/AgentLimitBadge.tsx @@ -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 `` 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 ( + + + {limitLabel} + + + {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. +
+ + heure inconnue + + setResumeTime(e.target.value)} + className="h-8 w-28 text-xs" + /> + +
+ )} + + {resumeFireAt !== undefined && ( + + + reprise dans {formatCountdown(resumeFireAt - now)} + + + + )} +
+ ); +} diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index 585e9e4..6e0763e 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -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 (
  • + {limitState && ( + void vm.cancelResume(a.id)} + onSetResumeAt={(resetsAtMs) => + void vm.setResumeAt(a.id, resetsAtMs) + } + /> + )} +
    {/* Profile hot-swap selector (Chantier A). Changing the engine abandons the conversation history → confirmation. */} diff --git a/frontend/src/features/agents/useAgents.ts b/frontend/src/features/agents/useAgents.ts index 256ecf7..4e2a56e 100644 --- a/frontend/src/features/agents/useAgents.ts +++ b/frontend/src/features/agents/useAgents.ts @@ -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; + /** + * 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; /** Last error message, or `null`. */ error: string | null; /** Whether a request is in flight. */ @@ -83,6 +113,21 @@ export interface AgentsViewModel { ) => Promise; /** Stops an agent PTY explicitly by closing its live session id. */ stopAgent: (agentId?: string) => Promise; + /** + * 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; + /** + * 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; } 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([]); const [selectedAgentId, setSelectedAgentId] = useState(null); @@ -106,6 +151,9 @@ export function useAgents(projectId: string): AgentsViewModel { const [delegationSourceByRequester, setDelegationSourceByRequester] = useState< Record >({}); + const [limitByAgent, setLimitByAgent] = useState< + Record + >({}); 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 => { + // 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 => { + // 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, }; } diff --git a/frontend/src/features/agents/useAgentsLimits.test.tsx b/frontend/src/features/agents/useAgentsLimits.test.tsx new file mode 100644 index 0000000..9353a82 --- /dev/null +++ b/frontend/src/features/agents/useAgentsLimits.test.tsx @@ -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 }) => ( + {children} + ); + 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(); + }); +}); diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 613d418..61a1c88 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -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; + /** + * 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; + /** + * 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; } /**