From fa5b826df5fad52c73169ab32c01e2c31aad49b3 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 16 Jun 2026 14:25:05 +0200 Subject: [PATCH 01/14] =?UTF-8?q?docs(session-limits):=20cadrage=20Archite?= =?UTF-8?q?ct=20=E2=80=94=20gestion=20des=20limites=20de=20session=20des?= =?UTF-8?q?=20agents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pose le cadrage de la feature « Gestion des limites de session des agents » avant tout code (lots LS1→LS8) : - ARCHITECTURE.md §21 : détection hiérarchique des limites de session + reprise auto annulable (domaine → adapter Claude → port Scheduler → service → parser regex → filet humain → app-tauri → frontend) ; état en mémoire uniquement, aucun schéma de persistance modifié. - .ideai/memory/session-limit-handling-design.md : design validé. - .ideai/memory/MEMORY.md : pointeur vers le design. Co-Authored-By: Claude Opus 4.8 --- .ideai/memory/MEMORY.md | 1 + .../memory/session-limit-handling-design.md | 26 +++ ARCHITECTURE.md | 185 ++++++++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 .ideai/memory/session-limit-handling-design.md 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.* From 0bf1eb3b11065b5933619663740365511773f204 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 16 Jun 2026 14:33:57 +0200 Subject: [PATCH 02/14] =?UTF-8?q?feat(session-limits):=20LS1=20=E2=80=94?= =?UTF-8?q?=20couche=20domaine=20(d=C3=A9tection=20+=20plan=20de=20reprise?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pose les briques pures du domaine pour la gestion des limites de session des agents (état en mémoire, aucun schéma de persistance modifié) : - session_limit.rs (nouveau) : SessionLimit, ResumePlan, RateLimitSource, plan_resume (calcul du plan de reprise annulable). - ports.rs : variante ReplyEvent::RateLimited. - readiness.rs : variante ReadinessSignal::RateLimited + classify. - profile.rs : RateLimitPattern + champ + builder. - events.rs : 5 variantes DomainEvent pour le cycle de vie limite/reprise. - lib.rs : module + re-exports. Tests QA inline (#[cfg(test)]) : 24 tests dédiés. `cargo test -p domain` = 165 passed / 0 failed, zéro régression. Co-Authored-By: Claude Opus 4.8 --- crates/domain/src/events.rs | 150 +++++++++++++++++++ crates/domain/src/lib.rs | 5 +- crates/domain/src/ports.rs | 20 +++ crates/domain/src/profile.rs | 175 ++++++++++++++++++++++ crates/domain/src/readiness.rs | 71 +++++++++ crates/domain/src/session_limit.rs | 227 +++++++++++++++++++++++++++++ 6 files changed, 647 insertions(+), 1 deletion(-) create mode 100644 crates/domain/src/session_limit.rs 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/lib.rs b/crates/domain/src/lib.rs index da35044..a1c619c 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; @@ -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, diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 5050088..de71833 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -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). 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()); + } +} From a1755e51bcbfff7989cf881f4120d2e864e79440 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 16 Jun 2026 14:44:53 +0200 Subject: [PATCH 03/14] =?UTF-8?q?feat(session-limits):=20LS2=20=E2=80=94?= =?UTF-8?q?=20adapter=20Claude=20niveau=201=20(infra)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Câble la détection de limite de session dans l'adapter Claude : - claude.rs : parse_event émet ReplyEvent::RateLimited ; nouvelle fonction pure parse_reset_ms + helpers ; parseur ISO maison ; doccomments T4. - mod.rs : 26 nouveaux tests QA (#[cfg(test)]) + 2 tests existants alignés sur le nouveau contrat. - conformance.rs : RateLimited ajouté aux événements non terminaux autorisés. `cargo test -p infrastructure` = 188 passed / 0 failed, zéro régression. Co-Authored-By: Claude Opus 4.8 --- crates/infrastructure/src/session/claude.rs | 192 +++++++++- .../infrastructure/src/session/conformance.rs | 6 +- crates/infrastructure/src/session/mod.rs | 359 +++++++++++++++++- 3 files changed, 545 insertions(+), 12 deletions(-) diff --git a/crates/infrastructure/src/session/claude.rs b/crates/infrastructure/src/session/claude.rs index 8b00329..94d08c4 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,173 @@ 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) +} + +/// 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. +const EPOCH_MS_THRESHOLD: i64 = 1_000_000_000_000; + +/// Convertit une [`Value`] JSON (entier / float / chaîne) en époche-ms, défensivement. +fn value_to_epoch_ms(v: &Value) -> Option { + if let Some(i) = v.as_i64() { + return Some(int_epoch_to_ms(i)); + } + if let Some(u) = v.as_u64() { + return Some(int_epoch_to_ms(i64::try_from(u).ok()?)); + } + if let Some(f) = v.as_f64() { + return Some(float_epoch_to_ms(f)); + } + if let Some(s) = v.as_str() { + 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)); + } + return parse_rfc3339_to_ms(t); + } + None +} + +/// Applique l'heuristique secondes-vs-ms à un epoch **entier**. +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). +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 + } +} + +/// **Parse une chaîne ISO-8601 / RFC3339** (`YYYY-MM-DDThh:mm:ss[.fff][Z|±hh:mm]`) en +/// époche-ms. Pur, dépendance-zéro (aucune crate de date). Best-effort : retourne +/// `None` sur toute forme non reconnue. Sans désignateur de fuseau ⇒ traité **UTC**. +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) +} + +/// 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. +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 +} + /// Adapter de session structurée Claude. /// /// Incarnation « un `claude -p` par tour » (§17.2 (b)) : chaque `send` relance la @@ -241,9 +417,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 + ); + } } From 253310bb3ea0687c6151b0321bd5819b88fad280 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 16 Jun 2026 15:07:48 +0200 Subject: [PATCH 04/14] =?UTF-8?q?feat(session-limits):=20LS3=20=E2=80=94?= =?UTF-8?q?=20port=20Scheduler=20+=20adapter=20TokioScheduler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduit l'abstraction de planification pour la reprise différée à la levée d'une limite de session : - domaine : trait Scheduler + enum ScheduledTask (ports.rs), ScheduleId via typed_id! (ids.rs), re-exports (lib.rs). - infra : TokioScheduler (scheduler/mod.rs, nouveau) + pub mod scheduler et re-export (lib.rs). Tests QA inline (#[cfg(test)]) : 7 tests scheduler (3× sans flaky). `cargo test -p infrastructure` = 195 passed / 0 failed ; domaine + infra builds 0 warning ; LS1/LS2 toujours verts. Co-Authored-By: Claude Opus 4.8 --- crates/domain/src/ids.rs | 7 + crates/domain/src/lib.rs | 8 +- crates/domain/src/ports.rs | 77 +++++- crates/infrastructure/src/lib.rs | 2 + crates/infrastructure/src/scheduler/mod.rs | 300 +++++++++++++++++++++ 5 files changed, 389 insertions(+), 5 deletions(-) create mode 100644 crates/infrastructure/src/scheduler/mod.rs 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 a1c619c..8fca7d6 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -65,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}; @@ -145,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 de71833..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; @@ -262,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 // --------------------------------------------------------------------------- @@ -1266,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/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index df9e914..fc8d01a 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -30,6 +30,7 @@ pub mod pty; pub mod remote; pub mod runtime; pub mod sandbox; +pub mod scheduler; pub mod session; pub mod store; @@ -59,6 +60,7 @@ pub use runtime::CliAgentRuntime; #[cfg(target_os = "linux")] pub use sandbox::LandlockSandbox; 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/scheduler/mod.rs b/crates/infrastructure/src/scheduler/mod.rs new file mode 100644 index 0000000..6a2100c --- /dev/null +++ b/crates/infrastructure/src/scheduler/mod.rs @@ -0,0 +1,300 @@ +//! [`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"); + if 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"); + if 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" + ); + } +} From 9000b4d09f7d803d0be466de17a3918822522ad8 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 16 Jun 2026 18:55:26 +0200 Subject: [PATCH 05/14] =?UTF-8?q?feat(session-limits):=20LS4=20=E2=80=94?= =?UTF-8?q?=20service=20application=20+=20r=C3=A9conciliation=20T4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orchestre la détection et la reprise au niveau application : - session_limit.rs (nouveau) : SessionLimitService + port AgentResumer + const RESUME_PROMPT. - structured.rs : réconciliation T4 — enum TurnOutcome + drain_with_readiness_outcome ; signatures historiques préservées. - agent/mod.rs + lib.rs : modules et re-exports. Tests QA (nouveaux) : tests/session_limit_service.rs (9) + tests/session_limit_t4.rs (7). `cargo test -p application` = tous binaires verts / 0 failed (16 nouveaux), zéro régression (drain_with_readiness_lot1 7/7, send_blocking_d1 9/9) ; builds domaine+infra+application 0 warning. Co-Authored-By: Claude Opus 4.8 --- crates/application/src/agent/mod.rs | 6 +- crates/application/src/agent/session_limit.rs | 229 +++++++++ crates/application/src/agent/structured.rs | 118 ++++- crates/application/src/lib.rs | 9 +- .../tests/session_limit_service.rs | 440 ++++++++++++++++++ crates/application/tests/session_limit_t4.rs | 205 ++++++++ 6 files changed, 987 insertions(+), 20 deletions(-) create mode 100644 crates/application/src/agent/session_limit.rs create mode 100644 crates/application/tests/session_limit_service.rs create mode 100644 crates/application/tests/session_limit_t4.rs 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..0cc756f --- /dev/null +++ b/crates/application/src/agent/session_limit.rs @@ -0,0 +1,229 @@ +//! [`SessionLimitService`] — orchestration applicative des **limites de session** +//! des agents (ARCHITECTURE §21.5) : **détecter → planifier → reprendre**, et +//! **annuler**. +//! +//! Service **pur-ports** (SOLID/hexagonal) : il ne dépend que de traits du domaine +//! ([`Clock`], [`Scheduler`], [`EventBus`]) et d'un port applicatif de reprise +//! ([`AgentResumer`], implémenté au composition root en LS7 par-dessus `LaunchAgent`). +//! Aucune dépendance vers un adapter concret ⇒ entièrement testable avec des fakes. +//! +//! # État en mémoire uniquement (§21.1-3) +//! +//! La seule mémoire du service est une table `agent_id → ScheduleId` des reprises +//! **armées** (pour pouvoir les annuler). Aucune persistance : à un redémarrage +//! d'IdeA le chemin `ListResumableAgents` existant prend le relais. +//! +//! # Dédoublonnage par agent (§21.10-4) +//! +//! Un agent n'a qu'**une** reprise armée à la fois : un second signal de limite +//! **rafraîchit** l'armement (annule l'ancien, arme le nouveau) au lieu d'empiler. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; + +use domain::ids::{AgentId, NodeId, ScheduleId}; +use domain::ports::{Clock, EventBus, ScheduledTask, Scheduler}; +use domain::session_limit::{plan_resume, RateLimitSource, ResumePlan, SessionLimit}; +use domain::DomainEvent; + +use crate::error::AppError; + +/// Prompt **court** envoyé à l'agent au moment de la reprise automatique (§21.5). +/// +/// `--resume` (via [`domain::ports::SessionPlan::Resume`]) porte déjà tout +/// l'historique : ce prompt n'a qu'à **réamorcer** le tour, pas reconstruire le +/// contexte. Volontairement neutre et model-agnostique. +pub const RESUME_PROMPT: &str = + "La limite de session est levée. Reprends là où tu t'étais arrêté."; + +/// Port applicatif de **reprise d'un agent** (frontière implémentée au composition +/// root, LS7). Calqué sur les autres traits-passerelles de l'application +/// ([`crate::agent::HandoffProvider`], [`crate::agent::ProviderSessionProvider`]) : +/// l'app-tauri le branche par-dessus le mécanisme de lancement existant +/// (`LaunchAgent` + `AgentSessionFactory`) avec [`domain::ports::SessionPlan::Resume`]. +/// +/// Le service ne sait **pas** relancer un agent lui-même (cela exige le `Project`, le +/// profil, le contexte préparé, le PTY… que seul `LaunchAgent` résout) ; il délègue +/// donc à ce port, en restant testable avec un fake. +#[async_trait] +pub trait AgentResumer: Send + Sync { + /// Relance/réattache l'agent `agent_id` dans sa cellule `node_id`, en reprenant la + /// conversation moteur `conversation_id` (`SessionPlan::Resume` côté lancement) et + /// en lui transmettant `resume_prompt` comme premier tour. + /// + /// # Errors + /// [`AppError`] si la relance échoue (profil/contexte introuvable, échec de + /// démarrage de session…). Le service propage l'erreur sans publier `AgentResumed`. + async fn resume( + &self, + agent_id: AgentId, + node_id: NodeId, + conversation_id: Option, + 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.events.publish(DomainEvent::AgentRateLimited { + agent_id, + resets_at_ms, + }); + // Dédoublonnage (§21.10-4) : un signal de rafraîchissement annule + // l'armement précédent (sans événement d'annulation : c'est interne). + self.disarm(agent_id); + let id = self.scheduler.arm( + fire_at_ms, + ScheduledTask::ResumeAgent { + agent_id, + node_id, + conversation_id, + }, + ); + self.armed.lock().expect("session-limit mutex sain").insert(agent_id, id); + self.events + .publish(DomainEvent::AgentResumeScheduled { agent_id, fire_at_ms }); + } + ResumePlan::HumanFallback => { + self.events.publish(DomainEvent::AgentRateLimited { + agent_id, + resets_at_ms: None, + }); + self.events.publish(DomainEvent::AgentRateLimitSuspected { + agent_id, + resets_at_ms: None, + }); + } + } + } + + /// **(b) Exécution de la reprise.** Consomme une [`ScheduledTask::ResumeAgent`] + /// échue (celle que `TokioScheduler` pousse dans le canal de remise ; le câblage du + /// récepteur dans le runtime est LS7). Retire l'entrée armée (le réveil a tiré), + /// relance l'agent via [`AgentResumer`] avec [`RESUME_PROMPT`], puis publie + /// `AgentResumed`. + /// + /// # Errors + /// [`AppError`] propagée par [`AgentResumer::resume`] (la relance a échoué) ; dans + /// ce cas `AgentResumed` n'est **pas** publié. + pub async fn execute_resume(&self, task: ScheduledTask) -> Result<(), AppError> { + let ScheduledTask::ResumeAgent { + agent_id, + node_id, + conversation_id, + } = task; + + // Le réveil a tiré : l'entrée armée n'a plus lieu d'être (qu'on réussisse ou non). + self.disarm(agent_id); + + self.resumer + .resume(agent_id, node_id, conversation_id, RESUME_PROMPT) + .await?; + + self.events.publish(DomainEvent::AgentResumed { agent_id }); + Ok(()) + } + + /// **(c) Annulation.** Désarme la reprise auto de `agent_id` (socle du « annulable »). + /// Retrouve le [`ScheduleId`], appelle [`Scheduler::cancel`] et, **seulement si** + /// l'annulation a réussi, retire l'entrée et publie `AgentResumeCancelled`. + /// + /// Renvoie `true` ssi la reprise a effectivement été annulée. + /// + /// # Course « cancel pile au tir » (vigilance QA, LS3) + /// Sous runtime multi-thread, le réveil peut tirer **pile** au moment de l'annulation : + /// [`Scheduler::cancel`] renvoie alors `false` (déjà tiré). Dans ce cas on **ne + /// publie pas** `AgentResumeCancelled` et on **laisse l'entrée** (l'`execute_resume` + /// en cours la retirera) : la reprise **suit son cours**, cohérent et sans + /// événement trompeur. + pub fn cancel_resume(&self, agent_id: AgentId) -> bool { + let id = self + .armed + .lock() + .expect("session-limit mutex sain") + .get(&agent_id) + .copied(); + let Some(id) = id else { + return false; // aucune reprise armée pour cet agent. + }; + + if self.scheduler.cancel(id) { + self.armed.lock().expect("session-limit mutex sain").remove(&agent_id); + self.events + .publish(DomainEvent::AgentResumeCancelled { agent_id }); + true + } else { + // Déjà tiré : la reprise suivra son cours, pas d'événement d'annulation. + false + } + } + + /// Retire (best-effort) l'armement de `agent_id` et annule le réveil sous-jacent + /// s'il existe. Usage interne (rafraîchissement / nettoyage post-tir) — **ne publie + /// aucun événement** (contrairement à [`Self::cancel_resume`]). + fn disarm(&self, agent_id: AgentId) { + let previous = self + .armed + .lock() + .expect("session-limit mutex sain") + .remove(&agent_id); + if let Some(id) = previous { + self.scheduler.cancel(id); + } + } +} 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/tests/session_limit_service.rs b/crates/application/tests/session_limit_service.rs new file mode 100644 index 0000000..bb3564d --- /dev/null +++ b/crates/application/tests/session_limit_service.rs @@ -0,0 +1,440 @@ +//! 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é" + ); +} 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")); +} From 98bfcf4f22298e91d88feed647fed8282d18ea0b Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 16 Jun 2026 20:02:24 +0200 Subject: [PATCH 06/14] =?UTF-8?q?feat(session-limits):=20LS5=20=E2=80=94?= =?UTF-8?q?=20d=C3=A9tecteur=20niveau=202=20d=C3=A9claratif=20+=20parsing?= =?UTF-8?q?=20temps=20partag=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute un détecteur niveau 2 (RateLimitParser, regex confiné à l'infra) qui repère les mentions de limite de session dans la sortie textuelle, et factorise le parsing d'heures dans un module pur (timeparse) partagé entre les niveaux 1 et 2. Le détecteur Claude niveau 1 est refactoré vers timeparse (~-121 lignes). Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 1 + crates/infrastructure/Cargo.toml | 5 + crates/infrastructure/src/lib.rs | 3 + crates/infrastructure/src/ratelimit/mod.rs | 384 ++++++++++++++++++++ crates/infrastructure/src/scheduler/mod.rs | 17 +- crates/infrastructure/src/session/claude.rs | 121 +----- crates/infrastructure/src/timeparse.rs | 315 ++++++++++++++++ 7 files changed, 724 insertions(+), 122 deletions(-) create mode 100644 crates/infrastructure/src/ratelimit/mod.rs create mode 100644 crates/infrastructure/src/timeparse.rs 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/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 fc8d01a..b05eb4a 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -27,12 +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; @@ -59,6 +61,7 @@ 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}; 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 index 6a2100c..5e60bb9 100644 --- a/crates/infrastructure/src/scheduler/mod.rs +++ b/crates/infrastructure/src/scheduler/mod.rs @@ -250,12 +250,11 @@ mod tests { .await .expect("chaque réveil doit tirer") .expect("canal ouvert"); - if let ScheduledTask::ResumeAgent { + // `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")); - } + } = task; + got.push(conversation_id.expect("conv présent")); } got.sort(); assert_eq!(got, vec!["a".to_owned(), "b".to_owned(), "c".to_owned()]); @@ -282,12 +281,10 @@ mod tests { .await .expect("les survivants doivent tirer") .expect("canal ouvert"); - if let ScheduledTask::ResumeAgent { + let ScheduledTask::ResumeAgent { conversation_id, .. - } = task - { - got.push(conversation_id.expect("conv présent")); - } + } = task; + got.push(conversation_id.expect("conv présent")); } got.sort(); assert_eq!(got, vec!["keep-1".to_owned(), "keep-2".to_owned()]); diff --git a/crates/infrastructure/src/session/claude.rs b/crates/infrastructure/src/session/claude.rs index 94d08c4..470e9b7 100644 --- a/crates/infrastructure/src/session/claude.rs +++ b/crates/infrastructure/src/session/claude.rs @@ -190,131 +190,28 @@ pub fn parse_reset_ms(rate_limit_info: &Value) -> Option { value_to_epoch_ms(raw) } -/// 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. -const EPOCH_MS_THRESHOLD: i64 = 1_000_000_000_000; - /// 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(int_epoch_to_ms(i)); + return Some(crate::timeparse::int_epoch_to_ms(i)); } if let Some(u) = v.as_u64() { - return Some(int_epoch_to_ms(i64::try_from(u).ok()?)); + return Some(crate::timeparse::int_epoch_to_ms(i64::try_from(u).ok()?)); } if let Some(f) = v.as_f64() { - return Some(float_epoch_to_ms(f)); + return Some(crate::timeparse::float_epoch_to_ms(f)); } if let Some(s) = v.as_str() { - 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)); - } - return parse_rfc3339_to_ms(t); + return crate::timeparse::parse_absolute_ms(s); } None } -/// Applique l'heuristique secondes-vs-ms à un epoch **entier**. -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). -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 - } -} - -/// **Parse une chaîne ISO-8601 / RFC3339** (`YYYY-MM-DDThh:mm:ss[.fff][Z|±hh:mm]`) en -/// époche-ms. Pur, dépendance-zéro (aucune crate de date). Best-effort : retourne -/// `None` sur toute forme non reconnue. Sans désignateur de fuseau ⇒ traité **UTC**. -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) -} - -/// 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. -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 -} - /// Adapter de session structurée Claude. /// /// Incarnation « un `claude -p` par tour » (§17.2 (b)) : chaque `send` relance la 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) + ); + } +} From ea94e756e286b2e3c12cf84f9bb4a4ab3e923222 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 16 Jun 2026 20:02:29 +0200 Subject: [PATCH 07/14] =?UTF-8?q?feat(session-limits):=20LS6=20=E2=80=94?= =?UTF-8?q?=20c=C3=A2blage=20des=20=C3=A9v=C3=A9nements=20de=20limite=20ve?= =?UTF-8?q?rs=20le=20front?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relaie les 5 nouvelles variantes DomainEvent (AgentRateLimited, ResumeScheduled, ResumeCancelled, Resumed, RateLimitSuspected) via leurs DTO miroirs et bras From dans events.rs, et propage ReplyEvent::RateLimited en chunk côté chat.rs pour que le front soit informé des suspensions/reprises de session. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/chat.rs | 7 ++- crates/app-tauri/src/events.rs | 88 ++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) 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/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); + } } From 9df592389cb3a28939fa246c54cb6d0b92b29f19 Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 17 Jun 2026 07:56:08 +0200 Subject: [PATCH 08/14] =?UTF-8?q?feat(session-limits):=20LS7=20=E2=80=94?= =?UTF-8?q?=20c=C3=A2blage=20backend=20app-tauri=20(taps=20niveaux=201&2?= =?UTF-8?q?=20+=20reprise=20annulable)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branche le SessionLimitService dans l'application Tauri et expose la surface de reprise/annulation au front. - application/agent/lifecycle.rs : LaunchAgentOutput.profile exposé (None sur réattache/idempotent, Some sur lancement effectif). - application/terminal/registry.rs : StructuredSessions::meta_for_session() (lookup agent/node par SessionId pour le tap niveau 1). - app-tauri/state.rs : ResumeContext(s), AppAgentResumer (impl du port AgentResumer au-dessus de LaunchAgent), instanciation + câblage du SessionLimitService (TokioScheduler + drain des réveils) dans AppState::build. - app-tauri/commands.rs : taps niveau 1 (agent_send) et niveau 2 (launch_agent, parser regex confiné), alimentation de resume_contexts, commande cancel_resume. - app-tauri/lib.rs : enregistrement de cancel_resume dans le handler. - app-tauri/Cargo.toml : dépendance async-trait. Tests : session_limit_wiring.rs (2 tests de composition) + meta_for_session dans structured_registry_d1.rs ; fixtures dto_agents/dto_chat ajustées (profile: None). Tout vert. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/Cargo.toml | 2 + crates/app-tauri/src/commands.rs | 88 ++++++++++ crates/app-tauri/src/lib.rs | 1 + crates/app-tauri/src/state.rs | 160 +++++++++++++++++- crates/app-tauri/tests/dto_agents.rs | 2 + crates/app-tauri/tests/dto_chat.rs | 3 + .../app-tauri/tests/session_limit_wiring.rs | 98 +++++++++++ crates/application/src/agent/lifecycle.rs | 21 +++ crates/application/src/terminal/registry.rs | 16 ++ .../tests/structured_registry_d1.rs | 18 ++ 10 files changed, 406 insertions(+), 3 deletions(-) create mode 100644 crates/app-tauri/tests/session_limit_wiring.rs 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/commands.rs b/crates/app-tauri/src/commands.rs index 38c8b90..77c94b7 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,26 @@ 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)) +} + /// `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/lib.rs b/crates/app-tauri/src/lib.rs index 59fd150..939d79d 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -165,6 +165,7 @@ pub fn run() { commands::launch_agent, commands::change_agent_profile, commands::agent_send, + commands::cancel_resume, 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..a4ad52d --- /dev/null +++ b/crates/app-tauri/tests/session_limit_wiring.rs @@ -0,0 +1,98 @@ +//! 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"); +} 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/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/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(); From 4fad0423e76f02ff022fffb0719bed315dc2c03c Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 17 Jun 2026 08:06:56 +0200 Subject: [PATCH 09/14] =?UTF-8?q?feat(session-limits):=20LS7-front=20?= =?UTF-8?q?=E2=80=94=20UI=20limites=20de=20session=20(badge=20+=20compte?= =?UTF-8?q?=20=C3=A0=20rebours=20+=20filet=20humain)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose la surface produit des limites de session côté React/TS, au-dessus du câblage backend (9df5923). - domain/index.ts : 5 variantes ajoutées au union DomainEvent (agentRateLimited / ResumeScheduled / ResumeCancelled / Resumed / RateLimitSuspected). - ports/index.ts : cancelResume(agentId) ajouté à InputGateway. - adapters/input.ts : TauriInputGateway.cancelResume → invoke("cancel_resume"). - adapters/mock/index.ts : MockInputGateway.cancelResume (cancelledResumes / cancelResumeResult). - features/agents/useAgents.ts : état limitByAgent + action cancelResume. - features/agents/AgentLimitBadge.tsx (nouveau) : badge + compte à rebours + bouton Annuler + helpers purs. - features/agents/AgentsPanel.tsx : câblage du badge. Tests : useAgentsLimits.test.tsx (13) + AgentLimitBadge.test.tsx (11), suite agents 63 tests verts, tsc --noEmit propre. Co-Authored-By: Claude Opus 4.8 --- frontend/src/adapters/input.ts | 6 + frontend/src/adapters/mock/index.ts | 13 + frontend/src/domain/index.ts | 49 ++++ .../features/agents/AgentLimitBadge.test.tsx | 149 +++++++++++ .../src/features/agents/AgentLimitBadge.tsx | 114 +++++++++ frontend/src/features/agents/AgentsPanel.tsx | 11 + frontend/src/features/agents/useAgents.ts | 117 ++++++++- .../features/agents/useAgentsLimits.test.tsx | 235 ++++++++++++++++++ frontend/src/ports/index.ts | 9 + 9 files changed, 702 insertions(+), 1 deletion(-) create mode 100644 frontend/src/features/agents/AgentLimitBadge.test.tsx create mode 100644 frontend/src/features/agents/AgentLimitBadge.tsx create mode 100644 frontend/src/features/agents/useAgentsLimits.test.tsx diff --git a/frontend/src/adapters/input.ts b/frontend/src/adapters/input.ts index b25406b..b2c78af 100644 --- a/frontend/src/adapters/input.ts +++ b/frontend/src/adapters/input.ts @@ -38,4 +38,10 @@ 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 }); + } } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index a4c0f70..9ef45d3 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -1559,6 +1559,14 @@ 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[] = []; + /** + * 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 +1583,11 @@ 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; + } } /** In-memory permissions gateway. */ 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..44822b0 --- /dev/null +++ b/frontend/src/features/agents/AgentLimitBadge.test.tsx @@ -0,0 +1,149 @@ +/** + * LS7-front — {@link AgentLimitBadge} presentation + its pure helpers + * (ARCHITECTURE §21). + * + * Two layers: + * - the exported pure helpers `formatCountdown` / `formatResetTime` (edge cases: + * 0, negative, sub-minute, multi-minute, midnight, seconds-stripped); + * - the rendered badge across its three states (limité jusqu'à HH:MM / + * "limité" without a time / "heure inconnue" for suspected without a reset), + * plus the "Annuler la reprise" button wiring when a resume is armed. + */ + +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +import { + AgentLimitBadge, + formatCountdown, + formatResetTime, +} 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("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' note for a suspected limit without a time", () => { + render( + , + ); + expect(screen.getByText("limité")).toBeTruthy(); + expect( + screen.getByText(/heure inconnue — reprise à préciser/), + ).toBeTruthy(); + }); + + it("does NOT show the 'heure inconnue' note 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(); + }); + + 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..f2d8889 --- /dev/null +++ b/frontend/src/features/agents/AgentLimitBadge.tsx @@ -0,0 +1,114 @@ +/** + * `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, cn } from "@/shared"; +import type { AgentLimitState } from "./useAgents"; + +/** 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; + /** Disables the cancel action (e.g. while another request is in flight). */ + busy?: boolean; +} + +export function AgentLimitBadge({ + state, + onCancelResume, + 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]); + + const limitLabel = + limitedUntil !== undefined + ? `limité jusqu'à ${formatResetTime(limitedUntil)}` + : "limité"; + + return ( + + + {limitLabel} + + + {suspected && limitedUntil === undefined && ( + // Human net (level 3, §21.1): the limit is suspected but no reliable time + // is known. IdeA never resumes blind. NOTE: there is no backend command + // yet to persist a user-entered resume time, so we only surface the state + // here. TODO(LS-front): wire an hour-entry form once the backend exposes a + // command to record the chosen resume instant — do NOT fabricate one. + + heure inconnue — reprise à préciser + + )} + + {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..ce3d7da 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)} + /> + )} +
    {/* 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..d60410e 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,14 @@ 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; } function describe(e: unknown): string { @@ -93,7 +131,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 +144,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 +214,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 +426,27 @@ 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], + ); + return { agents, selectedAgentId, @@ -341,6 +454,7 @@ export function useAgents(projectId: string): AgentsViewModel { profiles, liveAgents, delegationSourceByRequester, + limitByAgent, error, busy, runningAgentId, @@ -353,5 +467,6 @@ export function useAgents(projectId: string): AgentsViewModel { deleteAgent, launchAgent, stopAgent, + cancelResume, }; } 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..030d189 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -552,6 +552,15 @@ 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; } /** From c480d2820a6d1f6bb5e91530baff2b8bb06f0ecd Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 17 Jun 2026 08:44:45 +0200 Subject: [PATCH 10/14] =?UTF-8?q?feat(session-limits):=20LS8-backend=20?= =?UTF-8?q?=E2=80=94=20filet=20humain=20niveau=203=20(set=5Fresume=5Fat)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permet à l'humain de confirmer/forcer l'heure de reprise quand le niveau 2 a détecté une limite sans heure exploitable. - application/agent/session_limit.rs : refactor privé arm_scheduled (param resets_at_ms brut) partagé par on_rate_limited + nouvelle confirm_human_resume(agent_id, node_id, conversation_id, resets_at_ms) (source Human, réutilise la branche Scheduled, annulable). - app-tauri/commands.rs : commande set_resume_at(agent_id, resets_at_ms) (résout node_id via node_for_agent + conversation_id best-effort, NOT_FOUND si pas de cellule vivante). - app-tauri/lib.rs : set_resume_at enregistrée après cancel_resume. Réutilise les événements existants (AgentRateLimited + AgentResumeScheduled), aucun nouvel événement. Tests : +6 session_limit_service, +2 wiring, verts. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/commands.rs | 53 +++++ crates/app-tauri/src/lib.rs | 1 + .../app-tauri/tests/session_limit_wiring.rs | 69 ++++++ crates/application/src/agent/session_limit.rs | 88 ++++++-- .../tests/session_limit_service.rs | 201 ++++++++++++++++++ 5 files changed, 394 insertions(+), 18 deletions(-) diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 77c94b7..196f221 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -1386,6 +1386,59 @@ pub async fn cancel_resume( 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/lib.rs b/crates/app-tauri/src/lib.rs index 939d79d..cb61d5e 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -166,6 +166,7 @@ pub fn run() { 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/tests/session_limit_wiring.rs b/crates/app-tauri/tests/session_limit_wiring.rs index a4ad52d..ae1e2d8 100644 --- a/crates/app-tauri/tests/session_limit_wiring.rs +++ b/crates/app-tauri/tests/session_limit_wiring.rs @@ -96,3 +96,72 @@ async fn on_rate_limited_arms_a_cancellable_resume_over_the_real_bus() { } 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/session_limit.rs b/crates/application/src/agent/session_limit.rs index 0cc756f..09e35ea 100644 --- a/crates/application/src/agent/session_limit.rs +++ b/crates/application/src/agent/session_limit.rs @@ -120,24 +120,7 @@ impl SessionLimitService { fire_at_ms, conversation_id, } => { - self.events.publish(DomainEvent::AgentRateLimited { - agent_id, - resets_at_ms, - }); - // Dédoublonnage (§21.10-4) : un signal de rafraîchissement annule - // l'armement précédent (sans événement d'annulation : c'est interne). - self.disarm(agent_id); - let id = self.scheduler.arm( - fire_at_ms, - ScheduledTask::ResumeAgent { - agent_id, - node_id, - conversation_id, - }, - ); - self.armed.lock().expect("session-limit mutex sain").insert(agent_id, id); - self.events - .publish(DomainEvent::AgentResumeScheduled { agent_id, fire_at_ms }); + self.arm_scheduled(agent_id, fire_at_ms, node_id, conversation_id, resets_at_ms); } ResumePlan::HumanFallback => { self.events.publish(DomainEvent::AgentRateLimited { @@ -152,6 +135,75 @@ impl SessionLimitService { } } + /// **(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é), diff --git a/crates/application/tests/session_limit_service.rs b/crates/application/tests/session_limit_service.rs index bb3564d..ef08e1b 100644 --- a/crates/application/tests/session_limit_service.rs +++ b/crates/application/tests/session_limit_service.rs @@ -438,3 +438,204 @@ fn cancel_resume_when_scheduler_already_fired_is_false_no_event() { "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{{..}})" + ); +} From 5d9dd32c29a5bdaf194b44bbea4eae43bb28490f Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 17 Jun 2026 08:44:54 +0200 Subject: [PATCH 11/14] =?UTF-8?q?feat(session-limits):=20LS8-front=20?= =?UTF-8?q?=E2=80=94=20filet=20humain=20niveau=203=20(saisie=20d'heure=20d?= =?UTF-8?q?e=20reprise)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI permettant à l'humain de renseigner l'heure de reprise quand le niveau 2 détecte une limite sans heure exploitable. - ports/index.ts : setResumeAt(agentId, resetsAtMs) sur InputGateway. - adapters/input.ts : setResumeAt → invoke("set_resume_at"). - adapters/mock/index.ts : MockInputGateway.setResumeAt (resumeArmings[]). - features/agents/useAgents.ts : action setResumeAt (sans mutation optimiste). - features/agents/AgentLimitBadge.tsx : formulaire de saisie d'heure sur l'état suspected sans heure + helper pur timeInputToEpochMs (TODO LS7 retiré). - features/agents/AgentsPanel.tsx : câblage onSetResumeAt. Tests AgentLimitBadge.test.tsx mis à jour au nouveau contrat + couverture LS8 ; typecheck propre, suite agents verte. Co-Authored-By: Claude Opus 4.8 --- frontend/src/adapters/input.ts | 6 + frontend/src/adapters/mock/index.ts | 6 + .../features/agents/AgentLimitBadge.test.tsx | 121 +++++++++++++++--- .../src/features/agents/AgentLimitBadge.tsx | 88 +++++++++++-- frontend/src/features/agents/AgentsPanel.tsx | 3 + frontend/src/features/agents/useAgents.ts | 21 +++ frontend/src/ports/index.ts | 9 ++ 7 files changed, 225 insertions(+), 29 deletions(-) diff --git a/frontend/src/adapters/input.ts b/frontend/src/adapters/input.ts index b2c78af..e2ce146 100644 --- a/frontend/src/adapters/input.ts +++ b/frontend/src/adapters/input.ts @@ -44,4 +44,10 @@ export class TauriInputGateway implements InputGateway { // `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 9ef45d3..7c04261 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -1561,6 +1561,8 @@ export class MockInputGateway implements InputGateway { 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 @@ -1588,6 +1590,10 @@ export class MockInputGateway implements InputGateway { 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/features/agents/AgentLimitBadge.test.tsx b/frontend/src/features/agents/AgentLimitBadge.test.tsx index 44822b0..70bb544 100644 --- a/frontend/src/features/agents/AgentLimitBadge.test.tsx +++ b/frontend/src/features/agents/AgentLimitBadge.test.tsx @@ -1,13 +1,16 @@ /** - * LS7-front — {@link AgentLimitBadge} presentation + its pure helpers + * LS7/LS8-front — {@link AgentLimitBadge} presentation + its pure helpers * (ARCHITECTURE §21). * - * Two layers: - * - the exported pure helpers `formatCountdown` / `formatResetTime` (edge cases: - * 0, negative, sub-minute, multi-minute, midnight, seconds-stripped); - * - the rendered badge across its three states (limité jusqu'à HH:MM / - * "limité" without a time / "heure inconnue" for suspected without a reset), - * plus the "Annuler la reprise" button wiring when a resume is armed. + * 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"; @@ -17,6 +20,7 @@ import { AgentLimitBadge, formatCountdown, formatResetTime, + timeInputToEpochMs, } from "./AgentLimitBadge"; describe("formatCountdown", () => { @@ -74,13 +78,48 @@ describe("formatResetTime", () => { }); }); +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(); @@ -89,30 +128,74 @@ describe("AgentLimitBadge (render)", () => { }); it("shows plain 'limité' when no reset time is known", () => { - render(); - expect(screen.getByText("limité")).toBeTruthy(); - }); - - it("shows the 'heure inconnue' note for a suspected limit without a time", () => { render( - , + , ); expect(screen.getByText("limité")).toBeTruthy(); - expect( - screen.getByText(/heure inconnue — reprise à préciser/), - ).toBeTruthy(); }); - it("does NOT show the 'heure inconnue' note when a suspected limit has a time", () => { + 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", () => { @@ -122,6 +205,7 @@ describe("AgentLimitBadge (render)", () => { , ); // Countdown label present (≈ "reprise dans 1m 30s"). @@ -139,6 +223,7 @@ describe("AgentLimitBadge (render)", () => { , ); diff --git a/frontend/src/features/agents/AgentLimitBadge.tsx b/frontend/src/features/agents/AgentLimitBadge.tsx index f2d8889..16e9f52 100644 --- a/frontend/src/features/agents/AgentLimitBadge.tsx +++ b/frontend/src/features/agents/AgentLimitBadge.tsx @@ -13,9 +13,29 @@ import { useEffect, useState } from "react"; -import { Button, cn } from "@/shared"; +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([], { @@ -40,13 +60,19 @@ export interface AgentLimitBadgeProps { state: AgentLimitState; /** Invoked when the user clicks "Annuler la reprise". */ onCancelResume: () => void; - /** Disables the cancel action (e.g. while another request is in flight). */ + /** + * 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; @@ -60,6 +86,24 @@ export function AgentLimitBadge({ 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)}` @@ -82,15 +126,37 @@ export function AgentLimitBadge({ {limitLabel} - {suspected && limitedUntil === undefined && ( - // Human net (level 3, §21.1): the limit is suspected but no reliable time - // is known. IdeA never resumes blind. NOTE: there is no backend command - // yet to persist a user-entered resume time, so we only surface the state - // here. TODO(LS-front): wire an hour-entry form once the backend exposes a - // command to record the chosen resume instant — do NOT fabricate one. - - heure inconnue — reprise à préciser - + {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 && ( diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index ce3d7da..6e0763e 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -381,6 +381,9 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { state={limitState} busy={vm.busy} onCancelResume={() => void vm.cancelResume(a.id)} + onSetResumeAt={(resetsAtMs) => + void vm.setResumeAt(a.id, resetsAtMs) + } /> )} diff --git a/frontend/src/features/agents/useAgents.ts b/frontend/src/features/agents/useAgents.ts index d60410e..4e2a56e 100644 --- a/frontend/src/features/agents/useAgents.ts +++ b/frontend/src/features/agents/useAgents.ts @@ -121,6 +121,13 @@ export interface AgentsViewModel { * 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 { @@ -447,6 +454,19 @@ export function useAgents(projectId: string): AgentsViewModel { [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, @@ -468,5 +488,6 @@ export function useAgents(projectId: string): AgentsViewModel { launchAgent, stopAgent, cancelResume, + setResumeAt, }; } diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 030d189..61a1c88 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -561,6 +561,15 @@ export interface InputGateway { * `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; } /** From 3f3504efa31ac75c4bdc3a19d8fcadbd7e6e5e86 Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 17 Jun 2026 08:45:00 +0200 Subject: [PATCH 12/14] =?UTF-8?q?fix(test):=20corrige=20le=20compteur=20de?= =?UTF-8?q?=20gateways=20du=20mock=20(13=20=E2=86=92=2014)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le test asseyait « thirteen gateways » alors que la gateway permission (présente depuis eca2ba9) en porte le total à 14. Compteur périmé sans rapport avec session-limits, corrigé isolément avant le merge d'intégration. Co-Authored-By: Claude Opus 4.8 --- frontend/src/adapters/mock/mock.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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", From ba56be69513abb91363a269878118798c8a28bde Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 17 Jun 2026 09:13:53 +0200 Subject: [PATCH 13/14] =?UTF-8?q?chore(release):=20passe=20la=20version=20?= =?UTF-8?q?=C3=A0=200.2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump des manifestes (crates Rust + frontend) et des lockfiles vers 0.2.0 en préparation de la release. Ajoute /node_modules à .gitignore (tooling installé à la racine, jamais versionné). Co-Authored-By: Claude Opus 4.8 --- .gitignore | 2 ++ Cargo.lock | 8 ++++---- crates/app-tauri/Cargo.toml | 2 +- crates/app-tauri/tauri.conf.json | 2 +- crates/application/Cargo.toml | 2 +- crates/domain/Cargo.toml | 2 +- crates/infrastructure/Cargo.toml | 2 +- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- 9 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 87e1b36..45a16fe 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ # Dependencies and build output (package-lock.json IS committed). frontend/node_modules/ frontend/dist/ +# Root-level node_modules (dev tooling installs at repo root — never versioned). +/node_modules/ # npm/yarn/pnpm debug logs npm-debug.log* yarn-debug.log* diff --git a/Cargo.lock b/Cargo.lock index c6a6e82..de9d87d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -69,7 +69,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "app-tauri" -version = "0.1.0" +version = "0.2.0" dependencies = [ "application", "async-trait", @@ -88,7 +88,7 @@ dependencies = [ [[package]] name = "application" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", "domain", @@ -880,7 +880,7 @@ dependencies = [ [[package]] name = "domain" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", "serde", @@ -1936,7 +1936,7 @@ dependencies = [ [[package]] name = "infrastructure" -version = "0.1.0" +version = "0.2.0" dependencies = [ "application", "async-trait", diff --git a/crates/app-tauri/Cargo.toml b/crates/app-tauri/Cargo.toml index e3fa15d..95fef9b 100644 --- a/crates/app-tauri/Cargo.toml +++ b/crates/app-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "app-tauri" -version = "0.1.0" +version = "0.2.0" edition.workspace = true license.workspace = true rust-version.workspace = true diff --git a/crates/app-tauri/tauri.conf.json b/crates/app-tauri/tauri.conf.json index 4236c38..d9b1a64 100644 --- a/crates/app-tauri/tauri.conf.json +++ b/crates/app-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "IdeA", - "version": "0.1.0", + "version": "0.2.0", "identifier": "app.idea.ide", "build": { "frontendDist": "../../frontend/dist", diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index da1a968..dcc1cb0 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "application" -version = "0.1.0" +version = "0.2.0" edition.workspace = true license.workspace = true rust-version.workspace = true diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml index 99997c9..79ba88a 100644 --- a/crates/domain/Cargo.toml +++ b/crates/domain/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "domain" -version = "0.1.0" +version = "0.2.0" edition.workspace = true license.workspace = true rust-version.workspace = true diff --git a/crates/infrastructure/Cargo.toml b/crates/infrastructure/Cargo.toml index 85b3c71..b491516 100644 --- a/crates/infrastructure/Cargo.toml +++ b/crates/infrastructure/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "infrastructure" -version = "0.1.0" +version = "0.2.0" edition.workspace = true license.workspace = true rust-version.workspace = true diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7c2cd1c..a22e828 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "idea-frontend", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "idea-frontend", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@tauri-apps/api": "^2.1.1", "@tauri-apps/plugin-dialog": "^2.7.1", diff --git a/frontend/package.json b/frontend/package.json index c730040..64d39fc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "idea-frontend", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "scripts": { From 6969dc798806fd097ab4ecf0372f40223258cc49 Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 17 Jun 2026 09:14:01 +0200 Subject: [PATCH 14/14] =?UTF-8?q?chore(wip):=20=C3=A9tat=20runtime=20.idea?= =?UTF-8?q?i=20(conversations,=20layouts,=20m=C3=A9moire,=20skills)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot de l'état runtime accumulé sur develop : logs/handoffs de conversations, layouts, notes mémoire (dont git-owns-commit-merge-decisions) et catalogue de skills. Co-Authored-By: Claude Opus 4.8 --- .../handoff.md | 34 +++--- .../log.jsonl | 15 +++ .../handoff.md | 8 +- .../log.jsonl | 4 + .../handoff.md | 26 +++++ .../log.jsonl | 21 ++++ .../handoff.md | 13 ++- .../log.jsonl | 6 + .../handoff.md | 40 +++---- .../log.jsonl | 18 +++ .ideai/layouts.json | 104 ++++++++++++++++-- .ideai/memory/MEMORY.md | 1 + .../memory/git-owns-commit-merge-decisions.md | 12 ++ .../memory/session-limit-handling-design.md | 12 ++ .ideai/skills/index.json | 10 ++ .../a72dac60-641c-4417-b0d7-94b8539f817a.md | 32 ++++++ 16 files changed, 302 insertions(+), 54 deletions(-) create mode 100644 .ideai/conversations/6bc594e8-a37c-0dbd-1de6-6e3b73002cb4/handoff.md create mode 100644 .ideai/conversations/6bc594e8-a37c-0dbd-1de6-6e3b73002cb4/log.jsonl create mode 100644 .ideai/memory/git-owns-commit-merge-decisions.md create mode 100644 .ideai/skills/index.json create mode 100644 .ideai/skills/md/a72dac60-641c-4417-b0d7-94b8539f817a.md diff --git a/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/handoff.md b/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/handoff.md index df6fd65..19f10ee 100644 --- a/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/handoff.md +++ b/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/handoff.md @@ -1,26 +1,26 @@ --- -upTo: 26a58843-91dd-4d78-ad39-c8c223b1f92c +upTo: 7c627a21-5a9f-470e-9137-a63697ae87da objective: Tâche : ajouter UN test fonctionnel anti-régression de la communication inter-agent (round-trip question/réponse) au plus haut niveau de fidélité possible SANS lancer de vrai CLI IA (zéro token). Cont --- **Objectif :** Tâche : ajouter UN test fonctionnel anti-régression de la communication inter-agent (round-trip question/réponse) au plus haut niveau de fidélité possible SANS lancer de vrai CLI IA (zéro token). Cont -- **Response:** ✅ LP3-1 (domaine) — TOUS LES TESTS VERTS. 9 tests ajoutés, suite complète `cargo test -p domain` et `-p application` : 0 échec. TESTS AJOUTÉS (par emplacement) 1) crates/domain/src/permission.rs (module `#[cfg(test)] mod tests`) - `projector_key_serialises_to_stable_camel_case` : ProjectorKey::Claude→"\"claude\"", Codex→"\"codex\"" (forme fil stable, point 1). - `projector_key_round_trips` : round-trip serde_json des 2 variantes + désérialisation des littéraux "claude"/"codex" → variante attendue. - `permission_projection_empty_is_fully_empty` : `PermissionProjection::empty()` → files/args/env tous vides (invariant eff==None, point 6). 2) crates/domain/src/profile.rs (module `mcp_tests`) - `profile_default_has_no_projector` : `AgentProfile::new(...)` → projector None. - `profile_without_projector_omits_key_in_json` : skip_serializing_if → clé `projector` ABSENTE quand None (point 3). - `legacy_json_without_projector_deserialises_to_none` : JSON de profil SANS champ `projector` → None (COMPAT, point 2). - `with_projector_sets_and_round_trips_camel_case` : builder pose Some(Claude), sérialise `"projector":"claude"`, round-trip identique (points 2 & 4). 3) crates/application/src/agent/catalogue.rs (module `mcp_tests`) - `claude_and_codex_seed_their_projector_key` : builtin claude→Some(Claude), codex→Some(Codex) (point 5). - `gemini_and_aider_have_no_projector` : Gemini/Aider→None (point 5). Le trait `PermissionProjector` n'est PAS testé directement (pas d'impl concrète avant LP3-2 ; pas de projecteur factice ajouté pour ne pas sur-tester — la forme du contrat est déjà couverte par PermissionProjection::empty()). SORTIE RÉELLE (cargo test, ciblage par nom) test permission::tests::permission_projection_empty_is_fully_empty ... ok test permission::tests::projector_key_round_trips ... ok test permission::tests::projector_key_serialises_to_stable_camel_case ... ok test profile::mcp_tests::legacy_json_without_projector_deserialises_to_none ... ok test profile::mcp_tests::profile_default_has_no_projector ... ok test profile::mcp_tests::profile_without_projector_omits_key_in_json ... ok test profile::mcp_tests::with_projector_sets_and_round_trips_camel_case ... ok test agent::catalogue::mcp_tests::claude_and_codex_seed_their_projector_key ... ok test agent::catalogue::mcp_tests::gemini_and_aider_have_no_projector ... ok Suites globales : `cargo test -p domain` → toutes les cibles `test result: ok. 0 failed` (lib 20+ tests, structured_session_d0 16, window 4, doctests 0). `cargo test -p application` → `0 failed` sur toutes les cibles. Aucune régression. Rien à renvoyer à DevBackend — lot LP3-1 validé. -- **Prompt:** Tests du lot LP3-2 (infrastructure) — `ClaudePermissionProjector` et `CodexPermissionProjector`. DevBackend a extrait la logique de traduction (verbatim) derrière le trait `PermissionProjector`. Build `cargo build -p infrastructure` OK. À toi d'écrire/exécuter les tests. EMPLACEMENT : crates/infrastructure/src/permission/ (mod.rs, claude.rs, codex.rs). Mets les tests en `#[cfg(test)]` dans claude.rs / codex.rs (ou un mod tests dans permission/), cohérent avec le style infra existant. Référence utile : les tests de traduction d'origine étaient dans application/src/agent/lifecycle.rs ~l.2957-3068 — réutilise/relocalise leur intention. CONTRAT testé (rappel) : - `project(None, ctx)` → `PermissionProjection::empty()` (files/args/env vides) pour LES DEUX projecteurs. Cas invariant produit, à couvrir explicitement. - Claude `project(Some(eff), ctx)` → 1 fichier `Replace { rel_path: ".claude/settings.local.json", contents: }`, args/env vides ; `owned_replace_paths() == [".claude/settings.local.json"]`. - Codex `project(Some(eff), ctx)` → 1 fichier `MergeToml { rel_path: ".codex/config.toml", managed_keys: ["sandbox_mode","approval_policy"], contents: toml partiel }` + args `["--sandbox", , "--ask-for-approval", ]` ; `owned_replace_paths() == []`. CAS À COUVRIR : Claude : 1. Posture Allow / Ask / Deny → `defaultMode` attendu dans le JSON (bypassPermissions / acceptEdits / plan). Vérifie le mapping exact tel qu'implémenté. 2. deny-wins : un eff avec deny spécifique + allow large → l'entrée deny apparaît dans la liste deny du JSON. 3. additionalDirectories contient bien `ctx.project_root` (et JSON-escaping correct si le chemin contient un caractère à échapper — teste avec un chemin contenant un backslash/guillemet si pertinent). 4. Garde-fous `merge_default_deny` (sudo/rm -rf/mkfs/dd/shutdown/reboot) présents dans la liste deny. 5. Le JSON produit est un JSON VALIDE (parse-le avec serde_json::Value pour ne pas figer le format au caractère près — préfère des assertions sémantiques sur la valeur parsée plutôt que des comparaisons de chaîne brutes quand c'est possible). Codex : 6. Mapping posture→sandbox_mode : Deny→read-only, Ask/Allow→workspace-write. Et approval_policy : Allow→never, Ask/Deny→on-request. Couvre les 3 postures. 7. Les args reflètent les mêmes valeurs que le TOML (cohérence args ↔ contents). 8. owned_replace_paths vide. Construis les `EffectivePermissions` d'entrée via l'API domaine (resolve / PermissionSet / PermissionRule) comme dans les tests permission existants. Privilégie des assertions sémantiques robustes (JSON parsé, contains sur les listes) plutôt que des golden strings fragiles, sauf si un golden est clairement plus lisible. Exécute `cargo test -p infrastructure`. Réponds via idea_reply : tests ajoutés + emplacement + SORTIE RÉELLE cargo test. Tout échec → rapport d'erreur clair pour DevBackend. -- **Response:** ✅ LP3-2 (infrastructure) — TOUS LES TESTS VERTS. 11 tests ajoutés, `cargo test -p infrastructure` : 0 échec, aucune régression (147 tests lib + 0 fail sur toutes les cibles d'intégration). TESTS AJOUTÉS (par emplacement) A) crates/infrastructure/src/permission/claude.rs (`#[cfg(test)] mod tests`, 8 tests) - `project_none_is_empty` : `project(None, ctx)` → files/args/env vides (invariant produit). - `owned_replace_paths_is_the_settings_file` : == [".claude/settings.local.json"]. - `default_mode_maps_each_posture` (cas 1) : Allow→"bypassPermissions", Ask→"acceptEdits", Deny→"plan" (assertion sur JSON parsé `permissions.defaultMode`). - `specific_deny_with_broad_allow_appears_in_deny_list` (cas 2, deny-wins) : Write Deny `.ideai/**` + Write Allow `**` → la liste `deny` contient `Edit(.ideai/**)` ET `Write(.ideai/**)` (le Write se déploie en Edit+Write), et le `**` reste dans `allow`. - `additional_directories_contains_project_root_escaped` (cas 3) : root = `C:\Users\a"b\proj` (backslash + guillemet) → après parse JSON, `additionalDirectories[0]` == chemin brut verbatim (prouve l'échappement correct). - `default_deny_guardrails_are_present` (cas 4) : sudo / rm -rf / / rm -rf ~ / $HOME* / mkfs* / dd if=* / shutdown* / reboot* présents dans `deny`. - `produced_settings_has_expected_static_shape` (cas 5) : doc parsé via serde_json → `enabledMcpjsonServers[0]=="idea"`, `skipDangerousModePermissionPrompt==true`, `sandbox.enabled==false`. - `empty_rules_fall_back_to_broad_default_allow` : eff sans règle → `allow == [Read,Edit,Write,Bash]`. NB : toutes les assertions Claude passent par serde_json::from_str (pas de golden string fragile) ; le helper `project_json` vérifie au passage la forme du contrat (1 `Replace { rel_path == ".claude/settings.local.json" }`, args/env vides) et que le contenu est un JSON valide. B) crates/infrastructure/src/permission/codex.rs (`#[cfg(test)] mod tests`, 3 tests) - `project_none_is_empty` : `project(None, ctx)` → files/args/env vides (invariant produit). - `owned_replace_paths_is_empty` : == [] (config.toml co-owned, jamais supprimé). - `posture_maps_sandbox_and_approval_in_file_and_args` (cas 6+7+8) : sur les 3 postures Deny → sandbox "read-only", approval "on-request" Ask → sandbox "workspace-write", approval "on-request" Allow → sandbox "workspace-write", approval "never" Vérifie pour chacune : 1 `MergeToml { rel_path == ".codex/config.toml", managed_tables vide, managed_keys == ["sandbox_mode","approval_policy"] }`, le `contents` contient bien `sandbox_mode = ""` et `approval_policy = ""`, ET args == ["--sandbox", , "--ask-for-approval", ] (cohérence args↔contents), env vide. Les `EffectivePermissions` d'entrée sont construits via l'API domaine (`resolve(Some(&PermissionSet::new(rules, fallback)), None)` + `PermissionRule::file`), comme les tests `permission` existants. SORTIE RÉELLE (ciblage par nom, lib) test permission::claude::tests::project_none_is_empty ... ok test permission::claude::tests::owned_replace_paths_is_the_settings_file ... ok test permission::claude::tests::default_mode_maps_each_posture ... ok test permission::claude::tests::specific_deny_with_broad_allow_appears_in_deny_list ... ok test permission::claude::tests::additional_directories_contains_project_root_escaped ... ok test permission::claude::tests::default_deny_guardrails_are_present ... ok test permission::claude::tests::produced_settings_has_expected_static_shape ... ok test permission::claude::tests::empty_rules_fall_back_to_broad_default_allow ... ok test permission::codex::tests::project_none_is_empty ... ok test permission::codex::tests::owned_replace_paths_is_empty ... ok test permission::codex::tests::posture_maps_sandbox_and_approval_in_file_and_args ... ok test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 136 filtered out Suite complète `cargo test -p infrastructure` : lib 147 passed; 0 failed, et toutes les cibles d'intégration `0 failed`. Rien à renvoyer à DevBackend — lot LP3-2 validé. -- **Prompt:** Tests du lot LP3-3 (application) — l'étape `apply_permission_projection` + registre + découplage MCP dans LaunchAgent. DevBackend a câblé et a déjà fait passer `cargo test -p application` (en adaptant les anciens tests). À toi d'AJOUTER les tests qui prouvent le nouveau comportement de bout en bout (niveau application, FS mocké). Contexte API exposée : - `application::PermissionProjectorRegistry` : `new()`, `with(Arc)`, `insert(..)`, `get(key)`. - `LaunchAgent::with_permission_projectors(Arc)` (builder optionnel ; absent ⇒ aucune projection). - Projecteurs concrets dans `infrastructure` : `ClaudePermissionProjector`, `CodexPermissionProjector` (utilise-les pour peupler le registre dans les tests, ou des doubles de test si plus simple — à ton appréciation). - Sélection : `profile.projector` sinon fallback legacy (CLAUDE.md⇒Claude ; StructuredAdapter::Codex / TomlConfigHome⇒Codex). - Étape insérée après apply_injection + apply_mcp_config, avant le split structuré/PTY. Regarde d'abord les tests d'intégration existants de LaunchAgent (crates/application/tests/agent_lifecycle.rs et le FS mock utilisé, `fs.seed_writes()` etc.) pour réutiliser les fixtures/mocks en place. CAS À COUVRIR : 1. Sélection de clé : (a) profil avec `projector=Some(Claude)` → projecteur Claude utilisé ; (b) fallback legacy : profil sans projector mais convention-file CLAUDE.md → Claude ; (c) fallback legacy : profil sans projector mais StructuredAdapter::Codex (ou TomlConfigHome) → Codex ; (d) profil non projetable + pas de fallback → aucune projection. 2. Clobber `Replace` : profil Claude lancé 2× → `.claude/settings.local.json` est RÉÉCRIT (clobber), pas conservé tel quel. Prouve l'inversion vs l'ancien non-clobbering. 3. Merge `MergeToml` (Codex) : un `.codex/config.toml` préexistant contenant une table/clé NON gérée (ex. une entrée user ou mcp_servers) → après projection, sandbox_mode/approval_policy sont upsertées ET la clé non gérée est préservée. Idempotence : 2 projections ne dupliquent pas les clés. 4. Fold args/env : profil Codex → le `spec` final porte les args `--sandbox …/--ask-for-approval …` (vérifie via le mock/spawn capturé), pour le chemin PTY ET structuré si testable. 5. **Découplage MCP (le cas clé du lot)** : profil Codex SANS configuration MCP → la sandbox est tout de même projetée (args + .codex/config.toml). C'est la preuve que la projection ne dépend plus de apply_mcp_config. 6. No-op : (a) registre absent (builder non appelé) → aucun fichier de permission écrit (`fs.seed_writes()` / writes vides) ; (b) eff == None (aucune permission posée pour le projet/agent) → projection vide. 7. eff résolu : permissions posées au niveau projet/agent → bien reflétées dans le fichier projeté (au moins un smoke : posture Deny → mode plan côté Claude). Mets ces tests là où c'est cohérent (tests d'intégration agent_lifecycle.rs ou un nouveau tests/permission_projection.rs). Exécute `cargo test -p application` (et `-p infrastructure` si tu réutilises les projecteurs concrets — vérifie que rien ne casse). Réponds via idea_reply : tests ajoutés + emplacement + SORTIE RÉELLE. Tout échec → rapport clair pour DevBackend (c'est le lot le plus à risque, sois rigoureux sur le découplage MCP et le clobber). -- **Response:** ✅ LP3-3 (application) — TOUS LES TESTS VERTS. 11 tests d'intégration ajoutés, `cargo test -p application` ET `-p infrastructure` : 0 échec, aucune régression. EMPLACEMENT : crates/application/tests/agent_lifecycle.rs (réutilise les fakes en place ; section « LP3-3 — permission projection wiring » en fin de fichier). CHOIX ARCHITECTURAL (important) : l'application ne dépend QUE des ports domaine (cf. description du crate). Je n'ai donc PAS ajouté `infrastructure` en dev-dependency. À la place, deux **doubles fidèles** `FakeClaudeProjector` / `FakeCodexProjector` (impl du trait domaine `PermissionProjector`) reproduisant le mapping réel posture→mode (Allow→bypassPermissions, Ask→acceptEdits, Deny→plan ; Deny→read-only/on-request, Ask→workspace-write/on-request, Allow→workspace-write/never). La fidélité de traduction réelle est déjà couverte par LP3-2 ; ces tests prouvent le CÂBLAGE applicatif (sélection de clé, clobber Replace, merge MergeToml, fold args/env, découplage MCP, no-op). Ajouts utilitaires aux fakes existants : `FakeFs.seed_read` + lecture « last-write-wins » (pour merge/idempotence), `FakePermissionStore`, `full_registry()`, `perm_doc(posture)`, `codex_profile()`, helper `launch_with_projection(...)`. TESTS AJOUTÉS (11) 1) Sélection de clé : - `projection_selects_claude_from_explicit_projector_field` (1a) : projector=Some(Claude) gagne même avec convention GEMINI.md (le champ explicite prime sur l'heuristique) → seed Claude écrit, pas de config Codex. - `projection_falls_back_to_claude_from_convention_file` (1b) : pas de projector + CLAUDE.md → Claude. - `projection_falls_back_to_codex_from_structured_adapter` (1c) : pas de projector + StructuredAdapter::Codex → Codex (config + args --sandbox). - `projection_noop_for_unprojectable_profile` (1d) : GEMINI.md, ni projector ni signal Codex → aucune projection (aucun fichier, aucun arg). 2) `claude_replace_seed_is_clobbered_on_relaunch` : 2 lancements (session retirée entre les deux pour lever la garde singleton), seed pré-marqué existant → 2 écritures sur le MÊME chemin (clobber prouvé, inversion vs régime non-clobber MCP). 3) `codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged` : `.codex/config.toml` préexistant avec `user_key`+table `[mcp_servers.idea]` non gérés → après projection, sandbox_mode/approval_policy upsertés ET user_key+table préservés ; 2e projection → 1 seule occurrence de chaque clé gérée (idempotence), user_key toujours là. 4) `codex_projection_folds_args_into_spawn_spec` : args `--sandbox workspace-write` / `--ask-for-approval on-request` (posture Ask) présents dans l'ordre CLI dans le spec spawné (chemin PTY ; le chemin structuré hérite du même spec car le fold précède le split — pas de factory structurée câblée dans ces fixtures). 5) `codex_sandbox_projected_without_any_mcp_capability` (CAS CLÉ découplage MCP) : profil Codex SANS McpCapability (`profile.mcp.is_none()` vérifié) → la sandbox est tout de même projetée (`.codex/config.toml` écrit, `sandbox_mode="read-only"` posture Deny) + args foldés. Prouve que la projection ne dépend plus de apply_mcp_config. 6) No-op : - `no_registry_means_no_projection` (6a) : registre absent (builder non appelé) → aucun fichier de permission, même avec policy posée. - `no_policy_posed_means_empty_projection` (6b) : `ProjectPermissions::default()` (project_defaults=None) ⇒ resolve_for=None ⇒ projection vide même registre câblé. 7) `resolved_deny_posture_reflected_as_plan_mode` : policy projet Deny → resolve_for → projecteur → JSON parsé `permissions.defaultMode=="plan"` ET `additionalDirectories[0]=="/home/me/proj"` (le project_root a bien transité par le ProjectionContext). SORTIE RÉELLE cargo test -p application --test agent_lifecycle : test result: ok. 59 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (dont les 11 ci-dessus : projection_selects_claude_from_explicit_projector_field, projection_falls_back_to_claude_from_convention_file, projection_falls_back_to_codex_from_structured_adapter, projection_noop_for_unprojectable_profile, claude_replace_seed_is_clobbered_on_relaunch, codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged, codex_projection_folds_args_into_spawn_spec, codex_sandbox_projected_without_any_mcp_capability, no_registry_means_no_projection, no_policy_posed_means_empty_projection, resolved_deny_posture_reflected_as_plan_mode — tous ... ok) cargo test -p application (toutes cibles) : chaque `test result: ok. … 0 failed`. cargo test -p infrastructure (réutilisé indirectement, vérif non-régression) : toutes cibles `0 failed` (lib 147, etc.). Rien à renvoyer à DevBackend — lot LP3-3 validé. Note : le découplage MCP (cas 5) et le clobber (cas 2) sont prouvés explicitement comme demandé. -- **Prompt:** Tests du lot LP3-4 (application) — nettoyage des fichiers de permission orphelins au swap cross-profile dans `ChangeAgentProfile`. DevBackend a câblé l'étape 5b (entre invalidate_engine_link et relaunch). `cargo test -p application` déjà vert ; à toi d'ajouter les tests de swap qui prouvent le nettoyage. EMPLACEMENT : crates/application/tests/ (le fichier des tests ChangeAgentProfile existants ; réutilise les fixtures). Regarde d'abord les tests de swap existants pour réutiliser les fakes. POINT IMPORTANT (signalé par DevBackend) : `FileSystem::remove_file` a une impl PAR DÉFAUT no-op (Ok sans rien supprimer). Pour ASSERTER une suppression, ton fake FileSystem doit OVERRIDE `remove_file` (enregistrer les chemins supprimés, et réellement retirer de son état interne). Ajoute ça au fake utilisé par les tests de swap. Câblage à reproduire dans les tests : injecter le MÊME registre via `ChangeAgentProfile::with_permission_projectors(...)` (et `LaunchAgent::with_permission_projectors(...)` pour la relance). Tu peux réutiliser les doubles fidèles `FakeClaudeProjector`/`FakeCodexProjector` créés en LP3-3 (Claude → owned_replace_paths=[".claude/settings.local.json"] ; Codex → []). SCÉNARIOS À COUVRIR : 1. **Claude→Codex** : avant swap, `.claude/settings.local.json` existe dans le run dir de l'agent. Après swap → ce fichier est SUPPRIMÉ (présent dans les remove enregistrés par le fake / absent de l'état FS), ET la config Codex (`.codex/config.toml` + args) est projetée par la relance. C'est le scénario phare. 2. **Claude→Claude** (changement de profil même famille) : `owned(ancien) − owned(nouveau)` = vide → `.claude/settings.local.json` N'EST PAS supprimé (il est re-clobbé par la relance, pas retiré). 3. **Codex→Claude** : Codex n'a pas de Replace → rien supprimé côté nettoyage ; la relance écrit `.claude/settings.local.json`. (Le `.codex/config.toml` reste, jamais supprimé — vérifie qu'il n'est pas dans les remove.) 4. **No-op** : (a) registre absent (builder non appelé) → aucune suppression ; (b) profil précédent introuvable (supprimé entre-temps) → nettoyage sauté, pas d'échec. 5. **Best-effort** : suppression d'un fichier déjà absent → le swap réussit quand même (pas d'erreur propagée). 6. **NON-RÉGRESSION P8d** : sur un swap, vérifie que l'id de paire (conversation_id stable) / le handoff restent comme avant — réutilise/garde vert le(s) test(s) de swap existant(s) qui couvrent ça (ne les casse pas ; ajoute une assertion si pertinent). Exécute `cargo test -p application`. Réponds via idea_reply : tests ajoutés + emplacement + modifs au fake FileSystem + SORTIE RÉELLE. Tout échec → rapport clair pour DevBackend. Sois rigoureux sur le scénario 1 (Claude→Codex) et le 2 (Claude→Claude ne supprime pas). -- **Response:** ✅ LP3-4 (application) — TOUS LES TESTS VERTS. 7 tests de swap ajoutés, `cargo test -p application` : 0 échec sur les 28 cibles, aucune régression (dont les tests P8d existants restés verts). EMPLACEMENT : crates/application/tests/change_agent_profile.rs (réutilise les fixtures swap en place ; nouvelle section « LP3-4 — cleanup of orphan permission files at a cross-profile swap »). MODIFS AU FAKE FILESYSTEM (comme signalé par DevBackend) - `FakeFsInner` : ajout d'un champ `removed: Vec`. - Override de `remove_file` (l'impl par défaut du port est no-op) : enregistre le chemin dans `removed` ET retire réellement l'entrée de `files` → une suppression est assertable, et l'idempotence (remove d'un absent) reste Ok. - Accesseurs ajoutés : `removed()` (liste ordonnée des chemins supprimés), `has_file(path)` (présence dans l'état). + `FakePty::last_spawn_args()` pour asserter les args foldés par la relance. CÂBLAGE TEST (fidèle au prod) : nouvelle fixture `fixture_with_projection(agent, profiles, registry, perm_doc)` qui injecte le MÊME `full_registry()` via `ChangeAgentProfile::with_permission_projectors(...)` ET `LaunchAgent::with_permission_projectors(...)`, plus `LaunchAgent::with_permission_store(Allow)` pour que la projection de la relance soit non-vide. Doubles fidèles `FakeClaudeProjector` (owned_replace_paths=[".claude/settings.local.json"]) / `FakeCodexProjector` (owned=[], MergeToml `.codex/config.toml` + args --sandbox/--ask-for-approval) — application gardée sans dépendance à infrastructure. TESTS AJOUTÉS (7) 1) `swap_claude_to_codex_removes_claude_seed_and_projects_codex` (PHARE) : `.claude/settings.local.json` pré-existant dans le run dir stable → après swap : présent dans `removed()` ET absent de l'état FS ; la relance projette `.codex/config.toml` (sandbox_mode=workspace-write) + args `--sandbox` dans le spawn. 2) `swap_claude_to_claude_does_not_remove_seed` : owned(old)−owned(new)=∅ → le seed n'est PAS supprimé (absent de `removed()`) et reste présent (re-clobbé par la relance, jamais retiré). 3) `swap_codex_to_claude_removes_nothing_and_keeps_codex_config` : Codex n'a pas de Replace → `removed()` vide ; `.codex/config.toml` pré-existant toujours présent et JAMAIS dans `removed()` ; la relance écrit le seed Claude. 4a) `swap_without_registry_removes_nothing` : fixture par défaut SANS registre sur le swap → aucune suppression même sur Claude→Codex avec seed présent. 4b) `swap_with_unknown_previous_profile_skips_cleanup` : l'agent porte pid(1) mais le store ne connaît que pid(2)/pid(3) (ancien profil supprimé) → cleanup sauté, `removed()` vide, swap réussit sans erreur. 5) `swap_claude_to_codex_succeeds_when_seed_absent` : seed NON semé → la suppression est tout de même tentée (best-effort idempotent : présente dans `removed()`) et le swap réussit. 6) `swap_with_cleanup_preserves_pair_id_and_handoff` (NON-RÉGRESSION P8d) : swap Claude→Codex live avec cleanup qui fire ET handoff semé sous l'id de paire du leaf → après swap : seed supprimé, l'id de paire (conversation_id) est PRÉSERVÉ sur le leaf persisté, agent_was_running remis à false, et le handoff est ré-injecté dans le convention file de la relance (preuve que le cleanup ne perturbe pas l'id de paire/handoff). Les tests P8d préexistants (`live_swap_relaunches_with_preserved_pair_id_and_no_engine_resume`, `swap_preserves_uuid_pair_id_and_clears_engine_cache`, etc.) restent verts, inchangés. POINT D'AJUSTEMENT (pas un bug) : le `FakeRuntime` partagé de ce fichier matérialise toujours le convention file en `CLAUDE.md` quel que soit le profil ; le test 6 lit donc `CLAUDE.md` (le contenu/handoff est ce qui importe, pas le nom de fichier figé par le fake). SORTIE RÉELLE cargo test -p application --test change_agent_profile : test result: ok. 19 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (dont : swap_claude_to_codex_removes_claude_seed_and_projects_codex, swap_claude_to_claude_does_not_remove_seed, swap_codex_to_claude_removes_nothing_and_keeps_codex_config, swap_without_registry_removes_nothing, swap_with_unknown_previous_profile_skips_cleanup, swap_claude_to_codex_succeeds_when_seed_absent, swap_with_cleanup_preserves_pair_id_and_handoff — tous ok) cargo test -p application (toutes cibles) : 28× `test result: ok. … 0 failed`, aucune régression. Aucun warning sur les deux fichiers de test touchés. Rien à renvoyer à DevBackend — lot LP3-4 validé. Scénarios 1 (Claude→Codex supprime + projette) et 2 (Claude→Claude ne supprime pas) prouvés explicitement. -- **Prompt:** Ping de diagnostic. Calcule 9 + 6 et réponds-moi uniquement avec le résultat via idea_reply. -- **Prompt:** [Test de communication inter-agent] Ping. Réponds simplement via idea_reply avec « pong QA OK » pour confirmer que le canal fonctionne. -- **Response:** pong QA OK -- **Prompt:** [LP4-0 — suite de tests unitaires domaine] DevBackend a livré le sous-lot LP4-0 (domaine pur) sur le chantier permissions/enforcement OS. Écris et exécute une suite de tests unitaires exhaustive, puis relaie le résultat réel (vert/rouge avec sortie). Cible : `crates/domain/src/sandbox.rs` (fn pure `compile_sandbox_plan`) et `crates/domain/src/permission.rs` (fn pure `render_permission_summary`). Tout est pur, zéro I/O, pas de fake nécessaire. Invariants à couvrir impérativement : 1. `compile_sandbox_plan(None, ctx) == None` (rien posé ⇒ natif). Et `Some(eff)` avec une politique posée mais `allowed` vide ⇒ `Some` (pas `None`) : présence de politique ≠ grants non vides. 2. Mapping capabilities : Read→RO, Write→RW, Delete→RW. `ExecuteBash` n'est JAMAIS traduit en grant (borne Landlock = fichiers only). Vérifie qu'une politique purement Bash ne produit aucun PathGrant. 3. Fail-closed (le cœur du lot) : un glob Allow est réduit à son préfixe statique (chemin littéral avant le 1er métacaractère `*`/`?`/`[`). Le grant est ABANDONNÉ si un Deny a un préfixe statique qui chevauche (égal, ancêtre OU descendant). Cas clés à tester : Allow `**` + un seul Deny fichier ⇒ aucun grant racine ; Allow `src/**` + Deny `src/secret/**` ⇒ grant `src` abandonné (descendant) ; Allow `src/**` + Deny `other/**` ⇒ grant `src` conservé (disjoint). Teste aussi l'union d'accès (RO+RW) sur une même racine. 4. `default_posture` du plan reflète bien la posture résiduelle résolue. 5. `render_permission_summary(None) == None` ; et pour `Some`, le bloc Markdown DOIT mentionner explicitement que les fichiers sont OS-enforced/Landlock when supported ET que les commandes (ExecuteBash) restent advisory / NOT OS-locked (assertions sur le contenu). Place les tests là où c'est cohérent avec le style existant du crate domain (tests inline `#[cfg(test)] mod tests` dans sandbox.rs, et dans permission.rs pour le résumé). Exécute `cargo test -p domain` et confirme le workspace reste vert. Réponds via idea_reply avec : nombre de tests ajoutés, sortie de `cargo test -p domain` (résumé pass/fail), et tout écart constaté entre le comportement réel et les invariants ci-dessus (si un invariant n'est pas respecté, rapport d'erreur précis pour DevBackend). -- **Response:** LP4-0 — Suite de tests unitaires domaine : VERT. Aucun écart constaté, tous les invariants sont respectés. ## Tests ajoutés : 17 - **sandbox.rs** (14 tests, nouveau `#[cfg(test)] mod tests`) : - `none_eff_yields_no_plan` (inv.1 : None ⇒ None) - `policy_with_no_allow_still_yields_some_plan` (inv.1 : politique posée + allowed vide ⇒ Some, testé fallback-seul ET deny-seul) - `read_maps_to_ro`, `write_and_delete_map_to_rw` (inv.2 : Read→RO, Write/Delete→RW) - `bash_only_policy_produces_no_path_grant` (inv.2 : ExecuteBash jamais traduit, 0 PathGrant) - `root_glob_with_single_file_deny_drops_root_grant` (inv.3 : Allow `**` + 1 Deny fichier ⇒ aucun grant racine) - `descendant_deny_drops_overlapping_allow` (inv.3 : Allow `src/**` + Deny `src/secret/**` ⇒ `src` abandonné) - `disjoint_deny_keeps_allow` (inv.3 : Allow `src/**` + Deny `other/**` ⇒ `src` conservé) - `ancestor_deny_also_drops_allow` (inv.3 : symétrie, deny ancêtre abandonne aussi) - `sibling_prefix_is_not_a_descendant` (inv.3 : `src2` ≠ descendant de `src`, conscience des bornes de composant) - `accesses_union_on_a_shared_root` (inv.3 : RO+RW fusionnés sur 1 grant) - `static_prefix_of_literal_file_is_the_file_itself` (préfixe statique d'un littéral) - `default_posture_mirrors_resolved_fallback` (inv.4 : Allow/Ask/Deny) - `trailing_slash_on_project_root_is_normalised` (pas de `//`) - **permission.rs** (3 tests, ajoutés au mod tests existant) : - `summary_is_none_when_nothing_posed` (inv.5 : None ⇒ None) - `summary_states_files_os_enforced_and_commands_advisory` (inv.5 : assertions contenu — "OS-enforced", "Landlock", "advisory", "NOT OS-locked", "ExecuteBash", scope + matcher + posture affichés) - `summary_handles_empty_rule_lists_per_section` (sections vides honnêtes + boundary) ## `cargo test -p domain` (résumé) - lib : **139 passed; 0 failed** (dont les 17 ci-dessus, vérifiés par nom) - tests d'intégration + doc-tests : tous OK, 0 failed - Tous les binaires : `0 failed` ## Workspace `cargo test --workspace` : tout vert, **0 failed**, aucune erreur de compilation, aucun warning. Non-régression confirmée. Note pour DevBackend (observation, pas un bug) : dans `compile_sandbox_plan`, les Deny servant de barrières (`deny_roots`) sont collectés indépendamment de la capability — un Deny `Write` clôture donc aussi un Allow `Read` sur la même racine. C'est conforme à l'esprit fail-closed (sous-approximation conservatrice) et bien couvert par les tests ; je le signale uniquement au cas où la sémantique voulue serait par-capability. Si c'est intentionnel (probable vu la doc), rien à faire. -- **Prompt:** [LP4-0 — réécriture des tests après affinage sémantique « barrières Deny par classe d'accès »] Décision d'orchestration appliquée par DevBackend : dans `compile_sandbox_plan` (`crates/domain/src/sandbox.rs`), les barrières Deny sont désormais PAR CLASSE D'ACCÈS — RO (alimentée par Allow Read, fermée seulement par Deny Read) et RW (alimentée par Allow Write+Delete, fermée seulement par Deny Write+Delete). Un Deny d'une autre classe n'a aucun effet. ExecuteBash : toujours ignoré. DevBackend a marqué 3 de tes tests `#[ignore = "OBSOLETE … 2db80d41 … QA to rewrite"]` car ils encodaient l'ancienne sémantique capability-blind (un Deny Write faisait tomber un Allow Read). À réécrire pour qu'ils soient probants sous la nouvelle sémantique, puis retirer le `#[ignore]` : 1. `root_glob_with_single_file_deny_drops_root_grant` → utilise une barrière de MÊME classe : `Allow Read **` + `Deny Read ` ⇒ le grant racine RO doit être abandonné (fail-closed intra-classe). Garde l'esprit « un deny intra-classe fait tomber la racine ». 2. `descendant_deny_drops_overlapping_allow` → `Allow Read src/**` + `Deny Read src/secret/**` ⇒ grant `src` (RO) abandonné. ET ajoute l'assertion DUALE qui protège la décision produit : `Allow Read src/**` + `Deny Write src/secret/**` (classe différente, aucun Deny Read) ⇒ grant `src` CONSERVE le RO. C'est le cas clé d'autonomie à verrouiller. 3. `ancestor_deny_also_drops_allow` → `Allow Read src/sub/**` + `Deny Read src/**` ⇒ grant abandonné (barrière ancêtre même classe). Renforce aussi les 2 tests « affaiblis » signalés (`disjoint_deny_keeps_allow`, `sibling_prefix_is_not_a_descendant`) en utilisant une barrière de MÊME classe que l'allow, sinon ils prouvent la disjointness pour une mauvaise raison. Ajoute idéalement un test croisé explicite : sur une même racine, `Allow Read` + `Allow Write` + `Deny Write` même sous-arbre ⇒ le grant survit en RO seul (RW tombé, RO conservé) — la preuve directe de la granularité par classe. Exécute `cargo test -p domain` puis `cargo test --workspace`. Confirme 0 ignored restant sur sandbox.rs et 0 failed. Réponds via idea_reply avec : tests réécrits/ajoutés, sortie pass/fail, et confirmation qu'aucun `#[ignore]` LP4-0 ne subsiste. -- **Response:** LP4-0 — Réécriture sous sémantique « barrières Deny par classe d'accès » : VERT. Aucun `#[ignore]` LP4-0 ne subsiste. ## Tests réécrits (3 ex-ignorés, désormais probants en barrière MÊME classe) 1. `root_glob_with_single_file_deny_drops_root_grant` → renommé `root_glob_with_same_class_deny_drops_root_grant` : `Allow Read **` + `Deny Read secret.txt` ⇒ grant racine RO abandonné (fail-closed intra-classe RO). 2. `descendant_deny_drops_overlapping_allow` → renommé `descendant_same_class_deny_drops_overlapping_allow` : `Allow Read src/**` + `Deny Read src/secret/**` ⇒ grant `src` (RO) abandonné. 3. `ancestor_deny_also_drops_allow` → renommé `ancestor_same_class_deny_also_drops_allow` : `Allow Read src/sub/**` + `Deny Read src/**` ⇒ grant abandonné (barrière ancêtre même classe). ## Tests renforcés (barrière de MÊME classe que l'allow, sinon disjointness prouvée pour une mauvaise raison) - `disjoint_deny_keeps_allow` → `disjoint_same_class_deny_keeps_allow` : `Allow Read src/**` + `Deny Read other/**` ⇒ `src` conservé en RO. - `sibling_prefix_is_not_a_descendant` : passe la barrière en `Deny Read src/**` (vs Allow Read `src2/**`) ⇒ `src2` conservé par conscience des bornes de composant, pas par mismatch de classe. ## Tests ajoutés (verrous de la décision produit / granularité par classe) - `other_class_deny_does_not_amputate_read_allow` (assertion DUALE clé d'autonomie) : `Allow Read src/**` + `Deny Write src/secret/**` (classe différente) ⇒ `src` CONSERVE le RO. - `same_root_drops_rw_but_keeps_ro_under_a_write_deny` (preuve directe de la granularité par classe) : `Allow Read src/**` + `Allow Write src/**` + `Deny Write src/secret/**` ⇒ grant `src` survit en RO seul (RW tombé, RO conservé). ExecuteBash : toujours couvert par `bash_only_policy_produces_no_path_grant` (0 PathGrant). Invariants 1/2/4 et bords (sibling, préfixe statique, trailing slash, union RO|RW) inchangés et verts. ## Sortie pass/fail - `cargo test -p domain` (lib) : **141 passed; 0 failed; 0 ignored**. Bloc sandbox : **16 tests sandbox::tests, tous ok** (liste vérifiée par nom). Intégration + doc-tests : 0 failed. - `cargo test --workspace` : tout vert — 80 lignes « test result », **toutes 0 failed**, aucune erreur de compilation. - `grep -rn "#[ignore]"` sur `crates/domain/src/` : **aucun attribut `#[ignore]`** (seules des occurrences du mot « ignore » en commentaires subsistent). **0 ignored restant sur sandbox.rs confirmé.** -- **Prompt:** [LP4-1 — passe de tests d'intégration de l'enforcement Landlock] DevBackend a livré LP4-1 : `crates/infrastructure/src/sandbox/{mod,landlock,noop}.rs`. `LandlockSandbox` (Linux) applique un `SandboxPlan` en restreignant le thread courant (mécanisme : thread jetable + héritage du domaine Landlock au fork/exec, car portable-pty n'expose pas de pre_exec). Son test `landlock_write_only_plan_fences_writes_to_the_grant` passe déjà réellement sur ce kernel. Renforce/complète la couverture d'intégration (gated `#[cfg(target_os="linux")]` + garde runtime : skip propre si Landlock indisponible, comme les tests SSH/WSL). Couvre impérativement : 1. **PROPRIÉTÉ DE SÛRETÉ CRITIQUE — IdeA n'est jamais sandboxé.** Après un `enforce()` exécuté sur un thread jetable (comme le fait l'adapter), le THREAD principal/process de test (qui simule IdeA) doit toujours pouvoir écrire/lire HORS des racines du plan. Prouve que la restriction est confinée au thread jetable et n'a pas fui sur le process IdeA. C'est le garde-fou n°1 (un Landlock posé sur IdeA serait catastrophique). 2. **Dimension READ (RO).** Un plan avec un grant RO sur une racine donnée : depuis l'enfant restreint, lire un fichier SOUS la racine accordée ⇒ OK ; lire un fichier HORS racine ⇒ refusé (EACCES). Complète la dimension write déjà testée. (Note remontée par DevBackend, à confirmer par le test : dès qu'un grant RO est posé, TOUTES les lectures hors racines deviennent fermées — donc un plan read-restreint réaliste devra inclure les chemins système ; c'est un concern LP4-2, mais documente le comportement observé par un test.) 3. **Plan vide / bash-only** ⇒ `Enforced`, aucune restriction effective, pas d'erreur. 4. **NoopSandbox** ⇒ `kind()==Unsupported`, `enforce` (y compris posture Deny) ⇒ `Ok(Unsupported)`, jamais Err. 5. **Fail-closed posture Deny** : si tu peux simuler/forcer un cas où Landlock ne s'applique pas (sinon documente pourquoi non testable sur ce kernel), vérifie que posture `Deny` ⇒ `Err(KernelTooOld)` alors que posture non-Deny ⇒ `Ok(Unsupported)`. Exécute `cargo test -p infrastructure` puis `cargo test --workspace`. Réponds via idea_reply avec : tests ajoutés, lesquels ont réellement tourné vs skippés sur ce kernel, sortie pass/fail, et tout écart de comportement (notamment si la propriété de sûreté n°1 n'est PAS vérifiée — rapport d'erreur immédiat pour DevBackend, c'est bloquant). - **Response:** LP4-1 — Passe de tests d'intégration de l'enforcement Landlock : VERT. La propriété de sûreté n°1 (IdeA jamais sandboxé) est VÉRIFIÉE. Aucun écart. ## Tests ajoutés (4) - `sandbox::landlock::tests::enforcement_is_confined_to_the_enforcing_thread_idea_is_never_sandboxed` — **PROPRIÉTÉ DE SÛRETÉ n°1**. enforce() lancé sur un thread jetable (comme l'adapter), grant RW sur `granted`. On prouve d'abord que le thread jetable EST sandboxé (write hors racine = PermissionDenied), PUIS que le thread parent (≈ process IdeA) écrit ET lit toujours HORS racines ⇒ le domaine Landlock ne fuit pas sur IdeA. - `sandbox::landlock::tests::read_only_plan_fences_reads_to_the_grant` — **dimension READ (RO)**. Fichiers pré-créés avant enforce. Grant RO sur `granted` : lecture SOUS la racine = OK ; lecture HORS = PermissionDenied (EACCES). Confirme et documente le comportement signalé par DevBackend : dès qu'un grant RO est posé, la classe read est *handled* ⇒ TOUTES les lectures hors racines sont fermées (un plan read-restreint réaliste devra inclure les chemins système — concern LP4-2). - `sandbox::landlock::tests::empty_plan_under_deny_posture_is_enforced_without_restriction` — **plan vide / bash-only sous posture Deny**. Un plan `allowed` vide (= bash-only après compile LP4-0) avec `Posture::Deny` ⇒ `Enforced` sans aucune restriction et SANS `KernelTooOld` (la branche fail-closed n'est atteinte que si une classe a réellement été *handled*). - `sandbox::tests::noop_enforcer_is_unsupported_and_never_errors` (renforcé) — **NoopSandbox** : `kind()==Unsupported` ; `enforce` ⇒ `Ok(Unsupported)` jamais Err, désormais bouclé sur les 3 postures (Allow/Ask/Deny) ET avec un grant présent. (Existants conservés : `landlock_write_only_plan_fences_writes_to_the_grant`, `empty_plan_is_a_noop_enforced`, `default_enforcer_matches_the_build_target`.) ## Réellement tourné vs skippé sur ce kernel - **Tout a réellement tourné — 0 skip.** Aucune ligne « skipping: Landlock not available » émise (vérifié avec `--nocapture | grep -i skip` ⇒ « no tests skipped »). Les gardes runtime de skip sont en place (comme SSH/WSL) mais inutiles ici : ce kernel supporte Landlock, l'enforcement est réel (FullyEnforced) — write hors-grant et read hors-grant renvoient bien PermissionDenied. ## Invariant 5 (fail-closed posture Deny) — NON testable sur ce kernel, documenté - La branche `Err(KernelTooOld)` n'est atteinte que si `restrict_self()` renvoie `RulesetStatus::NotEnforced` ET posture==Deny. Sur un kernel Landlock-capable, `restrict_self` renvoie toujours FullyEnforced/PartiallyEnforced, jamais NotEnforced. Impossible de forcer NotEnforced sans injecter/mocker le crate `landlock` (l'adapter ne prend pas de dépendance injectable pour le statut). Le pendant « pas d'OS sandbox + non-Deny ⇒ Ok(Unsupported) » est, lui, couvert par NoopSandbox. Recommandation (non bloquante) pour DevBackend si on veut tester cette branche : extraire le mapping `(RulesetStatus, Posture) -> Result` en fn pure et la tester en table. ## Sortie pass/fail - `cargo test -p infrastructure` (lib) : **154 passed; 0 failed; 0 ignored**. Bloc sandbox : 8 tests, tous ok (write-only, read-only, safety-confinement, empty, empty+Deny, noop×3-postures, default_enforcer). - `cargo test -p infrastructure` (tous binaires + doc-tests) : **0 failed**. - `cargo test --workspace` : tout vert, **0 failed**, aucune erreur de compilation. Aucune violation de la propriété de sûreté n°1. Rien de bloquant à remonter. - **Prompt:** Test bout-en-bout de l'enforcement OS (lot LP4-3, sandbox Landlock), à ajouter et faire passer. Contexte : la chaîne policy → plan → PTY est désormais câblée. DevBackend vient de livrer (non committé) : - composition root : `PortablePtyAdapter::new().with_sandbox_enforcer(infrastructure::default_enforcer())` - launch path (`crates/application/src/agent/lifecycle.rs`, étape 5d) : `spec.sandbox = compile_sandbox_plan(effective_permissions.as_ref(), &SandboxContext{ project_root, run_dir })`. - l'enforcement effectif vit dans `PortablePtyAdapter::spawn` (chemin PTY brut), via `spawn_command_sandboxed` (thread restreint par Landlock puis fork). Objectif : UN test d'intégration de haute fidélité qui prouve que l'enforcement est réellement actif bout-en-bout, SANS lancer de vrai CLI IA (zéro token). Piste recommandée (la plus fidèle, déjà éprouvée par les tests Landlock existants dans `crates/infrastructure/src/sandbox/landlock.rs`) : passer par `PortablePtyAdapter` réel avec un enforcer Landlock, un `SpawnSpec` dont `sandbox = Some(plan)` où le plan autorise un répertoire et un `Deny` effectif sur un autre, et une commande shell inoffensive (`sh -c 'echo x > /chemin/interdit'`) ; asserter que l'écriture échoue (fichier absent / exit non nul) alors qu'une écriture dans le chemin autorisé réussit. Skippe proprement (`#[cfg(target_os="linux")]` + détection de l'ABI Landlock comme le font les tests existants) si le kernel ne supporte pas Landlock, pour ne pas rendre la CI rouge sur un runner sans Landlock. Réutilise au maximum les helpers/patterns des tests Landlock existants (regarde `sandbox/landlock.rs` tests : read_only_plan_fences_reads_to_the_grant, landlock_write_only_plan_fences_writes_to_the_grant, enforcement_is_confined_to_the_enforcing_thread). Le test doit cibler le chemin PTY (pas structuré). À la fin : exécute le test et donne la sortie réelle. NE committe PAS (figé par l'orchestrateur). Rends via idea_reply : le fichier de test ajouté, ce qu'il prouve, la stratégie de skip, et la sortie brute d'exécution. Si ça échoue, rapport d'erreur clair avec la sortie — on renverra à DevBackend. - **Response:** ✅ VERT — test bout-en-bout de l'enforcement OS (lot LP4-3) ajouté et passant, enforcement Landlock réellement actif sur le chemin PTY (pas skippé sur ce kernel). ## Fichier modifié `crates/infrastructure/src/pty/mod.rs` — nouveau module de test `#[cfg(all(test, target_os = "linux"))] mod sandbox_e2e_tests` (en plus du module `tests` existant, intact). 2 tests + helpers. ## Ce que ça prouve Test principal **`pty_spawn_enforces_sandbox_plan_end_to_end`** — exerce la chaîne réelle complète, ZÉRO CLI IA / ZÉRO token : `PortablePtyAdapter::new().with_sandbox_enforcer(default_enforcer())` (= LandlockSandbox) → `SpawnSpec { sandbox: Some(plan) }` avec `plan` = `PathGrant{ allowed RW }` seul, posture `Ask` → `adapter.spawn(...)` → branche `(Some(plan), Some(enforcer))` → `spawn_command_sandboxed` (thread restreint par Landlock puis fork/exec). - Commande inoffensive : `sh -c "echo outside > /out.txt; echo inside > /in.txt"`. - **Ordre volontaire** : l'écriture HORS-grant d'abord, l'écriture DANS le grant ensuite. L'apparition du marqueur `allowed/in.txt` est donc un signal *happens-after* prouvant que la tentative hors-grant a déjà eu lieu → assertion déterministe, pas de course avec l'enfant. - Asserts : (1) `allowed/in.txt` finit par exister (écriture autorisée passe ; reads non gouvernés car seul RW posé → `sh`/libc chargent normalement) ; (2) `denied/out.txt` **n'existe PAS** → l'écriture hors-grant a été bloquée par le kernel = « SANDBOX BREACH » sinon. Test compagnon **`pty_spawn_without_plan_does_not_sandbox`** — même adapter+enforcer mais `sandbox: None` → l'écriture dans le même dossier réussit. Garde-fou anti faux-positif : prouve que le blocage du 1er test vient bien du *plan enforcé*, pas d'une restriction PTY ambiante ou d'un échec d'écriture sans rapport. ## Stratégie de skip (CI/kernel sans Landlock) - Tout le module est `#[cfg(target_os = "linux")]` (pas de compilation hors Linux). - Helper `landlock_is_enforced()` qui sonde l'ABI exactement comme les tests adapter existants : `LandlockSandbox::enforce(plan RW)` sur un **thread jetable** (restriction irréversible → jamais sur le thread de test), skip si `SandboxStatus::Unsupported`. Le test principal `return` proprement avec message `eprintln!` si non enforcé → CI verte sur runner sans Landlock LSM. - Helpers réutilisés/calqués sur `sandbox/landlock.rs` : `fresh_dir(tag)` (temp dir unique, zéro dép tempfile), enforce sur thread jetable, posture `Ask`. Attente bornée via `wait_for(path, 5s)`. ## Sortie brute d'exécution ``` $ cargo test -p infrastructure --lib sandbox_e2e_tests -- --nocapture --test-threads=1 Finished `test` profile [unoptimized + debuginfo] target(s) in 1.53s Running unittests src/lib.rs (target/debug/deps/infrastructure-2cc30d2a1793b4e4) running 2 tests test pty::sandbox_e2e_tests::pty_spawn_enforces_sandbox_plan_end_to_end ... ok test pty::sandbox_e2e_tests::pty_spawn_without_plan_does_not_sandbox ... ok test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 154 filtered out; finished in 0.05s ``` Preuve que l'enforcement a **réellement** tourné (et non été skippé) sur ce kernel — les tests adapter Landlock existants ne logguent AUCUN « skipping » : ``` $ cargo test -p infrastructure --lib sandbox::landlock -- --nocapture test sandbox::landlock::tests::read_only_plan_fences_reads_to_the_grant ... ok test sandbox::landlock::tests::landlock_write_only_plan_fences_writes_to_the_grant ... ok test sandbox::landlock::tests::enforcement_is_confined_to_the_enforcing_thread_idea_is_never_sandboxed ... ok test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 151 filtered out; finished in 0.00s ``` Pas de commit (figé par l'orchestrateur, comme demandé). Aucune régression introduite : ajout de tests uniquement, module `tests` PTY existant inchangé. - **Prompt:** Tests bout-en-bout du lot LP4-4 — enforcement Landlock sur le chemin STRUCTURÉ (sessions Claude/Codex mode JSON). DevBackend a livré (non committé), build + suites existantes vertes. L'Architecte a validé l'approche et défini 7 invariants à couvrir. Objectif : prouver l'enforcement réellement actif bout-en-bout sur le chemin structuré, ZÉRO token (aucun vrai claude/codex — utilise un fake CLI / `sh` qui émet une ligne JSONL). Chaîne réelle livrée : `StructuredSessionFactory::new().with_sandbox_enforcer(default_enforcer())` (composition root) ; `AgentSessionFactory::start(.., sandbox: Option<&SandboxPlan>)` apparie plan (par-appel) + enforcer (par-instance) ; `SpawnLine.sandbox: Option` ; `run_turn(spec, timeout, enforcer)` route vers `run_turn_sandboxed`/`drain_sandboxed` (`#[cfg(target_os="linux")]`, thread jetable restreint par enforce() AVANT le spawn std, puis std::process::Command::spawn depuis ce thread → héritage Landlock ; timeout via oneshot killer + tokio::time::timeout ; fail-closed sur Err d'enforce). Pas de pre_exec (forbid(unsafe_code) ; héritage credentials garanti par le noyau). Réutilise les patterns/helpers des tests Landlock existants (`crates/infrastructure/src/sandbox/landlock.rs` tests + le module `sandbox_e2e_tests` ajouté dans `pty/mod.rs` au lot LP4-3) : `landlock_is_enforced()` (skip propre kernel sans Landlock), enforce sur thread jetable, `fresh_dir`, attente bornée. LES 7 INVARIANTS À COUVRIR (de l'Architecte) : 1. PARITÉ (test pivot) : fake CLI/`sh` qui émet une ligne JSONL et tente d'écrire HORS grant (doit être bloqué kernel) et DANS grant (doit réussir). Cible le chemin structuré (run_turn_sandboxed via la factory réelle). 2. COMPANION NÉGATIF : même factory+enforcer mais sandbox==None ⇒ écriture hors-grant RÉUSSIT (prouve que le blocage vient du plan, pas d'une restriction ambiante). 3. FAIL-CLOSED : posture Deny sur kernel sans Landlock ⇒ enforce Err ⇒ run_turn renvoie erreur (Start) et AUCUN child ne tourne (marqueur de sortie absent). 4. NO-OP PAR DÉFAUT : eff==None ⇒ plan None ⇒ chemin async tokio actuel, comportement natif (vérifie que conformance/D0/D3 restent verts sans changement de comportement). 5. CONFINEMENT IRRÉVERSIBILITÉ : deux tours successifs (sessions différentes) avec grants DISJOINTS ⇒ chacun voit son propre périmètre, le tour précédent n'affecte pas le suivant (thread jetable mort). 6. TIMEOUT SOUS SANDBOX : fake CLI qui ne ferme jamais stdout ⇒ run_turn(timeout) tue le child et renvoie Timeout, sans zombie ni thread bloqué. 7. RESUME PRÉSERVÉ : le conversation_id/session_id est toujours capté correctement après un tour sandboxé (la restriction FS ne casse pas le parsing). Tout derrière `#[cfg(target_os="linux")]` + skip via `landlock_is_enforced()`. À la fin : exécute et donne la sortie BRUTE. NE committe PAS (figé par l'orchestrateur). Rends via idea_reply : fichier(s) de test ajoutés, mapping test→invariant (les 7), stratégie de skip, et sortie d'exécution réelle. Si un invariant ne passe pas, rapport d'erreur clair avec sortie brute → je renvoie à DevBackend. -- **Response:** ✅ VERT — Tests bout-en-bout LP4-4 (enforcement Landlock sur le chemin STRUCTURÉ, sessions Claude/Codex JSON) ajoutés et passants. Les 7 invariants de l'Architecte couverts. ZÉRO token (aucun vrai claude/codex : `sh` qui émet une ligne JSONL + tente des écritures FS, ou le `FakeCli` scriptable existant). ## Fichiers - **Ajouté** : `crates/infrastructure/src/session/sandbox_e2e.rs` — 7 tests + helpers, derrière `#[cfg(all(test, target_os = "linux"))]`. - **Modifié** : `crates/infrastructure/src/session/mod.rs` — déclaration `#[cfg(all(test, target_os = "linux"))] mod sandbox_e2e;` (uniquement la ligne de module, suites existantes intactes). ## Mapping test → invariant (les 7) 1. **PARITÉ (pivot)** → `pty_structured_run_turn_enforces_plan_end_to_end` : `run_turn` + plan RW(allowed) + enforcer Landlock. Asserte : la ligne JSONL est **drainée** (parsing intact sous sandbox), écriture in-grant réussit, écriture hors-grant **bloquée noyau** (fichier absent). Exact pendant du pivot PTY LP4-3, mais via `process::run_turn_sandboxed`. 2. **COMPANION NÉGATIF** → `structured_run_turn_without_plan_does_not_sandbox` : même enforcer câblé mais `sandbox==None` ⇒ écriture hors-grant RÉUSSIT ⇒ le blocage (1) vient du plan, pas d'une restriction ambiante. 3. **FAIL-CLOSED** → `structured_run_turn_fail_closed_no_child_on_enforce_err` : enforcer double `AlwaysFailEnforcer` (renvoie `SandboxError::KernelTooOld`, simule fidèlement « Deny + kernel sans Landlock » de façon **déterministe**, indépendamment du kernel de CI). `run_turn` ⇒ `AgentSessionError::Start` ET le marqueur que le child aurait écrit reste **absent** (aucun enfant lancé). 4. **NO-OP PAR DÉFAUT** → `structured_run_turn_none_plan_is_native_path` : `plan==None` (même avec enforcer fourni) ⇒ chemin async tokio historique, lignes drainées + écriture arbitraire réussit (aucune restriction). Non-régression confirmée par la suite `session::` complète (cf. ci-dessous). 5. **CONFINEMENT/IRRÉVERSIBILITÉ** → `structured_two_turns_disjoint_grants_are_confined` : deux `run_turn` successifs à grants DISJOINTS (A puis B). Chacun écrit dans son grant (ok) et dans l'autre (bloqué). Le tour B écrit bien dans B ⇒ la restriction du thread jetable de A **n'a pas bavé**. 6. **TIMEOUT SOUS SANDBOX** → `structured_run_turn_timeout_under_sandbox` : `sleep 30` (stdout jamais fermé) via la branche sandboxée, `run_turn(timeout 250ms)` ⇒ `AgentSessionError::Timeout` rapidement (asserté < 10s, pas d'attente des 30s) ⇒ killer oneshot + `tokio::time::timeout` OK, pas de thread bloqué. 7. **RESUME PRÉSERVÉ** → `structured_sandboxed_turn_preserves_conversation_id` : via la **fabrique réelle** `StructuredSessionFactory::new().with_sandbox_enforcer(default_enforcer())`, profil Claude sur `FakeCli` (init+result), `start(.., Some(&plan))`. Sous enforcement actif, `send()` rend exactement un `Final` et `conversation_id()` ⇒ `Some("conv-sbx-1")` ⇒ la restriction FS ne casse pas le parsing/la capture d'id. ## Stratégie de skip - Tout le module est `#[cfg(all(test, target_os = "linux"))]` (rien hors Linux). - Helper `landlock_is_enforced()` (calqué sur `sandbox/landlock.rs` et `pty::sandbox_e2e_tests`) : `enforce` d'un plan RW sur **thread jetable** (restriction irréversible ⇒ jamais sur le thread de test), skip propre (`eprintln!` + `return`) si `SandboxStatus::Unsupported`. - Gardés par `landlock_is_enforced()` (exigent un fencing réel) : invariants **1, 5, 7**. Indépendants du kernel (déterministes sans Landlock) : **2** (contrôle natif), **3** (fake enforcer), **4** (plan None), **6** (timeout — branche sandboxée prise sous posture Ask, enforce Ok même sans Landlock). - Helpers réutilisés : `fresh_dir`, posture `Ask`, ligne JSONL `result` réaliste (format Claude vérifié), `FakeCli` de `conformance`. ## Sortie brute d'exécution ``` $ cargo test -p infrastructure --lib session::sandbox_e2e -- --nocapture --test-threads=1 Finished `test` profile [unoptimized + debuginfo] target(s) in 5.01s Running unittests src/lib.rs (target/debug/deps/infrastructure-2cc30d2a1793b4e4) running 7 tests test session::sandbox_e2e::pty_structured_run_turn_enforces_plan_end_to_end ... ok test session::sandbox_e2e::structured_run_turn_fail_closed_no_child_on_enforce_err ... ok test session::sandbox_e2e::structured_run_turn_none_plan_is_native_path ... ok test session::sandbox_e2e::structured_run_turn_timeout_under_sandbox ... ok test session::sandbox_e2e::structured_run_turn_without_plan_does_not_sandbox ... ok test session::sandbox_e2e::structured_sandboxed_turn_preserves_conversation_id ... ok test session::sandbox_e2e::structured_two_turns_disjoint_grants_are_confined ... ok test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 156 filtered out; finished in 0.27s ``` **Aucune ligne « skip » imprimée** (run `--nocapture`) ⇒ les invariants gardés (1, 5, 7) ont **réellement** posé l'enforcement sur ce kernel (Landlock actif), pas été contournés. Non-régression (invariant 4 : conformance/D0/D3 + voisins) : ``` === session === test result: ok. 56 passed; 0 failed; ... === sandbox === test result: ok. 7 passed; 0 failed; ... === pty === test result: ok. 5 passed; 0 failed; ... ``` Pas de commit (figé par l'orchestrateur, comme demandé). Ajout de tests uniquement + 1 ligne de déclaration de module ; aucun code de prod touché. \ No newline at end of file +- **Response:** ✅ VERT — Tests bout-en-bout LP4-4 (enforcement Landlock sur le chemin STRUCTURÉ, sessions Claude/Codex JSON) ajoutés et passants. Les 7 invariants de l'Architecte couverts. ZÉRO token (aucun vrai claude/codex : `sh` qui émet une ligne JSONL + tente des écritures FS, ou le `FakeCli` scriptable existant). ## Fichiers - **Ajouté** : `crates/infrastructure/src/session/sandbox_e2e.rs` — 7 tests + helpers, derrière `#[cfg(all(test, target_os = "linux"))]`. - **Modifié** : `crates/infrastructure/src/session/mod.rs` — déclaration `#[cfg(all(test, target_os = "linux"))] mod sandbox_e2e;` (uniquement la ligne de module, suites existantes intactes). ## Mapping test → invariant (les 7) 1. **PARITÉ (pivot)** → `pty_structured_run_turn_enforces_plan_end_to_end` : `run_turn` + plan RW(allowed) + enforcer Landlock. Asserte : la ligne JSONL est **drainée** (parsing intact sous sandbox), écriture in-grant réussit, écriture hors-grant **bloquée noyau** (fichier absent). Exact pendant du pivot PTY LP4-3, mais via `process::run_turn_sandboxed`. 2. **COMPANION NÉGATIF** → `structured_run_turn_without_plan_does_not_sandbox` : même enforcer câblé mais `sandbox==None` ⇒ écriture hors-grant RÉUSSIT ⇒ le blocage (1) vient du plan, pas d'une restriction ambiante. 3. **FAIL-CLOSED** → `structured_run_turn_fail_closed_no_child_on_enforce_err` : enforcer double `AlwaysFailEnforcer` (renvoie `SandboxError::KernelTooOld`, simule fidèlement « Deny + kernel sans Landlock » de façon **déterministe**, indépendamment du kernel de CI). `run_turn` ⇒ `AgentSessionError::Start` ET le marqueur que le child aurait écrit reste **absent** (aucun enfant lancé). 4. **NO-OP PAR DÉFAUT** → `structured_run_turn_none_plan_is_native_path` : `plan==None` (même avec enforcer fourni) ⇒ chemin async tokio historique, lignes drainées + écriture arbitraire réussit (aucune restriction). Non-régression confirmée par la suite `session::` complète (cf. ci-dessous). 5. **CONFINEMENT/IRRÉVERSIBILITÉ** → `structured_two_turns_disjoint_grants_are_confined` : deux `run_turn` successifs à grants DISJOINTS (A puis B). Chacun écrit dans son grant (ok) et dans l'autre (bloqué). Le tour B écrit bien dans B ⇒ la restriction du thread jetable de A **n'a pas bavé**. 6. **TIMEOUT SOUS SANDBOX** → `structured_run_turn_timeout_under_sandbox` : `sleep 30` (stdout jamais fermé) via la branche sandboxée, `run_turn(timeout 250ms)` ⇒ `AgentSessionError::Timeout` rapidement (asserté < 10s, pas d'attente des 30s) ⇒ killer oneshot + `tokio::time::timeout` OK, pas de thread bloqué. 7. **RESUME PRÉSERVÉ** → `structured_sandboxed_turn_preserves_conversation_id` : via la **fabrique réelle** `StructuredSessionFactory::new().with_sandbox_enforcer(default_enforcer())`, profil Claude sur `FakeCli` (init+result), `start(.., Some(&plan))`. Sous enforcement actif, `send()` rend exactement un `Final` et `conversation_id()` ⇒ `Some("conv-sbx-1")` ⇒ la restriction FS ne casse pas le parsing/la capture d'id. ## Stratégie de skip - Tout le module est `#[cfg(all(test, target_os = "linux"))]` (rien hors Linux). - Helper `landlock_is_enforced()` (calqué sur `sandbox/landlock.rs` et `pty::sandbox_e2e_tests`) : `enforce` d'un plan RW sur **thread jetable** (restriction irréversible ⇒ jamais sur le thread de test), skip propre (`eprintln!` + `return`) si `SandboxStatus::Unsupported`. - Gardés par `landlock_is_enforced()` (exigent un fencing réel) : invariants **1, 5, 7**. Indépendants du kernel (déterministes sans Landlock) : **2** (contrôle natif), **3** (fake enforcer), **4** (plan None), **6** (timeout — branche sandboxée prise sous posture Ask, enforce Ok même sans Landlock). - Helpers réutilisés : `fresh_dir`, posture `Ask`, ligne JSONL `result` réaliste (format Claude vérifié), `FakeCli` de `conformance`. ## Sortie brute d'exécution ``` $ cargo test -p infrastructure --lib session::sandbox_e2e -- --nocapture --test-threads=1 Finished `test` profile [unoptimized + debuginfo] target(s) in 5.01s Running unittests src/lib.rs (target/debug/deps/infrastructure-2cc30d2a1793b4e4) running 7 tests test session::sandbox_e2e::pty_structured_run_turn_enforces_plan_end_to_end ... ok test session::sandbox_e2e::structured_run_turn_fail_closed_no_child_on_enforce_err ... ok test session::sandbox_e2e::structured_run_turn_none_plan_is_native_path ... ok test session::sandbox_e2e::structured_run_turn_timeout_under_sandbox ... ok test session::sandbox_e2e::structured_run_turn_without_plan_does_not_sandbox ... ok test session::sandbox_e2e::structured_sandboxed_turn_preserves_conversation_id ... ok test session::sandbox_e2e::structured_two_turns_disjoint_grants_are_confined ... ok test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 156 filtered out; finished in 0.27s ``` **Aucune ligne « skip » imprimée** (run `--nocapture`) ⇒ les invariants gardés (1, 5, 7) ont **réellement** posé l'enforcement sur ce kernel (Landlock actif), pas été contournés. Non-régression (invariant 4 : conformance/D0/D3 + voisins) : ``` === session === test result: ok. 56 passed; 0 failed; ... === sandbox === test result: ok. 7 passed; 0 failed; ... === pty === test result: ok. 5 passed; 0 failed; ... ``` Pas de commit (figé par l'orchestrateur, comme demandé). Ajout de tests uniquement + 1 ligne de déclaration de module ; aucun code de prod touché. +- **Prompt:** Tests unitaires LS1 — couche DOMAINE de la feature « Gestion des limites de session des agents ». Branche feature/agent-session-limits. DevBackend vient de livrer le code pur (compile, 0 warning). À toi d'écrire les tests unitaires et de les exécuter (cycle §3, étape test). Tests purs, dans les modules concernés (cohérent avec le style de tests existant du crate domain, ex. les `#[cfg(test)] mod tests` de readiness.rs). Périmètre exact à couvrir (issu du livrable DevBackend) : readiness.rs (`ReadinessPolicy::classify`) : - `ReplyEvent::RateLimited { resets_at_ms }` → `Some(ReadinessSignal::RateLimited { resets_at_ms })` (avec Some(t) ET None) ; - non-régression : Final→TurnEnded ; TextDelta/ToolActivity/Heartbeat→None inchangés ; - `ReadinessSignal` reste `Copy` (test de compilation : copie implicite). session_limit.rs (`plan_resume`, `SessionLimit`, `ResumePlan`) : - `plan_resume` avec `resets_at_ms = Some(futur)` → `ResumePlan::Scheduled { fire_at_ms = resets_at, conversation_id transmis }` ; - clamp anti-passé : `resets_at_ms = Some(passé < now)` → `fire_at_ms == now_ms` (jamais dans le passé) ; - `resets_at_ms = None` → `ResumePlan::HumanFallback` ; - `SessionLimit::has_known_reset` true/false selon Some/None ; - conversation_id `Some`/`None` correctement propagé dans `Scheduled`. profile.rs (`RateLimitPattern` + champ) : - `RateLimitPattern::new` rejette un pattern vide → `DomainError::EmptyField` ; - round-trip serde de `AgentProfile` : clé `rateLimitPattern` OMISE quand `None` ; présente et correcte quand `Some` ; JSON legacy (sans la clé) → désérialise en `None` (rétro-compat) ; - camelCase respecté sur les champs de `RateLimitPattern` (`resetCapture`, `timeFormat`). events.rs : constructibilité + égalité `PartialEq` des 5 variantes (`AgentRateLimited`, `AgentResumeScheduled`, `AgentResumeCancelled`, `AgentResumed`, `AgentRateLimitSuspected`) avec les bons types de champs. Exécute `cargo test -p domain` et rends-moi : le rapport complet (nombre de tests, pass/fail), et en cas d'échec un rapport d'erreurs CLAIR (test concerné, attendu vs obtenu, sortie réelle) pour que je le renvoie à DevBackend. Ne modifie PAS le code de production — seulement les tests ; si un test révèle un vrai bug, signale-le sans le corriger toi-même. +- **Response:** ✅ LS1 — Tests unitaires DOMAINE « limites de session » : TOUT VERT. Commande : `cargo test -p domain` Résultat lib (cœur testé ici) : **165 passed; 0 failed; 0 ignored**. Le reste du crate (tests d'intégration profile/structured_session/window + doctests) reste vert également — aucune régression. Je n'ai touché QUE des tests (aucune ligne de code de prod modifiée). 24 nouveaux tests ajoutés, tous au bon endroit (modules `#[cfg(test)]` du crate domain), dans le style existant : readiness.rs (5 tests) - `rate_limited_with_known_reset_classifies_and_propagates_time` — Some(t) propagé tel quel - `rate_limited_without_reset_classifies_with_none` — None propagé - `rate_limited_is_not_classified_as_turn_ended` — non terminal - non-régression : Final→TurnEnded déjà couvert + `deltas_activities_and_heartbeats_are_non_terminal` (TextDelta/ToolActivity/Heartbeat→None) inchangés et verts - `readiness_signal_is_copy` — test de compilation : copie implicite (move ⇒ ne compilerait plus si le `Copy` sautait) session_limit.rs (8 tests) - `future_reset_schedules_at_reset_time_with_conversation_id` — Scheduled{fire_at_ms = resets_at, conversation_id transmis} - `past_reset_is_clamped_to_now_never_in_the_past` — clamp anti-passé : fire_at_ms == now - `reset_exactly_now_fires_at_now` — borne - `unknown_reset_falls_back_to_human` — None ⇒ HumanFallback - `conversation_id_{some,none}_is_propagated_into_scheduled` — propagation Some/None - `has_known_reset_is_true_with_some_and_false_with_none` profile.rs (7 tests, dans mcp_tests) - `rate_limit_pattern_new_rejects_empty_pattern` — DomainError::EmptyField{ field == "rateLimitPattern.pattern" } - `rate_limit_pattern_new_accepts_non_empty_pattern` - `profile_default_has_no_rate_limit_pattern` / `..._omits_key_in_json` — clé `rateLimitPattern` OMISE quand None (zéro régression) - `legacy_json_without_rate_limit_pattern_deserialises_to_none` — rétro-compat - `with_rate_limit_pattern_sets_and_round_trips_camel_case` — présente+correcte quand Some, camelCase `resetCapture`/`timeFormat` vérifié - `rate_limit_pattern_omits_unset_optional_fields_in_json` — reset_capture/time_format None ⇒ clés omises events.rs (6 tests, nouveau module de test) - constructibilité + égalité PartialEq des 5 variantes : `AgentRateLimited`, `AgentResumeScheduled`, `AgentResumeCancelled`, `AgentResumed`, `AgentRateLimitSuspected` (avec cas inégaux pour prouver la discrimination des champs) - `distinct_session_limit_variants_are_not_equal` — les variantes ne se confondent pas Aucun bug détecté dans le code de prod : tous les invariants du livrable DevBackend sont conformes (clamp `max(t, now)`, omission serde, camelCase, EmptyField, Copy préservé). Feature validée côté tests domaine — prêt pour la suite du cycle (Git : merge éventuel feature/* → develop). +- **Prompt:** Tests unitaires LS2 — adapter Claude niveau 1 (infra). Branche feature/agent-session-limits. DevBackend a livré (cargo build -p infrastructure OK, 0 warning). Le code est pur et testable sans process : fonction `parse_event` et nouvelle fonction pure `parse_reset_ms` (+ helpers privés) dans crates/infrastructure/src/session/claude.rs. Écris les tests et exécute-les, dans le style des tests existants de ce module (#[cfg(test)] mod tests de claude.rs). ATTENTION particulière : DevBackend a écrit un parseur ISO-8601/RFC3339 À LA MAIN (pas de chrono/time) + une heuristique secondes-vs-ms (seuil 10^12) + un algo jour-civil (days_from_civil). C'est du code délicat : teste-le rigoureusement, y compris les bords. Couverture à assurer : parse_reset_ms (époche-ms en sortie) : - noms de champ : `resetsAt`, `resets_at`, `reset_at`, `resetAt`, `reset` — chacun reconnu ; ordre de priorité si plusieurs présents (1er gagne) ; - epoch SECONDES (entier < 10^12) → ×1000 ; epoch MILLISECONDES (≥ 10^12) → tel quel ; le seuil exact (valeur juste sous / juste au-dessus de 10^12) ; - float epoch (secondes et ms) ; - chaîne contenant un entier/float (même heuristique) ; - ISO-8601 `...Z` → ms attendus ; ISO avec offset `±hh:mm` → converti en ms UTC corrects ; fraction de seconde `.fff` (tronquée/complétée à 3 chiffres) ; - robustesse : rate_limit_info absent / clé inconnue / valeur non numérique pourrie / chaîne ISO invalide → None (jamais de panique, jamais d'erreur) ; - days_from_civil & bissextiles : `1970-01-01T00:00:00Z` → 0 ; une date avec `2024-02-29` (année bissextile) valide ; vérifie au moins une date post-2001 connue (calcule l'epoch-ms attendu à la main ou via une valeur de référence sûre). parse_event (mapping) : - ligne `type:"rate_limit_event"` avec `rate_limit_info.resetsAt` → ParsedLine contenant `ReplyEvent::RateLimited { resets_at_ms: Some(...) }` ; - `rate_limit_event` sans rate_limit_info exploitable → `RateLimited { None }` (et PAS Heartbeat) ; - non-régression : `system`/init → Heartbeat + capture session_id inchangés ; `assistant` multi-blocs inchangé ; `result` → Final inchangé ; type inconnu → ignoré ; ligne vide → défaut ; JSON illisible → Decode. Non-terminalité (T4) au niveau adapter : - un enchaînement de lignes `rate_limit_event` puis `result` → le flux émis par `send` (ou la séquence parsée) est `[..., RateLimited, Final]` : RateLimited n'interrompt PAS, seul Final clôt. Si testable au niveau `send` avec le fake CLI existant, fais-le ; sinon teste au niveau séquence de parse_event. Exécute `cargo test -p infrastructure`. Rends : rapport complet (nb tests, pass/fail), et en cas d'échec un rapport CLAIR (test, attendu vs obtenu, sortie réelle) à renvoyer à DevBackend. Ne modifie QUE des tests ; si tu détectes un vrai bug (ex. heuristique de seuil fausse, offset ISO mal converti, bissextile faux), signale-le précisément SANS corriger le code de prod. +- **Response:** ✅ LS2 — Tests adapter Claude niveau 1 (infra) : VERT, mais ⚠️ j'ai trouvé 2 tests existants qui CASSAIENT au départ (régression de contrat assumée par le livrable §21 — détail plus bas). Commande : `cargo test -p infrastructure` Résultat lib (cœur testé) : **188 passed; 0 failed; 0 ignored**. Tous les fichiers d'intégration + doctests également verts. Aucune régression résiduelle. J'ai ajouté **26 tests LS2** (tous via fonctions pures `parse_reset_ms`/`parse_event`, sauf 1 via `send()`+FakeCli — jamais le vrai claude). Tout est dans `crates/infrastructure/src/session/mod.rs` (`#[cfg(test)] mod tests`, là où vivent réellement les tests claude.rs). parse_reset_ms — noms de champ & priorité - `recognises_every_field_name` : resetsAt / resets_at / reset_at / resetAt / reset chacun reconnu - `first_known_key_wins` : resetsAt prime sur reset (1er de l'ordre gagne) parse_reset_ms — heuristique secondes/ms + SEUIL - `integer_seconds_are_scaled_to_ms` (×1000) / `integer_millis_are_kept_as_is` - `threshold_boundary` : 10^12−1 ⇒ secondes (×1000) ; 10^12 pile ⇒ ms (tel quel, borne inclusive côté ms) - `float_seconds_preserve_fraction` (1_700_000_000.5 ⇒ 1_700_000_000_500) / `float_millis_kept_as_is` - `string_integer_*` / `string_float_*` : même heuristique sur chaînes numériques parse_reset_ms — ISO-8601 / RFC3339 (parseur maison) - `iso_utc_z` : "2023-11-14T22:13:20Z" ⇒ 1_700_000_000_000 (recoupé contre l'epoch-secondes connu) - `iso_positive_offset` / `iso_negative_offset` / `iso_compact_offset` (+01:00, −01:00, +0100 = même instant UTC) - `iso_fraction_padded_and_truncated` : .5⇒500, .123456⇒123 (tronqué), .7⇒700 (complété) - robustesse : `unknown_key_yields_none`, `non_numeric_garbage_yields_none` (bool/null/array/objet/chaîne pourrie), `invalid_iso_string_yields_none` days_from_civil & bissextiles - `unix_epoch_is_zero` : 1970-01-01T00:00:00Z ⇒ 0 - `leap_day_2024_02_29` : 2024-02-29T00:00:00Z ⇒ 1_709_164_800_000 (calculé à la main) - `known_post_2001_date` : 2021-01-01T00:00:00Z ⇒ 1_609_459_200_000 parse_event (mapping) + non-terminalité (T4) - `parse_event_rate_limit_with_reset_yields_rate_limited_some` : resetsAt exploitable ⇒ RateLimited{Some} - `parse_event_rate_limit_without_info_is_rate_limited_none_not_heartbeat` : absent / clé inconnue ⇒ RateLimited{None} (et PAS Heartbeat) - `parse_event_sequence_rate_limited_then_final_is_not_interrupted` : [RateLimited, Final] au niveau parse - `send_emits_rate_limited_intercalated_only_final_closes` (FakeCli) : flux complet = [Heartbeat, RateLimited, TextDelta, Final], le RateLimited n'interrompt PAS, un seul Final clôt — prouvé bout-en-bout ⚠️ DEUX TESTS EXISTANTS CASSAIENT au lancement initial (avant mes ajouts) — ce n'est PAS un bug de prod, c'est l'ancien contrat qui survivait : 1. `claude_parse_rate_limit_event_is_heartbeat` (mod.rs) — assertait l'ANCIEN comportement (rate_limit_event ⇒ Heartbeat). Le livrable §21 émet désormais RateLimited. → Je l'ai RENOMMÉ en `claude_parse_rate_limit_event_without_reset_is_rate_limited_none` et mis à jour l'assertion vers `RateLimited{None}`. 2. `claude_session_respects_port_contract` — le harnais de conformité `assert_agent_session_contract` (conformance.rs, module `#[cfg(test)]`) n'autorisait avant le Final que TextDelta/ToolActivity/Heartbeat. Le `claude_script()` contient une ligne rate_limit_event qui produit maintenant un RateLimited intercalé → rejeté. → J'ai ajouté `ReplyEvent::RateLimited { .. }` à la liste des événements non terminaux autorisés (conforme à §21-T4 : « s'intercale exactement comme un Heartbeat »). Ces deux modifs portent UNIQUEMENT sur du code de test (`#[cfg(test)]`), pas sur la prod. Je n'ai touché aucune ligne de `claude.rs`. Le code de prod de DevBackend est conforme au cadrage §21 sur tous les axes testés (noms de champ, priorité, seuil 10^12 exact, floats, offsets ISO signés, fractions ms, bissextile 2024, epoch Unix, robustesse zéro-panic, non-terminalité T4). Feature validée côté tests infra. Prêt pour la suite du cycle. +- **Prompt:** Tests LS3 — adapter `TokioScheduler` (port `Scheduler`, réveil one-shot annulable), crates/infrastructure/src/scheduler/mod.rs. Branche feature/agent-session-limits. DevBackend a livré (build infra OK, 0 warning). Écris et exécute les tests, dans le style des tests async existants de l'infra (#[tokio::test], cherche des exemples dans le crate pour la convention de runtime). Construction : `TokioScheduler::new(tx, clock)` où tx est l'émetteur d'un `tokio::sync::mpsc::unbounded_channel::()` et clock un `Arc` (utilise l'horloge système réelle ou un fake selon ce qui existe déjà ; les délais de test doivent rester COURTS pour ne pas ralentir la suite). Couverture : - arm tire APRÈS l'échéance : `arm(now + ~50ms, task)` → `rx.recv()` (sous un timeout de sécurité, ex. 1s) rend exactement la `ScheduledTask` armée ; et vérifie qu'AUCUNE tâche n'arrive AVANT l'échéance (try_recv juste après arm = vide) ; - cancel empêche le tir : `arm(now + grand délai, task)` puis `cancel(id)` retourne `true` → après un court laps, `rx` reste vide ; - cancel d'un id inconnu → `false` (ScheduleId::new_random()) ; - cancel APRÈS tir → `false` (armer délai court, attendre réception via recv, puis cancel) ; - deadline déjà passée (`arm(now - 1000, task)`) → tir quasi-immédiat (task reçue sous timeout court) ; - (si pertinent et stable) plusieurs arm concurrents → chacun tire / s'annule indépendamment, pas de fuite. Attention aux tests temporels : utilise des timeouts généreux pour la réception (éviter le flaky) mais des délais d'armement courts ; ne te fie pas à des sleeps fixes fragiles pour prouver l'ABSENCE d'un événement — préfère try_recv après un petit yield. Évite tout test dépendant de l'ordonnancement fin qui rendrait la suite instable. Exécute `cargo test -p infrastructure` (vérifie que les tests LS1/LS2 restent verts aussi). Rends : rapport complet (nb tests, pass/fail), signalement de tout flakiness potentiel, et en cas d'échec un rapport CLAIR (attendu vs obtenu) pour DevBackend. Ne modifie QUE des tests ; signale tout vrai bug (ex. course sur cancel, fuite de handle) sans corriger la prod. +- **Response:** ✅ LS3 — Tests `TokioScheduler` (port `Scheduler`, réveil one-shot annulable) : TOUT VERT, aucun flakiness observé. Commande : `cargo test -p infrastructure` Résultat lib : **195 passed; 0 failed; 0 ignored** (188 LS1/LS2 + **7 nouveaux LS3**). Tous les fichiers d'intégration + doctests verts. LS1/LS2 restent verts (aucune régression). Stabilité : suite scheduler relancée **3×** d'affilée → 7/7 à chaque fois, ~0,20 s. Pas de flaky détecté. 7 tests ajoutés dans `crates/infrastructure/src/scheduler/mod.rs` (`#[cfg(test)] mod tests`, `#[tokio::test]` runtime current-thread par défaut comme le reste de l'infra). Construction conforme : `TokioScheduler::new(tx, clock)` avec `tx = unbounded_channel::()` et `clock = Arc` (= `SystemClock` réel, horloge partagée pour des échéances cohérentes avec ce qu'`arm` relit). Délai d'armement court (50 ms), timeout de réception généreux (2 s) pour l'anti-flaky. - `arm_fires_after_deadline_with_exact_task` : `arm(now+50ms)` → la tâche EXACTE arrive sous timeout ; et `try_recv` juste après arm = `Empty` (rien AVANT l'échéance). - `cancel_before_deadline_prevents_fire` : `arm(now+50ms)` + `cancel` immédiat → `true` ; attente 4× l'échéance (200 ms) puis `try_recv` = `Empty`. Absence PROUVÉE (sans annulation, le délai court aurait tiré bien avant la fin de l'attente — pas un sleep fragile). Bonus : second `cancel(id)` = `false` (id retiré de la table). - `cancel_unknown_id_is_false` : `cancel(ScheduleId::new_random())` → `false`. - `cancel_after_fire_is_false` : arm court, on attend la réception (tir effectif), on cède l'exécuteur (8× `yield_now` pour finaliser l'état du JoinHandle), puis `cancel` → `false`. - `past_deadline_fires_immediately` : `arm(now-1000)` → tir quasi-immédiat (reçu sous 500 ms). - `multiple_concurrent_arms_all_fire` : 3 arms même échéance → les 3 tâches arrivent (comparées en ENSEMBLE trié, zéro dépendance à l'ordre) ; ids armés distincts. - `cancel_one_among_many_leaves_others_firing` : parmi 3 réveils, on annule le ciblé → seuls les 2 survivants arrivent, l'annulé jamais (`try_recv` final = `Empty`). Prouve l'indépendance des handles (pas d'annulation collatérale, pas de fuite). Note d'honnêteté sur le contrat « cancel APRÈS tir → false » : il repose sur `JoinHandle::is_finished()`. Entre l'envoi `tx.send` de la tâche de fond et la transition `is_finished()==true`, il existe une micro-fenêtre. En runtime **current-thread** (défaut `#[tokio::test]`), la tâche de fond est nécessairement pollée jusqu'à complétion AVANT que `recv()` ne rende la valeur, donc `is_finished()` est vrai au moment du `cancel` → déterministe. J'ai tout de même ajouté des `yield_now()` par robustesse. ⚠️ Si la prod était un jour exécutée sous un runtime **multi-thread**, ce contrat pourrait devenir une course (cancel d'une tâche tout juste tirée mais pas encore jointe → `Some(handle)` non fini → `abort()` no-op + retour `true` au lieu de `false`). Ce n'est PAS un bug aujourd'hui (le test est vert et stable), mais je le signale comme point de vigilance à surveiller si le drain LS4/LS7 venait à câbler le scheduler sous un runtime multi-thread. Je n'ai touché aucune ligne de prod. Feature LS3 validée côté tests infra. Prêt pour la suite du cycle. +- **Prompt:** Tests LS4 — couche application de la feature « limites de session » : `SessionLimitService` (crates/application/src/agent/session_limit.rs) + réconciliation T4 dans crates/application/src/agent/structured.rs. Branche feature/agent-session-limits. DevBackend a livré (cargo build -p application OK, 0 warning ; tests compilent). Écris et exécute les tests, avec des FAKES des ports (Clock fixe, Scheduler, EventBus, AgentResumer), dans le style des tests applicatifs existants (regarde comment structured.rs et les autres use cases sont testés, réutilise les fakes existants si présents). SessionLimitService — (a) détection→planification : - `on_rate_limited(agent, node, conv_id, Some(reset_futur))` → le fake Scheduler reçoit exactement 1 `arm(fire_at_ms, ScheduledTask::ResumeAgent{agent_id, node_id, conversation_id})` avec fire_at_ms cohérent (= resets_at, ou now si passé via plan_resume) ; le fake EventBus reçoit `AgentRateLimited{agent_id, resets_at_ms}` PUIS `AgentResumeScheduled{agent_id, fire_at_ms}` DANS CET ORDRE ; - `on_rate_limited(..., None)` → AUCUN arm ; events `AgentRateLimited{None}` puis `AgentRateLimitSuspected{None}` ; - dédoublonnage (§21.10-4) : deux `on_rate_limited` successifs pour le MÊME agent → l'ancien ScheduleId est cancel-é sur le Scheduler (sans émettre d'AgentResumeCancelled pour le dédoublonnage interne). (b) exécution de la reprise : - `execute_resume(ScheduledTask::ResumeAgent{...})` → le fake AgentResumer voit `resume(agent_id, node_id, conversation_id, RESUME_PROMPT)` (vérifie que le prompt passé == la const RESUME_PROMPT) ; EventBus reçoit `AgentResumed{agent_id}` ; l'entrée interne est retirée (un cancel_resume ultérieur → false) ; - AgentResumer qui retourne Err → l'erreur est propagée ET `AgentResumed` n'est PAS publié. (c) annulation : - `cancel_resume(agent)` après un `on_rate_limited` armé, fake Scheduler renvoyant true → retourne true, EventBus reçoit `AgentResumeCancelled{agent_id}` ; - `cancel_resume(agent)` sans armement préalable → false, aucun event ; - fake Scheduler renvoyant false (cas « déjà tiré ») → cancel_resume renvoie false ET n'émet PAS AgentResumeCancelled (la reprise suit son cours) — c'est le contrat anti-course important. Réconciliation T4 (drain_with_readiness_outcome / drain_to_final) : - flux `[RateLimited{Some(t)}]` sans Final → `Ok(TurnOutcome::RateLimited{Some(t)})` (fin gracieuse, PAS d'Err) ; - flux `[RateLimited{None}]` sans Final → `Ok(TurnOutcome::RateLimited{None})` ; - flux `[..., RateLimited, Final]` → `Ok(TurnOutcome::Completed(contenu))` (Final l'emporte) ; - flux `[TextDelta]` seul (ni Final ni RateLimited) → `Err(Io)` INCHANGÉ (non-régression critique : un vrai flux tronqué reste une erreur) ; - NON-RÉGRESSION : `send_blocking` et `drain_with_readiness` (signatures historiques) gardent leur comportement (RateLimited→Err(Io) côté send_blocking ; flux sans Final → Io). Vérifie que les tests existants (drain_with_readiness_lot1, send_blocking_d1, drain_marks_alive) restent verts. Exécute `cargo test -p application`. Rends : rapport complet (nb tests, pass/fail), confirmation explicite que les tests applicatifs préexistants restent verts (zéro régression), et en cas d'échec un rapport CLAIR (attendu vs obtenu) pour DevBackend. Ne modifie QUE des tests ; signale tout vrai bug sans corriger la prod. +- **Response:** ✅ LS4 — Tests couche application « limites de session » : TOUT VERT, zéro régression. Commande : `cargo test -p application` Résultat : **tous les binaires de test verts, 0 failed** sur l'ensemble du crate. 16 nouveaux tests LS4 ajoutés, répartis en 2 fichiers d'intégration (style des tests applicatifs existants, 100 % fakes des ports). Confirmation explicite NON-RÉGRESSION : les suites préexistantes citées restent vertes — - `drain_with_readiness_lot1.rs` : **7/7** ✓ - `send_blocking_d1.rs` : **9/9** ✓ - (et tout le reste du crate : project/agent_lifecycle/orchestrator/structured_launch/etc., 0 échec). === Nouveau fichier `tests/session_limit_service.rs` (9 tests) — fakes Clock fixe / Scheduler enregistreur-contrôlable / EventBus espion / AgentResumer espion-contrôlable === (a) détection→planification : - `on_rate_limited_future_arms_and_emits_in_order` : Some(reset futur) ⇒ EXACTEMENT 1 `arm(fire_at_ms==reset, ResumeAgent{agent,node,conv})` + events `AgentRateLimited` PUIS `AgentResumeScheduled` dans cet ordre. - `on_rate_limited_past_reset_clamps_fire_at_to_now` : reset passé ⇒ `fire_at_ms==now` (clamp anti-passé) ; l'event RateLimited garde l'heure brute passée, ResumeScheduled porte le now clampé. - `on_rate_limited_without_reset_is_human_fallback_no_arm` : None ⇒ AUCUN arm + events `AgentRateLimited{None}` puis `AgentRateLimitSuspected{None}`. - `on_rate_limited_twice_same_agent_dedups_cancelling_previous` : 2 signaux même agent ⇒ l'ancien ScheduleId est cancel-é avant réarmement, et AUCUN `AgentResumeCancelled` émis (dédoublonnage interne silencieux, §21.10-4). (b) exécution : - `execute_resume_calls_resumer_with_prompt_and_emits_resumed` : resume(agent,node,conv, prompt==RESUME_PROMPT vérifié) + `AgentResumed` publié + entrée retirée (cancel_resume ultérieur ⇒ false). - `execute_resume_propagates_error_without_emitting_resumed` : Resumer Err ⇒ erreur propagée ET `AgentResumed` NON publié. (c) annulation : - `cancel_resume_after_arm_returns_true_and_emits_cancelled` : cancel renvoyant true ⇒ true + bon ScheduleId passé + `AgentResumeCancelled` publié. - `cancel_resume_without_arm_is_false_no_event` : sans armement ⇒ false, Scheduler non sollicité, aucun event. - `cancel_resume_when_scheduler_already_fired_is_false_no_event` : Scheduler renvoyant false (« déjà tiré ») ⇒ false ET PAS d'`AgentResumeCancelled` (contrat anti-course important). === Nouveau fichier `tests/session_limit_t4.rs` (7 tests) — réconciliation §21.2-T4, fakes AgentSession + InputMediator === - `outcome_rate_limited_some_without_final_is_graceful` : `[RateLimited{Some(t)}]` ⇒ `Ok(TurnOutcome::RateLimited{Some(t)})` (PAS d'Err) + vérifie qu'un RateLimited ne marque PAS Idle. - `outcome_rate_limited_none_without_final_is_graceful` : `[RateLimited{None}]` ⇒ `Ok(RateLimited{None})`. - `outcome_rate_limited_then_final_is_completed` : `[.., RateLimited, Final]` ⇒ `Completed(contenu)` (Final l'emporte) + Idle marqué. - `outcome_truncated_stream_without_final_or_ratelimit_is_io_error` : `[TextDelta]` seul ⇒ `Err(Io)` INCHANGÉ (non-régression critique du flux tronqué). - non-régression signatures historiques : `drain_with_readiness_rate_limited_is_io_error`, `send_blocking_rate_limited_is_io_error` (limite ⇒ Io), `drain_with_readiness_nominal_still_completes` (cas nominal Final intact). Aucun bug détecté : le code DevBackend est conforme au cadrage §21.5 sur tous les axes (ordre des events, clamp via plan_resume, dédoublonnage silencieux, prompt constant, propagation d'erreur sans event, contrat anti-course du cancel). Je n'ai modifié AUCUNE ligne de prod (uniquement ajouté 2 fichiers de tests). Feature LS4 validée côté tests application. Prêt pour la suite du cycle (Git : merge éventuel). +- **Prompt:** Tests LS5 — parser regex niveau 2 (infra) : `RateLimitParser` (crates/infrastructure/src/ratelimit/mod.rs) + le module d'heure partagé `timeparse` (crates/infrastructure/src/timeparse.rs). Branche feature/agent-session-limits. DevBackend a livré (cargo build -p infrastructure OK, 0 warning ; tests LS2 toujours verts). Tout est pur (now_ms injecté), testable sans I/O. Écris et exécute les tests dans le style existant. RateLimitParser (new + detect + applies) : - `new` sur regex INVALIDE → None (jamais de panique) ; - pattern qui matche SANS reset_capture → `Some(SessionLimit{ resets_at_ms: None, source: Pattern, detected_at_ms == now_ms })` ; - capture nommée `(?P...)` + time_format ABSOLU : `epoch_s` (secondes→×1000), `epoch_ms` (tel quel), `iso8601`/`rfc3339` (`...Z` → ms attendus) → resets_at_ms corrects ; - time_format RELATIF (`relative_s`, capture « 600 », now=T) → `Some(resets_at_ms == T + 600_000)` ; - time_format MURAL (`wall`, capture « 3pm ») : now correspondant à 10h du jour → 15h AUJOURD'HUI (même jour UTC) ; now correspondant à 16h → 15h DEMAIN (passage de minuit, +24h). Choisis des now_ms calculés proprement (epoch connu) et calcule l'attendu à la main ; - pattern NE matche PAS → detect → None ; - pattern matche mais capture absente/valeur pourrie/non parsable → `Some(SessionLimit{ resets_at_ms: None })` (détection utile sans heure) ; - vérifie que `source == RateLimitSource::Pattern` dans tous les cas détectés ; - compilation du regex faite une seule fois (à new) — au minimum vérifie que detect peut être appelé plusieurs fois sans souci. applies(profile) : - profil avec structured_adapter (structuré) → false (même s'il a un rate_limit_pattern) ; - profil PTY (sans structured_adapter) AVEC rate_limit_pattern → true ; - profil PTY SANS rate_limit_pattern → false. timeparse (fonctions réexportées) : - `days_from_civil` : 1970-01-01 → 0 ; une année bissextile (2024-02-29) cohérente ; - `parse_wall_clock` : « 3pm » → (15,0,0) ; « 15:00:30 » → (15,0,30) ; format invalide → None ; - `wall_clock_to_ms` : même jour vs lendemain selon now ; - `parse_absolute_ms` : epoch s/ms/ISO cohérents (recoupe avec LS2 si utile). - NON-RÉGRESSION : confirme que les tests LS2 (parse_reset_ms/parse_event dans session/mod.rs) restent verts après le refactor qui a déplacé les helpers vers timeparse. Exécute `cargo test -p infrastructure`. Rends : rapport complet (nb tests, pass/fail), confirmation explicite que LS2/LS3 restent verts, et en cas d'échec rapport CLAIR (attendu vs obtenu) pour DevBackend. EN PLUS (petit nettoyage que DevBackend t'a signalé) : tes tests scheduler LS3 (crates/infrastructure/src/scheduler/mod.rs, ~lignes 253/285) déclenchent 2 warnings `irrefutable if let` car ScheduledTask est mono-variante aujourd'hui. Corrige ces 2 warnings dans le code de TEST (ex. déstructuration directe au lieu de `if let`) pour garder la suite sans warning — c'est du test, tu peux le modifier. Ne touche à aucun code de prod ; signale tout vrai bug sans le corriger. +- **Response:** ✅ LS5 — Tests parser regex niveau 2 (`RateLimitParser`) + module `timeparse` : TOUT VERT, 0 warning, zéro régression. Commande : `cargo test -p infrastructure` Résultat lib : **221 passed; 0 failed; 0 ignored** (195 antérieurs + **26 nouveaux LS5**). Tous les fichiers d'intégration + doctests verts. **Build de test : 0 warning** (vérifié `--no-run | grep -c warning` = 0). Confirmation explicite NON-RÉGRESSION : - **LS2** (`session::tests::parse_reset_ms*` / `parse_event*` / `send_emits_rate*`) : tous verts APRÈS le refactor qui a déplacé les helpers vers `timeparse` ✓ - **LS3** (`scheduler::tests::*`) : 7/7 verts ✓ (+ les 2 warnings `irrefutable if let` corrigés — voir plus bas) === `ratelimit/mod.rs` — 15 tests (in-file `#[cfg(test)] mod tests`) === new + detect : - `new_returns_none_on_invalid_regex` : regex invalide `"rate limit ("` ⇒ None (jamais de panique). - `detect_returns_none_when_pattern_does_not_match` : pas de match ⇒ None. - `detect_match_without_reset_capture_has_no_time` : match sans reset_capture ⇒ `SessionLimit{resets_at_ms:None, source:Pattern, detected_at_ms==now}`. - formats ABSOLUS : `detect_epoch_seconds_format` (×1000), `detect_epoch_millis_format` (tel quel), `detect_iso8601_format` (`2023-11-14T22:13:20Z`→1_700_000_000_000). - format RELATIF : `detect_relative_seconds_format_uses_now` (capture « 600 », now=T ⇒ T+600_000). - format MURAL (passage de minuit, math calculée à la main sur DAY_START=1_699_920_000_000 = 2023-11-14T00:00Z) : `detect_wall_clock_same_day_when_future` (now=10h, « 3pm » ⇒ 15h même jour) ; `detect_wall_clock_next_day_when_past` (now=16h ⇒ 15h DEMAIN, +24h). - capture inexploitable ⇒ détection sans heure : `detect_match_with_missing_capture_group_has_no_time`, `detect_match_with_unparsable_value_has_no_time` (⇒ `resets_at_ms:None`). - `detect_can_be_called_multiple_times` : regex compilé une seule fois, detect appelé 3× sans souci. - `source == RateLimitSource::Pattern` asserté dans tous les cas détectés. applies(profile) : - `applies_false_for_structured_profile_even_with_pattern` (structuré ⇒ false, niveau 1 prend la main) ; - `applies_true_for_pty_profile_with_pattern` (PTY + pattern ⇒ true) ; - `applies_false_for_pty_profile_without_pattern` (PTY sans pattern ⇒ false). === `timeparse.rs` — 11 tests === - `days_from_civil_epoch_is_zero` (1970-01-01→0) ; `days_from_civil_leap_day_2024` (2024-02-29→19782, + cohérence avec le 1ᵉʳ mars). - `parse_wall_clock_variants` (« 3pm »→(15,0,0), « 15:00:30 »→(15,0,30), « 3:30 pm »→(15,30,0), 12am→0h, 12pm→12h) ; `parse_wall_clock_rejects_invalid` (texte, 25:00, 10:75, trop de composantes ⇒ None). - `wall_clock_to_ms_*` : même jour / lendemain / cas-limite « cible == now » (⇒ lendemain, jamais le présent). - `parse_absolute_ms_*` : epoch s/ms, ISO8601, garbage→None (recoupe LS2). - `parse_rfc3339_offsets_convert_to_utc` : +01:00 et −01:00 ⇒ même instant UTC. === Nettoyage LS3 demandé === Les 2 warnings `irrefutable if let` (scheduler/mod.rs ~253/285, `ScheduledTask` mono-variante) sont corrigés : remplacés par une **déstructuration directe** `let ScheduledTask::ResumeAgent { conversation_id, .. } = task;` (code de TEST uniquement). Suite désormais 0 warning. Aucun bug détecté : le code DevBackend est conforme au cadrage §21 niveau 2 sur tous les axes (robustesse regex, stratégies de format absolu/relatif/mural, passage de minuit UTC, règle anti-double-détection `applies`, factorisation `timeparse` sans casser LS2). Je n'ai modifié AUCUN code de prod (uniquement ajouté des tests + corrigé mes propres tests LS3). Feature LS5 validée côté tests infra. Prêt pour la suite du cycle. +- **Prompt:** Tâche QA — tests unitaires de la slice LS7-front (UI limites de session des agents), branche `feature/agent-session-limits`. DevFrontend vient d'implémenter (typecheck `tsc --noEmit` propre, 39 tests agents existants toujours verts, non commité). Périmètre à couvrir (frontend/) : 1. `src/features/agents/useAgents.ts` — nouvel état `limitByAgent: Record` (`{ limitedUntil?, resumeFireAt?, suspected? }`) peuplé depuis 5 DOMAIN_EVENT dans l'abonnement `onDomainEvent`. À tester (via le mock gateway qui émet des events) : - `agentRateLimited` {agentId, resetsAtMs} → entrée `{ limitedUntil: resetsAtMs, suspected: false }`. - `agentResumeScheduled` {agentId, fireAtMs} → arme `resumeFireAt`. - `agentResumeCancelled` {agentId} → retire `resumeFireAt`, reste limité. - `agentResumed` {agentId} → supprime l'entrée (état effacé). - `agentRateLimitSuspected` {agentId, resetsAtMs?} → `{ ..., suspected: true }`, y compris le cas SANS `resetsAtMs` (heure inconnue). - séquence réaliste : rateLimited → resumeScheduled → cancelResume (action) → vérifier retrait optimiste + appel `input.cancelResume` (mock `cancelledResumes` / `cancelResumeResult`). - Action `cancelResume(agentId)` exposée par le hook : retrait optimiste + verdict backend renvoyé (teste les deux verdicts via `cancelResumeResult`). 2. `src/features/agents/AgentLimitBadge.tsx` — helpers purs exportés `formatResetTime(epochMs)` (→ HH:MM) et `formatCountdown(remainingMs)` (→ « Xm Ys »/« Ys », clampé à 0 pour négatif). Teste ces helpers purs directement (cas limites : 0, négatif, < 60 s, > 60 s, minuit). Teste le rendu du badge (limité jusqu'à HH:MM vs « limité » sans heure vs « heure inconnue » pour suspected sans resetsAtMs) et que le bouton « Annuler la reprise » appelle l'action quand `resumeFireAt` est armé. 3. Adapter mock `src/adapters/mock/index.ts` — `MockInputGateway.cancelResume` (`cancelledResumes`, `cancelResumeResult`) : couvre via les tests du hook. Suis le style des tests existants (cf. `singletonAgent.test.tsx`, `mock.test.ts`, les tests agents). Exécute la suite (`npm test` ou l'outil du repo) et RENDS-MOI via idea_reply : la commande exacte lancée, le résultat réel (nb tests passés/échoués), et le rapport d'erreurs complet si rouge. Si un test révèle un bug d'implémentation, décris-le précisément (fichier:ligne, attendu vs obtenu) — je relaierai à DevFrontend pour correction. Ne commit rien. +- **Response:** QA LS7-front — VERT. Aucun bug d'implémentation trouvé. Rien commité. ## Fichiers de test ajoutés (2) - `frontend/src/features/agents/useAgentsLimits.test.tsx` (13 tests) — état `limitByAgent` du hook via le `MockSystemGateway` qui émet les events + `MockInputGateway`. - `frontend/src/features/agents/AgentLimitBadge.test.tsx` (11 tests) — helpers purs + rendu du badge. ## Commandes exactes lancées (cwd = frontend/) 1. `npx vitest run src/features/agents/useAgentsLimits.test.tsx src/features/agents/AgentLimitBadge.test.tsx` → **Test Files 2 passed (2) · Tests 24 passed (24)** 2. `npx vitest run src/features/agents/` (suite agents complète, régression) → **Test Files 4 passed (4) · Tests 63 passed (63)** (39 existants + 24 nouveaux, 0 régression) ## Couverture livrée useAgents (§21), tous via events réels : - `agentRateLimited{resetsAtMs}` → `{limitedUntil, resumeFireAt:undefined, suspected:false}` ✓ - `agentResumeScheduled{fireAtMs}` → arme `resumeFireAt` par-dessus l'état limité ✓ - `agentResumeCancelled` → retire `resumeFireAt`, reste limité ✓ ; + no-op sur agent inconnu (aucune entrée créée) ✓ - `agentResumed` → entrée supprimée (`agentId in map === false`) ✓ - `agentRateLimitSuspected` AVEC et SANS `resetsAtMs` → `suspected:true`, `limitedUntil` undefined dans le cas sans heure ✓ - séquence réaliste rateLimited→scheduled→`cancelResume(action)` : retrait optimiste du countdown + agent toujours limité + `input.cancelledResumes === [agentId]` + verdict ✓ - action `cancelResume` : verdict `true` ET `false` (via `cancelResumeResult`), + appel du port même sans reprise armée ✓ - deux agents foldés indépendamment ✓ AgentLimitBadge : - `formatCountdown` : 0→"0s", négatif→"0s" (clamp), <60s→"Xs", ceil partiel (4.2s→"5s"), ≥60s→"Xm Ys" (60000→"1m 0s", 125000→"2m 5s") ✓ - `formatResetTime` : HH:MM sans secondes (même minute +30s = même libellé), minuit stable ✓ - rendu : "limité jusqu'à HH:MM" (heure connue) / "limité" (sans heure) / note "heure inconnue — reprise à préciser" (suspected sans heure) + absence de la note quand suspected A une heure ✓ - bouton "Annuler la reprise" : appelle `onCancelResume` au clic quand `resumeFireAt` armé, countdown rendu, bouton désactivé si `busy` ✓ MockInputGateway.cancelResume (`cancelledResumes`/`cancelResumeResult`) : couvert via les tests du hook. ## Note hors-périmètre (PRÉEXISTANTE, pas causée par LS7-front) `src/adapters/mock/mock.test.ts:16` échoue (1 test) : il assert « thirteen gateways » mais le `permission` gateway porte le compte à 14. Vérifié : la gateway `permission` existe déjà dans HEAD committé (eca2ba9) — l'assertion de comptage est simplement périmée, sans rapport avec les limites de session. Mes deux fichiers ne touchent pas ce test. Aucune action de ma part (hors scope) ; à relayer pour une mise à jour du compteur si tu veux la suite `adapters/mock` 100% verte. +- **Prompt:** Tâche QA — tests unitaires LS8-backend (filet humain niveau 3), branche `feature/agent-session-limits`. DevBackend a implémenté (build 0 erreur/0 warning, suites domain/application/app-tauri déjà vertes, non commité). À COUVRIR — nouvelle méthode `SessionLimitService::confirm_human_resume(agent_id, node_id, conversation_id, resets_at_ms: i64)` dans `crates/application/src/agent/session_limit.rs`. Teste via les fakes existants (Clock/Scheduler/EventBus) du fichier `crates/application/tests/session_limit_service.rs` (suis ce style). Cas (cadrage Architect) : (a) heure FUTURE ⇒ publie `AgentRateLimited{Some(resets_at_ms)}` puis `AgentResumeScheduled{fire_at_ms == resets_at_ms}` + un ScheduleId armé sur le scheduler. (b) heure PASSÉE (resets_at_ms < now) ⇒ clamp : `fire_at_ms == now` (reprise quasi-immédiate). Vérifie l'événement `AgentResumeScheduled` avec fire_at_ms == now. (c) DÉDOUBLONNAGE croisé : `confirm_human_resume` APRÈS un `on_rate_limited` déjà armé pour le même agent ⇒ le précédent réveil est désarmé, une seule entrée armée (et l'inverse : `on_rate_limited` après `confirm_human_resume`). Vérifie qu'il n'y a qu'un seul armement actif. (d) ANNULABILITÉ : `cancel_resume(agent_id)` après `confirm_human_resume` ⇒ publie `AgentResumeCancelled` et renvoie true (l'armement humain s'annule par la même voie). (e) PARITÉ auto/humain : même séquence d'événements qu'`on_rate_limited` cas Scheduled (à reset identique). À COUVRIR — commande `set_resume_at` (app-tauri) si testable au niveau intégration comme `session_limit_wiring.rs` : au minimum le cas `node_for_agent == None` ⇒ erreur NOT_FOUND (pas d'armement orphelin). Si le câblage rend ce test trop lourd (besoin d'une cellule vivante), note-le et concentre-toi sur la couverture application (a–e) qui est l'essentiel. Exécute (`cargo test -p application` + `-p app-tauri` si pertinent). RENDS-MOI via idea_reply : commande exacte, résultat réel (passés/échoués), rapport d'erreurs complet si rouge. Si un test révèle un bug (notamment sur le dédoublonnage croisé ou le clamp passé), décris-le précisément (attendu vs obtenu) — je relaierai à DevBackend. Ne commit rien. +- **Response:** QA LS8-backend (filet humain niveau 3) — VERT. Aucun bug d'implémentation trouvé (ni sur le dédoublonnage croisé, ni sur le clamp passé). Rien commité. ## Fichiers de test modifiés (2 — ajouts only, fakes existants réutilisés) - `crates/application/tests/session_limit_service.rs` : +6 tests `confirm_human_resume` (style des fakes Clock/Scheduler/EventBus existants). - `crates/app-tauri/tests/session_limit_wiring.rs` : +2 tests (précondition NOT_FOUND + parité runtime humaine sur le vrai bus). ## Commandes exactes lancées + résultats réels 1. `cargo test -p application --test session_limit_service` → **15 passed; 0 failed** (10 existants + 5 nouveaux). 2. `cargo test -p app-tauri --test session_limit_wiring` → **4 passed; 0 failed** (2 existants + 2 nouveaux). 3. `cargo test -p application` (régression complète) → **toutes suites vertes, 0 failed**. 4. `cargo test -p app-tauri` (régression complète) → **toutes suites vertes, 0 failed**. ## Couverture application (a–e) — l'essentiel - **(a) heure FUTURE** `confirm_human_resume_future_arms_and_emits_in_order` : exactement 1 `arm(fire_at_ms==resets_at_ms, ResumeAgent{agent,node,conv})`, 1 ScheduleId émis, events `AgentRateLimited{Some(reset)}` PUIS `AgentResumeScheduled{fire_at_ms==reset}` dans cet ordre. ✓ - **(b) heure PASSÉE** `confirm_human_resume_past_reset_clamps_fire_at_to_now` : `fire_at_ms == now` (clamp anti-passé) ; `AgentRateLimited` garde l'heure brute passée, `AgentResumeScheduled` porte le `now` clampé. ✓ - **(c) dédoublonnage CROISÉ** (les deux sens) : - `confirm_human_resume_after_auto_dedups_single_active_arm` : confirm APRÈS on_rate_limited → l'ancien ScheduleId auto est cancel-é (`cancels == [issued[0]]`), pas d'`AgentResumeCancelled` (silencieux). Unicité prouvée : 1er `cancel_resume`→true, 2e→false. ✓ - `auto_after_confirm_human_resume_dedups_single_active_arm` : on_rate_limited APRÈS confirm → symétrique, même invariant d'unicité. ✓ - **(d) ANNULABILITÉ** `cancel_resume_after_confirm_human_resume_returns_true_and_emits_cancelled` : renvoie `true` + publie `AgentResumeCancelled` (même voie que l'auto). ✓ - **(e) PARITÉ auto/humain** `confirm_human_resume_is_event_for_event_identical_to_auto_scheduled` : à reset futur identique, `bus.events()` ET `scheduler.armed()` strictement égaux entre `confirm_human_resume` et `on_rate_limited`. La source Human vs Structured n'a aucun effet observable. ✓ ## Couverture app-tauri `set_resume_at` - **NOT_FOUND** `set_resume_at_resolves_no_cell_for_an_agent_without_a_live_session` : ✓ couvert au niveau précondition. NOTE : la commande `#[tauri::command] set_resume_at` exige `State`, non constructible hors runtime Tauri — le harnais wiring existant n'invoque jamais les commandes, seulement le service. J'ai donc testé la **précondition exacte** qui produit le NOT_FOUND : sur un `AppState::build` neuf, `structured_sessions.node_for_agent(unknown)` ET `terminal_sessions.node_for_agent(unknown)` renvoient `None` → la branche `ok_or_else(NotFound)` (commands.rs:1420-1428) est prise → aucun armement orphelin. C'est la couverture maximale réaliste sans faire tourner Tauri. - **Parité runtime** `confirm_human_resume_arms_a_cancellable_resume_over_the_real_bus` : sur le vrai `TokioBroadcastEventBus`, `confirm_human_resume` publie `AgentRateLimited` puis `AgentResumeScheduled` et l'armement est annulable (`cancel_resume`→true), exactement comme la branche auto déjà testée. ✓ ## Observation (non bloquante, pas un bug) `confirm_human_resume` est total et défensif : le cas `ResumePlan::HumanFallback` y est inatteignable (`resets_at_ms` toujours `Some`) → traité en no-op via `if let Scheduled`. Conforme au cadrage Architect ; pas de test dédié (branche morte par construction). Build app-tauri OK (0 erreur). Rien n'a été commité. +- **Prompt:** Tâche QA — validation LS8-front (formulaire de saisie d'heure, filet humain niveau 3) sur `feature/agent-session-limits`. DevFrontend a implémenté ET ajusté/ajouté des tests (typecheck propre, `npx vitest run src/features/agents/ src/adapters/mock/` = 109 tests verts). Ton rôle : valider la couverture, exécuter, et signaler tout trou ou bug. Points à vérifier/exécuter : 1. Helper pur `timeInputToEpochMs(value, now)` (AgentLimitBadge.tsx) : cas couverts ? « HH:MM » valide → aujourd'hui à cette heure ; instant passé dans la journée → epoch < now (le backend clampe, pas le front) ; malformé/vide/`25:00`/`12:60` → `null`. Ajoute les cas manquants si besoin. 2. Soumission du formulaire (état suspected SANS heure) ⇒ `onSetResumeAt` / action hook `setResumeAt` appelée avec l'epoch attendu ; bouton désactivé si saisie vide ou `busy` ; saisie malformée (`null`) ne déclenche PAS d'appel. 3. Action hook `useAgents.setResumeAt(agentId, resetsAtMs)` : délègue bien au port `input.setResumeAt`, aucune mutation optimiste (vérifie que l'état ne bascule QUE sur réception de `agentResumeScheduled`, pas avant). Mock instrumenté `resumeArmings`. 4. Bascule complète : agent suspected sans heure → submit form → émettre `agentResumeScheduled` (via mock) → le badge passe à l'état nominal (countdown + bouton Annuler), le formulaire disparaît. 5. Non-régression : tout `src/features/agents/` + `src/adapters/mock/` vert. Confirme le compteur gateways corrigé (14) dans mock.test.ts. Exécute `npx vitest run src/features/agents/ src/adapters/mock/` (+ `tsc --noEmit` si tu veux). RENDS-MOI via idea_reply : commande exacte, résultat réel (passés/échoués), trous comblés (fichiers/tests ajoutés), et tout bug trouvé (attendu vs obtenu, fichier:ligne) — je relaierai à DevFrontend. Ne commit rien. \ No newline at end of file diff --git a/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/log.jsonl b/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/log.jsonl index d817152..7f93697 100644 --- a/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/log.jsonl +++ b/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/log.jsonl @@ -35,3 +35,18 @@ {"id":"0056caaf-4a8d-4d54-9efd-1f7292e2723a","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781590102120,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"✅ VERT — test bout-en-bout de l'enforcement OS (lot LP4-3) ajouté et passant, enforcement Landlock réellement actif sur le chemin PTY (pas skippé sur ce kernel).\n\n## Fichier modifié\n`crates/infrastructure/src/pty/mod.rs` — nouveau module de test `#[cfg(all(test, target_os = \"linux\"))] mod sandbox_e2e_tests` (en plus du module `tests` existant, intact). 2 tests + helpers.\n\n## Ce que ça prouve\nTest principal **`pty_spawn_enforces_sandbox_plan_end_to_end`** — exerce la chaîne réelle complète, ZÉRO CLI IA / ZÉRO token :\n`PortablePtyAdapter::new().with_sandbox_enforcer(default_enforcer())` (= LandlockSandbox) → `SpawnSpec { sandbox: Some(plan) }` avec `plan` = `PathGrant{ allowed RW }` seul, posture `Ask` → `adapter.spawn(...)` → branche `(Some(plan), Some(enforcer))` → `spawn_command_sandboxed` (thread restreint par Landlock puis fork/exec).\n- Commande inoffensive : `sh -c \"echo outside > /out.txt; echo inside > /in.txt\"`.\n- **Ordre volontaire** : l'écriture HORS-grant d'abord, l'écriture DANS le grant ensuite. L'apparition du marqueur `allowed/in.txt` est donc un signal *happens-after* prouvant que la tentative hors-grant a déjà eu lieu → assertion déterministe, pas de course avec l'enfant.\n- Asserts : (1) `allowed/in.txt` finit par exister (écriture autorisée passe ; reads non gouvernés car seul RW posé → `sh`/libc chargent normalement) ; (2) `denied/out.txt` **n'existe PAS** → l'écriture hors-grant a été bloquée par le kernel = « SANDBOX BREACH » sinon.\n\nTest compagnon **`pty_spawn_without_plan_does_not_sandbox`** — même adapter+enforcer mais `sandbox: None` → l'écriture dans le même dossier réussit. Garde-fou anti faux-positif : prouve que le blocage du 1er test vient bien du *plan enforcé*, pas d'une restriction PTY ambiante ou d'un échec d'écriture sans rapport.\n\n## Stratégie de skip (CI/kernel sans Landlock)\n- Tout le module est `#[cfg(target_os = \"linux\")]` (pas de compilation hors Linux).\n- Helper `landlock_is_enforced()` qui sonde l'ABI exactement comme les tests adapter existants : `LandlockSandbox::enforce(plan RW)` sur un **thread jetable** (restriction irréversible → jamais sur le thread de test), skip si `SandboxStatus::Unsupported`. Le test principal `return` proprement avec message `eprintln!` si non enforcé → CI verte sur runner sans Landlock LSM.\n- Helpers réutilisés/calqués sur `sandbox/landlock.rs` : `fresh_dir(tag)` (temp dir unique, zéro dép tempfile), enforce sur thread jetable, posture `Ask`. Attente bornée via `wait_for(path, 5s)`.\n\n## Sortie brute d'exécution\n```\n$ cargo test -p infrastructure --lib sandbox_e2e_tests -- --nocapture --test-threads=1\n Finished `test` profile [unoptimized + debuginfo] target(s) in 1.53s\n Running unittests src/lib.rs (target/debug/deps/infrastructure-2cc30d2a1793b4e4)\n\nrunning 2 tests\ntest pty::sandbox_e2e_tests::pty_spawn_enforces_sandbox_plan_end_to_end ... ok\ntest pty::sandbox_e2e_tests::pty_spawn_without_plan_does_not_sandbox ... ok\n\ntest result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 154 filtered out; finished in 0.05s\n```\n\nPreuve que l'enforcement a **réellement** tourné (et non été skippé) sur ce kernel — les tests adapter Landlock existants ne logguent AUCUN « skipping » :\n```\n$ cargo test -p infrastructure --lib sandbox::landlock -- --nocapture\ntest sandbox::landlock::tests::read_only_plan_fences_reads_to_the_grant ... ok\ntest sandbox::landlock::tests::landlock_write_only_plan_fences_writes_to_the_grant ... ok\ntest sandbox::landlock::tests::enforcement_is_confined_to_the_enforcing_thread_idea_is_never_sandboxed ... ok\ntest result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 151 filtered out; finished in 0.00s\n```\n\nPas de commit (figé par l'orchestrateur, comme demandé). Aucune régression introduite : ajout de tests uniquement, module `tests` PTY existant inchangé."} {"id":"8b23e383-731d-42bb-89fd-314079b69874","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781591869015,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tests bout-en-bout du lot LP4-4 — enforcement Landlock sur le chemin STRUCTURÉ (sessions Claude/Codex mode JSON). DevBackend a livré (non committé), build + suites existantes vertes. L'Architecte a validé l'approche et défini 7 invariants à couvrir. Objectif : prouver l'enforcement réellement actif bout-en-bout sur le chemin structuré, ZÉRO token (aucun vrai claude/codex — utilise un fake CLI / `sh` qui émet une ligne JSONL).\n\nChaîne réelle livrée : `StructuredSessionFactory::new().with_sandbox_enforcer(default_enforcer())` (composition root) ; `AgentSessionFactory::start(.., sandbox: Option<&SandboxPlan>)` apparie plan (par-appel) + enforcer (par-instance) ; `SpawnLine.sandbox: Option` ; `run_turn(spec, timeout, enforcer)` route vers `run_turn_sandboxed`/`drain_sandboxed` (`#[cfg(target_os=\"linux\")]`, thread jetable restreint par enforce() AVANT le spawn std, puis std::process::Command::spawn depuis ce thread → héritage Landlock ; timeout via oneshot killer + tokio::time::timeout ; fail-closed sur Err d'enforce). Pas de pre_exec (forbid(unsafe_code) ; héritage credentials garanti par le noyau).\n\nRéutilise les patterns/helpers des tests Landlock existants (`crates/infrastructure/src/sandbox/landlock.rs` tests + le module `sandbox_e2e_tests` ajouté dans `pty/mod.rs` au lot LP4-3) : `landlock_is_enforced()` (skip propre kernel sans Landlock), enforce sur thread jetable, `fresh_dir`, attente bornée.\n\nLES 7 INVARIANTS À COUVRIR (de l'Architecte) :\n1. PARITÉ (test pivot) : fake CLI/`sh` qui émet une ligne JSONL et tente d'écrire HORS grant (doit être bloqué kernel) et DANS grant (doit réussir). Cible le chemin structuré (run_turn_sandboxed via la factory réelle).\n2. COMPANION NÉGATIF : même factory+enforcer mais sandbox==None ⇒ écriture hors-grant RÉUSSIT (prouve que le blocage vient du plan, pas d'une restriction ambiante).\n3. FAIL-CLOSED : posture Deny sur kernel sans Landlock ⇒ enforce Err ⇒ run_turn renvoie erreur (Start) et AUCUN child ne tourne (marqueur de sortie absent).\n4. NO-OP PAR DÉFAUT : eff==None ⇒ plan None ⇒ chemin async tokio actuel, comportement natif (vérifie que conformance/D0/D3 restent verts sans changement de comportement).\n5. CONFINEMENT IRRÉVERSIBILITÉ : deux tours successifs (sessions différentes) avec grants DISJOINTS ⇒ chacun voit son propre périmètre, le tour précédent n'affecte pas le suivant (thread jetable mort).\n6. TIMEOUT SOUS SANDBOX : fake CLI qui ne ferme jamais stdout ⇒ run_turn(timeout) tue le child et renvoie Timeout, sans zombie ni thread bloqué.\n7. RESUME PRÉSERVÉ : le conversation_id/session_id est toujours capté correctement après un tour sandboxé (la restriction FS ne casse pas le parsing).\n\nTout derrière `#[cfg(target_os=\"linux\")]` + skip via `landlock_is_enforced()`. À la fin : exécute et donne la sortie BRUTE. NE committe PAS (figé par l'orchestrateur). Rends via idea_reply : fichier(s) de test ajoutés, mapping test→invariant (les 7), stratégie de skip, et sortie d'exécution réelle. Si un invariant ne passe pas, rapport d'erreur clair avec sortie brute → je renvoie à DevBackend."} {"id":"26a58843-91dd-4d78-ad39-c8c223b1f92c","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781592397496,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"✅ VERT — Tests bout-en-bout LP4-4 (enforcement Landlock sur le chemin STRUCTURÉ, sessions Claude/Codex JSON) ajoutés et passants. Les 7 invariants de l'Architecte couverts. ZÉRO token (aucun vrai claude/codex : `sh` qui émet une ligne JSONL + tente des écritures FS, ou le `FakeCli` scriptable existant).\n\n## Fichiers\n- **Ajouté** : `crates/infrastructure/src/session/sandbox_e2e.rs` — 7 tests + helpers, derrière `#[cfg(all(test, target_os = \"linux\"))]`.\n- **Modifié** : `crates/infrastructure/src/session/mod.rs` — déclaration `#[cfg(all(test, target_os = \"linux\"))] mod sandbox_e2e;` (uniquement la ligne de module, suites existantes intactes).\n\n## Mapping test → invariant (les 7)\n1. **PARITÉ (pivot)** → `pty_structured_run_turn_enforces_plan_end_to_end` : `run_turn` + plan RW(allowed) + enforcer Landlock. Asserte : la ligne JSONL est **drainée** (parsing intact sous sandbox), écriture in-grant réussit, écriture hors-grant **bloquée noyau** (fichier absent). Exact pendant du pivot PTY LP4-3, mais via `process::run_turn_sandboxed`.\n2. **COMPANION NÉGATIF** → `structured_run_turn_without_plan_does_not_sandbox` : même enforcer câblé mais `sandbox==None` ⇒ écriture hors-grant RÉUSSIT ⇒ le blocage (1) vient du plan, pas d'une restriction ambiante.\n3. **FAIL-CLOSED** → `structured_run_turn_fail_closed_no_child_on_enforce_err` : enforcer double `AlwaysFailEnforcer` (renvoie `SandboxError::KernelTooOld`, simule fidèlement « Deny + kernel sans Landlock » de façon **déterministe**, indépendamment du kernel de CI). `run_turn` ⇒ `AgentSessionError::Start` ET le marqueur que le child aurait écrit reste **absent** (aucun enfant lancé).\n4. **NO-OP PAR DÉFAUT** → `structured_run_turn_none_plan_is_native_path` : `plan==None` (même avec enforcer fourni) ⇒ chemin async tokio historique, lignes drainées + écriture arbitraire réussit (aucune restriction). Non-régression confirmée par la suite `session::` complète (cf. ci-dessous).\n5. **CONFINEMENT/IRRÉVERSIBILITÉ** → `structured_two_turns_disjoint_grants_are_confined` : deux `run_turn` successifs à grants DISJOINTS (A puis B). Chacun écrit dans son grant (ok) et dans l'autre (bloqué). Le tour B écrit bien dans B ⇒ la restriction du thread jetable de A **n'a pas bavé**.\n6. **TIMEOUT SOUS SANDBOX** → `structured_run_turn_timeout_under_sandbox` : `sleep 30` (stdout jamais fermé) via la branche sandboxée, `run_turn(timeout 250ms)` ⇒ `AgentSessionError::Timeout` rapidement (asserté < 10s, pas d'attente des 30s) ⇒ killer oneshot + `tokio::time::timeout` OK, pas de thread bloqué.\n7. **RESUME PRÉSERVÉ** → `structured_sandboxed_turn_preserves_conversation_id` : via la **fabrique réelle** `StructuredSessionFactory::new().with_sandbox_enforcer(default_enforcer())`, profil Claude sur `FakeCli` (init+result), `start(.., Some(&plan))`. Sous enforcement actif, `send()` rend exactement un `Final` et `conversation_id()` ⇒ `Some(\"conv-sbx-1\")` ⇒ la restriction FS ne casse pas le parsing/la capture d'id.\n\n## Stratégie de skip\n- Tout le module est `#[cfg(all(test, target_os = \"linux\"))]` (rien hors Linux).\n- Helper `landlock_is_enforced()` (calqué sur `sandbox/landlock.rs` et `pty::sandbox_e2e_tests`) : `enforce` d'un plan RW sur **thread jetable** (restriction irréversible ⇒ jamais sur le thread de test), skip propre (`eprintln!` + `return`) si `SandboxStatus::Unsupported`.\n- Gardés par `landlock_is_enforced()` (exigent un fencing réel) : invariants **1, 5, 7**. Indépendants du kernel (déterministes sans Landlock) : **2** (contrôle natif), **3** (fake enforcer), **4** (plan None), **6** (timeout — branche sandboxée prise sous posture Ask, enforce Ok même sans Landlock).\n- Helpers réutilisés : `fresh_dir`, posture `Ask`, ligne JSONL `result` réaliste (format Claude vérifié), `FakeCli` de `conformance`.\n\n## Sortie brute d'exécution\n```\n$ cargo test -p infrastructure --lib session::sandbox_e2e -- --nocapture --test-threads=1\n Finished `test` profile [unoptimized + debuginfo] target(s) in 5.01s\n Running unittests src/lib.rs (target/debug/deps/infrastructure-2cc30d2a1793b4e4)\n\nrunning 7 tests\ntest session::sandbox_e2e::pty_structured_run_turn_enforces_plan_end_to_end ... ok\ntest session::sandbox_e2e::structured_run_turn_fail_closed_no_child_on_enforce_err ... ok\ntest session::sandbox_e2e::structured_run_turn_none_plan_is_native_path ... ok\ntest session::sandbox_e2e::structured_run_turn_timeout_under_sandbox ... ok\ntest session::sandbox_e2e::structured_run_turn_without_plan_does_not_sandbox ... ok\ntest session::sandbox_e2e::structured_sandboxed_turn_preserves_conversation_id ... ok\ntest session::sandbox_e2e::structured_two_turns_disjoint_grants_are_confined ... ok\n\ntest result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 156 filtered out; finished in 0.27s\n```\n**Aucune ligne « skip » imprimée** (run `--nocapture`) ⇒ les invariants gardés (1, 5, 7) ont **réellement** posé l'enforcement sur ce kernel (Landlock actif), pas été contournés.\n\nNon-régression (invariant 4 : conformance/D0/D3 + voisins) :\n```\n=== session === test result: ok. 56 passed; 0 failed; ...\n=== sandbox === test result: ok. 7 passed; 0 failed; ...\n=== pty === test result: ok. 5 passed; 0 failed; ...\n```\n\nPas de commit (figé par l'orchestrateur, comme demandé). Ajout de tests uniquement + 1 ligne de déclaration de module ; aucun code de prod touché."} +{"id":"022eac69-edf1-40fd-ab5a-2c59c3f40583","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781613075655,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tests unitaires LS1 — couche DOMAINE de la feature « Gestion des limites de session des agents ». Branche feature/agent-session-limits. DevBackend vient de livrer le code pur (compile, 0 warning). À toi d'écrire les tests unitaires et de les exécuter (cycle §3, étape test). Tests purs, dans les modules concernés (cohérent avec le style de tests existant du crate domain, ex. les `#[cfg(test)] mod tests` de readiness.rs).\n\nPérimètre exact à couvrir (issu du livrable DevBackend) :\n\nreadiness.rs (`ReadinessPolicy::classify`) :\n- `ReplyEvent::RateLimited { resets_at_ms }` → `Some(ReadinessSignal::RateLimited { resets_at_ms })` (avec Some(t) ET None) ;\n- non-régression : Final→TurnEnded ; TextDelta/ToolActivity/Heartbeat→None inchangés ;\n- `ReadinessSignal` reste `Copy` (test de compilation : copie implicite).\n\nsession_limit.rs (`plan_resume`, `SessionLimit`, `ResumePlan`) :\n- `plan_resume` avec `resets_at_ms = Some(futur)` → `ResumePlan::Scheduled { fire_at_ms = resets_at, conversation_id transmis }` ;\n- clamp anti-passé : `resets_at_ms = Some(passé < now)` → `fire_at_ms == now_ms` (jamais dans le passé) ;\n- `resets_at_ms = None` → `ResumePlan::HumanFallback` ;\n- `SessionLimit::has_known_reset` true/false selon Some/None ;\n- conversation_id `Some`/`None` correctement propagé dans `Scheduled`.\n\nprofile.rs (`RateLimitPattern` + champ) :\n- `RateLimitPattern::new` rejette un pattern vide → `DomainError::EmptyField` ;\n- round-trip serde de `AgentProfile` : clé `rateLimitPattern` OMISE quand `None` ; présente et correcte quand `Some` ; JSON legacy (sans la clé) → désérialise en `None` (rétro-compat) ;\n- camelCase respecté sur les champs de `RateLimitPattern` (`resetCapture`, `timeFormat`).\n\nevents.rs : constructibilité + égalité `PartialEq` des 5 variantes (`AgentRateLimited`, `AgentResumeScheduled`, `AgentResumeCancelled`, `AgentResumed`, `AgentRateLimitSuspected`) avec les bons types de champs.\n\nExécute `cargo test -p domain` et rends-moi : le rapport complet (nombre de tests, pass/fail), et en cas d'échec un rapport d'erreurs CLAIR (test concerné, attendu vs obtenu, sortie réelle) pour que je le renvoie à DevBackend. Ne modifie PAS le code de production — seulement les tests ; si un test révèle un vrai bug, signale-le sans le corriger toi-même."} +{"id":"c69c2972-44de-436f-9288-65095f26a722","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781613195230,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"✅ LS1 — Tests unitaires DOMAINE « limites de session » : TOUT VERT.\n\nCommande : `cargo test -p domain`\nRésultat lib (cœur testé ici) : **165 passed; 0 failed; 0 ignored**. Le reste du crate (tests d'intégration profile/structured_session/window + doctests) reste vert également — aucune régression.\n\nJe n'ai touché QUE des tests (aucune ligne de code de prod modifiée). 24 nouveaux tests ajoutés, tous au bon endroit (modules `#[cfg(test)]` du crate domain), dans le style existant :\n\nreadiness.rs (5 tests)\n- `rate_limited_with_known_reset_classifies_and_propagates_time` — Some(t) propagé tel quel\n- `rate_limited_without_reset_classifies_with_none` — None propagé\n- `rate_limited_is_not_classified_as_turn_ended` — non terminal\n- non-régression : Final→TurnEnded déjà couvert + `deltas_activities_and_heartbeats_are_non_terminal` (TextDelta/ToolActivity/Heartbeat→None) inchangés et verts\n- `readiness_signal_is_copy` — test de compilation : copie implicite (move ⇒ ne compilerait plus si le `Copy` sautait)\n\nsession_limit.rs (8 tests)\n- `future_reset_schedules_at_reset_time_with_conversation_id` — Scheduled{fire_at_ms = resets_at, conversation_id transmis}\n- `past_reset_is_clamped_to_now_never_in_the_past` — clamp anti-passé : fire_at_ms == now\n- `reset_exactly_now_fires_at_now` — borne\n- `unknown_reset_falls_back_to_human` — None ⇒ HumanFallback\n- `conversation_id_{some,none}_is_propagated_into_scheduled` — propagation Some/None\n- `has_known_reset_is_true_with_some_and_false_with_none`\n\nprofile.rs (7 tests, dans mcp_tests)\n- `rate_limit_pattern_new_rejects_empty_pattern` — DomainError::EmptyField{ field == \"rateLimitPattern.pattern\" }\n- `rate_limit_pattern_new_accepts_non_empty_pattern`\n- `profile_default_has_no_rate_limit_pattern` / `..._omits_key_in_json` — clé `rateLimitPattern` OMISE quand None (zéro régression)\n- `legacy_json_without_rate_limit_pattern_deserialises_to_none` — rétro-compat\n- `with_rate_limit_pattern_sets_and_round_trips_camel_case` — présente+correcte quand Some, camelCase `resetCapture`/`timeFormat` vérifié\n- `rate_limit_pattern_omits_unset_optional_fields_in_json` — reset_capture/time_format None ⇒ clés omises\n\nevents.rs (6 tests, nouveau module de test)\n- constructibilité + égalité PartialEq des 5 variantes : `AgentRateLimited`, `AgentResumeScheduled`, `AgentResumeCancelled`, `AgentResumed`, `AgentRateLimitSuspected` (avec cas inégaux pour prouver la discrimination des champs)\n- `distinct_session_limit_variants_are_not_equal` — les variantes ne se confondent pas\n\nAucun bug détecté dans le code de prod : tous les invariants du livrable DevBackend sont conformes (clamp `max(t, now)`, omission serde, camelCase, EmptyField, Copy préservé). Feature validée côté tests domaine — prêt pour la suite du cycle (Git : merge éventuel feature/* → develop)."} +{"id":"72c62bbf-ad88-4978-b2d0-494e852aedb4","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781613624886,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tests unitaires LS2 — adapter Claude niveau 1 (infra). Branche feature/agent-session-limits. DevBackend a livré (cargo build -p infrastructure OK, 0 warning). Le code est pur et testable sans process : fonction `parse_event` et nouvelle fonction pure `parse_reset_ms` (+ helpers privés) dans crates/infrastructure/src/session/claude.rs. Écris les tests et exécute-les, dans le style des tests existants de ce module (#[cfg(test)] mod tests de claude.rs).\n\nATTENTION particulière : DevBackend a écrit un parseur ISO-8601/RFC3339 À LA MAIN (pas de chrono/time) + une heuristique secondes-vs-ms (seuil 10^12) + un algo jour-civil (days_from_civil). C'est du code délicat : teste-le rigoureusement, y compris les bords.\n\nCouverture à assurer :\n\nparse_reset_ms (époche-ms en sortie) :\n- noms de champ : `resetsAt`, `resets_at`, `reset_at`, `resetAt`, `reset` — chacun reconnu ; ordre de priorité si plusieurs présents (1er gagne) ;\n- epoch SECONDES (entier < 10^12) → ×1000 ; epoch MILLISECONDES (≥ 10^12) → tel quel ; le seuil exact (valeur juste sous / juste au-dessus de 10^12) ;\n- float epoch (secondes et ms) ;\n- chaîne contenant un entier/float (même heuristique) ;\n- ISO-8601 `...Z` → ms attendus ; ISO avec offset `±hh:mm` → converti en ms UTC corrects ; fraction de seconde `.fff` (tronquée/complétée à 3 chiffres) ;\n- robustesse : rate_limit_info absent / clé inconnue / valeur non numérique pourrie / chaîne ISO invalide → None (jamais de panique, jamais d'erreur) ;\n- days_from_civil & bissextiles : `1970-01-01T00:00:00Z` → 0 ; une date avec `2024-02-29` (année bissextile) valide ; vérifie au moins une date post-2001 connue (calcule l'epoch-ms attendu à la main ou via une valeur de référence sûre).\n\nparse_event (mapping) :\n- ligne `type:\"rate_limit_event\"` avec `rate_limit_info.resetsAt` → ParsedLine contenant `ReplyEvent::RateLimited { resets_at_ms: Some(...) }` ;\n- `rate_limit_event` sans rate_limit_info exploitable → `RateLimited { None }` (et PAS Heartbeat) ;\n- non-régression : `system`/init → Heartbeat + capture session_id inchangés ; `assistant` multi-blocs inchangé ; `result` → Final inchangé ; type inconnu → ignoré ; ligne vide → défaut ; JSON illisible → Decode.\n\nNon-terminalité (T4) au niveau adapter :\n- un enchaînement de lignes `rate_limit_event` puis `result` → le flux émis par `send` (ou la séquence parsée) est `[..., RateLimited, Final]` : RateLimited n'interrompt PAS, seul Final clôt. Si testable au niveau `send` avec le fake CLI existant, fais-le ; sinon teste au niveau séquence de parse_event.\n\nExécute `cargo test -p infrastructure`. Rends : rapport complet (nb tests, pass/fail), et en cas d'échec un rapport CLAIR (test, attendu vs obtenu, sortie réelle) à renvoyer à DevBackend. Ne modifie QUE des tests ; si tu détectes un vrai bug (ex. heuristique de seuil fausse, offset ISO mal converti, bissextile faux), signale-le précisément SANS corriger le code de prod."} +{"id":"3ed93c4c-7936-4399-9f88-95d8fd2d5a48","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781613859303,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"✅ LS2 — Tests adapter Claude niveau 1 (infra) : VERT, mais ⚠️ j'ai trouvé 2 tests existants qui CASSAIENT au départ (régression de contrat assumée par le livrable §21 — détail plus bas).\n\nCommande : `cargo test -p infrastructure`\nRésultat lib (cœur testé) : **188 passed; 0 failed; 0 ignored**. Tous les fichiers d'intégration + doctests également verts. Aucune régression résiduelle.\n\nJ'ai ajouté **26 tests LS2** (tous via fonctions pures `parse_reset_ms`/`parse_event`, sauf 1 via `send()`+FakeCli — jamais le vrai claude). Tout est dans `crates/infrastructure/src/session/mod.rs` (`#[cfg(test)] mod tests`, là où vivent réellement les tests claude.rs).\n\nparse_reset_ms — noms de champ & priorité\n- `recognises_every_field_name` : resetsAt / resets_at / reset_at / resetAt / reset chacun reconnu\n- `first_known_key_wins` : resetsAt prime sur reset (1er de l'ordre gagne)\n\nparse_reset_ms — heuristique secondes/ms + SEUIL\n- `integer_seconds_are_scaled_to_ms` (×1000) / `integer_millis_are_kept_as_is`\n- `threshold_boundary` : 10^12−1 ⇒ secondes (×1000) ; 10^12 pile ⇒ ms (tel quel, borne inclusive côté ms)\n- `float_seconds_preserve_fraction` (1_700_000_000.5 ⇒ 1_700_000_000_500) / `float_millis_kept_as_is`\n- `string_integer_*` / `string_float_*` : même heuristique sur chaînes numériques\n\nparse_reset_ms — ISO-8601 / RFC3339 (parseur maison)\n- `iso_utc_z` : \"2023-11-14T22:13:20Z\" ⇒ 1_700_000_000_000 (recoupé contre l'epoch-secondes connu)\n- `iso_positive_offset` / `iso_negative_offset` / `iso_compact_offset` (+01:00, −01:00, +0100 = même instant UTC)\n- `iso_fraction_padded_and_truncated` : .5⇒500, .123456⇒123 (tronqué), .7⇒700 (complété)\n- robustesse : `unknown_key_yields_none`, `non_numeric_garbage_yields_none` (bool/null/array/objet/chaîne pourrie), `invalid_iso_string_yields_none`\n\ndays_from_civil & bissextiles\n- `unix_epoch_is_zero` : 1970-01-01T00:00:00Z ⇒ 0\n- `leap_day_2024_02_29` : 2024-02-29T00:00:00Z ⇒ 1_709_164_800_000 (calculé à la main)\n- `known_post_2001_date` : 2021-01-01T00:00:00Z ⇒ 1_609_459_200_000\n\nparse_event (mapping) + non-terminalité (T4)\n- `parse_event_rate_limit_with_reset_yields_rate_limited_some` : resetsAt exploitable ⇒ RateLimited{Some}\n- `parse_event_rate_limit_without_info_is_rate_limited_none_not_heartbeat` : absent / clé inconnue ⇒ RateLimited{None} (et PAS Heartbeat)\n- `parse_event_sequence_rate_limited_then_final_is_not_interrupted` : [RateLimited, Final] au niveau parse\n- `send_emits_rate_limited_intercalated_only_final_closes` (FakeCli) : flux complet = [Heartbeat, RateLimited, TextDelta, Final], le RateLimited n'interrompt PAS, un seul Final clôt — prouvé bout-en-bout\n\n⚠️ DEUX TESTS EXISTANTS CASSAIENT au lancement initial (avant mes ajouts) — ce n'est PAS un bug de prod, c'est l'ancien contrat qui survivait :\n1. `claude_parse_rate_limit_event_is_heartbeat` (mod.rs) — assertait l'ANCIEN comportement (rate_limit_event ⇒ Heartbeat). Le livrable §21 émet désormais RateLimited. → Je l'ai RENOMMÉ en `claude_parse_rate_limit_event_without_reset_is_rate_limited_none` et mis à jour l'assertion vers `RateLimited{None}`.\n2. `claude_session_respects_port_contract` — le harnais de conformité `assert_agent_session_contract` (conformance.rs, module `#[cfg(test)]`) n'autorisait avant le Final que TextDelta/ToolActivity/Heartbeat. Le `claude_script()` contient une ligne rate_limit_event qui produit maintenant un RateLimited intercalé → rejeté. → J'ai ajouté `ReplyEvent::RateLimited { .. }` à la liste des événements non terminaux autorisés (conforme à §21-T4 : « s'intercale exactement comme un Heartbeat »).\n\nCes deux modifs portent UNIQUEMENT sur du code de test (`#[cfg(test)]`), pas sur la prod. Je n'ai touché aucune ligne de `claude.rs`. Le code de prod de DevBackend est conforme au cadrage §21 sur tous les axes testés (noms de champ, priorité, seuil 10^12 exact, floats, offsets ISO signés, fractions ms, bissextile 2024, epoch Unix, robustesse zéro-panic, non-terminalité T4).\n\nFeature validée côté tests infra. Prêt pour la suite du cycle."} +{"id":"4edcaae5-b36e-44fb-a54e-d289fde5adb3","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781615060341,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tests LS3 — adapter `TokioScheduler` (port `Scheduler`, réveil one-shot annulable), crates/infrastructure/src/scheduler/mod.rs. Branche feature/agent-session-limits. DevBackend a livré (build infra OK, 0 warning). Écris et exécute les tests, dans le style des tests async existants de l'infra (#[tokio::test], cherche des exemples dans le crate pour la convention de runtime).\n\nConstruction : `TokioScheduler::new(tx, clock)` où tx est l'émetteur d'un `tokio::sync::mpsc::unbounded_channel::()` et clock un `Arc` (utilise l'horloge système réelle ou un fake selon ce qui existe déjà ; les délais de test doivent rester COURTS pour ne pas ralentir la suite).\n\nCouverture :\n- arm tire APRÈS l'échéance : `arm(now + ~50ms, task)` → `rx.recv()` (sous un timeout de sécurité, ex. 1s) rend exactement la `ScheduledTask` armée ; et vérifie qu'AUCUNE tâche n'arrive AVANT l'échéance (try_recv juste après arm = vide) ;\n- cancel empêche le tir : `arm(now + grand délai, task)` puis `cancel(id)` retourne `true` → après un court laps, `rx` reste vide ;\n- cancel d'un id inconnu → `false` (ScheduleId::new_random()) ;\n- cancel APRÈS tir → `false` (armer délai court, attendre réception via recv, puis cancel) ;\n- deadline déjà passée (`arm(now - 1000, task)`) → tir quasi-immédiat (task reçue sous timeout court) ;\n- (si pertinent et stable) plusieurs arm concurrents → chacun tire / s'annule indépendamment, pas de fuite.\n\nAttention aux tests temporels : utilise des timeouts généreux pour la réception (éviter le flaky) mais des délais d'armement courts ; ne te fie pas à des sleeps fixes fragiles pour prouver l'ABSENCE d'un événement — préfère try_recv après un petit yield. Évite tout test dépendant de l'ordonnancement fin qui rendrait la suite instable.\n\nExécute `cargo test -p infrastructure` (vérifie que les tests LS1/LS2 restent verts aussi). Rends : rapport complet (nb tests, pass/fail), signalement de tout flakiness potentiel, et en cas d'échec un rapport CLAIR (attendu vs obtenu) pour DevBackend. Ne modifie QUE des tests ; signale tout vrai bug (ex. course sur cancel, fuite de handle) sans corriger la prod."} +{"id":"57562d0e-0f3a-4ce6-bef4-a0c0c6d4a1dd","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781615229196,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"✅ LS3 — Tests `TokioScheduler` (port `Scheduler`, réveil one-shot annulable) : TOUT VERT, aucun flakiness observé.\n\nCommande : `cargo test -p infrastructure`\nRésultat lib : **195 passed; 0 failed; 0 ignored** (188 LS1/LS2 + **7 nouveaux LS3**). Tous les fichiers d'intégration + doctests verts. LS1/LS2 restent verts (aucune régression).\n\nStabilité : suite scheduler relancée **3×** d'affilée → 7/7 à chaque fois, ~0,20 s. Pas de flaky détecté.\n\n7 tests ajoutés dans `crates/infrastructure/src/scheduler/mod.rs` (`#[cfg(test)] mod tests`, `#[tokio::test]` runtime current-thread par défaut comme le reste de l'infra). Construction conforme : `TokioScheduler::new(tx, clock)` avec `tx = unbounded_channel::()` et `clock = Arc` (= `SystemClock` réel, horloge partagée pour des échéances cohérentes avec ce qu'`arm` relit). Délai d'armement court (50 ms), timeout de réception généreux (2 s) pour l'anti-flaky.\n\n- `arm_fires_after_deadline_with_exact_task` : `arm(now+50ms)` → la tâche EXACTE arrive sous timeout ; et `try_recv` juste après arm = `Empty` (rien AVANT l'échéance).\n- `cancel_before_deadline_prevents_fire` : `arm(now+50ms)` + `cancel` immédiat → `true` ; attente 4× l'échéance (200 ms) puis `try_recv` = `Empty`. Absence PROUVÉE (sans annulation, le délai court aurait tiré bien avant la fin de l'attente — pas un sleep fragile). Bonus : second `cancel(id)` = `false` (id retiré de la table).\n- `cancel_unknown_id_is_false` : `cancel(ScheduleId::new_random())` → `false`.\n- `cancel_after_fire_is_false` : arm court, on attend la réception (tir effectif), on cède l'exécuteur (8× `yield_now` pour finaliser l'état du JoinHandle), puis `cancel` → `false`.\n- `past_deadline_fires_immediately` : `arm(now-1000)` → tir quasi-immédiat (reçu sous 500 ms).\n- `multiple_concurrent_arms_all_fire` : 3 arms même échéance → les 3 tâches arrivent (comparées en ENSEMBLE trié, zéro dépendance à l'ordre) ; ids armés distincts.\n- `cancel_one_among_many_leaves_others_firing` : parmi 3 réveils, on annule le ciblé → seuls les 2 survivants arrivent, l'annulé jamais (`try_recv` final = `Empty`). Prouve l'indépendance des handles (pas d'annulation collatérale, pas de fuite).\n\nNote d'honnêteté sur le contrat « cancel APRÈS tir → false » : il repose sur `JoinHandle::is_finished()`. Entre l'envoi `tx.send` de la tâche de fond et la transition `is_finished()==true`, il existe une micro-fenêtre. En runtime **current-thread** (défaut `#[tokio::test]`), la tâche de fond est nécessairement pollée jusqu'à complétion AVANT que `recv()` ne rende la valeur, donc `is_finished()` est vrai au moment du `cancel` → déterministe. J'ai tout de même ajouté des `yield_now()` par robustesse. ⚠️ Si la prod était un jour exécutée sous un runtime **multi-thread**, ce contrat pourrait devenir une course (cancel d'une tâche tout juste tirée mais pas encore jointe → `Some(handle)` non fini → `abort()` no-op + retour `true` au lieu de `false`). Ce n'est PAS un bug aujourd'hui (le test est vert et stable), mais je le signale comme point de vigilance à surveiller si le drain LS4/LS7 venait à câbler le scheduler sous un runtime multi-thread. Je n'ai touché aucune ligne de prod.\n\nFeature LS3 validée côté tests infra. Prêt pour la suite du cycle."} +{"id":"21d0d17e-98bc-4086-9390-9ecb313123e0","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781615800153,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tests LS4 — couche application de la feature « limites de session » : `SessionLimitService` (crates/application/src/agent/session_limit.rs) + réconciliation T4 dans crates/application/src/agent/structured.rs. Branche feature/agent-session-limits. DevBackend a livré (cargo build -p application OK, 0 warning ; tests compilent). Écris et exécute les tests, avec des FAKES des ports (Clock fixe, Scheduler, EventBus, AgentResumer), dans le style des tests applicatifs existants (regarde comment structured.rs et les autres use cases sont testés, réutilise les fakes existants si présents).\n\nSessionLimitService — (a) détection→planification :\n- `on_rate_limited(agent, node, conv_id, Some(reset_futur))` → le fake Scheduler reçoit exactement 1 `arm(fire_at_ms, ScheduledTask::ResumeAgent{agent_id, node_id, conversation_id})` avec fire_at_ms cohérent (= resets_at, ou now si passé via plan_resume) ; le fake EventBus reçoit `AgentRateLimited{agent_id, resets_at_ms}` PUIS `AgentResumeScheduled{agent_id, fire_at_ms}` DANS CET ORDRE ;\n- `on_rate_limited(..., None)` → AUCUN arm ; events `AgentRateLimited{None}` puis `AgentRateLimitSuspected{None}` ;\n- dédoublonnage (§21.10-4) : deux `on_rate_limited` successifs pour le MÊME agent → l'ancien ScheduleId est cancel-é sur le Scheduler (sans émettre d'AgentResumeCancelled pour le dédoublonnage interne).\n\n(b) exécution de la reprise :\n- `execute_resume(ScheduledTask::ResumeAgent{...})` → le fake AgentResumer voit `resume(agent_id, node_id, conversation_id, RESUME_PROMPT)` (vérifie que le prompt passé == la const RESUME_PROMPT) ; EventBus reçoit `AgentResumed{agent_id}` ; l'entrée interne est retirée (un cancel_resume ultérieur → false) ;\n- AgentResumer qui retourne Err → l'erreur est propagée ET `AgentResumed` n'est PAS publié.\n\n(c) annulation :\n- `cancel_resume(agent)` après un `on_rate_limited` armé, fake Scheduler renvoyant true → retourne true, EventBus reçoit `AgentResumeCancelled{agent_id}` ;\n- `cancel_resume(agent)` sans armement préalable → false, aucun event ;\n- fake Scheduler renvoyant false (cas « déjà tiré ») → cancel_resume renvoie false ET n'émet PAS AgentResumeCancelled (la reprise suit son cours) — c'est le contrat anti-course important.\n\nRéconciliation T4 (drain_with_readiness_outcome / drain_to_final) :\n- flux `[RateLimited{Some(t)}]` sans Final → `Ok(TurnOutcome::RateLimited{Some(t)})` (fin gracieuse, PAS d'Err) ;\n- flux `[RateLimited{None}]` sans Final → `Ok(TurnOutcome::RateLimited{None})` ;\n- flux `[..., RateLimited, Final]` → `Ok(TurnOutcome::Completed(contenu))` (Final l'emporte) ;\n- flux `[TextDelta]` seul (ni Final ni RateLimited) → `Err(Io)` INCHANGÉ (non-régression critique : un vrai flux tronqué reste une erreur) ;\n- NON-RÉGRESSION : `send_blocking` et `drain_with_readiness` (signatures historiques) gardent leur comportement (RateLimited→Err(Io) côté send_blocking ; flux sans Final → Io). Vérifie que les tests existants (drain_with_readiness_lot1, send_blocking_d1, drain_marks_alive) restent verts.\n\nExécute `cargo test -p application`. Rends : rapport complet (nb tests, pass/fail), confirmation explicite que les tests applicatifs préexistants restent verts (zéro régression), et en cas d'échec un rapport CLAIR (attendu vs obtenu) pour DevBackend. Ne modifie QUE des tests ; signale tout vrai bug sans corriger la prod."} +{"id":"b2d6ce1b-51db-4460-a452-4d15ba5ed008","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781628889022,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"✅ LS4 — Tests couche application « limites de session » : TOUT VERT, zéro régression.\n\nCommande : `cargo test -p application`\nRésultat : **tous les binaires de test verts, 0 failed** sur l'ensemble du crate. 16 nouveaux tests LS4 ajoutés, répartis en 2 fichiers d'intégration (style des tests applicatifs existants, 100 % fakes des ports).\n\nConfirmation explicite NON-RÉGRESSION : les suites préexistantes citées restent vertes —\n- `drain_with_readiness_lot1.rs` : **7/7** ✓\n- `send_blocking_d1.rs` : **9/9** ✓\n- (et tout le reste du crate : project/agent_lifecycle/orchestrator/structured_launch/etc., 0 échec).\n\n=== Nouveau fichier `tests/session_limit_service.rs` (9 tests) — fakes Clock fixe / Scheduler enregistreur-contrôlable / EventBus espion / AgentResumer espion-contrôlable ===\n(a) détection→planification :\n- `on_rate_limited_future_arms_and_emits_in_order` : Some(reset futur) ⇒ EXACTEMENT 1 `arm(fire_at_ms==reset, ResumeAgent{agent,node,conv})` + events `AgentRateLimited` PUIS `AgentResumeScheduled` dans cet ordre.\n- `on_rate_limited_past_reset_clamps_fire_at_to_now` : reset passé ⇒ `fire_at_ms==now` (clamp anti-passé) ; l'event RateLimited garde l'heure brute passée, ResumeScheduled porte le now clampé.\n- `on_rate_limited_without_reset_is_human_fallback_no_arm` : None ⇒ AUCUN arm + events `AgentRateLimited{None}` puis `AgentRateLimitSuspected{None}`.\n- `on_rate_limited_twice_same_agent_dedups_cancelling_previous` : 2 signaux même agent ⇒ l'ancien ScheduleId est cancel-é avant réarmement, et AUCUN `AgentResumeCancelled` émis (dédoublonnage interne silencieux, §21.10-4).\n(b) exécution :\n- `execute_resume_calls_resumer_with_prompt_and_emits_resumed` : resume(agent,node,conv, prompt==RESUME_PROMPT vérifié) + `AgentResumed` publié + entrée retirée (cancel_resume ultérieur ⇒ false).\n- `execute_resume_propagates_error_without_emitting_resumed` : Resumer Err ⇒ erreur propagée ET `AgentResumed` NON publié.\n(c) annulation :\n- `cancel_resume_after_arm_returns_true_and_emits_cancelled` : cancel renvoyant true ⇒ true + bon ScheduleId passé + `AgentResumeCancelled` publié.\n- `cancel_resume_without_arm_is_false_no_event` : sans armement ⇒ false, Scheduler non sollicité, aucun event.\n- `cancel_resume_when_scheduler_already_fired_is_false_no_event` : Scheduler renvoyant false (« déjà tiré ») ⇒ false ET PAS d'`AgentResumeCancelled` (contrat anti-course important).\n\n=== Nouveau fichier `tests/session_limit_t4.rs` (7 tests) — réconciliation §21.2-T4, fakes AgentSession + InputMediator ===\n- `outcome_rate_limited_some_without_final_is_graceful` : `[RateLimited{Some(t)}]` ⇒ `Ok(TurnOutcome::RateLimited{Some(t)})` (PAS d'Err) + vérifie qu'un RateLimited ne marque PAS Idle.\n- `outcome_rate_limited_none_without_final_is_graceful` : `[RateLimited{None}]` ⇒ `Ok(RateLimited{None})`.\n- `outcome_rate_limited_then_final_is_completed` : `[.., RateLimited, Final]` ⇒ `Completed(contenu)` (Final l'emporte) + Idle marqué.\n- `outcome_truncated_stream_without_final_or_ratelimit_is_io_error` : `[TextDelta]` seul ⇒ `Err(Io)` INCHANGÉ (non-régression critique du flux tronqué).\n- non-régression signatures historiques : `drain_with_readiness_rate_limited_is_io_error`, `send_blocking_rate_limited_is_io_error` (limite ⇒ Io), `drain_with_readiness_nominal_still_completes` (cas nominal Final intact).\n\nAucun bug détecté : le code DevBackend est conforme au cadrage §21.5 sur tous les axes (ordre des events, clamp via plan_resume, dédoublonnage silencieux, prompt constant, propagation d'erreur sans event, contrat anti-course du cancel). Je n'ai modifié AUCUNE ligne de prod (uniquement ajouté 2 fichiers de tests).\n\nFeature LS4 validée côté tests application. Prêt pour la suite du cycle (Git : merge éventuel)."} +{"id":"709f1b0f-9080-4c36-89fb-d98b4ca91d18","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781629372044,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tests LS5 — parser regex niveau 2 (infra) : `RateLimitParser` (crates/infrastructure/src/ratelimit/mod.rs) + le module d'heure partagé `timeparse` (crates/infrastructure/src/timeparse.rs). Branche feature/agent-session-limits. DevBackend a livré (cargo build -p infrastructure OK, 0 warning ; tests LS2 toujours verts). Tout est pur (now_ms injecté), testable sans I/O. Écris et exécute les tests dans le style existant.\n\nRateLimitParser (new + detect + applies) :\n- `new` sur regex INVALIDE → None (jamais de panique) ;\n- pattern qui matche SANS reset_capture → `Some(SessionLimit{ resets_at_ms: None, source: Pattern, detected_at_ms == now_ms })` ;\n- capture nommée `(?P...)` + time_format ABSOLU : `epoch_s` (secondes→×1000), `epoch_ms` (tel quel), `iso8601`/`rfc3339` (`...Z` → ms attendus) → resets_at_ms corrects ;\n- time_format RELATIF (`relative_s`, capture « 600 », now=T) → `Some(resets_at_ms == T + 600_000)` ;\n- time_format MURAL (`wall`, capture « 3pm ») : now correspondant à 10h du jour → 15h AUJOURD'HUI (même jour UTC) ; now correspondant à 16h → 15h DEMAIN (passage de minuit, +24h). Choisis des now_ms calculés proprement (epoch connu) et calcule l'attendu à la main ;\n- pattern NE matche PAS → detect → None ;\n- pattern matche mais capture absente/valeur pourrie/non parsable → `Some(SessionLimit{ resets_at_ms: None })` (détection utile sans heure) ;\n- vérifie que `source == RateLimitSource::Pattern` dans tous les cas détectés ;\n- compilation du regex faite une seule fois (à new) — au minimum vérifie que detect peut être appelé plusieurs fois sans souci.\n\napplies(profile) :\n- profil avec structured_adapter (structuré) → false (même s'il a un rate_limit_pattern) ;\n- profil PTY (sans structured_adapter) AVEC rate_limit_pattern → true ;\n- profil PTY SANS rate_limit_pattern → false.\n\ntimeparse (fonctions réexportées) :\n- `days_from_civil` : 1970-01-01 → 0 ; une année bissextile (2024-02-29) cohérente ;\n- `parse_wall_clock` : « 3pm » → (15,0,0) ; « 15:00:30 » → (15,0,30) ; format invalide → None ;\n- `wall_clock_to_ms` : même jour vs lendemain selon now ;\n- `parse_absolute_ms` : epoch s/ms/ISO cohérents (recoupe avec LS2 si utile).\n- NON-RÉGRESSION : confirme que les tests LS2 (parse_reset_ms/parse_event dans session/mod.rs) restent verts après le refactor qui a déplacé les helpers vers timeparse.\n\nExécute `cargo test -p infrastructure`. Rends : rapport complet (nb tests, pass/fail), confirmation explicite que LS2/LS3 restent verts, et en cas d'échec rapport CLAIR (attendu vs obtenu) pour DevBackend.\n\nEN PLUS (petit nettoyage que DevBackend t'a signalé) : tes tests scheduler LS3 (crates/infrastructure/src/scheduler/mod.rs, ~lignes 253/285) déclenchent 2 warnings `irrefutable if let` car ScheduledTask est mono-variante aujourd'hui. Corrige ces 2 warnings dans le code de TEST (ex. déstructuration directe au lieu de `if let`) pour garder la suite sans warning — c'est du test, tu peux le modifier. Ne touche à aucun code de prod ; signale tout vrai bug sans le corriger."} +{"id":"0cf62699-f131-42d3-ba5f-2a3128b368ac","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781629538014,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"✅ LS5 — Tests parser regex niveau 2 (`RateLimitParser`) + module `timeparse` : TOUT VERT, 0 warning, zéro régression.\n\nCommande : `cargo test -p infrastructure`\nRésultat lib : **221 passed; 0 failed; 0 ignored** (195 antérieurs + **26 nouveaux LS5**). Tous les fichiers d'intégration + doctests verts. **Build de test : 0 warning** (vérifié `--no-run | grep -c warning` = 0).\n\nConfirmation explicite NON-RÉGRESSION :\n- **LS2** (`session::tests::parse_reset_ms*` / `parse_event*` / `send_emits_rate*`) : tous verts APRÈS le refactor qui a déplacé les helpers vers `timeparse` ✓\n- **LS3** (`scheduler::tests::*`) : 7/7 verts ✓ (+ les 2 warnings `irrefutable if let` corrigés — voir plus bas)\n\n=== `ratelimit/mod.rs` — 15 tests (in-file `#[cfg(test)] mod tests`) ===\nnew + detect :\n- `new_returns_none_on_invalid_regex` : regex invalide `\"rate limit (\"` ⇒ None (jamais de panique).\n- `detect_returns_none_when_pattern_does_not_match` : pas de match ⇒ None.\n- `detect_match_without_reset_capture_has_no_time` : match sans reset_capture ⇒ `SessionLimit{resets_at_ms:None, source:Pattern, detected_at_ms==now}`.\n- formats ABSOLUS : `detect_epoch_seconds_format` (×1000), `detect_epoch_millis_format` (tel quel), `detect_iso8601_format` (`2023-11-14T22:13:20Z`→1_700_000_000_000).\n- format RELATIF : `detect_relative_seconds_format_uses_now` (capture « 600 », now=T ⇒ T+600_000).\n- format MURAL (passage de minuit, math calculée à la main sur DAY_START=1_699_920_000_000 = 2023-11-14T00:00Z) : `detect_wall_clock_same_day_when_future` (now=10h, « 3pm » ⇒ 15h même jour) ; `detect_wall_clock_next_day_when_past` (now=16h ⇒ 15h DEMAIN, +24h).\n- capture inexploitable ⇒ détection sans heure : `detect_match_with_missing_capture_group_has_no_time`, `detect_match_with_unparsable_value_has_no_time` (⇒ `resets_at_ms:None`).\n- `detect_can_be_called_multiple_times` : regex compilé une seule fois, detect appelé 3× sans souci.\n- `source == RateLimitSource::Pattern` asserté dans tous les cas détectés.\napplies(profile) :\n- `applies_false_for_structured_profile_even_with_pattern` (structuré ⇒ false, niveau 1 prend la main) ;\n- `applies_true_for_pty_profile_with_pattern` (PTY + pattern ⇒ true) ;\n- `applies_false_for_pty_profile_without_pattern` (PTY sans pattern ⇒ false).\n\n=== `timeparse.rs` — 11 tests ===\n- `days_from_civil_epoch_is_zero` (1970-01-01→0) ; `days_from_civil_leap_day_2024` (2024-02-29→19782, + cohérence avec le 1ᵉʳ mars).\n- `parse_wall_clock_variants` (« 3pm »→(15,0,0), « 15:00:30 »→(15,0,30), « 3:30 pm »→(15,30,0), 12am→0h, 12pm→12h) ; `parse_wall_clock_rejects_invalid` (texte, 25:00, 10:75, trop de composantes ⇒ None).\n- `wall_clock_to_ms_*` : même jour / lendemain / cas-limite « cible == now » (⇒ lendemain, jamais le présent).\n- `parse_absolute_ms_*` : epoch s/ms, ISO8601, garbage→None (recoupe LS2).\n- `parse_rfc3339_offsets_convert_to_utc` : +01:00 et −01:00 ⇒ même instant UTC.\n\n=== Nettoyage LS3 demandé ===\nLes 2 warnings `irrefutable if let` (scheduler/mod.rs ~253/285, `ScheduledTask` mono-variante) sont corrigés : remplacés par une **déstructuration directe** `let ScheduledTask::ResumeAgent { conversation_id, .. } = task;` (code de TEST uniquement). Suite désormais 0 warning.\n\nAucun bug détecté : le code DevBackend est conforme au cadrage §21 niveau 2 sur tous les axes (robustesse regex, stratégies de format absolu/relatif/mural, passage de minuit UTC, règle anti-double-détection `applies`, factorisation `timeparse` sans casser LS2). Je n'ai modifié AUCUN code de prod (uniquement ajouté des tests + corrigé mes propres tests LS3).\n\nFeature LS5 validée côté tests infra. Prêt pour la suite du cycle."} +{"id":"e1ce3fe9-33e8-45e7-9f2d-20ba0b5bd357","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781676150560,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tâche QA — tests unitaires de la slice LS7-front (UI limites de session des agents), branche `feature/agent-session-limits`. DevFrontend vient d'implémenter (typecheck `tsc --noEmit` propre, 39 tests agents existants toujours verts, non commité).\n\nPérimètre à couvrir (frontend/) :\n1. `src/features/agents/useAgents.ts` — nouvel état `limitByAgent: Record` (`{ limitedUntil?, resumeFireAt?, suspected? }`) peuplé depuis 5 DOMAIN_EVENT dans l'abonnement `onDomainEvent`. À tester (via le mock gateway qui émet des events) :\n - `agentRateLimited` {agentId, resetsAtMs} → entrée `{ limitedUntil: resetsAtMs, suspected: false }`.\n - `agentResumeScheduled` {agentId, fireAtMs} → arme `resumeFireAt`.\n - `agentResumeCancelled` {agentId} → retire `resumeFireAt`, reste limité.\n - `agentResumed` {agentId} → supprime l'entrée (état effacé).\n - `agentRateLimitSuspected` {agentId, resetsAtMs?} → `{ ..., suspected: true }`, y compris le cas SANS `resetsAtMs` (heure inconnue).\n - séquence réaliste : rateLimited → resumeScheduled → cancelResume (action) → vérifier retrait optimiste + appel `input.cancelResume` (mock `cancelledResumes` / `cancelResumeResult`).\n - Action `cancelResume(agentId)` exposée par le hook : retrait optimiste + verdict backend renvoyé (teste les deux verdicts via `cancelResumeResult`).\n2. `src/features/agents/AgentLimitBadge.tsx` — helpers purs exportés `formatResetTime(epochMs)` (→ HH:MM) et `formatCountdown(remainingMs)` (→ « Xm Ys »/« Ys », clampé à 0 pour négatif). Teste ces helpers purs directement (cas limites : 0, négatif, < 60 s, > 60 s, minuit). Teste le rendu du badge (limité jusqu'à HH:MM vs « limité » sans heure vs « heure inconnue » pour suspected sans resetsAtMs) et que le bouton « Annuler la reprise » appelle l'action quand `resumeFireAt` est armé.\n3. Adapter mock `src/adapters/mock/index.ts` — `MockInputGateway.cancelResume` (`cancelledResumes`, `cancelResumeResult`) : couvre via les tests du hook.\n\nSuis le style des tests existants (cf. `singletonAgent.test.tsx`, `mock.test.ts`, les tests agents). Exécute la suite (`npm test` ou l'outil du repo) et RENDS-MOI via idea_reply : la commande exacte lancée, le résultat réel (nb tests passés/échoués), et le rapport d'erreurs complet si rouge. Si un test révèle un bug d'implémentation, décris-le précisément (fichier:ligne, attendu vs obtenu) — je relaierai à DevFrontend pour correction. Ne commit rien."} +{"id":"5538d4ca-de04-4dfa-b0ed-bc47a29a1d09","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781676343879,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"QA LS7-front — VERT. Aucun bug d'implémentation trouvé. Rien commité.\n\n## Fichiers de test ajoutés (2)\n- `frontend/src/features/agents/useAgentsLimits.test.tsx` (13 tests) — état `limitByAgent` du hook via le `MockSystemGateway` qui émet les events + `MockInputGateway`.\n- `frontend/src/features/agents/AgentLimitBadge.test.tsx` (11 tests) — helpers purs + rendu du badge.\n\n## Commandes exactes lancées (cwd = frontend/)\n1. `npx vitest run src/features/agents/useAgentsLimits.test.tsx src/features/agents/AgentLimitBadge.test.tsx`\n → **Test Files 2 passed (2) · Tests 24 passed (24)**\n2. `npx vitest run src/features/agents/` (suite agents complète, régression)\n → **Test Files 4 passed (4) · Tests 63 passed (63)** (39 existants + 24 nouveaux, 0 régression)\n\n## Couverture livrée\nuseAgents (§21), tous via events réels :\n- `agentRateLimited{resetsAtMs}` → `{limitedUntil, resumeFireAt:undefined, suspected:false}` ✓\n- `agentResumeScheduled{fireAtMs}` → arme `resumeFireAt` par-dessus l'état limité ✓\n- `agentResumeCancelled` → retire `resumeFireAt`, reste limité ✓ ; + no-op sur agent inconnu (aucune entrée créée) ✓\n- `agentResumed` → entrée supprimée (`agentId in map === false`) ✓\n- `agentRateLimitSuspected` AVEC et SANS `resetsAtMs` → `suspected:true`, `limitedUntil` undefined dans le cas sans heure ✓\n- séquence réaliste rateLimited→scheduled→`cancelResume(action)` : retrait optimiste du countdown + agent toujours limité + `input.cancelledResumes === [agentId]` + verdict ✓\n- action `cancelResume` : verdict `true` ET `false` (via `cancelResumeResult`), + appel du port même sans reprise armée ✓\n- deux agents foldés indépendamment ✓\n\nAgentLimitBadge :\n- `formatCountdown` : 0→\"0s\", négatif→\"0s\" (clamp), <60s→\"Xs\", ceil partiel (4.2s→\"5s\"), ≥60s→\"Xm Ys\" (60000→\"1m 0s\", 125000→\"2m 5s\") ✓\n- `formatResetTime` : HH:MM sans secondes (même minute +30s = même libellé), minuit stable ✓\n- rendu : \"limité jusqu'à HH:MM\" (heure connue) / \"limité\" (sans heure) / note \"heure inconnue — reprise à préciser\" (suspected sans heure) + absence de la note quand suspected A une heure ✓\n- bouton \"Annuler la reprise\" : appelle `onCancelResume` au clic quand `resumeFireAt` armé, countdown rendu, bouton désactivé si `busy` ✓\n\nMockInputGateway.cancelResume (`cancelledResumes`/`cancelResumeResult`) : couvert via les tests du hook.\n\n## Note hors-périmètre (PRÉEXISTANTE, pas causée par LS7-front)\n`src/adapters/mock/mock.test.ts:16` échoue (1 test) : il assert « thirteen gateways » mais le `permission` gateway porte le compte à 14. Vérifié : la gateway `permission` existe déjà dans HEAD committé (eca2ba9) — l'assertion de comptage est simplement périmée, sans rapport avec les limites de session. Mes deux fichiers ne touchent pas ce test. Aucune action de ma part (hors scope) ; à relayer pour une mise à jour du compteur si tu veux la suite `adapters/mock` 100% verte."} +{"id":"2acee2f9-cf46-4f65-8131-8a66751c55e0","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781676775264,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tâche QA — tests unitaires LS8-backend (filet humain niveau 3), branche `feature/agent-session-limits`. DevBackend a implémenté (build 0 erreur/0 warning, suites domain/application/app-tauri déjà vertes, non commité).\n\nÀ COUVRIR — nouvelle méthode `SessionLimitService::confirm_human_resume(agent_id, node_id, conversation_id, resets_at_ms: i64)` dans `crates/application/src/agent/session_limit.rs`. Teste via les fakes existants (Clock/Scheduler/EventBus) du fichier `crates/application/tests/session_limit_service.rs` (suis ce style). Cas (cadrage Architect) :\n(a) heure FUTURE ⇒ publie `AgentRateLimited{Some(resets_at_ms)}` puis `AgentResumeScheduled{fire_at_ms == resets_at_ms}` + un ScheduleId armé sur le scheduler.\n(b) heure PASSÉE (resets_at_ms < now) ⇒ clamp : `fire_at_ms == now` (reprise quasi-immédiate). Vérifie l'événement `AgentResumeScheduled` avec fire_at_ms == now.\n(c) DÉDOUBLONNAGE croisé : `confirm_human_resume` APRÈS un `on_rate_limited` déjà armé pour le même agent ⇒ le précédent réveil est désarmé, une seule entrée armée (et l'inverse : `on_rate_limited` après `confirm_human_resume`). Vérifie qu'il n'y a qu'un seul armement actif.\n(d) ANNULABILITÉ : `cancel_resume(agent_id)` après `confirm_human_resume` ⇒ publie `AgentResumeCancelled` et renvoie true (l'armement humain s'annule par la même voie).\n(e) PARITÉ auto/humain : même séquence d'événements qu'`on_rate_limited` cas Scheduled (à reset identique).\n\nÀ COUVRIR — commande `set_resume_at` (app-tauri) si testable au niveau intégration comme `session_limit_wiring.rs` : au minimum le cas `node_for_agent == None` ⇒ erreur NOT_FOUND (pas d'armement orphelin). Si le câblage rend ce test trop lourd (besoin d'une cellule vivante), note-le et concentre-toi sur la couverture application (a–e) qui est l'essentiel.\n\nExécute (`cargo test -p application` + `-p app-tauri` si pertinent). RENDS-MOI via idea_reply : commande exacte, résultat réel (passés/échoués), rapport d'erreurs complet si rouge. Si un test révèle un bug (notamment sur le dédoublonnage croisé ou le clamp passé), décris-le précisément (attendu vs obtenu) — je relaierai à DevBackend. Ne commit rien."} +{"id":"a2c0999e-227a-4cfb-99f5-2e8b80ce84f0","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781676917946,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"QA LS8-backend (filet humain niveau 3) — VERT. Aucun bug d'implémentation trouvé (ni sur le dédoublonnage croisé, ni sur le clamp passé). Rien commité.\n\n## Fichiers de test modifiés (2 — ajouts only, fakes existants réutilisés)\n- `crates/application/tests/session_limit_service.rs` : +6 tests `confirm_human_resume` (style des fakes Clock/Scheduler/EventBus existants).\n- `crates/app-tauri/tests/session_limit_wiring.rs` : +2 tests (précondition NOT_FOUND + parité runtime humaine sur le vrai bus).\n\n## Commandes exactes lancées + résultats réels\n1. `cargo test -p application --test session_limit_service`\n → **15 passed; 0 failed** (10 existants + 5 nouveaux).\n2. `cargo test -p app-tauri --test session_limit_wiring`\n → **4 passed; 0 failed** (2 existants + 2 nouveaux).\n3. `cargo test -p application` (régression complète) → **toutes suites vertes, 0 failed**.\n4. `cargo test -p app-tauri` (régression complète) → **toutes suites vertes, 0 failed**.\n\n## Couverture application (a–e) — l'essentiel\n- **(a) heure FUTURE** `confirm_human_resume_future_arms_and_emits_in_order` : exactement 1 `arm(fire_at_ms==resets_at_ms, ResumeAgent{agent,node,conv})`, 1 ScheduleId émis, events `AgentRateLimited{Some(reset)}` PUIS `AgentResumeScheduled{fire_at_ms==reset}` dans cet ordre. ✓\n- **(b) heure PASSÉE** `confirm_human_resume_past_reset_clamps_fire_at_to_now` : `fire_at_ms == now` (clamp anti-passé) ; `AgentRateLimited` garde l'heure brute passée, `AgentResumeScheduled` porte le `now` clampé. ✓\n- **(c) dédoublonnage CROISÉ** (les deux sens) :\n - `confirm_human_resume_after_auto_dedups_single_active_arm` : confirm APRÈS on_rate_limited → l'ancien ScheduleId auto est cancel-é (`cancels == [issued[0]]`), pas d'`AgentResumeCancelled` (silencieux). Unicité prouvée : 1er `cancel_resume`→true, 2e→false. ✓\n - `auto_after_confirm_human_resume_dedups_single_active_arm` : on_rate_limited APRÈS confirm → symétrique, même invariant d'unicité. ✓\n- **(d) ANNULABILITÉ** `cancel_resume_after_confirm_human_resume_returns_true_and_emits_cancelled` : renvoie `true` + publie `AgentResumeCancelled` (même voie que l'auto). ✓\n- **(e) PARITÉ auto/humain** `confirm_human_resume_is_event_for_event_identical_to_auto_scheduled` : à reset futur identique, `bus.events()` ET `scheduler.armed()` strictement égaux entre `confirm_human_resume` et `on_rate_limited`. La source Human vs Structured n'a aucun effet observable. ✓\n\n## Couverture app-tauri `set_resume_at`\n- **NOT_FOUND** `set_resume_at_resolves_no_cell_for_an_agent_without_a_live_session` : ✓ couvert au niveau précondition. NOTE : la commande `#[tauri::command] set_resume_at` exige `State`, non constructible hors runtime Tauri — le harnais wiring existant n'invoque jamais les commandes, seulement le service. J'ai donc testé la **précondition exacte** qui produit le NOT_FOUND : sur un `AppState::build` neuf, `structured_sessions.node_for_agent(unknown)` ET `terminal_sessions.node_for_agent(unknown)` renvoient `None` → la branche `ok_or_else(NotFound)` (commands.rs:1420-1428) est prise → aucun armement orphelin. C'est la couverture maximale réaliste sans faire tourner Tauri.\n- **Parité runtime** `confirm_human_resume_arms_a_cancellable_resume_over_the_real_bus` : sur le vrai `TokioBroadcastEventBus`, `confirm_human_resume` publie `AgentRateLimited` puis `AgentResumeScheduled` et l'armement est annulable (`cancel_resume`→true), exactement comme la branche auto déjà testée. ✓\n\n## Observation (non bloquante, pas un bug)\n`confirm_human_resume` est total et défensif : le cas `ResumePlan::HumanFallback` y est inatteignable (`resets_at_ms` toujours `Some`) → traité en no-op via `if let Scheduled`. Conforme au cadrage Architect ; pas de test dédié (branche morte par construction).\n\nBuild app-tauri OK (0 erreur). Rien n'a été commité."} +{"id":"7c627a21-5a9f-470e-9137-a63697ae87da","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781677172091,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tâche QA — validation LS8-front (formulaire de saisie d'heure, filet humain niveau 3) sur `feature/agent-session-limits`. DevFrontend a implémenté ET ajusté/ajouté des tests (typecheck propre, `npx vitest run src/features/agents/ src/adapters/mock/` = 109 tests verts). Ton rôle : valider la couverture, exécuter, et signaler tout trou ou bug.\n\nPoints à vérifier/exécuter :\n1. Helper pur `timeInputToEpochMs(value, now)` (AgentLimitBadge.tsx) : cas couverts ? « HH:MM » valide → aujourd'hui à cette heure ; instant passé dans la journée → epoch < now (le backend clampe, pas le front) ; malformé/vide/`25:00`/`12:60` → `null`. Ajoute les cas manquants si besoin.\n2. Soumission du formulaire (état suspected SANS heure) ⇒ `onSetResumeAt` / action hook `setResumeAt` appelée avec l'epoch attendu ; bouton désactivé si saisie vide ou `busy` ; saisie malformée (`null`) ne déclenche PAS d'appel.\n3. Action hook `useAgents.setResumeAt(agentId, resetsAtMs)` : délègue bien au port `input.setResumeAt`, aucune mutation optimiste (vérifie que l'état ne bascule QUE sur réception de `agentResumeScheduled`, pas avant). Mock instrumenté `resumeArmings`.\n4. Bascule complète : agent suspected sans heure → submit form → émettre `agentResumeScheduled` (via mock) → le badge passe à l'état nominal (countdown + bouton Annuler), le formulaire disparaît.\n5. Non-régression : tout `src/features/agents/` + `src/adapters/mock/` vert. Confirme le compteur gateways corrigé (14) dans mock.test.ts.\n\nExécute `npx vitest run src/features/agents/ src/adapters/mock/` (+ `tsc --noEmit` si tu veux). RENDS-MOI via idea_reply : commande exacte, résultat réel (passés/échoués), trous comblés (fichiers/tests ajoutés), et tout bug trouvé (attendu vs obtenu, fichier:ligne) — je relaierai à DevFrontend. Ne commit rien."} diff --git a/.ideai/conversations/09b15ec3-ce2f-0af2-2903-ef699df9de41/handoff.md b/.ideai/conversations/09b15ec3-ce2f-0af2-2903-ef699df9de41/handoff.md index 8112c9a..475025b 100644 --- a/.ideai/conversations/09b15ec3-ce2f-0af2-2903-ef699df9de41/handoff.md +++ b/.ideai/conversations/09b15ec3-ce2f-0af2-2903-ef699df9de41/handoff.md @@ -1,5 +1,5 @@ --- -upTo: a6169530-fc73-4057-91c6-900da4a611fb +upTo: 50c3e999-c6a3-4d8b-aef3-b6c273ed9afc objective: [Ping inter-agent depuis Main] Test du pont MCP inter-agents. Si tu reçois ce message, réponds via idea_reply avec : (1) "DevFrontend OK — pont inter-agent fonctionnel", (2) le résultat de 144 ÷ 12, e --- **Objectif :** [Ping inter-agent depuis Main] Test du pont MCP inter-agents. Si tu reçois ce message, réponds via idea_reply avec : (1) "DevFrontend OK — pont inter-agent fonctionnel", (2) le résultat de 144 ÷ 12, e @@ -11,4 +11,8 @@ objective: [Ping inter-agent depuis Main] Test du pont MCP inter-agents. Si tu r - **Prompt:** [Test de communication inter-agent] Ping. Réponds simplement via idea_reply avec « pong DevFrontend OK » pour confirmer que le canal fonctionne. - **Response:** pong DevFrontend OK - **Prompt:** Tâche : nettoyage/correction de bugs d'affichage UI (frontend React/TS). Project root : /home/anthony/Documents/Projects/IdeA. Aucune nouvelle feature, uniquement du fix CSS/layout. Voici 3 problèmes constatés visuellement (captures non transmissibles, je les décris) : PROBLÈME 1 — Barre de navigation horizontale (top bar listant : Projects, Context, Agents, Templates, Skills, Permissions, …). - Cette barre n'est PAS scrollable et n'est PAS correctement mise à l'échelle. Quand il y a trop d'entrées, les derniers items débordent et sont coupés (on voit « Skills » puis « P… » tronqué, « Permissions » coupé). - Attendu : la barre doit gérer le débordement proprement — soit scroll horizontal (overflow-x:auto avec masquage de scrollbar propre), soit wrap/scale correct. Les entrées ne doivent jamais être croppées. Vérifier flex-shrink/min-width des items. PROBLÈME 2 — Panneau « Agents » (liste des agents : Main, Architect, DevBackend, DevFrontend, QA, TestConversation, …). - Les items de la liste s'overlappent (chevauchement vertical) et sont croppés. Le nom de l'agent, le profil (« Claude Code »), l'id de run (longue chaîne en bleu qui wrap mal sur plusieurs lignes), la dropdown de profil et les boutons Stop/Delete/Launch se chevauchent et débordent du panneau. - Attendu : chaque carte d'agent doit avoir une hauteur qui s'adapte à son contenu (pas de height fixe qui cause l'overlap), les éléments alignés proprement, l'id long tronqué avec ellipsis ou wrap contrôlé, et la dropdown + boutons ne doivent pas déborder/chevaucher le texte voisin. Vérifier les position absolute éventuels et les gaps flex/grid. PROBLÈME 3 — Formulaire de création d'agent (champs : Name, Template, Profile, bouton Create). - Les champs du formulaire s'overlappent les uns sur les autres (Name / Template / Profile trop serrés/superposés). - BUG fonctionnel : quand on sélectionne une valeur dans les dropdowns (Template / Profile), la sélection NE s'affiche PAS dans le champ de la droplist (le label sélectionné ne se reflète pas visuellement). À investiguer : binding value/onChange du composant select, ou affichage du label sélectionné si c'est un dropdown custom. - Attendu : formulaire avec espacement correct (pas de chevauchement), et les dropdowns affichent bien la valeur choisie. Merci de : 1. Localiser les composants concernés (top nav bar, panneau Agents, formulaire création d'agent). 2. Corriger le CSS/layout et le bug de binding des dropdowns. 3. Lancer un build frontend pour t'assurer que ça compile (npm run build ou équivalent), sans casser le reste. 4. Me renvoyer via idea_reply la liste des fichiers modifiés + un résumé des corrections, pour que je puisse enchaîner sur le rebuild de l'AppImage. -- **Response:** Bugs d'affichage corrigés. Build vert (`npm run build` = tsc --noEmit + vite build OK) et tests verts (`vitest run` agents + projects : 47/47 passent). ## Fichiers modifiés (2) - `frontend/src/features/projects/ProjectsView.tsx` — la « top bar » - `frontend/src/features/agents/AgentsPanel.tsx` — panneau Agents + formulaire de création ## Diagnostic La « top bar » décrite n'est pas une nav OS mais le **strip de tabs du sidebar** (`SIDEBAR_TABS` : Projects, Context, Agents, Templates, Skills, Perms, Memory, Git) dans `ProjectsView.tsx`. Cause racine commune aux 3 problèmes : **largeur trop contrainte** dans le sidebar de 320px (`w-80`). ## Corrections **PROBLÈME 1 — barre de tabs croppée** - Avant : 8 boutons en `flex-1` sans gestion de débordement → les derniers labels coupés. - Après : les tabs sont dans un conteneur `overflow-x-auto` **scrollable horizontalement**, chaque tab passe en `shrink-0 whitespace-nowrap` (plus jamais croppé). Scrollbar masquée proprement (`[scrollbar-width:none]` + `[&::-webkit-scrollbar]:hidden`). Le bouton « collapse » («) reste épinglé à droite, hors zone de scroll. **PROBLÈME 2 — cartes d'agents qui se chevauchent** - Avant : `
  • ` en `flex items-center justify-between` → le bloc nom/profil et la colonne de contrôles (dropdown profil + Sync/Stop/Launch/Delete, en `shrink-0`) se disputaient la largeur ; l'id de run long (`running in IdeA · `) wrappait et débordait. - Après : carte en **`flex-col`** — bloc nom/profil/statut en haut, rangée de contrôles en dessous (`flex-wrap`, plus de `shrink-0` qui écrasait le texte). Hauteur adaptative au contenu (plus de chevauchement). L'id long est désormais **tronqué avec ellipsis** (`block w-full min-w-0 truncate`). **PROBLÈME 3 — formulaire de création (champs serrés + dropdowns « vides »)** - Avant : form en `flex flex-wrap items-end` avec 3 champs `min-w-0 flex-1` → selects écrasés à ~90px ; le texte sélectionné (« Claude Code ») était **clippé derrière la flèche native** du select, d'où l'impression que la sélection ne s'affichait pas. - Après : form en **`flex-col gap-3`**, chaque champ en pleine largeur. Le binding des `` était déjà correct (`value`/`onChange` contrôlés, natifs) — c'était bien un problème de largeur, pas de binding. En pleine largeur la valeur choisie s'affiche normalement. Bouton Create passé en `self-end`. Aucune nouvelle dépendance, aucun changement de logique métier ni de contrat IPC/gateway. Les tests s'appuient sur les `aria-label` (inchangés), d'où les 47/47 verts. Tu peux enchaîner sur le rebuild de l'AppImage (rappel mémoire : c'est l'AppImage qui tourne qu'il faut reconstruire, pas seulement les sources). +- **Prompt:** Tâche LS7-front (React/TS) — UI de gestion des limites de session des agents (ARCHITECTURE §21). On est sur la branche `feature/agent-session-limits`. Le backend est terminé et vert (commit 9df5923) : il émet déjà 5 DOMAIN_EVENT vers le front et expose une commande Tauri `cancel_resume(agentId) -> bool`. CONTRAT WIRE (DTO sérialisés camelCase, source = crates/app-tauri/src/events.rs) : - `agentRateLimited` { agentId: string, resetsAt?: number /* epoch-ms, absent si inconnu */ } - `agentResumeScheduled` { agentId: string, fireAt: number /* epoch-ms, échéance du réveil */ } ⚠️ champ wire = `fireAtMs` → vérifie le nom exact sérialisé (camelCase de `fire_at_ms` = `fireAtMs`) ; idem `resetsAtMs` pour les autres. Aligne-toi sur le JSON réel. - `agentResumeCancelled` { agentId: string } - `agentResumed` { agentId: string } - `agentRateLimitSuspected` { agentId: string, resetsAt?: number } (Vérifie les noms de champs exacts dans events.rs : `resets_at_ms`→`resetsAtMs`, `fire_at_ms`→`fireAtMs`. Ne devine pas, lis le fichier.) À FAIRE : 1) Ajouter les 5 variantes au union `DomainEvent` de `src/domain/index.ts` (mêmes noms `type` que le wire), avec les champs exacts. 2) Exposer la commande `cancel_resume` dans le port gateway approprié (cf. `src/ports/index.ts`) + son implémentation dans l'adapter système (`src/adapters/system.ts`) ET le mock (`src/adapters/mock/index.ts`). RÈGLE ARCHI STRICTE : les composants ne touchent JAMAIS `invoke()` ni `@tauri-apps/api` — tout passe par les gateways/ports (hexagonal §1.3). Suis le patron d'une commande existante (ex. `interruptAgent` si elle existe, sinon une autre commande agent). 3) Tracker l'état limite par agent dans `useAgents` (view-model hook). Inspire-toi du patron existant `delegationSourceByRequester` qui se peuple depuis les events. État par agent : { limitedUntil?: number, resumeFireAt?: number, suspected?: bool }. Mises à jour : - `agentRateLimited` → limité (badge « limité jusqu'à HH:MM » si resetsAt connu, sinon « limité »). - `agentResumeScheduled` → arme le compte à rebours jusqu'à fireAt + bouton « Annuler la reprise ». - `agentResumeCancelled` → retire le compte à rebours (reste limité, sans reprise auto). - `agentResumed` → efface tout l'état limite de l'agent. - `agentRateLimitSuspected` → filet humain : signaler que la limite est détectée sans heure fiable et demander à l'utilisateur (au minimum un état/affichage « heure inconnue » ; le formulaire de saisie d'heure peut être un placeholder documenté si le backend n'a pas encore de commande pour l'enregistrer — dans ce cas, expose juste l'état et un TODO clair, NE fabrique pas de commande inexistante). 4) UI : badge limite + compte à rebours + bouton « Annuler la reprise » (appelle `cancel_resume` via le gateway) dans la vue agents/terminal cohérente avec le style existant. Composant testable (gateways mockés). NE PAS committer toi-même (l'agent Git tranche les commits). NE PAS toucher au backend Rust. Reste cohérent avec le style TS/React existant. Quand c'est implémenté, QA écrira/exécutera les tests — donc structure pour la testabilité (logique d'état dans le hook, pas dans le JSX). Rends-moi via idea_reply : la liste des fichiers modifiés/créés, les noms de champs wire exacts que tu as constatés dans events.rs, et tout point de friction (notamment sur le filet humain niveau 3 si aucune commande backend n'existe pour enregistrer l'heure saisie). +- **Response:** LS7-front terminé. Typecheck `tsc --noEmit` propre + 39 tests agents existants verts (aucune régression). Non commité (Git tranche). ## Noms de champs wire constatés (lus dans crates/app-tauri/src/events.rs, `DomainEventDto`, serde camelCase + skip_serializing_if sur les Option) - `agentRateLimited` → { `agentId`: string, `resetsAtMs`?: number /* absent si null */ } - `agentResumeScheduled` → { `agentId`: string, `fireAtMs`: number /* toujours présent */ } - `agentResumeCancelled` → { `agentId`: string } - `agentResumed` → { `agentId`: string } - `agentRateLimitSuspected` → { `agentId`: string, `resetsAtMs`?: number /* absent si null */ } ⚠️ Confirmé : ce sont bien `resetsAtMs` / `fireAtMs` (suffixe `Ms`), PAS `resetsAt`/`fireAt` comme l'énoncé du ticket le suggérait. Je me suis aligné sur le JSON réel. Vérifié aussi par le test backend `rate_limited_relays_to_dto_and_wire` qui asserte `json["resetsAtMs"]`. Commande Tauri `cancel_resume` (commands.rs:1381) : signature `cancel_resume(agent_id: String) -> Result` — argument **nu** `agentId` (PAS d'enveloppe `{ request }` contrairement à interrupt_agent/delegation_delivered). Adapter aligné en conséquence. Renvoie `true` ssi une reprise était armée et a été désarmée. ## Fichiers modifiés 1. `frontend/src/domain/index.ts` — 5 variantes ajoutées au union `DomainEvent` (mêmes `type` que le wire, champs exacts `resetsAtMs?`/`fireAtMs`). 2. `frontend/src/ports/index.ts` — `cancelResume(agentId: string): Promise` ajouté à `InputGateway` (port choisi : c'est le gateway des contrôles agent hors-bande, cohérent avec `interrupt`). 3. `frontend/src/adapters/input.ts` — `TauriInputGateway.cancelResume` → `invoke("cancel_resume", { agentId })` (arg nu, typé ``). 4. `frontend/src/adapters/mock/index.ts` — `MockInputGateway.cancelResume` : enregistre dans `cancelledResumes: string[]`, renvoie `cancelResumeResult` (défaut `true`, flippable par les tests). 5. `frontend/src/features/agents/useAgents.ts` — état `limitByAgent: Record` (interface exportée `{ limitedUntil?, resumeFireAt?, suspected? }`), peuplé depuis les 5 events dans l'abonnement `onDomainEvent` existant (même patron que `delegationSourceByRequester`) : - `agentRateLimited` → `{ limitedUntil: resetsAtMs, suspected: false }` (conserve un `resumeFireAt` éventuel). - `agentResumeScheduled` → arme `resumeFireAt = fireAtMs`. - `agentResumeCancelled` → retire `resumeFireAt`, reste limité. - `agentResumed` → supprime l'entrée (efface tout). - `agentRateLimitSuspected` → `{ limitedUntil: resetsAtMs, suspected: true }`. - Action `cancelResume(agentId)` exposée : retrait optimiste du compte à rebours + appel `input.cancelResume`, renvoie le verdict backend. ## Fichier créé 6. `frontend/src/features/agents/AgentLimitBadge.tsx` — composant présentational + 2 helpers purs exportés (`formatResetTime(epochMs)` → `HH:MM`, `formatCountdown(remainingMs)` → `Xm Ys`/`Ys`, clampé à 0). Badge « limité jusqu'à HH:MM » / « limité », compte à rebours vivant (clock locale 1 s armée seulement si `resumeFireAt`), bouton « Annuler la reprise ». Câblé dans `AgentsPanel.tsx` (import + rendu conditionnel sous chaque ligne d'agent quand `vm.limitByAgent[a.id]` existe). ## Point de friction — filet humain niveau 3 (§21.1) Conformément à la consigne, je n'ai **fabriqué aucune commande inexistante**. Le seul point d'entrée backend disponible est `cancel_resume` ; **il n'existe pas de commande pour enregistrer une heure de reprise saisie par l'utilisateur**. Donc pour `agentRateLimitSuspected` sans `resetsAtMs`, le badge affiche l'état « limité · heure inconnue — reprise à préciser » (état + `role="note"`), sans formulaire de saisie. Un `TODO(LS-front)` documenté est posé dans `AgentLimitBadge.tsx` : câbler un formulaire d'heure dès que le backend exposera une commande pour persister l'instant choisi. Si tu veux ce filet pleinement fonctionnel, il faut une tâche backend (ex. `set_resume_at(agentId, epochMs)`) à cadrer avec Architect/DevBackend — je m'y connecterai côté front. Prêt pour QA (logique d'état dans le hook + helpers purs isolés du JSX, mocks en place avec `cancelledResumes`/`cancelResumeResult`). +- **Prompt:** Deux livrables distincts sur `feature/agent-session-limits` (Git en fera 2 commits séparés — ne commit pas toi-même). ═══ LIVRABLE 1 — LS8-front : formulaire de saisie d'heure (filet humain niveau 3) ═══ Le backend expose maintenant une commande Tauri `set_resume_at(agentId: string, resetsAtMs: number) -> void` (argument nu `{ agentId, resetsAtMs }`, comme `cancel_resume`). Elle arme la MÊME reprise annulable que l'auto et réémet `agentResumeScheduled` — donc une fois appelée, ton badge bascule TOUT SEUL de « heure inconnue » vers l'état nominal « limité jusqu'à HH:MM » + compte à rebours + bouton Annuler (déjà câblés en LS7). Aucun nouvel événement à consommer. À FAIRE : 1. Port : ajoute `setResumeAt(agentId: string, resetsAtMs: number): Promise` à `InputGateway` (`src/ports/index.ts`), à côté de `cancelResume`. 2. Adapter Tauri (`src/adapters/input.ts`) : `setResumeAt` → `invoke("set_resume_at", { agentId, resetsAtMs })`. 3. Mock (`src/adapters/mock/index.ts`) : `MockInputGateway.setResumeAt` enregistre dans un tableau (ex. `resumeArmings: { agentId, resetsAtMs }[]`) pour les tests ; suis le patron de `cancelledResumes`. 4. Hook `useAgents` : expose une action `setResumeAt(agentId, resetsAtMs)` qui délègue au port (pas de mutation optimiste nécessaire — l'event `agentResumeScheduled` rebasculera l'état). 5. UI `AgentLimitBadge.tsx` : sur l'état SUSPECTED SANS heure (`suspected === true` && pas de `limitedUntil`/`resumeFireAt`), remplace le TODO/placeholder par un petit formulaire : un input heure (type `time` ou `datetime-local`) + bouton « Programmer la reprise ». À la soumission : convertir la saisie en epoch-ms (si input `time` seul = aujourd'hui à HH:MM ; si déjà passé dans la journée, le backend clampe à maintenant ⇒ reprise immédiate, donc pas besoin de validation stricte côté front, mais documente le comportement). Appeler l'action `setResumeAt(agentId, epochMs)`. Garde un helper pur exporté pour la conversion (ex. `timeInputToEpochMs(value, now)`) pour la testabilité. Style cohérent avec le reste du badge ; désactive pendant `busy`. 6. Retire le `TODO(LS-front)` posé en LS7 (le filet est maintenant complet). ═══ LIVRABLE 2 — fix test préexistant (hygiène, commit séparé) ═══ `src/adapters/mock/mock.test.ts:16` asserte « thirteen gateways » mais il y en a 14 (la gateway `permission` existe déjà dans HEAD). Mets le compteur à jour (14, et le libellé texte si présent) pour que `npx vitest run src/adapters/mock/` repasse vert. NE change RIEN d'autre dans ce fichier ; vérifie juste que le compte réel est bien 14 avant de figer le nombre. Lance `npx vitest run src/features/agents/ src/adapters/mock/` + `tsc --noEmit` et RENDS-MOI via idea_reply : fichiers modifiés/créés par livrable, le helper de conversion exact, le résultat des deux commandes (typecheck + tests), et le nombre réel de gateways constaté. Structure pour testabilité (logique dans le hook + helpers purs). NE COMMIT PAS — Git tranche, en 2 commits séparés (LS8-front / fix-test). +- **Response:** Les deux livrables sont prêts. NON commité (Git tranche, 2 commits séparés). ## Résultats des commandes - `npx tsc --noEmit` : **propre** (0 erreur). - `npx vitest run src/features/agents/ src/adapters/mock/` : **109 tests / 8 fichiers, tous verts**. ## Nombre réel de gateways constaté : **14** Le `Gateways` (ports/index.ts) en a 14 ; l'array du test en listait 13 — il manquait `permission` (présente dans HEAD). Compteur + libellé + array corrigés. ## Helper de conversion exact (pur, exporté, testé) ```ts // AgentLimitBadge.tsx 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(); } ``` Comportement documenté : input `time` seul ⇒ aujourd'hui à HH:MM (même jour calendaire que `now`). Une heure déjà passée renvoie un instant < now **sans validation stricte** — le backend clampe à maintenant ⇒ reprise immédiate (commenté dans le code et couvert par un test). Empty/malformé/`25:00`/`12:60` ⇒ `null` (saisie incomplète ignorée). ═══ LIVRABLE 1 — LS8-front (commit « LS8-front ») ═══ Fichiers modifiés : 1. `frontend/src/ports/index.ts` — `setResumeAt(agentId, resetsAtMs): Promise` ajouté à `InputGateway`, à côté de `cancelResume`. 2. `frontend/src/adapters/input.ts` — `setResumeAt` → `invoke("set_resume_at", { agentId, resetsAtMs })` (args nus, comme cancel_resume). 3. `frontend/src/adapters/mock/index.ts` — `MockInputGateway.setResumeAt` enregistre dans `resumeArmings: { agentId, resetsAtMs }[]` (patron `cancelledResumes`). 4. `frontend/src/features/agents/useAgents.ts` — action `setResumeAt(agentId, resetsAtMs)` exposée, délègue au port, **aucune mutation optimiste** (l'event `agentResumeScheduled` rebascule l'état via l'abonnement existant). 5. `frontend/src/features/agents/AgentLimitBadge.tsx` — état SUSPECTED sans heure (`suspected===true && !limitedUntil && !resumeFireAt`) : le placeholder/TODO LS7 est remplacé par un **formulaire** (input `type="time"` aria-label `resume time` + bouton `Programmer la reprise` aria-label `schedule resume`, désactivés pendant `busy`/saisie vide). Soumission → `timeInputToEpochMs` → `onSetResumeAt(epochMs)`. Une fois armé, le badge bascule **tout seul** vers « limité jusqu'à HH:MM » + compte à rebours + Annuler (chaîne LS7 inchangée). TODO(LS-front) **retiré**. 6. `frontend/src/features/agents/AgentsPanel.tsx` — passe `onSetResumeAt={(ms) => void vm.setResumeAt(a.id, ms)}` au badge. 7. `frontend/src/features/agents/AgentLimitBadge.test.tsx` — **test QA préexistant (LS7) mis à jour au nouveau contrat** (prop requise `onSetResumeAt`, le cas « suspected sans heure » teste maintenant le formulaire au lieu de l'ancien texte placeholder) + ajout couverture LS8 : `timeInputToEpochMs` (jour calendaire, instant passé, null malformé), soumission du formulaire ⇒ `onSetResumeAt` appelé avec l'epoch-ms attendu, bouton désactivé si vide. *(Sans cette MAJ le typecheck cassait — la prop est devenue obligatoire et le rendu du cas suspected a changé. Aucune logique de prod altérée, seulement l'alignement du test.)* ═══ LIVRABLE 2 — fix test (commit « fix-test » séparé) ═══ Fichier modifié : - `frontend/src/adapters/mock/mock.test.ts` — « thirteen » → « fourteen », `"permission"` inséré dans l'array attendu (ordre alphabétique). **Rien d'autre touché** dans ce fichier. ## Point de friction / note Petit ajustement non listé mais nécessaire : le `
    ` portait initialement le même `aria-label="schedule resume"` que son bouton submit, ce qui faisait matcher 2 éléments avec `getByLabelText`. J'ai renommé le label du form en `"resume time form"` (le bouton garde `schedule resume`). Aucune incidence fonctionnelle. Le filet humain niveau 3 est désormais **complet** : détection → formulaire de saisie → arme la reprise annulable via `set_resume_at` → bascule auto vers l'état nominal. Prêt pour QA (logique d'état dans le hook, helpers purs isolés, mocks instrumentés `resumeArmings`/`cancelledResumes`). \ No newline at end of file diff --git a/.ideai/conversations/09b15ec3-ce2f-0af2-2903-ef699df9de41/log.jsonl b/.ideai/conversations/09b15ec3-ce2f-0af2-2903-ef699df9de41/log.jsonl index 7cd8fb0..dd80214 100644 --- a/.ideai/conversations/09b15ec3-ce2f-0af2-2903-ef699df9de41/log.jsonl +++ b/.ideai/conversations/09b15ec3-ce2f-0af2-2903-ef699df9de41/log.jsonl @@ -6,3 +6,7 @@ {"id":"6a286e41-aa26-4bcb-8530-282bd09dde95","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781544388479,"source":{"kind":"agent","agentId":"af7f86da-76bc-48e1-9900-71f45a624800"},"role":"response","text":"pong DevFrontend OK"} {"id":"808e5ed2-57d7-4839-906e-bd66886852b7","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781593284048,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tâche : nettoyage/correction de bugs d'affichage UI (frontend React/TS). Project root : /home/anthony/Documents/Projects/IdeA. Aucune nouvelle feature, uniquement du fix CSS/layout. Voici 3 problèmes constatés visuellement (captures non transmissibles, je les décris) :\n\nPROBLÈME 1 — Barre de navigation horizontale (top bar listant : Projects, Context, Agents, Templates, Skills, Permissions, …).\n- Cette barre n'est PAS scrollable et n'est PAS correctement mise à l'échelle. Quand il y a trop d'entrées, les derniers items débordent et sont coupés (on voit « Skills » puis « P… » tronqué, « Permissions » coupé).\n- Attendu : la barre doit gérer le débordement proprement — soit scroll horizontal (overflow-x:auto avec masquage de scrollbar propre), soit wrap/scale correct. Les entrées ne doivent jamais être croppées. Vérifier flex-shrink/min-width des items.\n\nPROBLÈME 2 — Panneau « Agents » (liste des agents : Main, Architect, DevBackend, DevFrontend, QA, TestConversation, …).\n- Les items de la liste s'overlappent (chevauchement vertical) et sont croppés. Le nom de l'agent, le profil (« Claude Code »), l'id de run (longue chaîne en bleu qui wrap mal sur plusieurs lignes), la dropdown de profil et les boutons Stop/Delete/Launch se chevauchent et débordent du panneau.\n- Attendu : chaque carte d'agent doit avoir une hauteur qui s'adapte à son contenu (pas de height fixe qui cause l'overlap), les éléments alignés proprement, l'id long tronqué avec ellipsis ou wrap contrôlé, et la dropdown + boutons ne doivent pas déborder/chevaucher le texte voisin. Vérifier les position absolute éventuels et les gaps flex/grid.\n\nPROBLÈME 3 — Formulaire de création d'agent (champs : Name, Template, Profile, bouton Create).\n- Les champs du formulaire s'overlappent les uns sur les autres (Name / Template / Profile trop serrés/superposés).\n- BUG fonctionnel : quand on sélectionne une valeur dans les dropdowns (Template / Profile), la sélection NE s'affiche PAS dans le champ de la droplist (le label sélectionné ne se reflète pas visuellement). À investiguer : binding value/onChange du composant select, ou affichage du label sélectionné si c'est un dropdown custom.\n- Attendu : formulaire avec espacement correct (pas de chevauchement), et les dropdowns affichent bien la valeur choisie.\n\nMerci de :\n1. Localiser les composants concernés (top nav bar, panneau Agents, formulaire création d'agent).\n2. Corriger le CSS/layout et le bug de binding des dropdowns.\n3. Lancer un build frontend pour t'assurer que ça compile (npm run build ou équivalent), sans casser le reste.\n4. Me renvoyer via idea_reply la liste des fichiers modifiés + un résumé des corrections, pour que je puisse enchaîner sur le rebuild de l'AppImage."} {"id":"a6169530-fc73-4057-91c6-900da4a611fb","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781593486944,"source":{"kind":"agent","agentId":"af7f86da-76bc-48e1-9900-71f45a624800"},"role":"response","text":"Bugs d'affichage corrigés. Build vert (`npm run build` = tsc --noEmit + vite build OK) et tests verts (`vitest run` agents + projects : 47/47 passent).\n\n## Fichiers modifiés (2)\n- `frontend/src/features/projects/ProjectsView.tsx` — la « top bar »\n- `frontend/src/features/agents/AgentsPanel.tsx` — panneau Agents + formulaire de création\n\n## Diagnostic\nLa « top bar » décrite n'est pas une nav OS mais le **strip de tabs du sidebar** (`SIDEBAR_TABS` : Projects, Context, Agents, Templates, Skills, Perms, Memory, Git) dans `ProjectsView.tsx`. Cause racine commune aux 3 problèmes : **largeur trop contrainte** dans le sidebar de 320px (`w-80`).\n\n## Corrections\n\n**PROBLÈME 1 — barre de tabs croppée**\n- Avant : 8 boutons en `flex-1` sans gestion de débordement → les derniers labels coupés.\n- Après : les tabs sont dans un conteneur `overflow-x-auto` **scrollable horizontalement**, chaque tab passe en `shrink-0 whitespace-nowrap` (plus jamais croppé). Scrollbar masquée proprement (`[scrollbar-width:none]` + `[&::-webkit-scrollbar]:hidden`). Le bouton « collapse » («) reste épinglé à droite, hors zone de scroll.\n\n**PROBLÈME 2 — cartes d'agents qui se chevauchent**\n- Avant : `
  • ` en `flex items-center justify-between` → le bloc nom/profil et la colonne de contrôles (dropdown profil + Sync/Stop/Launch/Delete, en `shrink-0`) se disputaient la largeur ; l'id de run long (`running in IdeA · `) wrappait et débordait.\n- Après : carte en **`flex-col`** — bloc nom/profil/statut en haut, rangée de contrôles en dessous (`flex-wrap`, plus de `shrink-0` qui écrasait le texte). Hauteur adaptative au contenu (plus de chevauchement). L'id long est désormais **tronqué avec ellipsis** (`block w-full min-w-0 truncate`).\n\n**PROBLÈME 3 — formulaire de création (champs serrés + dropdowns « vides »)**\n- Avant : form en `flex flex-wrap items-end` avec 3 champs `min-w-0 flex-1` → selects écrasés à ~90px ; le texte sélectionné (« Claude Code ») était **clippé derrière la flèche native** du select, d'où l'impression que la sélection ne s'affichait pas.\n- Après : form en **`flex-col gap-3`**, chaque champ en pleine largeur. Le binding des `