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 <noreply@anthropic.com>
26 lines
86 KiB
Markdown
26 lines
86 KiB
Markdown
---
|
||
upTo: 21ed91d7-af2d-4537-9952-8236c7faafe4
|
||
objective: IMPLÉMENTATION (code de production uniquement — PAS les nouveaux tests, c'est QA qui les écrit ; mais ne casse aucun test existant). Cadrage validé par l'Architect. Objectif : permettre la délégation
|
||
---
|
||
**Objectif :** IMPLÉMENTATION (code de production uniquement — PAS les nouveaux tests, c'est QA qui les écrit ; mais ne casse aucun test existant). Cadrage validé par l'Architect. Objectif : permettre la délégation
|
||
|
||
- **Prompt:** Lot LP4-4 — implémenter l'enforcement Landlock sur le chemin STRUCTURÉ (sessions Claude/Codex mode JSON), aujourd'hui non sandboxé. L'Architecte a validé l'approche (GO). Implémente exactement ce découpage ; ne réinvente pas la stratégie. STRATÉGIE VALIDÉE (approche b) : transposer la technique du PTY (`spawn_command_sandboxed` dans `crates/infrastructure/src/pty/mod.rs`). Le `pre_exec(enforce)` est INTERDIT (landlock alloue → deadlock malloc post-fork en process multithreadé). À la place : `enforce(plan)` sur un THREAD JETABLE AVANT le fork, puis spawn `std::process` synchrone depuis ce thread (l'enfant hérite le domaine Landlock via fork+exec), réconcilié à l'async par `spawn_blocking`. Le chemin non-sandboxé (sandbox==None OU pas d'enforcer, et tout non-Linux) reste le `drain` async tokio ACTUEL strictement inchangé (zéro régression). CONTRAT À MODIFIER : 1. `crates/domain/src/ports.rs` (~537) — `AgentSessionFactory::start` : ajouter param `sandbox: Option<&SandboxPlan>` (SandboxPlan est domaine, franchit déjà le port via SpawnSpec.sandbox — cohérent). 2. `crates/infrastructure/src/session/process.rs` — ajouter `pub sandbox: Option<SandboxPlan>` à `SpawnLine` ; `run_turn` reçoit `enforcer: Option<&Arc<dyn SandboxEnforcer>>` ; nouveau `drain_sandboxed` : thread jetable → `enforcer.enforce(&plan)` (fail-closed : Err ⇒ échec du tour, AUCUN child ne tourne) → `std::process::Command::spawn` → poser un `unsafe { cmd.pre_exec(|| Ok(())) }` VIDE (async-signal-safe) pour forcer le chemin fork+exec déterministe → drain bloquant stdin/stdout→EOF→wait → Vec<String>. Le thread meurt avec sa restriction. TIMEOUT sous sandbox : le thread renvoie son killer (Arc<Mutex<Child>> ou pid) via un oneshot juste après spawn ; `tokio::time::timeout` sur le JoinHandle ; à expiration kill le child → EOF → le thread finit → renvoyer Timeout (pas de zombie/thread bloqué). 3. `crates/infrastructure/src/session/factory.rs` — `StructuredSessionFactory` gagne `Option<Arc<dyn SandboxEnforcer>>` + builder `with_sandbox_enforcer(...)` (jumeau exact de PortablePtyAdapter::with_sandbox_enforcer) ; `start` apparie plan (par-appel) + enforcer (par-instance) et les injecte dans `ClaudeSdkSession::new`/`CodexExecSession::new`. 4. `crates/infrastructure/src/session/claude.rs` (build_spawn_line ~187, send ~195) & `codex.rs` (~162/195) — stocker plan+enforcer, remplir `SpawnLine.sandbox`, passer l'enforcer à `run_turn`. 5. `crates/application/src/agent/lifecycle.rs` — `launch_structured` (~1620) reçoit le plan (`spec.sandbox`, déjà calculé en step 5d) et le relaie à `factory.start`. 6. `crates/app-tauri/src/state.rs` (~408) — `StructuredSessionFactory::new().with_sandbox_enforcer(infrastructure::default_enforcer())`. 7. FAKES à mettre à jour (nouvelle signature start) : `crates/domain/tests/structured_session_d0.rs`, `crates/application/tests/structured_launch_d3.rs`, `crates/application/tests/orchestrator_service.rs`. INVARIANT PRODUIT : eff==None ⇒ plan None ⇒ SpawnLine.sandbox None ⇒ drain async tokio inchangé, comportement natif. Respecte l'hexagonal (la résolution policy→plan reste pure dans le domaine ; l'infra ne fait qu'orchestrer + enforcer). À la fin : `cargo build` + `cargo test -p domain -p infrastructure -p application -p app-tauri` verts. NE committe PAS (figé par l'orchestrateur). Rends via idea_reply : fichiers modifiés, comment le timeout sous sandbox est géré, et la sortie RÉELLE des tests. Si quelque chose bloque ou ne passe pas, rapport clair avec sortie brute — je renverrai à l'Architecte ou on bouclera. Les tests d'enforcement e2e du chemin structuré seront écrits ensuite par QA (les 7 invariants), tu n'as pas à les écrire, mais assure-toi que les suites existantes restent vertes.
|
||
- **Response:** LP4-4 — enforcement Landlock sur le chemin STRUCTURÉ : FAIT, build + tests verts (80 suites, 0 failed). NON committé (figé par l'orchestrateur). ⚠️ UNE DIVERGENCE ASSUMÉE vs le cadrage (le `pre_exec` vide), détaillée plus bas — à valider par l'Architecte. Tout le reste est conforme au découpage. ## Fichiers modifiés (12) Production (6) : 1. `crates/domain/src/ports.rs` — `AgentSessionFactory::start` gagne `sandbox: Option<&crate::sandbox::SandboxPlan>` (dernier param). 2. `crates/infrastructure/src/session/process.rs` — `SpawnLine.sandbox: Option<SandboxPlan>` ; `run_turn(spec, timeout, enforcer: Option<&Arc<dyn SandboxEnforcer>>)` ; nouveau `run_turn_sandboxed` (thread jetable + 2 oneshot killer/résultat + `tokio::time::timeout`) et `drain_sandboxed` (drain bloquant std), tous deux `#[cfg(target_os = "linux")]`. 3. `crates/infrastructure/src/session/factory.rs` — `StructuredSessionFactory` porte `Option<Arc<dyn SandboxEnforcer>>` + builder `with_sandbox_enforcer(...)` (jumeau de `PortablePtyAdapter`) ; `start` apparie plan (param, par-appel) + enforcer (champ, par-instance) → `ClaudeSdkSession::new`/`CodexExecSession::new`. (N'est plus `Copy`/`Debug` car porte un `Arc<dyn …>` ; `new()` n'est plus `const`.) 4. `crates/infrastructure/src/session/claude.rs` — stocke `sandbox`+`sandbox_enforcer`, remplit `SpawnLine.sandbox`, passe l'enforcer à `run_turn`. 5. `crates/infrastructure/src/session/codex.rs` — idem Claude. 6. `crates/application/src/agent/lifecycle.rs` — `launch_structured` reçoit `sandbox: Option<&SandboxPlan>` et le relaie à `factory.start` ; le call site passe `spec.sandbox.as_ref()` (déjà calculé en step 5d de LP4-3). 7. `crates/app-tauri/src/state.rs` — `StructuredSessionFactory::new().with_sandbox_enforcer(infrastructure::default_enforcer())`. Fakes/tests adaptés à la nouvelle signature (5) : `domain/tests/structured_session_d0.rs`, `application/tests/structured_launch_d3.rs`, `application/tests/orchestrator_service.rs` (les 3 fakes `start` + leurs call sites), plus `infrastructure/src/session/{mod.rs,conformance.rs}` (SpawnLine literals + tous les `run_turn`/`ClaudeSdkSession::new`/`CodexExecSession::new` internes). ## Gestion du timeout sous sandbox Le thread jetable n'est pas annulable de l'extérieur. Deux canaux `tokio::sync::oneshot` le réconcilient avec l'async : - **killer** : juste après le spawn, le thread envoie un `Arc<Mutex<std::process::Child>>` ; il a au préalable **sorti `stdout`** du child (`.take()`) pour drainer SANS tenir le lock — donc le killer peut verrouiller à tout instant. - **résultat** : le thread envoie `Result<Vec<String>, _>` à la fin. `tokio::time::timeout(dur, done_rx)` : - fini dans les temps ⇒ on rend le résultat ; - **expiration** ⇒ on `killer_rx.await` puis `child.lock().kill()` → l'enfant meurt → `stdout` EOF → la boucle de drain du thread sort → `child.wait()` (reap, **pas de zombie**) → le thread se termine. On rend `AgentSessionError::Timeout`. Le `JoinHandle` est détaché (pas de thread bloqué). `done_tx` lâché sans valeur (panique) ⇒ `Io`. `enforce` est **fail-closed** : `Err` ⇒ `AgentSessionError::Start` AVANT tout spawn (aucun enfant ne tourne). ## ⚠️ Divergence : pas de `pre_exec` vide (à arbitrer Architecte) Le cadrage demandait `unsafe { cmd.pre_exec(|| Ok(())) }` pour forcer std sur le chemin déterministe `fork`+`exec`. **Impossible ici** : la crate `infrastructure` est `#![forbid(unsafe_code)]` (lib.rs:12) — un `forbid` ne peut PAS être relâché localement par `#[allow]`. Le PTY n'avait jamais eu besoin d'`unsafe` : il obtient `fork`+`exec` gratuitement via le `pre_exec` **interne de portable-pty**. Sur le chemin structuré on pilote `std::process` en direct, donc le `pre_exec` serait à nous → `unsafe` → interdit. J'ai donc **retiré le `pre_exec`** sans perte de garantie de sécurité : `landlock_restrict_self` restreint le **thread courant et toute sa descendance**, héritage assuré par le noyau à travers `fork`/`clone`/`vfork` ET préservé par `execve` — **y compris via `posix_spawn`** (= `clone`+`execve` sous le capot), car l'enforcement vit au niveau des *credentials* de la tâche, hors d'atteinte de l'espace utilisateur. Le `fork`+`exec` n'était une garantie que de **déterminisme**, pas de sécurité ; l'enforce()-avant-spawn-sur-le-même-thread suffit. La technique validée (thread jetable, enforce avant spawn, fail-closed, reap) est intacte. Deux options pour l'Architecte s'il veut le `fork`+`exec` strict du cadrage : (a) rester comme livré (zéro unsafe, héritage noyau — mon choix par défaut) ; (b) passer lib.rs en `#![deny(unsafe_code)]` + `#[allow(unsafe_code)]` scoppé sur `drain_sandboxed` pour rétablir le `pre_exec`. J'attends son verdict avant de toucher l'invariant crate-wide. ## Invariant produit conservé `eff==None` ⇒ plan `None` ⇒ `SpawnLine.sandbox==None` ⇒ `run_turn` emprunte le **drain async tokio inchangé** (et **tout** non-Linux y reste, via `#[cfg(target_os="linux")]` sur le routage). Zéro régression. Hexagonal respecté : résolution policy→plan pure (domaine, step 5d), l'infra ne fait qu'orchestrer + enforcer ; le plan franchit le port en valeur domaine. ## Sortie réelle des tests `cargo build` : Finished, OK. `cargo test -p domain -p infrastructure -p application -p app-tauri` : **80 suites, toutes `ok`, 0 failed, 0 panic**. Notables : - domain (lib) : 141 passed ; `structured_session_d0` (fake start nouvelle signature) vert. - infrastructure (lib) : 156 passed (incluait 154 avant LP4-4 ; les nouveaux chemins compilent et tous les tests session/process restent verts) ; suites sandbox/permission toutes 0 failed. - application (lib) : 72 passed ; `structured_launch_d3`, `orchestrator_service` verts. - app-tauri : 47 passed. Un seul warning, **pré-existant et hors périmètre** : `method writes is never used` (orchestrator_service.rs:1641, helper de fixture mort avant LP4-4). Prêt pour QA (7 invariants e2e d'enforcement du chemin structuré : agent structuré lancé avec Deny ⇒ écriture impossible, etc.).
|
||
- **Prompt:** LS1 — couche DOMAINE de la feature « Gestion des limites de session des agents ». Branche courante : feature/agent-session-limits (commit cadrage fa5b826). Code PUR uniquement, aucune I/O, aucun ajout de dépendance (surtout PAS `regex` dans le crate domain). Respecte strictement le cadrage Architect (ARCHITECTURE.md §21) et les 5 corrections hexagonales. À implémenter dans crates/domain : 1. `ports.rs` — étendre l'enum `ReplyEvent` avec une variante `RateLimited { resets_at_ms: Option<i64> }`. CONTRAINTE CLÉ (T4) : `RateLimited` est NON TERMINAL — il s'intercale comme `Heartbeat`, seul `Final` clôt le flux. Documente-le dans le doccomment de la variante, en cohérence avec le contrat `ReplyStream` existant. 2. `readiness.rs` — étendre `ReadinessSignal` avec une variante `RateLimited { resets_at_ms: Option<i64> }` et mettre à jour `ReadinessPolicy::classify` pour mapper `ReplyEvent::RateLimited` → `Some(ReadinessSignal::RateLimited{..})`. Garde la sémantique existante (Final→TurnEnded ; TextDelta/ToolActivity/Heartbeat→None). `RateLimited` n'est PAS terminal (il ne marque pas le tour comme fini au sens TurnEnded). 3. `session_limit.rs` (NOUVEAU module, déclaré dans lib.rs) : - VO `SessionLimit` portant au minimum `resets_at_ms: Option<i64>` (+ éventuellement l'instant de détection si utile, en i64 époche-ms). Type pur, Clone/Debug/PartialEq/Eq. - fonction pure `plan_resume(...)` qui, à partir d'un `SessionLimit` (+ données nécessaires comme conversation_id), calcule le plan de reprise (deadline de réveil = resets_at_ms, et l'info nécessaire pour relancer). Reste 100% pur/testable, pas de Scheduler ici (le port Scheduler c'est LS3). 4. `profile.rs` — ajouter un champ optionnel `rate_limit_pattern` (donnée déclarative pour le niveau 2 : le PATTERN brut + ce qu'il faut pour extraire l'heure, mais SANS moteur regex — le domaine stocke seulement la donnée, le moteur vivra en infra au LS5). Calque-toi sur la façon dont `prompt_ready_pattern` est porté. Type dédié `RateLimitPattern` si pertinent. Veille à la sérialisation serde et à la rétro-compat (champ optionnel/`#[serde(default)]`). Écris le code propre, documenté (doccomments fr cohérents avec l'existant), faiblement couplé. NE touche PAS encore aux adapters infra ni à l'application (lots ultérieurs). Quand c'est prêt : ne lance pas les tests toi-même au-delà d'un `cargo check`/`cargo build` du crate domain pour valider la compilation — l'écriture et l'exécution des tests unitaires reviennent à QA (binôme). Rends-moi : la liste des fichiers modifiés/créés, les signatures publiques ajoutées, et le résultat du `cargo build -p domain`.
|
||
- **Response:** LS1 (couche DOMAINE) terminé. Code pur, zéro I/O, zéro nouvelle dépendance (pas de `regex` dans domain). `cargo build -p domain` = **OK, 0 warning** (Finished dev profile, 1.88s). ## Fichiers **Modifiés** - `crates/domain/src/ports.rs` — variante `ReplyEvent::RateLimited { resets_at_ms: Option<i64> }` ; doccomment explicite « jamais terminal » (T4) + note d'intégration « clos sans Final = fin gracieuse limitée, pas une erreur ». - `crates/domain/src/readiness.rs` — variante `ReadinessSignal::RateLimited { resets_at_ms: Option<i64> }` (enum reste `Copy`) ; `classify` mappe `ReplyEvent::RateLimited` → `Some(ReadinessSignal::RateLimited{..})` (seul arm ajouté ; Final→TurnEnded et TextDelta/ToolActivity/Heartbeat→None inchangés). - `crates/domain/src/profile.rs` — type `RateLimitPattern` (donnée pure, pas de moteur regex) + champ `AgentProfile.rate_limit_pattern: Option<RateLimitPattern>` (`#[serde(default, skip_serializing_if = "Option::is_none")]`, défaut `None` dans `new`) + builder `with_rate_limit_pattern`. - `crates/domain/src/lib.rs` — `pub mod session_limit;` + re-exports (`RateLimitPattern` ; `plan_resume, ResumePlan, RateLimitSource, SessionLimit`). **Créé** - `crates/domain/src/session_limit.rs` — VO `SessionLimit` + `RateLimitSource` + `ResumePlan` + fn pure `plan_resume`. ## Signatures publiques ajoutées **ports.rs** - `ReplyEvent::RateLimited { resets_at_ms: Option<i64> }` **readiness.rs** - `ReadinessSignal::RateLimited { resets_at_ms: Option<i64> }` - `ReadinessPolicy::classify(&ReplyEvent) -> Option<ReadinessSignal>` (comportement étendu, signature inchangée) **session_limit.rs** - `enum RateLimitSource { Structured, Pattern, Human }` (Copy) - `struct SessionLimit { resets_at_ms: Option<i64>, detected_at_ms: i64, source: RateLimitSource }` (Copy/Debug/PartialEq/Eq) - `const fn SessionLimit::new(Option<i64>, i64, RateLimitSource) -> Self` - `const fn SessionLimit::has_known_reset(&self) -> bool` - `enum ResumePlan { Scheduled { fire_at_ms: i64, conversation_id: Option<String> }, HumanFallback }` - `fn plan_resume(now_ms: i64, limit: &SessionLimit, conversation_id: Option<String>) -> ResumePlan` **profile.rs** - `struct RateLimitPattern { pattern: String, reset_capture: Option<String>, time_format: Option<String> }` (serde camelCase ; champs optionnels omis si `None`) - `fn RateLimitPattern::new(impl Into<String>, Option<String>, Option<String>) -> Result<Self, DomainError>` (rejette pattern vide → `EmptyField`) - `AgentProfile.rate_limit_pattern: Option<RateLimitPattern>` - `fn AgentProfile::with_rate_limit_pattern(self, RateLimitPattern) -> Self` ## Décisions de conception (à valider) 1. **`plan_resume` renvoie un `enum ResumePlan`** plutôt que `Option`/`{fire_at_ms}` nu : la variante `HumanFallback` matérialise « pas de plan auto » (resets_at_ms `None` → filet humain §21.1-3) tout en respectant la signature `-> ResumePlan` du cadrage, et `Scheduled` porte le `conversation_id` que tu m'as demandé d'inclure (« l'info nécessaire pour relancer »). `ScheduledTask`/`AgentId`/`NodeId` du §21.4 restent côté application (LS3/LS4), je ne les ai pas tirés dans le domaine. 2. **Clamp anti-passé** : `fire_at_ms = max(resets_at_ms, now_ms)` — on ne programme jamais une échéance dans le passé (reset déjà écoulé → reprise immédiate). Documenté ; testable purement. 3. `RateLimitPattern.reset_capture` et `time_format` sont **optionnels** : un motif peut détecter la limite sans extraire d'heure → `RateLimited{None}` → filet humain. Opaques au domaine (le moteur regex + parsing d'heure = infra LS5). ## Point d'attention pour la suite (PAS traité, hors périmètre du ticket) - **`events.rs`** (5 variantes `AgentRateLimited`/`AgentResumeScheduled`/`AgentResumeCancelled`/`AgentResumed`/`AgentRateLimitSuspected`) fait partie de LS1 au §21.9 mais **n'était pas** dans les 4 items de mon ticket — je ne l'ai donc pas implémenté. À me reconfier si tu veux compléter LS1. - **Impact downstream attendu** : `ReplyEvent` gagne une variante ⇒ les `match` exhaustifs en `infrastructure` (session/claude.rs, codex.rs…) **ne compileront plus** tant que LS2 n'a pas ajouté l'arm. Normal et attendu (j'ai bien laissé infra/application intacts) ; `cargo build --workspace` échouera jusqu'à LS2. QA (binôme) peut écrire les tests LS1 : `classify` mappe RateLimited ; `plan_resume` avec/sans `resets_at` + clamp passé ; round-trip serde du profil (clé `rateLimitPattern` omise si `None`, legacy JSON → `None`) ; `RateLimitPattern::new` rejette vide ; `ReadinessSignal` reste `Copy`.
|
||
- **Prompt:** Complément LS1 (domaine) — il reste les variantes d'événements du §21.9 que ton ticket précédent ne couvrait pas. Toujours sur feature/agent-session-limits, code pur, pas de dépendance nouvelle. Dans crates/domain/src/events.rs : ajoute au type d'événement de domaine (DomainEvent) les 5 variantes prévues au cadrage §21.9 : - `AgentRateLimited` (un agent vient d'entrer en limite ; porte au minimum agent_id et resets_at_ms: Option<i64>) - `AgentResumeScheduled` (réveil de reprise armé ; agent_id + fire_at_ms: i64) - `AgentResumeCancelled` (l'utilisateur a annulé la reprise auto ; agent_id) - `AgentResumed` (l'agent a effectivement été relancé ; agent_id) - `AgentRateLimitSuspected` (filet humain niveau 3 : limite suspectée, confirmation à demander ; agent_id + resets_at_ms: Option<i64> si dispo) Aligne-toi STRICTEMENT sur la forme des variantes existantes de DomainEvent (mêmes conventions de nommage de champs, types d'ids déjà utilisés comme AgentId, doccomments fr cohérents). Respecte le §21 d'ARCHITECTURE.md pour les champs exacts si le cadrage les précise. Mets à jour les re-exports lib.rs si nécessaire. Ne touche à rien d'autre (infra/application intacts). Rends : fichiers modifiés, signatures des 5 variantes, et résultat de `cargo build -p domain`.
|
||
- **Response:** Complément LS1 terminé. Code pur, zéro dépendance nouvelle. `cargo build -p domain` = **OK, 0 warning** (Finished, 0.77s). ## Fichier modifié - `crates/domain/src/events.rs` — 5 variantes ajoutées à `DomainEvent`, insérées juste avant `PtyOutput`. `lib.rs` **inchangé** : `DomainEvent` est déjà re-exporté (`pub use events::{DomainEvent, OrchestrationSource};`) et les variantes d'enum n'ont pas de re-export propre. Rien d'autre touché (infra/application intacts). ## Signatures des 5 variantes ```rust AgentRateLimited { agent_id: AgentId, resets_at_ms: Option<i64> } AgentResumeScheduled { agent_id: AgentId, fire_at_ms: i64 } AgentResumeCancelled { agent_id: AgentId } AgentResumed { agent_id: AgentId } AgentRateLimitSuspected { agent_id: AgentId, resets_at_ms: Option<i64> } ``` Choix de champs alignés sur le cadrage §21.3/§21.9 et sur les conventions existantes de `DomainEvent` : - `agent_id: AgentId` (type d'id déjà utilisé partout dans l'enum) ; - `resets_at_ms: Option<i64>` (époche-ms, homogène avec `Clock`/`SessionLimit`/`ReplyEvent::RateLimited` ; T1) ; - `fire_at_ms: i64` (échéance du réveil, même type/sémantique que `ResumePlan::Scheduled.fire_at_ms`). - Doccomments fr cohérents avec le vocabulaire §21 (limité/reset/reprise auto annulable/filet humain niveau 3). `AgentRateLimitSuspected` porte `resets_at_ms: Option<i64>` (le cadrage le donne « si dispo ») : `None` = l'utilisateur fournira l'heure. ## Vérifs - Pas de `match` exhaustif sur `DomainEvent` cassé dans le crate domain (seul `input.rs` le référence, en construction d'événements — build vert). - Rappel inchangé : `cargo build --workspace` échouera toujours tant que LS2 n'a pas câblé l'arm `ReplyEvent::RateLimited` côté infra (et l'éventuel relais IPC des nouveaux events en LS7) — attendu. QA peut compléter les tests LS1 : présence/forme des 5 variantes (constructibilité + égalité `PartialEq`), types de champs.
|
||
- **Prompt:** LS2 — niveau 1 (structuré) côté INFRA, adapter Claude. Branche feature/agent-session-limits (LS1 mergé : domaine porte déjà ReplyEvent::RateLimited { resets_at_ms: Option<i64> }, non terminal). Respecte ARCHITECTURE.md §21 et les corrections T4. Objectif : exploiter le `rate_limit_event` que l'adapter Claude voit DÉJÀ mais jette aujourd'hui (infrastructure/session/claude.rs, fonction pure `parse_event`, ~ligne 90 : actuellement `Some("rate_limit_event") => vec![ReplyEvent::Heartbeat]`). Il faut en extraire l'heure de reset et émettre `ReplyEvent::RateLimited { resets_at_ms }` à la place. À faire : 1. `infrastructure/session/claude.rs` — `parse_event` : - Pour `type == "rate_limit_event"` : lire `rate_limit_info` et en extraire le timestamp de reset → `resets_at_ms: Option<i64>` (époche-ms). Émettre `ReplyEvent::RateLimited { resets_at_ms }` au lieu du Heartbeat. Si `rate_limit_info` est absent/illisible/sans champ de reset → `resets_at_ms = None` (on émet quand même RateLimited{None} : limite détectée, heure inconnue → filet humain en aval). Robustesse : jamais d'erreur sur un rate_limit_event malformé. - SPIKE à résoudre proprement : le format réel du champ de reset n'est pas garanti. Isole le parsing du timestamp dans une FONCTION PURE dédiée (ex. `fn parse_reset_ms(rate_limit_info: &Value) -> Option<i64>`) qui gère défensivement les formats plausibles et les convertit tous en époche-ms : (a) entier epoch en SECONDES, (b) entier epoch en MILLISECONDES, (c) chaîne ISO-8601/RFC3339. Heuristique secondes-vs-ms documentée (seuil de magnitude). Cherche les noms de champ plausibles (`resetsAt`, `resets_at`, `reset_at`, `retryAfter`/`retry_after` relatif en secondes → now+delta… mais comme parse_event est pur et n'a pas `now`, traite le relatif via une variante distincte si présent, sinon ignore et documente). Documente le format retenu et les hypothèses (comme le fait déjà l'en-tête de claude.rs pour le spike S1). 2. T4 — RateLimited NON TERMINAL : dans la boucle `send` de claude.rs, `RateLimited` ne doit PAS rompre le flux (seul `Final` rompt via `break 'lines`). Il s'intercale comme un Heartbeat. VÉRIFIE aussi le drain de process (infrastructure/session/process.rs, `drain_bounded`/`run_turn`) : un tour qui se clôt SANS `Final` PARCE QUE l'agent est limité ne doit pas devenir `AgentSessionError::Io`/timeout — c'est une fin gracieuse. Si le drain considère aujourd'hui « pas de Final » comme une erreur, adapte pour qu'un flux contenant un `RateLimited` terminal-de-fait soit traité gracieusement. Décris précisément ce que tu changes ici (c'est le point d'intégration sensible signalé par Architect). 3. Corrige les `match` exhaustifs sur `ReplyEvent` désormais cassés en infra : `session/codex.rs` et tout autre consommateur. Pour Codex, si tu n'as pas de signal de limite fiable sous la main, ajoute simplement l'arm `ReplyEvent::RateLimited { .. }` traité comme non terminal/équivalent Heartbeat (le signal Codex natif est un spike ultérieur) — ne fabrique rien de faux. Ne touche pas à l'application/front (lots suivants). NE traite pas le niveau 2 regex (LS5). Garde le parsing isolé et pur pour que QA le teste sans process. Lance `cargo build -p infrastructure` (et `cargo build --workspace` pour voir où en sont les match restants — note ce qui casse encore et relève d'un lot ultérieur). Rends : fichiers modifiés, la signature + les hypothèses de `parse_reset_ms`, ce que tu as changé pour T4 dans process.rs, et le résultat des builds.
|
||
- **Response:** LS2 (niveau 1 structuré, adapter Claude) terminé. `cargo build -p infrastructure` = **OK, 0 warning**. `cargo build -p domain` = OK. `cargo build --workspace` = **2 erreurs résiduelles, uniquement dans app-tauri** (attendu, relève de LS7/LS8 — détail plus bas). ## Fichiers modifiés - `crates/infrastructure/src/session/claude.rs` — seul fichier touché : - `parse_event` : `rate_limit_event` émet désormais `ReplyEvent::RateLimited { resets_at_ms }` (au lieu de jeter l'info en `Heartbeat`). `resets_at_ms = value.get("rate_limit_info").and_then(parse_reset_ms)` ⇒ absence/illisibilité ⇒ `RateLimited{None}` (jamais d'erreur). - **Nouvelle fonction pure `parse_reset_ms`** + helpers privés purs (`value_to_epoch_ms`, `int_epoch_to_ms`, `float_epoch_to_ms`, `parse_rfc3339_to_ms`, `split_tz`, `split_seconds_frac`, `days_from_civil`). - Doccomments (en-tête `parse_event` + commentaire boucle `send`) mis à jour pour le mapping et la non-terminalité T4. **Aucune dépendance ajoutée** (pas de `chrono`/`time` : parser ISO écrit à la main, pur — `regex` reste réservé à LS5 par T2). ## `parse_reset_ms` — signature & hypothèses ```rust pub fn parse_reset_ms(rate_limit_info: &Value) -> Option<i64> // -> époche-ms ``` - **Noms de champ** essayés dans l'ordre, 1er présent gagne : `resetsAt`, `resets_at`, `reset_at`, `resetAt`, `reset`. - **Conversion par type** (`value_to_epoch_ms`) : entier/float epoch **secondes** (magnitude `<10^12`) ⇒ ×1000 ; epoch **ms** (`≥10^12`) ⇒ tel quel ; **chaîne** ⇒ tentée entier/float (même heuristique) puis **ISO-8601/RFC3339**. - **Heuristique s-vs-ms** : seuil `EPOCH_MS_THRESHOLD = 10^12` (10^12 ms ≈ 2001-09 ; 10^12 s ≈ an 33658 ⇒ toute date ≥1970 tombe du bon côté). Documenté en clair. - **ISO/RFC3339** (`parse_rfc3339_to_ms`) : `YYYY-MM-DDThh:mm:ss[.fff][Z|±hh:mm]`, fraction tronquée/complétée à 3 chiffres (ms), conversion jour-civil via algo de Howard Hinnant (`days_from_civil`, exact, bissextiles/siècles). - **Hypothèses assumées** (documentées dans le doccomment) : 1. **`retryAfter`/`retry_after` (relatif, secondes) NON exploité** : le résoudre exige `now`, or `parse_event`/`parse_reset_ms` sont **purs sans horloge** ⇒ ignoré ⇒ `RateLimited{None}` (filet humain). Une résolution `now+delta` pourra être faite côté application (LS4, qui détient `Clock`). 2. **Chaîne ISO sans fuseau ⇒ traitée UTC** (best-effort ; l'heure murale locale relève du niveau 2/LS5, §21.10-2). 3. Jamais de panique ni d'erreur : tout cas inexploitable ⇒ `None`. - **Spike §21.10-1** : le format réel du champ reste à confirmer sur un vrai `rate_limit_event` ; le parser couvre défensivement les 3 formats plausibles et le domaine ne voit que des époche-ms quoi qu'il arrive. ## T4 — non-terminalité (point d'intégration sensible) - **claude.rs `send`** : la boucle ne rompt (`break 'lines`) **que** sur `Final` (`matches!(event, ReplyEvent::Final{..})`). `RateLimited` (comme `Heartbeat`) **ne rompt pas** : il s'intercale, le flux continue jusqu'au `Final` ou la clôture. Comportement déjà correct par construction — j'ai seulement corrigé le commentaire (qui décrivait `rate_limit_event` comme un « heartbeat de fin »). - **process.rs : AUCUN changement nécessaire, et c'est volontaire.** `run_turn`/`drain` drainent **toutes** les lignes jusqu'à EOF puis `wait()` et renvoient `Ok(lignes)` ; ils n'ont **aucune notion de `Final`** et ne transforment **jamais** « pas de Final » en `Io`/`Timeout` (le `Timeout` n'arrive que sur un vrai dépassement de l'horloge murale). Un tour clos sans `Final` parce que limité y est donc **déjà** une fin gracieuse. C'est confirmé par le test existant `stream_without_final_is_silently_ok_at_adapter_level` (`session/mod.rs:794`). - **Où vit réellement « flux clos sans Final ⇒ Io » : dans l'APPLICATION**, pas l'infra — `application/src/agent/structured.rs::drain_bounded_events` (tests `drain_with_readiness_lot1.rs:248`, `send_blocking_d1.rs:161`). La réconciliation T4 (« clos + RateLimited vu ⇒ pas d'Io, issue limitée ») doit donc se faire **là**, ce qui correspond exactement au périmètre **LS4** du cadrage (§21.5). Je n'y ai pas touché (hors périmètre LS2). À flaguer pour LS4. ## Codex (point 3 du ticket) - `session/codex.rs` **n'a PAS de `match` exhaustif sur `ReplyEvent`** : `parse_event` filtre sur le `type` (chaîne) avec un bras `_ => {}`, et la boucle `send` utilise `matches!(…, Final)`. **Rien à corriger, il compile tel quel.** Je n'ai donc **rien ajouté** côté Codex (pas de signal de limite fiable sous la main ⇒ niveau 2/LS5 ou spike ultérieur §21.10-3 ; « ne fabrique rien de faux »). ## Builds restants à corriger en lot ultérieur (ce qui casse encore, hors LS2) `cargo build --workspace` ⇒ **2 erreurs, toutes dans `app-tauri`** (présentation/composition root) : 1. `crates/app-tauri/src/chat.rs:185` (`to_reply_chunk`) — `match` exhaustif sur `ReplyEvent`, `RateLimited` non couvert. ⇒ **LS8** (mapping vers un `ReplyChunk` / DTO front). 2. `crates/app-tauri/src/events.rs:261` — `match` exhaustif sur `DomainEvent`, les **5 nouvelles variantes** (`AgentRateLimited`, `AgentResumeScheduled`, `AgentResumeCancelled`, `AgentResumed`, `AgentRateLimitSuspected`) non couvertes. ⇒ **LS7** (relais IPC camelCase). Aucun autre crate ne casse (domain/application/infrastructure verts). ## Pour QA (binôme LS2) `parse_reset_ms` / `parse_event` sont **purs, testables sans process** : - `rate_limit_event` avec `resetsAt` epoch **secondes** ⇒ `RateLimited{Some(ms ×1000)}` ; - idem epoch **ms** ⇒ tel quel ; **ISO-8601 `…Z`** ⇒ ms attendus ; **ISO avec offset `±hh:mm`** ⇒ ms UTC ; - `rate_limit_event` **sans** `rate_limit_info` / clé inconnue / valeur pourrie ⇒ `RateLimited{None}` (jamais d'erreur) ; - un tour `rate_limit_event` + `result` ⇒ flux `[…, RateLimited, Final]` (RateLimited n'interrompt pas, Final clôt) ; - `days_from_civil`/bissextiles : `1970-01-01T00:00:00Z ⇒ 0`, `2024-02-29T…` valide.
|
||
- **Prompt:** LS3 — le SEUL port neuf de la feature « limites de session » : `Scheduler` (réveil one-shot annulable) + son adapter d'infra. Branche feature/agent-session-limits (LS1+LS2 mergés). Respecte ARCHITECTURE.md §21 (notamment §21.4) et le motif de dispatch par DONNÉE pure déjà utilisé pour l'orchestrateur (§14.3) : pas de closure qui traverse la frontière domaine. 1. DOMAINE — crates/domain/src/ports.rs (+ re-exports lib.rs) : - Nouveau trait `Scheduler` (Send + Sync) : - `arm(&self, deadline_ms: i64, task: ScheduledTask) -> ScheduleId` — arme une minuterie one-shot qui, à l'échéance (époche-ms absolue, cohérent avec Clock::now_millis / ResumePlan::Scheduled.fire_at_ms), rend la tâche disponible pour exécution côté application. Si deadline ≤ now, l'échéance doit se déclencher au plus tôt (immédiat) — mais le clamp anti-passé est déjà fait par plan_resume côté domaine, donc documente juste le comportement. - `cancel(&self, id: ScheduleId) -> bool` — annule un réveil armé non encore tiré ; retourne true si effectivement annulé, false s'il n'existait pas / déjà tiré (idempotent, jamais d'erreur). C'est ce qui sous-tend « reprise auto ANNULABLE ». - `ScheduleId` : type d'id opaque (regarde comment les autres ids du domaine sont faits — ids.rs — et aligne-toi ; si un id généré est nécessaire, suis le motif existant). - `ScheduledTask` : DONNÉE pure (pas de closure). Variante `ResumeAgent { agent_id: AgentId, node_id: NodeId, conversation_id: Option<String> }` (cf. §21.4). Enum extensible. - Détermine la bonne forme async : regarde si les autres ports du domaine sont `#[async_trait]` ; aligne-toi. `arm`/`cancel` peuvent être synchrones si l'implémentation in-memory n'a pas besoin d'await — choisis selon ce qui est cohérent avec l'usage côté application (LS4) et documente. - Définis comment la tâche échue est REMISE à l'application : le port ne doit PAS exécuter la reprise lui-même (pas de dépendance vers l'application). Suis le motif orchestrateur §14.3 : par ex. l'adapter pousse la `ScheduledTask` échue dans un canal/une file que l'application draine, OU un mécanisme déjà en place. Inspecte infrastructure/orchestrator/ et application/orchestrator/ pour réutiliser EXACTEMENT le même patron de dispatch par donnée plutôt que d'en inventer un. Décris le mécanisme retenu. 2. INFRA — crates/infrastructure/src/scheduler/ (NOUVEAU module, déclaré dans lib.rs) : - `TokioScheduler` implémentant `Scheduler` : minuterie one-shot via tokio (tokio::time / spawn + abort pour l'annulation). In-memory uniquement (aucune persistance — conforme à la décision « état en mémoire »). `cancel` abort le handle correspondant. Thread-safe (Arc/Mutex sur la table id→handle). - Respecte la convention d'injection/composition existante ; ne touche PAS encore au composition root app-tauri (LS7) — fournis juste le type construisible et testable. NE traite pas LS4 (le service applicatif qui consomme ce port) ni la réconciliation T4. Reste dans le périmètre du port + adapter. Vérifie `cargo build -p domain` et `cargo build -p infrastructure`. Rends : fichiers créés/modifiés, signatures publiques (trait Scheduler, ScheduleId, ScheduledTask), le mécanisme de remise des tâches échues à l'application (et quel patron existant tu réutilises), et le résultat des builds. Garde l'adapter testable (QA testera arm tire bien après l'échéance, cancel empêche le tir, cancel d'un id inconnu = false).
|
||
- **Response:** LS3 (port `Scheduler` + adapter `TokioScheduler`) terminé. `cargo build -p domain` = **OK, 0 warning** ; `cargo build -p infrastructure` = **OK, 0 warning**. ## Fichiers **Créés** - `crates/infrastructure/src/scheduler/mod.rs` — `TokioScheduler`. **Modifiés** - `crates/domain/src/ids.rs` — nouvel id typé `ScheduleId` (via la macro `typed_id!`, comme tous les ids du domaine ⇒ `Copy`/`Hash`/`Eq`, `new_random()`/`from_uuid()`). - `crates/domain/src/ports.rs` — `enum ScheduledTask` + `trait Scheduler` ; import ids étendu (`NodeId`, `ScheduleId`). - `crates/domain/src/lib.rs` — re-exports `ScheduleId`, `Scheduler`, `ScheduledTask`. - `crates/infrastructure/src/lib.rs` — `pub mod scheduler;` + `pub use scheduler::TokioScheduler;`. ## Signatures publiques **Domaine** (`ports.rs`) ```rust pub enum ScheduledTask { ResumeAgent { agent_id: AgentId, node_id: NodeId, conversation_id: Option<String> }, } // #[derive(Debug, Clone, PartialEq, Eq)], extensible (Open/Closed) pub trait Scheduler: Send + Sync { fn arm(&self, deadline_ms: i64, task: ScheduledTask) -> ScheduleId; fn cancel(&self, id: ScheduleId) -> bool; } ``` - `ScheduleId` = newtype `Uuid` (ids.rs), opaque/annulable. - **Synchrone, PAS d'`async_trait`** : aligné sur la famille des ports non bloquants (`Clock`/`IdGenerator`/`EventBus`). `arm` ne fait qu'enregistrer une minuterie (l'attente vit en tâche de fond), `cancel` ne fait qu'annuler — aucun `await` au point d'appel, donc on évite le boxing. Cohérent avec l'usage applicatif LS4 (le service appelle arm/cancel sans await). - `arm` : `deadline_ms` en **époche-ms absolues** (cohérent `Clock::now_millis` / `ResumePlan::Scheduled.fire_at_ms`). `deadline ≤ now` ⇒ déclenchement immédiat (documenté ; le clamp anti-passé est déjà fait par `plan_resume`). - `cancel` : idempotent, **sans erreur** — `true` ssi un réveil non encore tiré a été désarmé ; `false` si inconnu ou déjà tiré. **Infra** (`scheduler/mod.rs`) ```rust pub struct TokioScheduler { /* tx, clock, handles */ } impl TokioScheduler { pub fn new(tx: tokio::sync::mpsc::UnboundedSender<ScheduledTask>, clock: Arc<dyn Clock>) -> Self; } impl Scheduler for TokioScheduler { /* arm/cancel */ } ``` ## Mécanisme de remise des tâches échues — patron réutilisé **Dispatch par DONNÉE pure, calqué EXACTEMENT sur le watcher d'orchestrateur (§14.3 / `infrastructure/src/orchestrator/mod.rs`)** : là-bas l'adapter watcher valide une requête-donnée et la dispatche vers `OrchestratorService` via un canal ; l'adapter n'exécute pas le use case lui-même. Ici : `TokioScheduler::new(tx, clock)` reçoit le **bout émetteur** d'un `mpsc` (le récepteur appartient au drain applicatif, branché en LS4/LS7). `arm` calcule `delay = max(0, deadline_ms - clock.now_millis())`, `tokio::spawn` une tâche qui `tokio::time::sleep(delay).await` puis **pousse la `ScheduledTask` (la valeur) dans `tx`** — jamais d'exécution de reprise dans l'adapter, **aucune dépendance vers `application`**, **aucune closure** ne franchit la frontière (seule une donnée transite). Le port reste donc côté domaine pur. `cancel` = `handle.abort()` sur le `JoinHandle` rangé dans une table `Arc<Mutex<HashMap<ScheduleId, JoinHandle>>>`. Détails de robustesse : - canal **non borné** (`UnboundedSender`) : événements rares/basse fréquence ⇒ ne jamais perdre/retarder une reprise ni bloquer la tâche de fond ; erreur d'envoi (récepteur lâché = IdeA s'arrête) ignorée. - `cancel` correct sans course : `remove(id)` puis `is_finished()` ⇒ `None` (inconnu) ⇒ false ; tiré-mais-présent ⇒ false ; armé non tiré ⇒ `abort()` + true. - table bornée : `arm` élague les handles déjà terminés (`retain(!is_finished())`) sous le même verrou (fait stable, pas de course). - **en mémoire uniquement** (§21.1-3) : aucune persistance. ## Périmètre respecté Pas de LS4 (service applicatif consommateur), pas de réconciliation T4, **pas touché au composition root app-tauri** (LS7). `TokioScheduler` est construisible et testable seul. ## Builds - `cargo build -p domain` ✅ 0 warning - `cargo build -p infrastructure` ✅ 0 warning - (`--workspace` reste cassé sur `app-tauri` uniquement, inchangé depuis LS2 : `chat.rs:185` → LS8, `events.rs:261` → LS7. LS3 n'ajoute aucune rupture.) ## Pour QA (binôme LS3) `TokioScheduler` testable sous `#[tokio::test]` avec un `mpsc::unbounded_channel()` + une horloge : - **arm tire après l'échéance** : `arm(now+50ms, task)` ⇒ `rx.recv()` (sous timeout) rend la `task` ; vérifier qu'aucune tâche n'arrive avant. - **cancel empêche le tir** : `arm(now+10_000ms, task)` puis `cancel(id) == true` ⇒ `rx` reste vide (try_recv après court délai). - **cancel d'un id inconnu = false** : `cancel(ScheduleId::new_random()) == false`. - **cancel après tir = false** : armer un délai court, attendre la réception, puis `cancel(id) == false`. - **deadline passée ⇒ tir immédiat** : `arm(now-1000, task)` ⇒ task reçue quasi-immédiatement. Injecter un `Clock` fixe (ou `SystemClock`) ; le `sleep` reste temps-réel (délais courts).
|
||
- **Prompt:** LS4 — couche APPLICATION de la feature « limites de session » : le `SessionLimitService` qui orchestre détecter→planifier→reprendre, + la réconciliation T4. Branche feature/agent-session-limits (LS1+LS2+LS3 committés). Respecte ARCHITECTURE.md §21.5 et les motifs applicatifs existants. AVANT de coder, INSPECTE pour réutiliser l'existant : application/agent/structured.rs (drain_bounded_events), application/agent/lifecycle.rs + usecases.rs (comment un agent est lancé/repris : LaunchAgent, AgentSessionFactory, SessionPlan::Resume, conversation_id), application/orchestrator/service.rs (comment un service applicatif draine un canal de tâches-données — même patron que celui que TokioScheduler alimente). Aligne-toi sur ces patrons, n'invente pas un nouveau style. Périmètre APPLICATION uniquement (pas de app-tauri/front = LS7/LS8) : 1. NOUVEAU crates/application/src/agent/session_limit.rs — `SessionLimitService` (+ déclaré dans agent/mod.rs). Trois responsabilités, via les ports déjà injectés (Clock, Scheduler, EventBus, AgentSessionFactory/le mécanisme de lancement existant, AgentContextStore au besoin) : a. DÉTECTION→PLANIFICATION : à partir d'un signal `ReadinessSignal::RateLimited { resets_at_ms }` (ou équivalent remonté par le drain structuré) pour un agent/cellule donné(e) : construire un `domain::SessionLimit` (detected_at_ms = Clock::now_millis, source = Structured), appeler `domain::plan_resume(now, &limit, conversation_id)`. Selon le `ResumePlan` : - `Scheduled { fire_at_ms, conversation_id }` ⇒ `Scheduler::arm(fire_at_ms, ScheduledTask::ResumeAgent { agent_id, node_id, conversation_id })` ; publier `DomainEvent::AgentRateLimited { agent_id, resets_at_ms }` PUIS `DomainEvent::AgentResumeScheduled { agent_id, fire_at_ms }`. Conserver le ScheduleId (table interne agent_id→ScheduleId en mémoire, pour pouvoir annuler) — état EN MÉMOIRE uniquement. - `HumanFallback` ⇒ publier `DomainEvent::AgentRateLimited { agent_id, resets_at_ms: None }` et `DomainEvent::AgentRateLimitSuspected { agent_id, resets_at_ms: None }` (le filet humain UI/confirmation = LS6/LS8 ; ici on émet juste l'événement). b. EXÉCUTION DE LA REPRISE : une méthode (testable) qui consomme une `ScheduledTask::ResumeAgent` échue (celle que TokioScheduler pousse dans le mpsc ; le CÂBLAGE du récepteur dans le runtime Tauri = LS7, mais fournis ici la méthode que LS7 appellera) : relancer/réattacher l'agent via le mécanisme de lancement existant avec `SessionPlan::Resume` (conversation_id) et envoyer un prompt de reprise court (ex. « La limite de session est levée. Reprends là où tu t'étais arrêté. »). Puis publier `DomainEvent::AgentResumed { agent_id }`. Retirer l'entrée de la table. c. ANNULATION : `cancel_resume(agent_id)` ⇒ retrouver le ScheduleId, `Scheduler::cancel(id)`, et si annulé publier `DomainEvent::AgentResumeCancelled { agent_id }`. C'est le socle de « reprise auto ANNULABLE ». NOTE de vigilance remontée par QA en LS3 : sous runtime multi-thread, `Scheduler::cancel` peut renvoyer true/false à la marge si on annule pile au moment du tir ; gère proprement le cas « cancel a renvoyé false parce que déjà tiré » (ne pas publier AgentResumeCancelled si le cancel a échoué ; la reprise suivra son cours). Documente ce choix. 2. RÉCONCILIATION T4 — crates/application/src/agent/structured.rs (`drain_bounded_events`) : aujourd'hui un flux clos SANS `Final` devient une erreur (AgentSessionError::Io / équivalent). Adapte pour qu'un tour qui se termine sans Final MAIS dont le flux a contenu un `ReplyEvent::RateLimited` soit traité comme une FIN GRACIEUSE « limitée » (pas une erreur) : remonter une issue distincte exploitable par le service (a) — par ex. la dernière info de limite (resets_at_ms) plutôt que de propager une Io. Inspecte la signature/retour actuel de drain_bounded_events et choisis la forme la plus propre (variante de retour / type d'issue) cohérente avec l'existant. NE casse aucun comportement existant (un vrai flux sans Final ET sans RateLimited reste une erreur comme avant). C'est le point d'intégration sensible signalé par Architect — décris précisément ce que tu changes. Respecte SOLID/hexagonal : le service ne dépend que de ports (traits), aucune dépendance concrète d'infra. Garde tout testable par QA (fakes de Scheduler/Clock/EventBus/factory). Vérifie `cargo build -p application` (et note l'état `--workspace` : app-tauri restera cassé jusqu'à LS7/LS8). Rends : fichiers créés/modifiés, signatures publiques de SessionLimitService (constructeur + méthodes a/b/c), la forme exacte de la réconciliation T4 dans drain_bounded_events (ancien retour vs nouveau), et les builds.
|
||
- **Response:** LS4 (couche APPLICATION) terminé. `cargo build -p application` = **OK, 0 warning** ; tests compilent ; le test existant `drain_marks_alive…` reste **vert** (zéro régression). `--workspace` casse toujours uniquement sur `app-tauri` (LS7/LS8), inchangé. ## Fichiers **Créé** - `crates/application/src/agent/session_limit.rs` — `SessionLimitService` + port `AgentResumer` + const `RESUME_PROMPT`. **Modifiés** - `crates/application/src/agent/structured.rs` — réconciliation T4 (enum `TurnOutcome` + drain enrichi). - `crates/application/src/agent/mod.rs` — `mod session_limit;` + re-exports. - `crates/application/src/lib.rs` — re-exports crate-root. ## Signatures publiques `SessionLimitService` ```rust pub const RESUME_PROMPT: &str = "La limite de session est levée. Reprends là où tu t'étais arrêté."; #[async_trait] pub trait AgentResumer: Send + Sync { async fn resume(&self, agent_id: AgentId, node_id: NodeId, conversation_id: Option<String>, resume_prompt: &str) -> Result<(), AppError>; } impl SessionLimitService { pub fn new(clock: Arc<dyn Clock>, scheduler: Arc<dyn Scheduler>, events: Arc<dyn EventBus>, resumer: Arc<dyn AgentResumer>) -> Self; // (a) détection → planification (sync : arm/cancel/publish ne sont pas async) pub fn on_rate_limited(&self, agent_id: AgentId, node_id: NodeId, conversation_id: Option<String>, resets_at_ms: Option<i64>); // (b) exécution de la reprise (consomme la ScheduledTask échue poussée par TokioScheduler) pub async fn execute_resume(&self, task: ScheduledTask) -> Result<(), AppError>; // (c) annulation (socle du « annulable ») pub fn cancel_resume(&self, agent_id: AgentId) -> bool; } ``` ### Décision de conception clé — port `AgentResumer` `LaunchAgent` exige `Project` + profil + contexte préparé + PTY (résolus seulement par lui) : le service ne peut pas relancer seul sans dupliquer cette résolution. J'ai donc introduit le **port applicatif `AgentResumer`**, **exactement dans le style des passerelles existantes** `HandoffProvider`/`ProviderSessionProvider`/`McpRuntime` (traits définis dans l'application, implémentés au composition root app-tauri). LS7 l'implémentera par-dessus `LaunchAgent` + `AgentSessionFactory` avec `SessionPlan::Resume`. Service 100 % testable avec un fake `AgentResumer`. **Aucun nouveau style inventé.** ### Comportements - **(a)** : `SessionLimit::new(resets_at_ms, now=Clock::now_millis, Structured)` → `plan_resume`. `Scheduled{fire_at_ms, conversation_id}` ⇒ publie `AgentRateLimited`, **dédoublonne** (annule un armement antérieur du même agent sans événement, §21.10-4), `Scheduler::arm(fire_at_ms, ScheduledTask::ResumeAgent{agent_id,node_id,conversation_id})`, mémorise le `ScheduleId` (table `agent_id→ScheduleId` **en mémoire**), publie `AgentResumeScheduled{fire_at_ms}`. `HumanFallback` ⇒ publie `AgentRateLimited{None}` puis `AgentRateLimitSuspected{None}` (filet humain ; confirmation UI = LS6/LS8). - **(b)** : retire l'entrée armée (le réveil a tiré), `resumer.resume(..., RESUME_PROMPT)`, publie `AgentResumed{agent_id}`. Erreur de relance propagée ⇒ `AgentResumed` **non** publié. - **(c)** : retrouve le `ScheduleId` ; `Scheduler::cancel` ⇒ si `true` : retire l'entrée + publie `AgentResumeCancelled` + renvoie `true` ; si `false` (**course « cancel pile au tir »** signalée par QA en LS3) : **ne publie pas**, laisse l'entrée (l'`execute_resume` en cours la retirera), renvoie `false` — la reprise suit son cours. Documenté. ## Réconciliation T4 — forme exacte dans `structured.rs` Nouveau type public : ```rust pub enum TurnOutcome { Completed(String), RateLimited { resets_at_ms: Option<i64> } } ``` **Avant → après** du chemin de drain unique : - `drain_to_final` : retour `Result<String, AgentSessionError>` → **`Result<TurnOutcome, AgentSessionError>`**. Il mémorise le dernier `ReplyEvent::RateLimited{resets_at_ms}` traversé. Sur `Final` ⇒ `Ok(Completed(content))` (un `Final` l'emporte toujours, même après un `RateLimited` — cas LS2 `[…,RateLimited,Final]`). Sur **flux clos sans `Final`** : si un `RateLimited` a été vu ⇒ `Ok(RateLimited{resets_at_ms})` (fin gracieuse, §21.2-T4) ; **sinon** ⇒ `Err(Io(...sans Final))` **inchangé**. - `drain_bounded_events` : retour aligné sur `Result<TurnOutcome,...>` ; `Timeout` inchangé. - **Signatures publiques historiques préservées (zéro régression)** : - `send_blocking(...) -> Result<String, _>` : `Completed→Ok` ; `RateLimited→Err(Io)` (le rendez-vous synchrone `ask` n'a pas de contenu — comportement identique à avant). - `drain_with_readiness(...) -> Result<String, _>` : idem (thin wrapper) ⇒ **l'appelant orchestrateur `service.rs:1067` est inchangé**. - **Nouveau point d'entrée riche** : `pub async fn drain_with_readiness_outcome(...) -> Result<TurnOutcome, _>` — même branchement readiness, mais renvoie l'issue complète. C'est ce que LS7 câblera sur le tour délégué pour appeler `SessionLimitService::on_rate_limited`. `mark_idle` reste piloté **uniquement** par `Final` (un `RateLimited` ne fait **pas** avancer la FIFO, §21.5). Invariant respecté : **un vrai flux sans `Final` ET sans `RateLimited` reste une `Io`** (test existant `drain_marks_alive…` toujours vert ; les tests `drain_with_readiness_lot1.rs:248` / `send_blocking_d1.rs:161` « sans Final ⇒ Io » restent valides car ces flux n'ont pas de `RateLimited`). ## Builds - `cargo build -p application` ✅ 0 warning - `cargo test -p application --no-run` ✅ (tests compilent) ; `--lib structured` ✅ 1 passed - `cargo build --workspace` ❌ **app-tauri uniquement** : `chat.rs:185` (`ReplyEvent::RateLimited` non couvert → LS8) + `events.rs:261` (5 variantes `DomainEvent` → LS7). Inchangé depuis LS2 ; LS4 n'ajoute aucune rupture. ## Pour QA (binôme LS4) - **(a)** fakes `Clock`(fixe)/`Scheduler`/`EventBus` : `on_rate_limited(Some(reset_futur))` ⇒ 1 `arm(fire_at_ms)` + events `AgentRateLimited` puis `AgentResumeScheduled` dans l'ordre ; `on_rate_limited(None)` ⇒ `AgentRateLimited{None}` + `AgentRateLimitSuspected{None}`, **aucun** `arm`. Dédoublonnage : 2 appels successifs ⇒ l'ancien `ScheduleId` est `cancel`-é. - **(b)** fake `AgentResumer` : `execute_resume(ResumeAgent{…})` ⇒ `resume(..., RESUME_PROMPT)` appelé + `AgentResumed` publié + entrée retirée ; `resume` qui échoue ⇒ `Err` propagée, **pas** d'`AgentResumed`. - **(c)** `cancel_resume` après `on_rate_limited` ⇒ `cancel` appelé, `true`, `AgentResumeCancelled` publié ; `cancel_resume` sans armement ⇒ `false` sans event ; fake `Scheduler` renvoyant `false` (déjà tiré) ⇒ `cancel_resume` renvoie `false` **sans** event. - **T4** : `drain_with_readiness_outcome` sur un flux `[RateLimited{Some(t)}]` (sans Final) ⇒ `Ok(TurnOutcome::RateLimited{Some(t)})` ; `[RateLimited, Final]` ⇒ `Ok(Completed)` ; `[TextDelta]` seul (ni Final ni RateLimited) ⇒ `Err(Io)`.
|
||
- **Prompt:** LS5 — niveau 2 (repli déclaratif) côté INFRA : le parser regex qui détecte une limite de session dans la sortie texte d'un agent PTY/TUI sans adapter structuré. Branche feature/agent-session-limits (LS1→LS4 committés). Respecte ARCHITECTURE.md §21 (T2 notamment). Rappel T2 : le DOMAINE ne porte que la DONNÉE du motif (`domain::profile::RateLimitPattern { pattern, reset_capture, time_format }`, déjà livré en LS1) ; le MOTEUR regex + le parsing d'heure vivent en INFRA. C'est ici qu'on ajoute la dépendance `regex` — UNIQUEMENT au Cargo.toml du crate `infrastructure`, jamais au domaine. À faire : 1. crates/infrastructure/Cargo.toml — ajouter la dépendance `regex` (version cohérente avec l'écosystème du workspace ; regarde Cargo.lock / les versions déjà présentes pour t'aligner). 2. NOUVEAU module crates/infrastructure/src/ratelimit/ (déclaré dans lib.rs) — un `RateLimitParser` (nom à confirmer selon les conventions) qui, à partir d'un `&RateLimitPattern` et d'un fragment de sortie texte (+ l'heure courante `now_ms` injectée, car contrairement à LS2 on PEUT avoir besoin de résoudre une heure murale/relative), produit un `Option<SessionLimit>` (ou `Option<i64> resets_at_ms` que l'appelant emballe — choisis la forme la plus propre et cohérente avec la façon dont LS4 consomme la détection). Comportement : - Compiler le `pattern` regex. Compilation invalide ⇒ pas de détection (None), JAMAIS de panique ni d'erreur fatale (un profil mal configuré par l'utilisateur ne doit pas planter IdeA — robustesse « solide même pour un novice »). Idéalement, compiler paresseusement/une seule fois si tu peux mettre en cache, mais sans sur-ingénierie. - Si le pattern matche le texte ⇒ limite DÉTECTÉE. Si `reset_capture` est renseigné, extraire le groupe de capture (nommé de préférence, ex. (?P<reset>...)) et le parser en époche-ms selon `time_format` : * Réutilise le savoir de parsing d'heure que tu as déjà écrit en LS2 (parse_reset_ms / parseur ISO maison) si pertinent et factorisable proprement — sinon garde-le isolé ici. NE duplique pas bêtement : si une fonction de LS2 est réutilisable, expose-la/partage-la proprement (module commun infra) plutôt que copier-coller. * `time_format` guide l'interprétation : heure ABSOLUE (epoch/ISO) → directement en ms ; heure MURALE LOCALE (ex. « resets at 3pm ») → convertir avec now_ms comme référence (aujourd'hui à 15h, et si c'est déjà passé dans la journée → demain ; documente la règle de passage de minuit — spike §21.10-2) ; DÉLAI RELATIF (ex. « retry after 600s ») → now_ms + delta. - Si le pattern matche mais qu'aucune heure exploitable n'en sort ⇒ limite détectée AVEC resets_at_ms = None (⇒ filet humain en aval). C'est volontaire : détecter sans heure reste utile. 3. Garde le parsing PUR et isolé (le moteur regex est un détail, la logique d'extraction/conversion d'heure est testable sans I/O). L'intégration de ce parser dans le flux PTY (brancher la sortie terminal d'un agent non-structuré vers ce parser, puis vers SessionLimitService::on_rate_limited) relève du câblage — précise si tu la fais ici proprement via un port déjà existant, ou si tu la laisses à LS7 ; ne bricole pas un branchement PTY ad hoc. Attention dédoublonnage (§21.10-4) : si un agent a À LA FOIS un adapter structuré (niveau 1) ET un rate_limit_pattern (niveau 2), il ne faut pas détecter deux fois la même limite. Documente comment on évite ça (probablement : niveau 2 ne s'applique QU'aux agents sans adapter structuré — précise la règle de sélection). Vérifie `cargo build -p infrastructure`. Rends : fichiers créés/modifiés, la dépendance regex ajoutée (version), la signature publique du parser, comment tu réutilises (ou non) le parsing d'heure de LS2, la règle anti-double-détection niveau1/niveau2, et ce que tu laisses à LS7 pour le câblage PTY. Garde tout testable par QA (pattern qui matche avec/sans capture, heure absolue/murale/relative, regex invalide → None sans panique, passage de minuit).
|
||
- **Response:** LS5 (niveau 2 déclaratif, infra) terminé. `cargo build -p infrastructure` = **OK, 0 warning**. Les 5 tests LS2 restent **verts** après refactor (DRY du parsing d'heure). ## Fichiers **Créés** - `crates/infrastructure/src/ratelimit/mod.rs` — `RateLimitParser` + règle `applies`. - `crates/infrastructure/src/timeparse.rs` — parsing d'heure **pur partagé** (factorisé depuis LS2). **Modifiés** - `crates/infrastructure/Cargo.toml` — dépendance `regex`. - `crates/infrastructure/src/session/claude.rs` — délègue le parsing d'heure à `crate::timeparse` (plus de duplication). - `crates/infrastructure/src/lib.rs` — `pub mod ratelimit; pub mod timeparse;` + `pub use ratelimit::RateLimitParser;`. ## Dépendance ajoutée `regex = "1"` **au seul `Cargo.toml` d'infrastructure** (jamais au domaine, T2). Résout en **1.12.3**, déjà présente transitivement dans `Cargo.lock` (zéro nouvelle version dans l'arbre ; tire `aho-corasick`/`regex-automata`/`regex-syntax` déjà verrouillés). ## Signature publique du parser ```rust // Détecteur (motif compilé UNE fois à la construction). pub struct RateLimitParser { /* regex, reset_capture, time_format */ } impl RateLimitParser { pub fn new(pattern: &RateLimitPattern) -> Option<Self>; // None si regex invalide pub fn detect(&self, text: &str, now_ms: i64) -> Option<SessionLimit>; } // Règle de sélection anti-double-détection (§21.10-4). pub fn applies(profile: &AgentProfile) -> bool; ``` **Forme de retour : `Option<SessionLimit>`** (et non `Option<i64>`), car il faut 3 états : `None` = pas de limite ; `Some(SessionLimit{resets_at_ms: Some})` = limite + heure ; `Some(SessionLimit{resets_at_ms: None})` = **limite détectée sans heure** (filet humain) — un `Option<i64>` ne distingue pas les 2 derniers. Le `SessionLimit` est rempli avec `source = RateLimitSource::Pattern`, `detected_at_ms = now_ms`. **Robustesse** : `new` renvoie `None` sur regex invalide (profil pourri ⇒ pas de détecteur, **jamais de panique**). Compilation **une seule fois** (cache dans la struct), pas par fragment. **Stratégies d'heure** (déduites une fois du `time_format`, enum interne `ResetTimeFormat`) : `Auto` (None/inconnu → best-effort absolu epoch/ISO) ; `epoch_s|epoch_seconds|unix_s` ; `epoch_ms|epoch_millis|unix_ms` ; `iso8601|rfc3339|iso` ; `relative_s|relative_seconds|duration_s|retry_after_s` (→ `now+delta`) ; `relative_ms|relative_millis` ; `wall|wall_clock|local|hh:mm` (heure murale « 3pm »/« 15:00 »). Capture par **groupe nommé** en priorité (`(?P<reset>…)`), repli sur index décimal. Match sans capture exploitable ⇒ `resets_at_ms: None` (détection utile sans heure). ## Réutilisation du parsing d'heure de LS2 (pas de copier-coller) J'ai **factorisé** les helpers génériques de LS2 (qui vivaient en privé dans `claude.rs`) dans un nouveau module partagé `crate::timeparse` : `int_epoch_to_ms`/`float_epoch_to_ms`, `parse_rfc3339_to_ms` (+ `split_tz`/`split_seconds_frac`), `days_from_civil` (algo Howard Hinnant), `parse_absolute_ms`, `EPOCH_MS_THRESHOLD`. `claude.rs::value_to_epoch_ms` **délègue** maintenant à `timeparse` (seule l'extraction depuis `serde_json::Value` reste côté Claude). Le niveau 2 réutilise les **mêmes** fonctions. **Ajouts** propres à LS5 dans `timeparse` : `wall_clock_to_ms` (heure murale + passage de minuit) et `parse_wall_clock` (« 3pm »/« 15:00:30 » → h,m,s). Tout reste **pur** (`now_ms` injecté), testable sans I/O. **Passage de minuit (spike §21.10-2)** : `wall_clock_to_ms` calcule « aujourd'hui à h:m:s » sur la **journée UTC** contenant `now_ms` ; si c'est ≤ now ⇒ **+24 h** (demain). Limite assumée et documentée : faute de base de fuseaux (dépendance-zéro), la journée de référence est UTC ; le rollover borne l'erreur à « jamais dans le passé ». Un offset de fuseau explicite (champ futur) lèverait la limite. ## Règle anti-double-détection niveau 1 / niveau 2 (§21.10-4) Source **unique** : `ratelimit::applies(profile) == profile.structured_adapter.is_none() && profile.rate_limit_pattern.is_some()`. Le niveau 2 ne s'applique **qu'aux agents sans adapter structuré** (les structurés détectent par le niveau 1 = flux machine, LS2). Le câblage (LS7) **doit** consulter `applies` avant d'instancier un `RateLimitParser` ⇒ jamais deux détections du même épisode. Documenté dans le module. ## Laissé à LS7 (câblage PTY — pas bricolé ici) Je **n'ai pas** branché la sortie PTY → parser → service (intégration runtime, intriquée avec le watcher de prompt / `MediatedInbox` qui observe déjà la sortie PTY, et l'accès au `SessionLimitService`). LS7 devra : 1. à l'activation d'un agent où `ratelimit::applies(profile)` ⇒ construire `RateLimitParser::new(profile.rate_limit_pattern)` ; 2. **armer ce parser sur le même flux de sortie PTY** que le watcher de prompt (réutiliser l'armement existant, §21.6) ; 3. sur `detect(fragment, clock.now_millis()) == Some(limit)` ⇒ appeler `SessionLimitService` pour planifier la reprise. ⚠️ **Point d'attention pour LS7/LS4** : `SessionLimitService::on_rate_limited(...)` (LS4) reconstruit en interne un `SessionLimit` avec `source = Structured`. Le parser niveau 2 produit déjà un `SessionLimit{source: Pattern}`. Pour préserver la traçabilité de source (UI), LS7 aura besoin soit d'un point d'entrée du service acceptant un `SessionLimit`/une `source` pré-construit(e), soit d'un petit amendement de `on_rate_limited` (param `source`). Je **n'ai pas** modifié LS4 (discipline de lot) ; à arbitrer par toi. La donnée existe (le parser la porte), il ne reste qu'à la router. ## Builds - `cargo build -p infrastructure` ✅ 0 warning ; tests LS2 (5) ✅ verts. - `cargo build --workspace` ❌ **app-tauri uniquement** (inchangé : `chat.rs:185` → LS8, `events.rs:261` → LS7). LS5 n'ajoute aucune rupture. - Note non bloquante : 2 warnings `irrefutable if let` dans le **code de TEST** de `scheduler/mod.rs` (253/285, écrit par QA en LS3 ; `ScheduledTask` est mono-variante aujourd'hui) — **hors** code LS5, à corriger côté QA (ou disparaîtra dès qu'une 2ᵉ variante de `ScheduledTask` existera). ## Pour QA (binôme LS5) `RateLimitParser` 100 % testable sans I/O (`now_ms` injecté) : - pattern qui matche **sans** `reset_capture` ⇒ `Some(SessionLimit{resets_at_ms: None, source: Pattern})` ; - avec capture nommée + `time_format` **absolu** (`epoch_s`/`epoch_ms`/`iso8601`) ⇒ ms attendus ; - **relatif** (`relative_s`, ex. capture « 600 », now=T) ⇒ `T + 600_000` ; - **murale** (`wall`, « 3pm ») : si now=10h ⇒ 15h **aujourd'hui** ; si now=16h ⇒ 15h **demain** (passage de minuit) ; - regex **invalide** (`new`) ⇒ `None` (pas de panique) ; - pas de match ⇒ `detect` ⇒ `None` ; - capture présente mais valeur pourrie / groupe absent ⇒ `Some(SessionLimit{resets_at_ms: None})` ; - `applies` : profil structuré ⇒ `false` ; profil PTY + pattern ⇒ `true` ; profil PTY sans pattern ⇒ `false`. - `timeparse` testable directement (réexporté) : `wall_clock_to_ms`, `parse_wall_clock`, `parse_absolute_ms`, `days_from_civil`.
|
||
- **Prompt:** LS6 — Câblage des événements de limite de session vers le front (app-tauri). Project root: /home/anthony/Documents/Projects/IdeA. Contexte : LS1 a ajouté au domaine 5 nouvelles variantes `DomainEvent` (AgentRateLimited{agent_id, resets_at_ms: Option<i64>}, AgentResumeScheduled{agent_id, fire_at_ms: i64}, AgentResumeCancelled{agent_id}, AgentResumed{agent_id}, AgentRateLimitSuspected{agent_id, resets_at_ms: Option<i64>}) et la variante `ReplyEvent::RateLimited{resets_at_ms: Option<i64>}`. Le workspace ne compile plus : `app-tauri` a deux `match` non exhaustifs. Travail attendu (faire compiler le workspace, propre et cohérent avec le style existant) : 1. `crates/app-tauri/src/events.rs` : - Ajouter 5 variantes correspondantes à l'enum wire `DomainEventDto` (vers la ligne 28), chacune `#[serde(rename_all = "camelCase")]`, avec les `agent_id` sérialisés en `String` (via `.to_string()`) comme les autres variantes, et les champs temps (`resets_at_ms`/`fire_at_ms`) en `Option<i64>`/`i64`. Documenter chaque variante (doc-comment) dans le même esprit que les voisines. - Ajouter les 5 bras correspondants dans `impl From<&DomainEvent> for DomainEventDto` (vers la ligne 259), sur le modèle de `AgentBusyChanged`/`AgentLivenessChanged`. 2. `crates/app-tauri/src/chat.rs` : dans `chunk_from_event` (~ligne 184), traiter `ReplyEvent::RateLimited { .. }`. Sémantique domaine (ports.rs §21.2-T4) : un `RateLimited` est NON terminal et SANS contenu chat, exactement comme `Heartbeat` — il doit donc mapper vers `None` (le badge UI vient du bus `DomainEvent::AgentRateLimited`, pas du flux chat). Aligne le commentaire sur celui de `Heartbeat`. Contraintes : respecte SOLID/hexagonal (le DTO wire reste un miroir pur du domaine, aucune logique). Ne touche QUE app-tauri. À la fin, lance `cargo build --workspace` et `cargo test -p app-tauri` et rapporte la sortie réelle (succès/échec). Si des tests unitaires de relais existent (ex. autour de la ligne 427 d'events.rs pour AgentLivenessChanged), ajoute au moins un test de relais pour AgentRateLimited sur le même modèle. Rends ton résultat via idea_reply avec un résumé des fichiers modifiés et la sortie de compilation/tests.
|
||
- **Response:** LS6 terminé — workspace recompile, app-tauri vert. **Fichiers modifiés (app-tauri uniquement)** 1. `crates/app-tauri/src/events.rs` - Enum `DomainEventDto` : ajout de 5 variantes miroir (insérées avant `PtyOutput`, respectant l'ordre du domaine), chacune `#[serde(rename_all = "camelCase")]`, `agent_id: String` : - `AgentRateLimited { agent_id, resets_at_ms: Option<i64> }` - `AgentResumeScheduled { agent_id, fire_at_ms: i64 }` - `AgentResumeCancelled { agent_id }` - `AgentResumed { agent_id }` - `AgentRateLimitSuspected { agent_id, resets_at_ms: Option<i64> }` Les champs `Option<i64>` portent `#[serde(skip_serializing_if = "Option::is_none")]` (cohérent avec les voisins `DelegationReady`). Doc-comments dans le même esprit que les variantes existantes (réf. §21). - `impl From<&DomainEvent> for DomainEventDto` : 5 bras correspondants (modèle `AgentBusyChanged`/`AgentLivenessChanged`), `agent_id.to_string()`, champs temps copiés (`*resets_at_ms` / `*fire_at_ms`). DTO = miroir pur, zéro logique. - Test de relais ajouté `rate_limited_relays_to_dto_and_wire` (sur le modèle de `liveness_changed_*`) : vérifie `type == "agentRateLimited"`, `agentId` et `resetsAtMs`. 2. `crates/app-tauri/src/chat.rs` - `chunk_from_event` : ajout du bras `ReplyEvent::RateLimited { .. } => None` (non terminal, sans contenu chat — comme `Heartbeat`, ports §21.2-T4). Commentaire de la fonction étendu pour expliquer que le badge UI vient du bus `DomainEvent::AgentRateLimited`, pas du flux chat. **Sortie compilation/tests (réelle)** - `cargo build --workspace` : `Finished dev profile ... in 16.89s` ✅ - `cargo test -p app-tauri` : tous verts, dont : - `events::tests::rate_limited_relays_to_dto_and_wire ... ok` - `events::tests::liveness_changed_{stalled,alive}_relays_to_dto_and_wire ... ok` - `chunk_from_event_*` (heartbeat/final/text_delta/tool_activity) ... ok - Module events : `test result: ok. 43 passed; 0 failed` - Aucun échec sur l'ensemble des binaires/tests d'intégration d'app-tauri. Contraintes respectées : SOLID/hexagonal (wire DTO = miroir pur du domaine), périmètre limité à app-tauri.
|
||
- **Prompt:** LS7 — câblage de la feature session-limits au composition root (app-tauri). Project root: /home/anthony/Documents/Projects/IdeA. Périmètre : UNIQUEMENT app-tauri (state.rs, commands.rs, lib.rs) + au besoin exposer un champ sur LaunchAgentOutput. Aucun nouveau port domaine, aucun nouvel adapter infra : c'est du câblage de l'existant. Architect a fourni la carte de câblage suivante ; suis-la fidèlement, dans l'ORDRE D'IMPLÉMENTATION recommandé. Vérifie chaque ancrage (fichier/struct/ligne) toi-même avant d'éditer car les lignes ont pu bouger. === CARTE DE CÂBLAGE ARCHITECT === 1. INSTANCIATION DU SERVICE — dans crates/app-tauri/src/state.rs, AppState::build. Réutiliser clock (SystemClock, Arc<dyn Clock>) et event_bus partagé (events_port, Arc<dyn EventBus>). Séquence (après construction de launch_agent et project_store, avant le bloc orchestrateur) : a. let (resume_tx, resume_rx) = tokio::sync::mpsc::unbounded_channel::<ScheduledTask>(); b. let scheduler = Arc::new(TokioScheduler::new(resume_tx, Arc::clone(&clock) as Arc<dyn Clock>)) as Arc<dyn Scheduler>; c. let resumer = Arc::new(AppAgentResumer::new(...)) as Arc<dyn application::AgentResumer>; d. let session_limit_service = Arc::new(SessionLimitService::new(Arc::clone(&clock) as Arc<dyn Clock>, scheduler, Arc::clone(&events_port), resumer)); Ajouter champ `pub session_limit_service: Arc<SessionLimitService>` à AppState et le renvoyer dans le littéral final. resume_rx N'entre PAS dans AppState : il est moved dans la tâche de drain spawné dans build (§5). Imports : application::{SessionLimitService, AgentResumer}, domain::ports::{Scheduler, ScheduledTask}, infrastructure::TokioScheduler. 2. PORT AgentResumer → LaunchAgent — nouvel adapter AppAgentResumer dans state.rs, à côté des passerelles AppHandoffProvider / AppProviderSessionProvider / AppRecordTurnProvider (même patron impl application::Trait for AppXxx). impl application::AgentResumer { async fn resume(agent_id, node_id, conversation_id, resume_prompt) -> Result<(),AppError> } recompose un LaunchAgentInput et appelle self.launch_agent.execute(...) (le MÊME Arc<LaunchAgent> que la commande launch_agent). LaunchAgent applique déjà SessionPlan::Resume quand conversation_id présent. ⚠️ POINT DUR : AgentResumer::resume et ScheduledTask::ResumeAgent ne portent PAS de project_id, mais LaunchAgentInput exige Project complet + rows/cols + mcp_runtime. Solution : AppAgentResumer détient un Arc<Mutex<HashMap<AgentId, ResumeContext>>> (ResumeContext = { project: Project, rows: u16, cols: u16 }) ALIMENTÉ par la commande launch_agent (là où project/rows/cols sont en main) et lu au resume. mcp_runtime recalculé dans resume via crate::mcp_endpoint::{idea_exe_path, mcp_endpoint} (même recette que la commande launch_agent). store_port injecté en repli. Injection du resume_prompt (constante application::RESUME_PROMPT) comme premier tour : pour le chemin PTY natif, réutiliser le médiateur d'entrée / portail d'écriture PTY (MediatedInbox) plutôt qu'un write brut. 3. TAP NIVEAU 1 (structuré) — dans crates/app-tauri/src/commands.rs, fn agent_send, boucle de pump du ReplyStream. AVANT chunk_from_event : `if let ReplyEvent::RateLimited { resets_at_ms } = &event { service.on_rate_limited(agent_id, node_id, conversation_id, *resets_at_ms); }` puis continuer le drain (non terminal). Récup node_id/agent_id : ajouter méthode meta_for_session(&SessionId)->Option<(AgentId,NodeId)> sur StructuredSessions (crates/application/src/terminal/registry.rs, jumeau de live_agents, lookup dans entries). conversation_id : passer None (acceptable LS7). Ce tap est DORMANT en composition B-2 mais à câbler pour forward-compat. Arc::clone(&state.session_limit_service) avant le thread::spawn, move dans le thread. 4. TAP NIVEAU 2 (PTY) — chemin ACTIF — dans commands.rs, fn launch_agent, branche PTY (if output.structured.is_none() + thread::spawn du pump d'octets) : a. Sélection §21.10-4 : appeler infrastructure::ratelimit::applies(&profile) avant d'armer. Besoin : exposer le AgentProfile (ou au minimum le RateLimitPattern) résolu sur LaunchAgentOutput (LaunchAgent::execute le résout déjà en interne — option la plus propre, zéro I/O). b. RateLimitParser::new(&pattern) (Option ⇒ regex invalide = pas de détecteur, jamais de panique), construit une fois par lancement, déplacé dans le thread de pump. c. Dans la boucle for chunk in stream, après send_output : String::from_utf8_lossy(&chunk) puis parser.detect(&text, clock.now_millis()). Sur Some(SessionLimit) ⇒ service.on_rate_limited(agent_id, node_id, conversation_id, limit.resets_at_ms). agent_id/node_id/conversation_id (request.conversation_id) déjà en main dans la commande ⇒ cloner avant thread::spawn. Besoin d'un Arc<dyn Clock> (réutiliser SystemClock). Anti-double-détection garantie par applies (structured_adapter.is_none()). Fragmentation PTY : best-effort par fragment pour LS7 (note QA). 5. DRAIN DU SCHEDULER — resume_rx drainé dans une tâche détachée spawné DANS AppState::build sur le patron EXACT de sweep_stalled : utiliser tauri::async_runtime::spawn (PAS tokio::spawn — build tourne dans le hook setup sans runtime ambiant). Boucle : while let Some(task) = resume_rx.recv().await { if let Err(e) = service.execute_resume(task).await { /* log best-effort */ } }. service (Arc) et resume_rx moved dans la closure. 6. COMMANDE TAURI cancel_resume — dans commands.rs : #[tauri::command] pub async fn cancel_resume(agent_id: String, state: State<'_, AppState>) -> Result<bool, ErrorDto> { let id = parse_agent_id(&agent_id)?; Ok(state.session_limit_service.cancel_resume(id)) }. Enregistrer dans crates/app-tauri/src/lib.rs generate_handler![ … ] (à côté des commandes agent, ex. après change_agent_profile). ORDRE : (1)+(5) avec un AgentResumer stub d'abord (valide armement→canal→execute_resume→AgentResumed), puis (6) cancel_resume, puis (4) tap N2 PTY, puis (2) AppAgentResumer réel + registre ResumeContext alimenté par launch_agent, puis (3) tap N1 structuré + meta_for_session. CONFORMITÉ : domaine pur, regex/parse confinés infra, service pur-ports — ne rien violer. Pas de panique sur profil mal configuré / resume sans contexte (repli propre). === FIN CARTE === À la fin : `cargo build --workspace` puis `cargo test --workspace` et rapporte la SORTIE RÉELLE (succès/échec, nombres). Ajoute des tests unitaires côté app-tauri pour ce qui est testable sans Tauri runtime (ex. AppAgentResumer avec un LaunchAgent fake, meta_for_session sur StructuredSessions, la commande cancel_resume si possible). Rends via idea_reply : fichiers modifiés, décisions prises sur les points durs, et la sortie compilation/tests.
|
||
- **Response:** LS7 terminé — workspace recompile, suite complète verte (83 binaires de test, 0 échec). Câblage suivi fidèlement, dans l'ordre recommandé. ## Fichiers modifiés **app-tauri (cœur du câblage)** - `crates/app-tauri/Cargo.toml` : `async-trait` déplacé/ajouté en `[dependencies]` (il n'était qu'en `[dev-dependencies]` ⇒ l'impl du port async `AgentResumer` côté lib ne compilait pas). - `crates/app-tauri/src/state.rs` : - **(1) Instanciation du service** dans `AppState::build`, après `input_mediator` et avant le bloc orchestrateur : `unbounded_channel::<ScheduledTask>()`, `TokioScheduler` (clock partagé), `AppAgentResumer`, `SessionLimitService::new(clock, scheduler, events_port, resumer)`. Champ `pub session_limit_service: Arc<SessionLimitService>` + `pub resume_contexts: ResumeContexts` ajoutés à `AppState` et au littéral final. - **(2) Port `AgentResumer` → `LaunchAgent`** : nouvel adapter `AppAgentResumer` (passerelle, même patron que `AppHandoffProvider`/`AppProviderSessionProvider`). Recompose un `LaunchAgentInput` à partir du `ResumeContext` lu + recalcule `mcp_runtime` via `mcp_endpoint::{idea_exe_path, mcp_endpoint}`, appelle le **même** `Arc<LaunchAgent>`, puis injecte `RESUME_PROMPT` comme premier tour via le **portail d'entrée** (`InputMediator::enqueue`, pas de write brut, §20). Types `ResumeContext { project, rows, cols }` + alias `ResumeContexts = Arc<Mutex<HashMap<AgentId, ResumeContext>>>`. - **(5) Drain du scheduler** : tâche détachée `tauri::async_runtime::spawn` (patron exact de `sweep_stalled`) : `while let Some(task) = resume_rx.recv().await { service.execute_resume(task).await }` (log best-effort sur erreur). - `crates/app-tauri/src/commands.rs` : - **(4) Tap niveau 2 (PTY, chemin actif)** dans `launch_agent` : sélection §21.10-4 via `infrastructure::ratelimit::applies(&output.profile)`, `RateLimitParser::new(pattern)` construit une fois, déplacé dans le thread de pump ; dans la boucle, avant `send_output`, `String::from_utf8_lossy` + `parser.detect(text, clock.now_millis())` ⇒ `service.on_rate_limited(agent_id, host_node_id, conversation_id, limit.resets_at_ms)`. Alimente aussi `state.resume_contexts` (projet cloné + rows/cols) à chaque lancement. - **(3) Tap niveau 1 (structuré, dormant mais câblé)** dans `agent_send` : `meta_for_session(&sid)` résout `(agent_id, node_id)` ; sur `ReplyEvent::RateLimited { resets_at_ms }` ⇒ `service.on_rate_limited(.., None, *resets_at_ms)` puis on continue le drain (`conversation_id = None`, acceptable LS7). - **(6) Commande `cancel_resume(agent_id) -> Result<bool>`** déléguant à `session_limit_service.cancel_resume`. - `crates/app-tauri/src/lib.rs` : `commands::cancel_resume` enregistré dans `generate_handler!`. **application (sanctionné par la carte)** - `crates/application/src/agent/lifecycle.rs` : champ `pub profile: Option<AgentProfile>` exposé sur `LaunchAgentOutput` (résolu déjà en interne, zéro I/O) ; `Some(profile.clone())` sur les 2 lancements effectifs (PTY + structuré), `None` sur les 3 réattaches (rebind/idempotent). - `crates/application/src/terminal/registry.rs` : `StructuredSessions::meta_for_session(&SessionId) -> Option<(AgentId, NodeId)>` (jumeau « inverse » de `live_agents`, lookup direct). **Tests** - `crates/app-tauri/tests/session_limit_wiring.rs` (nouveau) : `cancel_resume` no-op propre pour agent inconnu ; `on_rate_limited` arme une reprise annulable et publie `AgentRateLimited`→`AgentResumeScheduled`→(après cancel)`AgentResumeCancelled` sur le **vrai** bus via `AppState::build`. - `crates/application/tests/structured_registry_d1.rs` : test `meta_for_session` (résout agent+cellule, `None` si id inconnu/retiré). - Tests existants `dto_agents.rs` / `dto_chat.rs` mis à jour avec `profile: None` (nouveau champ du struct). ## Décisions sur les points durs - **Profil pour la sélection N2** : exposé en `Option<AgentProfile>` sur `LaunchAgentOutput` (option « la plus propre » de la carte) plutôt que dupliquer la règle ; app-tauri appelle `infrastructure::ratelimit::applies` ⇒ règle anti-double-détection N1/N2 reste l'unique source infra. `None` sur réattache (best-effort : pas d'armement, le tour n'a pas de nouvelle session). - **`AgentResumer::resume` sans `project_id`** : registre `ResumeContexts` partagé (composition root ↔ commande `launch_agent`) ; alimenté là où `Project`/taille sont en main. Resume « à l'aveugle » (contexte absent) ⇒ `Err(NotFound)` propre ⇒ `AgentResumed` non publié, jamais de panique. - **Injection `RESUME_PROMPT`** : via `InputMediator::enqueue` (portail d'écriture unique, §20), `PendingReply` lâché (reprise ≠ délégation corrélée). - **Horloge du tap N2** : `infrastructure::SystemClock` local au pump (l'`AppState` n'expose pas le clock). - **Limitations notées (best-effort LS7)** : fragmentation PTY (motif coupé entre 2 fragments) ⇒ raté best-effort par fragment ; le ré-abonnement du pump PTY n'est pas recâblé sur la reprise auto hors-commande (l'agent tourne, le front peut réattacher). ## Sortie compilation/tests (réelle) - `cargo build --workspace` : `Finished dev profile ... in 18.04s` ✅ (0 warning sur les crates touchées). - `cargo test --workspace` : **83 binaires `test result: ok`, 0 failed**, 0 erreur. Dont : - `tests/session_limit_wiring.rs` : `cancel_resume_is_a_clean_noop_for_unknown_agent` ok, `on_rate_limited_arms_a_cancellable_resume_over_the_real_bus` ok. - `application` `structured_meta_for_session_resolves_agent_and_node` ok (12 passed). - infrastructure 221 passed, app-tauri (events/pty/orchestrator/dto…) verts. Conformité hexagonale respectée : DTO/wire inchangés côté domaine, regex/parse confinés infra (`applies`/`RateLimitParser`), service pur-ports, aucun nouveau port domaine ni adapter infra (câblage de l'existant uniquement).
|
||
- **Prompt:** Tâche LS8-backend — filet humain niveau 3 « armement par heure saisie » (ARCHITECTURE §21.1). Branche `feature/agent-session-limits`. Cadrage Architect (verdict B) ci-dessous, à implémenter tel quel. CONTEXTE : niveaux 1/2 détectent→planifient une reprise auto annulable. Niveau 3 : `agentRateLimitSuspected` est émis sans heure fiable ; il manque la boucle d'action pour que l'utilisateur saisisse l'heure et arme la MÊME reprise (annulable). DOMAINE : RIEN à ajouter — `domain::session_limit` a déjà `RateLimitSource::Human`, `plan_resume` (clampe à `now` si heure passée ⇒ reprise immédiate), `ResumePlan::Scheduled`. 1) APPLICATION — `SessionLimitService` (crates/application, cherche le module session_limit/service) : - Factorise la branche `ResumePlan::Scheduled` actuelle de `on_rate_limited` en une méthode privée `fn arm_scheduled(&self, agent_id, fire_at_ms, node_id, conversation_id)` qui fait : publish `AgentRateLimited{Some(t)}` → `disarm` (dédoublonnage existant) → `scheduler.arm(ScheduledTask::ResumeAgent{...})` → mémoriser le `ScheduleId` → publish `AgentResumeScheduled{fire_at_ms}`. `on_rate_limited` appelle cette privée pour son cas Scheduled (comportement identique, zéro régression). - Ajoute la méthode publique : ```rust /// (d) Filet humain (§21.1 niveau 3). L'utilisateur a saisi l'heure de reset /// pour un agent en limite SUSPECTÉE. Construit une SessionLimit source `Human`, /// calcule le plan et arme la reprise EXACTEMENT comme la branche auto : mêmes /// événements, même dédoublonnage, même annulabilité via cancel_resume. pub fn confirm_human_resume(&self, agent_id: AgentId, node_id: NodeId, conversation_id: Option<String>, resets_at_ms: i64) ``` Corps : `SessionLimit::new(Some(resets_at_ms), now, RateLimitSource::Human)` → `plan_resume` → sur `ResumePlan::Scheduled{fire_at_ms}` appelle `arm_scheduled(...)`. (Vérifie les noms/signatures exacts de `SessionLimit::new`, `plan_resume`, `ResumePlan` dans le domaine et aligne-toi dessus.) `execute_resume` et `cancel_resume` restent INCHANGÉS (l'armement humain s'annule/s'exécute par les mêmes voies : invariant = un seul mécanisme de reprise). 2) APP-TAURI — commande miroir de `cancel_resume` (crates/app-tauri/src/commands.rs) : ```rust #[tauri::command] pub async fn set_resume_at(agent_id: String, resets_at_ms: i64, state: State<'_, AppState>) -> Result<(), ErrorDto> ``` Corps : `parse_agent_id` → résoudre côté backend (le front n'a que l'agent_id) : - `node_id` : via la registry des sessions vivantes (cherche un `node_for_agent`/équivalent sur `TerminalSessions`/`StructuredSessions` ; tu as déjà ajouté `meta_for_session` en LS7 — trouve/ajoute le lookup agent→node). Si pas de cellule vivante ⇒ `ErrorDto` NOT_FOUND/INVALID (la saisie n'a pas de cible). - `conversation_id` : best-effort via la session structurée de l'agent (`None` toléré). Puis `state.session_limit_service.confirm_human_resume(id, node_id, conv_id, resets_at_ms)`. Enregistre la commande dans `lib.rs` à côté de `cancel_resume`. 3) ÉVÉNEMENTS : n'en crée AUCUN. `confirm_human_resume` réémet la paire existante `AgentRateLimited{Some}` + `AgentResumeScheduled{fire_at_ms}` (déjà relayées au front). Annulation/reprise via `AgentResumeCancelled`/`AgentResumed` inchangés. Compile (`cargo build`) et lance les tests des crates touchées. NE COMMIT PAS (Git tranche). Rends-moi via idea_reply : fichiers modifiés, signature exacte des méthodes domaine que tu as constatées (SessionLimit::new, plan_resume, ResumePlan), le lookup agent→node que tu as utilisé/ajouté, et le résultat de compilation/tests. QA écrira les tests ensuite — structure pour la testabilité.
|
||
- **Response:** LS8-backend (filet humain niveau 3) implémenté tel que cadré — verdict B. Build OK, tous tests verts, NON commité. ## Signatures domaine constatées (crates/domain/src/session_limit.rs) — RIEN ajouté - `RateLimitSource::{Structured, Pattern, Human}` (enum Copy). - `SessionLimit::new(resets_at_ms: Option<i64>, detected_at_ms: i64, source: RateLimitSource) -> Self` (const, ordre des args = (Option heure, now, source)). - `plan_resume(now_ms: i64, limit: &SessionLimit, conversation_id: Option<String>) -> ResumePlan`. - `ResumePlan::Scheduled { fire_at_ms: i64, conversation_id: Option<String> }` | `ResumePlan::HumanFallback`. Le clamp anti-passé (`fire_at_ms = resets_at_ms.max(now_ms)`) est dans `plan_resume` ⇒ heure saisie déjà passée = reprise immédiate, gratis. ## Fichiers modifiés 1. **crates/application/src/agent/session_limit.rs** - Factorisé la branche `ResumePlan::Scheduled` de `on_rate_limited` en privée `fn arm_scheduled(&self, agent_id, fire_at_ms, node_id, conversation_id, resets_at_ms: Option<i64>)`. `on_rate_limited` l'appelle pour son cas Scheduled → comportement identique (les 9 tests `session_limit_service` existants passent inchangés). - ⚠️ **Écart assumé vs signature suggérée** : j'ai ajouté un 5e param `resets_at_ms: Option<i64>` à `arm_scheduled`. Raison : l'événement `AgentRateLimited` d'origine publie l'**heure de reset brute** (l'Option d'entrée), PAS `fire_at_ms` (qui est clampé). Les deux diffèrent si le reset est passé. Pour garantir le « zéro régression » exigé, j'ai propagé l'heure de reset brute au lieu de republier `Some(fire_at_ms)`. Les deux appelants passent `Some(...)`. - Ajouté la publique `pub fn confirm_human_resume(&self, agent_id: AgentId, node_id: NodeId, conversation_id: Option<String>, resets_at_ms: i64)` : `SessionLimit::new(Some(resets_at_ms), now, RateLimitSource::Human)` → `plan_resume` → sur `Scheduled` appelle `arm_scheduled(..., Some(resets_at_ms))`. Cas `HumanFallback` traité en no-op défensif (inatteignable car `resets_at_ms` toujours `Some`). `execute_resume` et `cancel_resume` INCHANGÉS. 2. **crates/app-tauri/src/commands.rs** — ajout `#[tauri::command] pub async fn set_resume_at(agent_id: String, resets_at_ms: i64, state) -> Result<(), ErrorDto>`, miroir de `cancel_resume`. 3. **crates/app-tauri/src/lib.rs** — `commands::set_resume_at` enregistrée juste après `commands::cancel_resume` dans le `generate_handler!`. ## Lookup agent→node utilisé (existant, rien ajouté) La registry des sessions vivantes expose déjà `node_for_agent`. `set_resume_at` résout : - `node_id = state.structured_sessions.node_for_agent(&id).or_else(|| state.terminal_sessions.node_for_agent(&id))` (structuré d'abord car il porte aussi la conv ; repli terminal/PTY). Aucune cellule vivante ⇒ `ErrorDto::from(AppError::NotFound(...))`. - `conversation_id` best-effort = `state.structured_sessions.session_for_agent(&id).and_then(|s| s.conversation_id())` (via `AgentSession::conversation_id() -> Option<String>`, ports.rs:549). `None` toléré. Méthodes constatées dans crates/application/src/terminal/registry.rs : `TerminalSessions::node_for_agent` (l.175), `StructuredSessions::node_for_agent` (l.375), `session_for_agent`, `meta_for_session`. ## Événements : AUCUN créé. `confirm_human_resume` → `arm_scheduled` réémet la paire existante `AgentRateLimited{Some(t)}` + `AgentResumeScheduled{fire_at_ms}`. Annulation/reprise par les voies inchangées (`AgentResumeCancelled`/`AgentResumed`). ## Compilation / tests - `cargo build -p application -p app-tauri` : Finished, 0 erreur/0 warning. - `cargo test -p domain -p application` : tout vert (domain 39, session_limit_service 9, session_limit_t4 7, + le reste). - `cargo test -p app-tauri` : tout vert (dont wiring 43). ## Pour QA (testabilité) - `confirm_human_resume` testable 100 % sans I/O via les fakes existants (Clock/Scheduler/EventBus) déjà utilisés dans `tests/session_limit_service.rs`. Cas à couvrir : (a) heure future ⇒ `AgentRateLimited{Some}` puis `AgentResumeScheduled{fire_at_ms=resets_at_ms}` + ScheduleId mémorisé ; (b) heure passée ⇒ `fire_at_ms == now` (reprise immédiate) ; (c) dédoublonnage : un `confirm_human_resume` après un armement existant annule le précédent (1 seule entrée armée) ; (d) annulabilité : `cancel_resume` après `confirm_human_resume` publie `AgentResumeCancelled` ; (e) parité auto/humain : même séquence d'événements que `on_rate_limited` cas Scheduled. - `set_resume_at` : `node_for_agent` None ⇒ NOT_FOUND. |