docs(session-limits): cadrage Architect — gestion des limites de session des agents

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 14:25:05 +02:00
parent 401c18ad3c
commit fa5b826df5
3 changed files with 212 additions and 0 deletions

View File

@ -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.

View File

@ -0,0 +1,26 @@
---
name: session-limit-handling-design
description: Design valide pour la detection des limites de session des agents et la reprise auto a la levee.
metadata:
type: project
---
Feature : detecter quand un agent IA est en limite de session (et jusqu'a quelle heure), puis reprendre ou il en etait une fois la limite levee. Cadree le 2026-06-16.
Constat dur : l'heure exacte de reset n'existe nulle part de facon universelle (ni OS, ni code de sortie, ni API inter-modeles) — elle est fabriquee par le fournisseur et seulement exposee dans le flux de sa CLI. Donc « 100% fiable + zero dependance modele + heure exacte » sont incompatibles simultanement ; on vise le meilleur compromis via un detecteur hierarchique.
Solution retenue — detecteur hierarchique calque sur la hierarchie de readiness existante ([[remaining-work-idea-agent-control-ide]]) :
- Niveau 1 (solide) : l'adapter structure extrait limite + reset du flux machine. Pour Claude, `rate_limit_event.rate_limit_info` est DEJA parse dans `infrastructure/session/claude.rs` mais jete (reduit a Heartbeat) — il suffit de lire `resetsAt`.
- Niveau 2 (configurable) : champ de profil `rate_limit_pattern` (regex + capture heure) pour agents PTY/TUI sans adapter structure, dans la lignee des profils declaratifs §9.
- Niveau 3 (filet humain) : si rien ne matche mais agent `Stalled` (deja prevu dans `domain/readiness.rs`), IdeA DEMANDE a l'utilisateur. Garantit le « 100% meme pour un novice » : jamais d'inaction silencieuse.
Model-agnostic tenu AU DOMAINE : le domaine ne connait que `RateLimited { until: Option<Instant> }`. Le savoir specifique modele est confine aux adapters/profils (philosophie §9).
Reprise = partie facile et deja model-agnostique : pivot sur `conversation_id` du moteur + `--resume` natif (deja cable dans `session/claude.rs` + `agent/resume.rs`) qui portent tout l'historique. Pas de reconstruction manuelle fragile.
Decisions produit verrouillees (2026-06-16) :
- Reprise : AUTOMATIQUE a l'heure de reset, ANNULABLE (fenetre + notification).
- Couverture : les TROIS niveaux d'emblee (incl. repli regex niveau 2).
- Etat : EN MEMOIRE uniquement (pas de persistance de SessionLimit). Consequence assumee : le reveil auto ne joue que tant qu'IdeA reste ouvert ; si l'IDE est ferme/rouvert apres le reset, le chemin existant `ListResumableAgents` (agent_was_running/conversation_id) prend le relais.
Decoupage (cycle dev/test §3) : 1) domaine (variante `ReplyEvent::RateLimited`, `ReadinessSignal::RateLimited`, etat `SessionLimit`) ; 2) adapter Claude (extraire resetsAt) ; 3) profil (`rate_limit_pattern`) ; 4) application (planificateur de reprise sur le port Clock) ; 5) UI (badge « limite jusqu'a HH:MM » + filet humain).

View File

@ -2160,4 +2160,189 @@ La décision frontière l'évite : **la frontière propre est jugée côté fron
---
## 21. Gestion des limites de session des agents — détection hiérarchique + reprise auto annulable (cadrage 2026-06-16)
> Cadrage produit verrouillé avec l'utilisateur le 2026-06-16. **Étape 1 du cycle §3 — AUCUN code.**
> Besoin : IdeA doit savoir **quand** un agent est en limite de session **et jusqu'à quand**, puis lui
> demander de **reprendre où il en était** une fois la limite levée. Priorités : (1) **SOLIDE** (marche
> même pour un novice, ~100 % des cas) ; (2) **sans dépendance au modèle** autant que possible.
### 21.0 État du terrain (lu dans le code, pas présumé)
- `domain/src/ports.rs` — `ReplyEvent` = `{ TextDelta, ToolActivity, Heartbeat, Final }`. **Aujourd'hui un
`rate_limit_event` Claude est réduit à `Heartbeat`** (`infrastructure/src/session/claude.rs:90`,
commentaire « fenêtre de limite de débit ») : l'info `rate_limit_info.resetsAt` est **lue puis jetée**.
- `domain/src/readiness.rs` — `ReadinessSignal` = `{ TurnEnded, ExplicitReply, PromptReady, Stalled, TimedOut }`.
`Stalled`/`TimedOut` sont **réservés au lot 2** (vocabulaire présent, **non produits** par `classify`).
- `domain/src/input.rs` — deux axes d'état **orthogonaux** déjà posés : `AgentBusyState{Idle,Busy}` et
`AgentLiveness{Alive,Stalled}`. La limite de session sera un **3ᵉ axe orthogonal**.
- Reprise déjà câblée et **model-agnostique** : pivot `conversation_id` du moteur (`LeafCell.conversation_id`)
+ `SessionPlan::{None,Assign,Resume}` (`ports.rs`) + `build_spawn_line` (`session/claude.rs:200`, `--resume`)
+ `ListResumableAgents` (`application/agent/resume.rs`) + `SessionInspector` (`inspector/claude.rs`, « dernier sujet »).
- `domain/src/ports.rs` — `Clock::now_millis() -> i64` (**épochе-millis**). **Il n'existe AUCUN port de
minuterie** (pas de « réveille-moi à T »). C'est le seul vrai manque.
### 21.1 Décisions d'architecture (tranchées)
1. **Détecteur HIÉRARCHIQUE** calqué sur la hiérarchie de readiness existante. Trois niveaux, *premier
qui matche gagne*, jamais d'inaction silencieuse :
- **Niveau 1 — structuré (solide)** : l'adapter structuré extrait limite + reset du flux machine. Pour
Claude, lire `rate_limit_info.resetsAt` **au lieu de le jeter**.
- **Niveau 2 — déclaratif (configurable)** : champ de profil `rate_limit_pattern` (motif + capture de
l'heure) pour agents **PTY/TUI sans adapter structuré**, dans la lignée des profils déclaratifs §9.
- **Niveau 3 — filet humain** : rien ne matche **mais** l'agent est `Stalled` (lot 2) ⇒ IdeA **demande**
à l'utilisateur. Garantit le « 100 % même pour un novice » : jamais d'inaction silencieuse.
2. **Model-agnostic tenu AU DOMAINE.** Le domaine ne connaît qu'un fait neutre : « limité, reset à T (peut-
être) ». Tout savoir spécifique modèle (forme du `rate_limit_event`, regex d'une TUI, parsing d'une heure
locale) reste **confiné aux adapters/profils** (philosophie §9).
3. **État EN MÉMOIRE uniquement** (décidé). **Aucune persistance** de `SessionLimit`, **aucun nouveau store**,
**aucun schéma `.ideai/` modifié**. Conséquence assumée : le réveil auto ne joue que tant qu'IdeA reste
ouvert ; IDE fermé/rouvert après le reset ⇒ le chemin **existant** `ListResumableAgents`
(`agent_was_running`/`conversation_id`) prend le relais (popup de reprise, pas d'auto).
4. **Reprise AUTOMATIQUE à l'heure de reset, ANNULABLE** (fenêtre + notification UI). Réutilise
`SessionPlan::Resume` + un **prompt de reprise court** ; `--resume` porte tout l'historique (zéro
reconstruction manuelle).
5. **Un seul nouveau port** : `Scheduler` (minuterie one-shot annulable). Tout le reste **réutilise**
l'existant (`Clock`, `EventBus`, `AgentSession`/`Factory`, `LaunchAgent`, `InputMediator`).
### 21.2 Tensions avec l'archi hexagonale existante — et leur résolution (à lire avant de coder)
| # | Tension (proposition initiale) | Résolution retenue |
|---|---|---|
| **T1** | `RateLimited { until: Option<Instant> }`. `std::time::Instant` est **monotone, non sérialisable, non horloge murale**, et **absent du domaine** (qui parle `i64` époche-millis via `Clock`). | **Abandonner `Instant`** → `resets_at_ms: Option<i64>` (époche-millis), homogène avec `Clock::now_millis` et `AgentBusyState.since_ms`, serde-friendly. **Déviation assumée de la proposition.** |
| **T2** | `rate_limit_pattern` = **regex + capture**. Or le domaine est **dépendance-zéro** ; `prompt_ready_pattern` a été délibérément choisi **littéral** pour éviter la dépendance `regex`. | Le **domaine ne stocke que de la donnée** : `RateLimitPattern { pattern, reset_capture, time_format }` (chaînes). Le **moteur regex + le parsing d'heure vivent en infra** (un composant `RateLimitParser` / le watcher PTY). `regex` est ajouté au `Cargo.toml` de **`infrastructure` uniquement**. Domaine pur préservé (§1.4 / §9). |
| **T3** | « armer un réveil sur le port `Clock` ». `Clock` ne sait que **donner l'heure**, pas **réveiller**. | Nouveau port **`Scheduler`** (ISP : minuterie fine, une responsabilité). `Clock` reste pour « maintenant ». |
| **T4** | `RateLimited` comme événement de tour. Le contrat `ReplyStream` dit **« seul `Final` est terminal »** et `claude.rs` **rompt** la boucle au `Final`. | `ReplyEvent::RateLimited` est **non terminal** (comme `Heartbeat`) : il s'intercale, le flux continue jusqu'à `Final` **ou** clôture. **Point d'intégration** : un tour clos **sans `Final`** *parce que* limité ne doit **pas** devenir `AgentSessionError::Io` (« flux clos sans Final ») — `drain_bounded` doit traiter « clos + `RateLimited` vu » comme une **fin gracieuse limitée**, pas une erreur. |
| **T5** | Niveau 3 « si agent `Stalled` ». Or `Stalled` est **réservé** au lot 2 (non produit aujourd'hui). | Le **niveau 3 dépend du lot 2** (détection de stagnation). À livrer **après** ; d'ici là, niveaux 1+2 couvrent le structuré et le PTY configuré. **Dépendance explicitée** dans le découpage. |
### 21.3 Modèle de domaine (ajouts purs, I/O-free)
- **`ReplyEvent::RateLimited { resets_at_ms: Option<i64> }`** (`ports.rs`) — non terminal, model-agnostique.
- **`ReadinessSignal::RateLimited { resets_at_ms: Option<i64> }`** (`readiness.rs`) — l'enum **reste `Copy`**
(`Option<i64>` est `Copy`). `ReadinessPolicy::classify(ReplyEvent::RateLimited{..})` ⇒
`Some(ReadinessSignal::RateLimited{..})` (seul ajout au `match`).
- **`domain/src/session_limit.rs` (NOUVEAU)** :
- `SessionLimit { resets_at_ms: Option<i64>, detected_at_ms: i64, source: RateLimitSource }` (VO).
- `RateLimitSource { Structured, Pattern, Human }` (traçabilité du niveau, pour l'UI).
- **fonction pure** `plan_resume(now_ms, &SessionLimit) -> ResumePlan` avec
`ResumePlan { fire_at_ms: i64 }` (si `resets_at_ms` absent ⇒ pas de plan auto ⇒ filet humain).
Toute la logique de calendrier est **pure et testable sans I/O** (cf. `LayoutTree`, §7.2).
- **`AgentProfile.rate_limit_pattern: Option<RateLimitPattern>`** + `with_rate_limit_pattern` (builder),
`#[serde(default, skip_serializing_if = "Option::is_none")]` ⇒ **zéro régression** de sérialisation
(mêmes tests que `liveness`/`prompt_ready_pattern`). `RateLimitPattern` = **donnée** (cf. T2), validée a
minima (motif non vide) par un constructeur *parse-don't-validate*.
- **`events.rs`** : `AgentRateLimited { agent_id, resets_at_ms: Option<i64> }`,
`AgentResumeScheduled { agent_id, fire_at_ms }`, `AgentResumeCancelled { agent_id }`,
`AgentResumed { agent_id }`, `AgentRateLimitSuspected { agent_id }` (niveau 3). Discrets, basse fréquence.
### 21.4 Le port `Scheduler` (frontière domaine — le seul nouveau port)
```rust
/// Minuterie one-shot **annulable** (ARCHITECTURE §21). `Clock` dit l'heure ; ce
/// port *réveille* à une échéance absolue. In-memory (aucune persistance, §21.1-3).
pub trait Scheduler: Send + Sync {
/// Arme un réveil à `deadline_ms` (époche-ms) qui, à échéance, **pousse** `task`
/// vers le drain applicatif. Renvoie un id annulable.
fn arm(&self, deadline_ms: i64, task: ScheduledTask) -> ScheduleId;
/// Annule un réveil armé (idempotent ; `false` si déjà tiré/inconnu).
fn cancel(&self, id: ScheduleId) -> bool;
}
/// Intention model-agnostique exécutée à l'échéance (donnée pure, pas de closure
/// traversant la frontière). Calqué sur l'esprit du dispatch orchestrateur §14.3.
pub enum ScheduledTask {
ResumeAgent { agent_id: AgentId, node_id: NodeId, conversation_id: Option<String> },
}
```
- **Pourquoi un port plutôt qu'un `tokio::sleep` direct** : garder l'**application testable sans temps réel**
(un `Scheduler` fake déclenche à la demande) et l'inversion de dépendance (§1.2-D). `Clock` **reste** le
port d'« heure courante ».
- **Adapter** : `TokioScheduler` (`infrastructure/src/scheduler/`) — `tokio::time::sleep_until` + table de
`JoinHandle` (abort = `cancel`) ; à l'échéance il **pousse `task`** dans un `mpsc` fourni à la construction
(le drain est côté application). **Direction des dépendances respectée** : infra → canal → application
(exactement le motif du watcher orchestrateur §14.3, qui draine un dispatch unique).
### 21.5 Application — service de limite & reprise
- **`application/src/agent/session_limit.rs` (NOUVEAU)** : `SessionLimitService`, qui compose **uniquement
des ports/use-cases existants** + `Scheduler` :
- `on_rate_limited(agent, SessionLimit)` : enregistre une entrée **en mémoire**, `plan_resume`, `Scheduler::
arm(fire_at_ms, ResumeAgent{..})`, publie `AgentRateLimited` + `AgentResumeScheduled`.
- `cancel_resume(agent)` : `Scheduler::cancel`, publie `AgentResumeCancelled` (la fenêtre annulable UI).
- `resume_now(agent)` / drain de `ScheduledTask::ResumeAgent` : **compose `LaunchAgent` / `AgentSessionFactory::
start` avec `SessionPlan::Resume{conversation_id}`** + **prompt de reprise court** ; publie `AgentResumed`.
- `confirm_manual(agent, resets_at_ms)` (niveau 3) : même chemin que niveau 1 avec `source = Human`.
- **`application/src/agent/structured.rs`** : dans `drain_with_readiness`, réagir à
`ReadinessSignal::RateLimited{resets_at_ms}` ⇒ `SessionLimitService::on_rate_limited`. **Réconcilier T4** :
un flux clos **sans `Final`** alors qu'un `RateLimited` a été vu ⇒ **pas** d'`Io`, mais une issue « limité »
(l'agent reste vivant ; le service arme la reprise). Le `mark_idle`/FIFO reste piloté par `Final`/timeout.
- **Niveau 3 (filet humain)** : à brancher **après le lot 2** — quand le détecteur de stagnation passe
`Alive→Stalled` et qu'**aucune** `SessionLimit` n'est connue pour l'agent, le service publie
`AgentRateLimitSuspected` ⇒ l'UI demande (jamais d'auto sans heure).
### 21.6 Infrastructure — où vit chaque détail modèle
- **Niveau 1** — `infrastructure/src/session/claude.rs` `parse_event` : `rate_limit_event` ⇒ lire
`rate_limit_info.resetsAt`, le **normaliser en époche-ms** (ISO-8601/époch → `i64`) et émettre
`ReplyEvent::RateLimited{resets_at_ms}` (non terminal) **au lieu** du `Heartbeat` actuel. Ajuster la boucle
`send` (le `break 'lines` reste sur `Final` ; `RateLimited` ne rompt pas). `session/codex.rs` : mapper
l'équivalent Codex **s'il existe**, sinon s'en remettre au niveau 2.
- **Niveau 2** — `infrastructure/src/ratelimit/` (NOUVEAU) `RateLimitParser` : compile le
`rate_limit_pattern` du profil (**`regex`, dépendance infra seulement**, cf. T2), observe le flux PTY du
handle lié (réutilise l'armement du watcher de prompt, `MediatedInbox`), et à un match calcule
`resets_at_ms` (capture d'heure + `Clock` + date du jour) puis émet `ReplyEvent::RateLimited` /
notifie le service. **Aucune** regex ne franchit la frontière domaine.
- **Port `Scheduler`** — `infrastructure/src/scheduler/` `TokioScheduler` (cf. §21.4).
- `infrastructure/src/clock/` — **inchangé** (réutilisé pour « maintenant » et le calcul d'heure de reset).
### 21.7 Présentation (app-tauri + frontend)
- **`app-tauri`** (composition root) : instancier `TokioScheduler` + `SessionLimitService`, démarrer le
**drain des `ScheduledTask`** (boucle de fond, jumelle du watcher orchestrateur). Commandes :
`cancel_agent_resume`, `resume_agent_now`, `confirm_agent_rate_limit{resets_at_ms}`. Relais des nouveaux
`DomainEvent` en events IPC **camelCase** (`agentRateLimited`, `agentResumeScheduled`, …).
- **`frontend`** (`features/agents` + `features/terminals`) : **badge « limité jusqu'à HH:MM »** + compte à
rebours + bouton **« Annuler la reprise »** (fenêtre annulable) ; **dialogue du filet humain** (niveau 3 :
« limite détectée mais heure inconnue — reprendre à ? »). Étendre un `gateway` UI (port TS) + son adapter
Tauri + les mocks (§1.3). Le « dernier sujet » du `SessionInspector` enrichit la notification.
### 21.8 Conformité hexagonale & SOLID
- **Domaine pur** : `SessionLimit`/`plan_resume`/`classify` testables **sans I/O ni temps réel** (T1 époche-ms,
T2 regex hors domaine). **S** : `SessionLimitService` = une intention (détecter→planifier→reprendre) ;
`Scheduler` = une responsabilité (minuter). **O** : ajouter un moteur niveau 1 = un adapter ; ajouter une
TUI niveau 2 = **de la donnée** (`rate_limit_pattern`), pas de code. **L** : tout `Scheduler` (réel/fake)
substituable. **I** : `Scheduler` minimal (`arm`/`cancel`), distinct de `Clock`. **D** : l'application
reçoit `Arc<dyn Scheduler>` injecté au composition root.
### 21.9 Découpage en LOTS testables (cycle dev↔QA §3) — ordonné
| Lot | Couche | Contenu | Vert quand |
|---|---|---|---|
| **LS1** | domaine | `ReplyEvent::RateLimited` ; `ReadinessSignal::RateLimited` + `classify` ; `session_limit.rs` (VO + `plan_resume`) ; `events.rs` (5 variantes) ; `profile.rate_limit_pattern` + builder | tests purs : `classify` mappe RateLimited ; `plan_resume` (avec/sans `resets_at`) ; round-trip serde profil (clé omise si `None`, legacy→`None`) ; `ReadinessSignal` reste `Copy` |
| **LS2** | infra (niv. 1) | `claude.rs` `parse_event` extrait `resetsAt`→époche-ms→`RateLimited` ; boucle `send` (pas de rupture sur RateLimited) ; idem Codex si applicable | conformance : une ligne `rate_limit_event` ⇒ `RateLimited{Some(ms)}` ; un tour `rate_limit_event`+`result` ⇒ `[…,RateLimited,Final]` ; sans `resetsAt` ⇒ `RateLimited{None}` |
| **LS3** | domaine+infra | port `Scheduler` + `ScheduledTask` ; `TokioScheduler` (arm/cancel + push mpsc) | `arm` tire la tâche à l'échéance (deadline courte) ; `cancel` empêche le tir ; `cancel` post-tir = `false` |
| **LS4** | application | `SessionLimitService` (on_rate_limited/cancel/resume_now/drain) ; `structured.rs` réconcilie T4 | mocks `Scheduler`+`AgentSession` : `on_rate_limited` arme + publie ; `cancel` annule ; drain compose `SessionPlan::Resume{id}` + prompt ; « clos sans Final + RateLimited » ⇒ **pas** d'`Io` |
| **LS5** | infra (niv. 2) | `ratelimit/RateLimitParser` (regex, dép. infra) + armement sur le watcher PTY ; conso `rate_limit_pattern` | sur une sortie TUI échantillon : match ⇒ `resets_at_ms` calculé ; profil sans motif ⇒ aucun faux positif |
| **LS6** | app+front | **niveau 3** (après lot 2) : `Stalled` sans limite connue ⇒ `AgentRateLimitSuspected` + `confirm_agent_rate_limit` | mock : transition `Stalled` sans `SessionLimit` ⇒ 1 `AgentRateLimitSuspected` ; `confirm_manual` arme comme niv. 1 |
| **LS7** | app-tauri | wiring scheduler+service+drain ; commandes `cancel`/`resume_now`/`confirm` ; relais events camelCase | `cargo build`/tests commands ; events mappés ; drain démarré à open/create_project |
| **LS8** | frontend | badge « limité jusqu'à HH:MM » + countdown + Annuler ; dialogue filet humain ; gateway+adapter+mocks | Vitest avec gateway mock : badge sur `agentRateLimited` ; Annuler appelle `cancel_agent_resume` ; dialogue sur `agentRateLimitSuspected` |
**Ordre** : LS1 → (LS2 ∥ LS3) → LS4 → LS5 → **LS6 (gate : lot 2 stagnation livré)** → LS7 → LS8.
**LS1+LS2+LS4** donnent déjà le niveau 1 de bout en bout (Claude) : le cœur de valeur est atteint tôt.
### 21.10 Points ouverts (spikes)
1. **Format de `resetsAt`** (Claude) : époch vs ISO-8601 vs durée relative — à vérifier sur un vrai
`rate_limit_event` (spike LS2). Le domaine ne voit que des **époche-ms** quoi qu'il arrive.
2. **Heure locale → époche** (niveau 2) : une capture « 15:00 » est une **heure murale locale** ⇒ composer
avec la date du jour + fuseau via `Clock` ; gérer le passage de minuit (reset « demain »). Confiné infra.
3. **Codex** : existe-t-il un signal structuré de limite dans `codex exec --json` ? Sinon niveau 2 obligatoire
pour Codex (spike LS2).
4. **Double détection** : niveau 1 **et** niveau 2 pourraient matcher le même épisode ⇒ le service
**dédoublonne par agent** (une `SessionLimit` vivante par agent ; le second signal rafraîchit, n'empile pas).
---
*Document maintenu par l'Agent Architecture — base du jalon « cadrage architecture » avant tout code applicatif.*