release(0.2.0): intègre develop dans main
Deuxième release d'IdeA. Intègre la feature « limites de session des agents » (détecteur hiérarchique 3 niveaux + reprise auto annulable + filet humain), par-dessus la base 0.1.0 : - LS1 domaine (détection + plan de reprise) - LS2 adapter Claude niveau 1, LS3 Scheduler/TokioScheduler - LS4 service application + réconciliation, LS5 détecteur niveau 2 déclaratif - LS6 events vers le front, LS7 câblage backend + UI (badge/compte à rebours) - LS8 filet humain niveau 3 (set_resume_at + saisie d'heure de reprise) Version bumpée à 0.2.0. Suite de tests workspace verte. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@ -7,6 +7,8 @@
|
||||
# Dependencies and build output (package-lock.json IS committed).
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
# Root-level node_modules (dev tooling installs at repo root — never versioned).
|
||||
/node_modules/
|
||||
# npm/yarn/pnpm debug logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,5 +1,5 @@
|
||||
---
|
||||
upTo: a6169530-fc73-4057-91c6-900da4a611fb
|
||||
upTo: 50c3e999-c6a3-4d8b-aef3-b6c273ed9afc
|
||||
objective: [Ping inter-agent depuis Main] Test du pont MCP inter-agents. Si tu reçois ce message, réponds via idea_reply avec : (1) "DevFrontend OK — pont inter-agent fonctionnel", (2) le résultat de 144 ÷ 12, e
|
||||
---
|
||||
**Objectif :** [Ping inter-agent depuis Main] Test du pont MCP inter-agents. Si tu reçois ce message, réponds via idea_reply avec : (1) "DevFrontend OK — pont inter-agent fonctionnel", (2) le résultat de 144 ÷ 12, e
|
||||
@ -11,4 +11,8 @@ objective: [Ping inter-agent depuis Main] Test du pont MCP inter-agents. Si tu r
|
||||
- **Prompt:** [Test de communication inter-agent] Ping. Réponds simplement via idea_reply avec « pong DevFrontend OK » pour confirmer que le canal fonctionne.
|
||||
- **Response:** pong DevFrontend OK
|
||||
- **Prompt:** Tâche : nettoyage/correction de bugs d'affichage UI (frontend React/TS). Project root : /home/anthony/Documents/Projects/IdeA. Aucune nouvelle feature, uniquement du fix CSS/layout. Voici 3 problèmes constatés visuellement (captures non transmissibles, je les décris) : PROBLÈME 1 — Barre de navigation horizontale (top bar listant : Projects, Context, Agents, Templates, Skills, Permissions, …). - Cette barre n'est PAS scrollable et n'est PAS correctement mise à l'échelle. Quand il y a trop d'entrées, les derniers items débordent et sont coupés (on voit « Skills » puis « P… » tronqué, « Permissions » coupé). - Attendu : la barre doit gérer le débordement proprement — soit scroll horizontal (overflow-x:auto avec masquage de scrollbar propre), soit wrap/scale correct. Les entrées ne doivent jamais être croppées. Vérifier flex-shrink/min-width des items. PROBLÈME 2 — Panneau « Agents » (liste des agents : Main, Architect, DevBackend, DevFrontend, QA, TestConversation, …). - Les items de la liste s'overlappent (chevauchement vertical) et sont croppés. Le nom de l'agent, le profil (« Claude Code »), l'id de run (longue chaîne en bleu qui wrap mal sur plusieurs lignes), la dropdown de profil et les boutons Stop/Delete/Launch se chevauchent et débordent du panneau. - Attendu : chaque carte d'agent doit avoir une hauteur qui s'adapte à son contenu (pas de height fixe qui cause l'overlap), les éléments alignés proprement, l'id long tronqué avec ellipsis ou wrap contrôlé, et la dropdown + boutons ne doivent pas déborder/chevaucher le texte voisin. Vérifier les position absolute éventuels et les gaps flex/grid. PROBLÈME 3 — Formulaire de création d'agent (champs : Name, Template, Profile, bouton Create). - Les champs du formulaire s'overlappent les uns sur les autres (Name / Template / Profile trop serrés/superposés). - BUG fonctionnel : quand on sélectionne une valeur dans les dropdowns (Template / Profile), la sélection NE s'affiche PAS dans le champ de la droplist (le label sélectionné ne se reflète pas visuellement). À investiguer : binding value/onChange du composant select, ou affichage du label sélectionné si c'est un dropdown custom. - Attendu : formulaire avec espacement correct (pas de chevauchement), et les dropdowns affichent bien la valeur choisie. Merci de : 1. Localiser les composants concernés (top nav bar, panneau Agents, formulaire création d'agent). 2. Corriger le CSS/layout et le bug de binding des dropdowns. 3. Lancer un build frontend pour t'assurer que ça compile (npm run build ou équivalent), sans casser le reste. 4. Me renvoyer via idea_reply la liste des fichiers modifiés + un résumé des corrections, pour que je puisse enchaîner sur le rebuild de l'AppImage.
|
||||
- **Response:** Bugs d'affichage corrigés. Build vert (`npm run build` = tsc --noEmit + vite build OK) et tests verts (`vitest run` agents + projects : 47/47 passent). ## Fichiers modifiés (2) - `frontend/src/features/projects/ProjectsView.tsx` — la « top bar » - `frontend/src/features/agents/AgentsPanel.tsx` — panneau Agents + formulaire de création ## Diagnostic La « top bar » décrite n'est pas une nav OS mais le **strip de tabs du sidebar** (`SIDEBAR_TABS` : Projects, Context, Agents, Templates, Skills, Perms, Memory, Git) dans `ProjectsView.tsx`. Cause racine commune aux 3 problèmes : **largeur trop contrainte** dans le sidebar de 320px (`w-80`). ## Corrections **PROBLÈME 1 — barre de tabs croppée** - Avant : 8 boutons en `flex-1` sans gestion de débordement → les derniers labels coupés. - Après : les tabs sont dans un conteneur `overflow-x-auto` **scrollable horizontalement**, chaque tab passe en `shrink-0 whitespace-nowrap` (plus jamais croppé). Scrollbar masquée proprement (`[scrollbar-width:none]` + `[&::-webkit-scrollbar]:hidden`). Le bouton « collapse » («) reste épinglé à droite, hors zone de scroll. **PROBLÈME 2 — cartes d'agents qui se chevauchent** - Avant : `<li>` en `flex items-center justify-between` → le bloc nom/profil et la colonne de contrôles (dropdown profil + Sync/Stop/Launch/Delete, en `shrink-0`) se disputaient la largeur ; l'id de run long (`running in IdeA · <id>`) wrappait et débordait. - Après : carte en **`flex-col`** — bloc nom/profil/statut en haut, rangée de contrôles en dessous (`flex-wrap`, plus de `shrink-0` qui écrasait le texte). Hauteur adaptative au contenu (plus de chevauchement). L'id long est désormais **tronqué avec ellipsis** (`block w-full min-w-0 truncate`). **PROBLÈME 3 — formulaire de création (champs serrés + dropdowns « vides »)** - Avant : form en `flex flex-wrap items-end` avec 3 champs `min-w-0 flex-1` → selects écrasés à ~90px ; le texte sélectionné (« Claude Code ») était **clippé derrière la flèche native** du select, d'où l'impression que la sélection ne s'affichait pas. - Après : form en **`flex-col gap-3`**, chaque champ en pleine largeur. Le binding des `<select>` était déjà correct (`value`/`onChange` contrôlés, natifs) — c'était bien un problème de largeur, pas de binding. En pleine largeur la valeur choisie s'affiche normalement. Bouton Create passé en `self-end`. Aucune nouvelle dépendance, aucun changement de logique métier ni de contrat IPC/gateway. Les tests s'appuient sur les `aria-label` (inchangés), d'où les 47/47 verts. Tu peux enchaîner sur le rebuild de l'AppImage (rappel mémoire : c'est l'AppImage qui tourne qu'il faut reconstruire, pas seulement les sources).
|
||||
- **Response:** Bugs d'affichage corrigés. Build vert (`npm run build` = tsc --noEmit + vite build OK) et tests verts (`vitest run` agents + projects : 47/47 passent). ## Fichiers modifiés (2) - `frontend/src/features/projects/ProjectsView.tsx` — la « top bar » - `frontend/src/features/agents/AgentsPanel.tsx` — panneau Agents + formulaire de création ## Diagnostic La « top bar » décrite n'est pas une nav OS mais le **strip de tabs du sidebar** (`SIDEBAR_TABS` : Projects, Context, Agents, Templates, Skills, Perms, Memory, Git) dans `ProjectsView.tsx`. Cause racine commune aux 3 problèmes : **largeur trop contrainte** dans le sidebar de 320px (`w-80`). ## Corrections **PROBLÈME 1 — barre de tabs croppée** - Avant : 8 boutons en `flex-1` sans gestion de débordement → les derniers labels coupés. - Après : les tabs sont dans un conteneur `overflow-x-auto` **scrollable horizontalement**, chaque tab passe en `shrink-0 whitespace-nowrap` (plus jamais croppé). Scrollbar masquée proprement (`[scrollbar-width:none]` + `[&::-webkit-scrollbar]:hidden`). Le bouton « collapse » («) reste épinglé à droite, hors zone de scroll. **PROBLÈME 2 — cartes d'agents qui se chevauchent** - Avant : `<li>` en `flex items-center justify-between` → le bloc nom/profil et la colonne de contrôles (dropdown profil + Sync/Stop/Launch/Delete, en `shrink-0`) se disputaient la largeur ; l'id de run long (`running in IdeA · <id>`) wrappait et débordait. - Après : carte en **`flex-col`** — bloc nom/profil/statut en haut, rangée de contrôles en dessous (`flex-wrap`, plus de `shrink-0` qui écrasait le texte). Hauteur adaptative au contenu (plus de chevauchement). L'id long est désormais **tronqué avec ellipsis** (`block w-full min-w-0 truncate`). **PROBLÈME 3 — formulaire de création (champs serrés + dropdowns « vides »)** - Avant : form en `flex flex-wrap items-end` avec 3 champs `min-w-0 flex-1` → selects écrasés à ~90px ; le texte sélectionné (« Claude Code ») était **clippé derrière la flèche native** du select, d'où l'impression que la sélection ne s'affichait pas. - Après : form en **`flex-col gap-3`**, chaque champ en pleine largeur. Le binding des `<select>` était déjà correct (`value`/`onChange` contrôlés, natifs) — c'était bien un problème de largeur, pas de binding. En pleine largeur la valeur choisie s'affiche normalement. Bouton Create passé en `self-end`. Aucune nouvelle dépendance, aucun changement de logique métier ni de contrat IPC/gateway. Les tests s'appuient sur les `aria-label` (inchangés), d'où les 47/47 verts. Tu peux enchaîner sur le rebuild de l'AppImage (rappel mémoire : c'est l'AppImage qui tourne qu'il faut reconstruire, pas seulement les sources).
|
||||
- **Prompt:** Tâche LS7-front (React/TS) — UI de gestion des limites de session des agents (ARCHITECTURE §21). On est sur la branche `feature/agent-session-limits`. Le backend est terminé et vert (commit 9df5923) : il émet déjà 5 DOMAIN_EVENT vers le front et expose une commande Tauri `cancel_resume(agentId) -> bool`. CONTRAT WIRE (DTO sérialisés camelCase, source = crates/app-tauri/src/events.rs) : - `agentRateLimited` { agentId: string, resetsAt?: number /* epoch-ms, absent si inconnu */ } - `agentResumeScheduled` { agentId: string, fireAt: number /* epoch-ms, échéance du réveil */ } ⚠️ champ wire = `fireAtMs` → vérifie le nom exact sérialisé (camelCase de `fire_at_ms` = `fireAtMs`) ; idem `resetsAtMs` pour les autres. Aligne-toi sur le JSON réel. - `agentResumeCancelled` { agentId: string } - `agentResumed` { agentId: string } - `agentRateLimitSuspected` { agentId: string, resetsAt?: number } (Vérifie les noms de champs exacts dans events.rs : `resets_at_ms`→`resetsAtMs`, `fire_at_ms`→`fireAtMs`. Ne devine pas, lis le fichier.) À FAIRE : 1) Ajouter les 5 variantes au union `DomainEvent` de `src/domain/index.ts` (mêmes noms `type` que le wire), avec les champs exacts. 2) Exposer la commande `cancel_resume` dans le port gateway approprié (cf. `src/ports/index.ts`) + son implémentation dans l'adapter système (`src/adapters/system.ts`) ET le mock (`src/adapters/mock/index.ts`). RÈGLE ARCHI STRICTE : les composants ne touchent JAMAIS `invoke()` ni `@tauri-apps/api` — tout passe par les gateways/ports (hexagonal §1.3). Suis le patron d'une commande existante (ex. `interruptAgent` si elle existe, sinon une autre commande agent). 3) Tracker l'état limite par agent dans `useAgents` (view-model hook). Inspire-toi du patron existant `delegationSourceByRequester` qui se peuple depuis les events. État par agent : { limitedUntil?: number, resumeFireAt?: number, suspected?: bool }. Mises à jour : - `agentRateLimited` → limité (badge « limité jusqu'à HH:MM » si resetsAt connu, sinon « limité »). - `agentResumeScheduled` → arme le compte à rebours jusqu'à fireAt + bouton « Annuler la reprise ». - `agentResumeCancelled` → retire le compte à rebours (reste limité, sans reprise auto). - `agentResumed` → efface tout l'état limite de l'agent. - `agentRateLimitSuspected` → filet humain : signaler que la limite est détectée sans heure fiable et demander à l'utilisateur (au minimum un état/affichage « heure inconnue » ; le formulaire de saisie d'heure peut être un placeholder documenté si le backend n'a pas encore de commande pour l'enregistrer — dans ce cas, expose juste l'état et un TODO clair, NE fabrique pas de commande inexistante). 4) UI : badge limite + compte à rebours + bouton « Annuler la reprise » (appelle `cancel_resume` via le gateway) dans la vue agents/terminal cohérente avec le style existant. Composant testable (gateways mockés). NE PAS committer toi-même (l'agent Git tranche les commits). NE PAS toucher au backend Rust. Reste cohérent avec le style TS/React existant. Quand c'est implémenté, QA écrira/exécutera les tests — donc structure pour la testabilité (logique d'état dans le hook, pas dans le JSX). Rends-moi via idea_reply : la liste des fichiers modifiés/créés, les noms de champs wire exacts que tu as constatés dans events.rs, et tout point de friction (notamment sur le filet humain niveau 3 si aucune commande backend n'existe pour enregistrer l'heure saisie).
|
||||
- **Response:** LS7-front terminé. Typecheck `tsc --noEmit` propre + 39 tests agents existants verts (aucune régression). Non commité (Git tranche). ## Noms de champs wire constatés (lus dans crates/app-tauri/src/events.rs, `DomainEventDto`, serde camelCase + skip_serializing_if sur les Option) - `agentRateLimited` → { `agentId`: string, `resetsAtMs`?: number /* absent si null */ } - `agentResumeScheduled` → { `agentId`: string, `fireAtMs`: number /* toujours présent */ } - `agentResumeCancelled` → { `agentId`: string } - `agentResumed` → { `agentId`: string } - `agentRateLimitSuspected` → { `agentId`: string, `resetsAtMs`?: number /* absent si null */ } ⚠️ Confirmé : ce sont bien `resetsAtMs` / `fireAtMs` (suffixe `Ms`), PAS `resetsAt`/`fireAt` comme l'énoncé du ticket le suggérait. Je me suis aligné sur le JSON réel. Vérifié aussi par le test backend `rate_limited_relays_to_dto_and_wire` qui asserte `json["resetsAtMs"]`. Commande Tauri `cancel_resume` (commands.rs:1381) : signature `cancel_resume(agent_id: String) -> Result<bool, _>` — argument **nu** `agentId` (PAS d'enveloppe `{ request }` contrairement à interrupt_agent/delegation_delivered). Adapter aligné en conséquence. Renvoie `true` ssi une reprise était armée et a été désarmée. ## Fichiers modifiés 1. `frontend/src/domain/index.ts` — 5 variantes ajoutées au union `DomainEvent` (mêmes `type` que le wire, champs exacts `resetsAtMs?`/`fireAtMs`). 2. `frontend/src/ports/index.ts` — `cancelResume(agentId: string): Promise<boolean>` ajouté à `InputGateway` (port choisi : c'est le gateway des contrôles agent hors-bande, cohérent avec `interrupt`). 3. `frontend/src/adapters/input.ts` — `TauriInputGateway.cancelResume` → `invoke("cancel_resume", { agentId })` (arg nu, typé `<boolean>`). 4. `frontend/src/adapters/mock/index.ts` — `MockInputGateway.cancelResume` : enregistre dans `cancelledResumes: string[]`, renvoie `cancelResumeResult` (défaut `true`, flippable par les tests). 5. `frontend/src/features/agents/useAgents.ts` — état `limitByAgent: Record<string, AgentLimitState>` (interface exportée `{ limitedUntil?, resumeFireAt?, suspected? }`), peuplé depuis les 5 events dans l'abonnement `onDomainEvent` existant (même patron que `delegationSourceByRequester`) : - `agentRateLimited` → `{ limitedUntil: resetsAtMs, suspected: false }` (conserve un `resumeFireAt` éventuel). - `agentResumeScheduled` → arme `resumeFireAt = fireAtMs`. - `agentResumeCancelled` → retire `resumeFireAt`, reste limité. - `agentResumed` → supprime l'entrée (efface tout). - `agentRateLimitSuspected` → `{ limitedUntil: resetsAtMs, suspected: true }`. - Action `cancelResume(agentId)` exposée : retrait optimiste du compte à rebours + appel `input.cancelResume`, renvoie le verdict backend. ## Fichier créé 6. `frontend/src/features/agents/AgentLimitBadge.tsx` — composant présentational + 2 helpers purs exportés (`formatResetTime(epochMs)` → `HH:MM`, `formatCountdown(remainingMs)` → `Xm Ys`/`Ys`, clampé à 0). Badge « limité jusqu'à HH:MM » / « limité », compte à rebours vivant (clock locale 1 s armée seulement si `resumeFireAt`), bouton « Annuler la reprise ». Câblé dans `AgentsPanel.tsx` (import + rendu conditionnel sous chaque ligne d'agent quand `vm.limitByAgent[a.id]` existe). ## Point de friction — filet humain niveau 3 (§21.1) Conformément à la consigne, je n'ai **fabriqué aucune commande inexistante**. Le seul point d'entrée backend disponible est `cancel_resume` ; **il n'existe pas de commande pour enregistrer une heure de reprise saisie par l'utilisateur**. Donc pour `agentRateLimitSuspected` sans `resetsAtMs`, le badge affiche l'état « limité · heure inconnue — reprise à préciser » (état + `role="note"`), sans formulaire de saisie. Un `TODO(LS-front)` documenté est posé dans `AgentLimitBadge.tsx` : câbler un formulaire d'heure dès que le backend exposera une commande pour persister l'instant choisi. Si tu veux ce filet pleinement fonctionnel, il faut une tâche backend (ex. `set_resume_at(agentId, epochMs)`) à cadrer avec Architect/DevBackend — je m'y connecterai côté front. Prêt pour QA (logique d'état dans le hook + helpers purs isolés du JSX, mocks en place avec `cancelledResumes`/`cancelResumeResult`).
|
||||
- **Prompt:** Deux livrables distincts sur `feature/agent-session-limits` (Git en fera 2 commits séparés — ne commit pas toi-même). ═══ LIVRABLE 1 — LS8-front : formulaire de saisie d'heure (filet humain niveau 3) ═══ Le backend expose maintenant une commande Tauri `set_resume_at(agentId: string, resetsAtMs: number) -> void` (argument nu `{ agentId, resetsAtMs }`, comme `cancel_resume`). Elle arme la MÊME reprise annulable que l'auto et réémet `agentResumeScheduled` — donc une fois appelée, ton badge bascule TOUT SEUL de « heure inconnue » vers l'état nominal « limité jusqu'à HH:MM » + compte à rebours + bouton Annuler (déjà câblés en LS7). Aucun nouvel événement à consommer. À FAIRE : 1. Port : ajoute `setResumeAt(agentId: string, resetsAtMs: number): Promise<void>` à `InputGateway` (`src/ports/index.ts`), à côté de `cancelResume`. 2. Adapter Tauri (`src/adapters/input.ts`) : `setResumeAt` → `invoke("set_resume_at", { agentId, resetsAtMs })`. 3. Mock (`src/adapters/mock/index.ts`) : `MockInputGateway.setResumeAt` enregistre dans un tableau (ex. `resumeArmings: { agentId, resetsAtMs }[]`) pour les tests ; suis le patron de `cancelledResumes`. 4. Hook `useAgents` : expose une action `setResumeAt(agentId, resetsAtMs)` qui délègue au port (pas de mutation optimiste nécessaire — l'event `agentResumeScheduled` rebasculera l'état). 5. UI `AgentLimitBadge.tsx` : sur l'état SUSPECTED SANS heure (`suspected === true` && pas de `limitedUntil`/`resumeFireAt`), remplace le TODO/placeholder par un petit formulaire : un input heure (type `time` ou `datetime-local`) + bouton « Programmer la reprise ». À la soumission : convertir la saisie en epoch-ms (si input `time` seul = aujourd'hui à HH:MM ; si déjà passé dans la journée, le backend clampe à maintenant ⇒ reprise immédiate, donc pas besoin de validation stricte côté front, mais documente le comportement). Appeler l'action `setResumeAt(agentId, epochMs)`. Garde un helper pur exporté pour la conversion (ex. `timeInputToEpochMs(value, now)`) pour la testabilité. Style cohérent avec le reste du badge ; désactive pendant `busy`. 6. Retire le `TODO(LS-front)` posé en LS7 (le filet est maintenant complet). ═══ LIVRABLE 2 — fix test préexistant (hygiène, commit séparé) ═══ `src/adapters/mock/mock.test.ts:16` asserte « thirteen gateways » mais il y en a 14 (la gateway `permission` existe déjà dans HEAD). Mets le compteur à jour (14, et le libellé texte si présent) pour que `npx vitest run src/adapters/mock/` repasse vert. NE change RIEN d'autre dans ce fichier ; vérifie juste que le compte réel est bien 14 avant de figer le nombre. Lance `npx vitest run src/features/agents/ src/adapters/mock/` + `tsc --noEmit` et RENDS-MOI via idea_reply : fichiers modifiés/créés par livrable, le helper de conversion exact, le résultat des deux commandes (typecheck + tests), et le nombre réel de gateways constaté. Structure pour testabilité (logique dans le hook + helpers purs). NE COMMIT PAS — Git tranche, en 2 commits séparés (LS8-front / fix-test).
|
||||
- **Response:** Les deux livrables sont prêts. NON commité (Git tranche, 2 commits séparés). ## Résultats des commandes - `npx tsc --noEmit` : **propre** (0 erreur). - `npx vitest run src/features/agents/ src/adapters/mock/` : **109 tests / 8 fichiers, tous verts**. ## Nombre réel de gateways constaté : **14** Le `Gateways` (ports/index.ts) en a 14 ; l'array du test en listait 13 — il manquait `permission` (présente dans HEAD). Compteur + libellé + array corrigés. ## Helper de conversion exact (pur, exporté, testé) ```ts // AgentLimitBadge.tsx export function timeInputToEpochMs(value: string, now: number): number | null { const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim()); if (!match) return null; const hours = Number(match[1]); const minutes = Number(match[2]); if (hours > 23 || minutes > 59) return null; const d = new Date(now); d.setHours(hours, minutes, 0, 0); return d.getTime(); } ``` Comportement documenté : input `time` seul ⇒ aujourd'hui à HH:MM (même jour calendaire que `now`). Une heure déjà passée renvoie un instant < now **sans validation stricte** — le backend clampe à maintenant ⇒ reprise immédiate (commenté dans le code et couvert par un test). Empty/malformé/`25:00`/`12:60` ⇒ `null` (saisie incomplète ignorée). ═══ LIVRABLE 1 — LS8-front (commit « LS8-front ») ═══ Fichiers modifiés : 1. `frontend/src/ports/index.ts` — `setResumeAt(agentId, resetsAtMs): Promise<void>` ajouté à `InputGateway`, à côté de `cancelResume`. 2. `frontend/src/adapters/input.ts` — `setResumeAt` → `invoke("set_resume_at", { agentId, resetsAtMs })` (args nus, comme cancel_resume). 3. `frontend/src/adapters/mock/index.ts` — `MockInputGateway.setResumeAt` enregistre dans `resumeArmings: { agentId, resetsAtMs }[]` (patron `cancelledResumes`). 4. `frontend/src/features/agents/useAgents.ts` — action `setResumeAt(agentId, resetsAtMs)` exposée, délègue au port, **aucune mutation optimiste** (l'event `agentResumeScheduled` rebascule l'état via l'abonnement existant). 5. `frontend/src/features/agents/AgentLimitBadge.tsx` — état SUSPECTED sans heure (`suspected===true && !limitedUntil && !resumeFireAt`) : le placeholder/TODO LS7 est remplacé par un **formulaire** (input `type="time"` aria-label `resume time` + bouton `Programmer la reprise` aria-label `schedule resume`, désactivés pendant `busy`/saisie vide). Soumission → `timeInputToEpochMs` → `onSetResumeAt(epochMs)`. Une fois armé, le badge bascule **tout seul** vers « limité jusqu'à HH:MM » + compte à rebours + Annuler (chaîne LS7 inchangée). TODO(LS-front) **retiré**. 6. `frontend/src/features/agents/AgentsPanel.tsx` — passe `onSetResumeAt={(ms) => void vm.setResumeAt(a.id, ms)}` au badge. 7. `frontend/src/features/agents/AgentLimitBadge.test.tsx` — **test QA préexistant (LS7) mis à jour au nouveau contrat** (prop requise `onSetResumeAt`, le cas « suspected sans heure » teste maintenant le formulaire au lieu de l'ancien texte placeholder) + ajout couverture LS8 : `timeInputToEpochMs` (jour calendaire, instant passé, null malformé), soumission du formulaire ⇒ `onSetResumeAt` appelé avec l'epoch-ms attendu, bouton désactivé si vide. *(Sans cette MAJ le typecheck cassait — la prop est devenue obligatoire et le rendu du cas suspected a changé. Aucune logique de prod altérée, seulement l'alignement du test.)* ═══ LIVRABLE 2 — fix test (commit « fix-test » séparé) ═══ Fichier modifié : - `frontend/src/adapters/mock/mock.test.ts` — « thirteen » → « fourteen », `"permission"` inséré dans l'array attendu (ordre alphabétique). **Rien d'autre touché** dans ce fichier. ## Point de friction / note Petit ajustement non listé mais nécessaire : le `<form>` portait initialement le même `aria-label="schedule resume"` que son bouton submit, ce qui faisait matcher 2 éléments avec `getByLabelText`. J'ai renommé le label du form en `"resume time form"` (le bouton garde `schedule resume`). Aucune incidence fonctionnelle. Le filet humain niveau 3 est désormais **complet** : détection → formulaire de saisie → arme la reprise annulable via `set_resume_at` → bascule auto vers l'état nominal. Prêt pour QA (logique d'état dans le hook, helpers purs isolés, mocks instrumentés `resumeArmings`/`cancelledResumes`).
|
||||
@ -6,3 +6,7 @@
|
||||
{"id":"6a286e41-aa26-4bcb-8530-282bd09dde95","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781544388479,"source":{"kind":"agent","agentId":"af7f86da-76bc-48e1-9900-71f45a624800"},"role":"response","text":"pong DevFrontend OK"}
|
||||
{"id":"808e5ed2-57d7-4839-906e-bd66886852b7","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781593284048,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tâche : nettoyage/correction de bugs d'affichage UI (frontend React/TS). Project root : /home/anthony/Documents/Projects/IdeA. Aucune nouvelle feature, uniquement du fix CSS/layout. Voici 3 problèmes constatés visuellement (captures non transmissibles, je les décris) :\n\nPROBLÈME 1 — Barre de navigation horizontale (top bar listant : Projects, Context, Agents, Templates, Skills, Permissions, …).\n- Cette barre n'est PAS scrollable et n'est PAS correctement mise à l'échelle. Quand il y a trop d'entrées, les derniers items débordent et sont coupés (on voit « Skills » puis « P… » tronqué, « Permissions » coupé).\n- Attendu : la barre doit gérer le débordement proprement — soit scroll horizontal (overflow-x:auto avec masquage de scrollbar propre), soit wrap/scale correct. Les entrées ne doivent jamais être croppées. Vérifier flex-shrink/min-width des items.\n\nPROBLÈME 2 — Panneau « Agents » (liste des agents : Main, Architect, DevBackend, DevFrontend, QA, TestConversation, …).\n- Les items de la liste s'overlappent (chevauchement vertical) et sont croppés. Le nom de l'agent, le profil (« Claude Code »), l'id de run (longue chaîne en bleu qui wrap mal sur plusieurs lignes), la dropdown de profil et les boutons Stop/Delete/Launch se chevauchent et débordent du panneau.\n- Attendu : chaque carte d'agent doit avoir une hauteur qui s'adapte à son contenu (pas de height fixe qui cause l'overlap), les éléments alignés proprement, l'id long tronqué avec ellipsis ou wrap contrôlé, et la dropdown + boutons ne doivent pas déborder/chevaucher le texte voisin. Vérifier les position absolute éventuels et les gaps flex/grid.\n\nPROBLÈME 3 — Formulaire de création d'agent (champs : Name, Template, Profile, bouton Create).\n- Les champs du formulaire s'overlappent les uns sur les autres (Name / Template / Profile trop serrés/superposés).\n- BUG fonctionnel : quand on sélectionne une valeur dans les dropdowns (Template / Profile), la sélection NE s'affiche PAS dans le champ de la droplist (le label sélectionné ne se reflète pas visuellement). À investiguer : binding value/onChange du composant select, ou affichage du label sélectionné si c'est un dropdown custom.\n- Attendu : formulaire avec espacement correct (pas de chevauchement), et les dropdowns affichent bien la valeur choisie.\n\nMerci de :\n1. Localiser les composants concernés (top nav bar, panneau Agents, formulaire création d'agent).\n2. Corriger le CSS/layout et le bug de binding des dropdowns.\n3. Lancer un build frontend pour t'assurer que ça compile (npm run build ou équivalent), sans casser le reste.\n4. Me renvoyer via idea_reply la liste des fichiers modifiés + un résumé des corrections, pour que je puisse enchaîner sur le rebuild de l'AppImage."}
|
||||
{"id":"a6169530-fc73-4057-91c6-900da4a611fb","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781593486944,"source":{"kind":"agent","agentId":"af7f86da-76bc-48e1-9900-71f45a624800"},"role":"response","text":"Bugs d'affichage corrigés. Build vert (`npm run build` = tsc --noEmit + vite build OK) et tests verts (`vitest run` agents + projects : 47/47 passent).\n\n## Fichiers modifiés (2)\n- `frontend/src/features/projects/ProjectsView.tsx` — la « top bar »\n- `frontend/src/features/agents/AgentsPanel.tsx` — panneau Agents + formulaire de création\n\n## Diagnostic\nLa « top bar » décrite n'est pas une nav OS mais le **strip de tabs du sidebar** (`SIDEBAR_TABS` : Projects, Context, Agents, Templates, Skills, Perms, Memory, Git) dans `ProjectsView.tsx`. Cause racine commune aux 3 problèmes : **largeur trop contrainte** dans le sidebar de 320px (`w-80`).\n\n## Corrections\n\n**PROBLÈME 1 — barre de tabs croppée**\n- Avant : 8 boutons en `flex-1` sans gestion de débordement → les derniers labels coupés.\n- Après : les tabs sont dans un conteneur `overflow-x-auto` **scrollable horizontalement**, chaque tab passe en `shrink-0 whitespace-nowrap` (plus jamais croppé). Scrollbar masquée proprement (`[scrollbar-width:none]` + `[&::-webkit-scrollbar]:hidden`). Le bouton « collapse » («) reste épinglé à droite, hors zone de scroll.\n\n**PROBLÈME 2 — cartes d'agents qui se chevauchent**\n- Avant : `<li>` en `flex items-center justify-between` → le bloc nom/profil et la colonne de contrôles (dropdown profil + Sync/Stop/Launch/Delete, en `shrink-0`) se disputaient la largeur ; l'id de run long (`running in IdeA · <id>`) wrappait et débordait.\n- Après : carte en **`flex-col`** — bloc nom/profil/statut en haut, rangée de contrôles en dessous (`flex-wrap`, plus de `shrink-0` qui écrasait le texte). Hauteur adaptative au contenu (plus de chevauchement). L'id long est désormais **tronqué avec ellipsis** (`block w-full min-w-0 truncate`).\n\n**PROBLÈME 3 — formulaire de création (champs serrés + dropdowns « vides »)**\n- Avant : form en `flex flex-wrap items-end` avec 3 champs `min-w-0 flex-1` → selects écrasés à ~90px ; le texte sélectionné (« Claude Code ») était **clippé derrière la flèche native** du select, d'où l'impression que la sélection ne s'affichait pas.\n- Après : form en **`flex-col gap-3`**, chaque champ en pleine largeur. Le binding des `<select>` était déjà correct (`value`/`onChange` contrôlés, natifs) — c'était bien un problème de largeur, pas de binding. En pleine largeur la valeur choisie s'affiche normalement. Bouton Create passé en `self-end`.\n\nAucune nouvelle dépendance, aucun changement de logique métier ni de contrat IPC/gateway. Les tests s'appuient sur les `aria-label` (inchangés), d'où les 47/47 verts.\n\nTu peux enchaîner sur le rebuild de l'AppImage (rappel mémoire : c'est l'AppImage qui tourne qu'il faut reconstruire, pas seulement les sources)."}
|
||||
{"id":"9b53cc9b-ffed-42c9-9961-fefb5aca677e","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781675890156,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tâche LS7-front (React/TS) — UI de gestion des limites de session des agents (ARCHITECTURE §21). On est sur la branche `feature/agent-session-limits`. Le backend est terminé et vert (commit 9df5923) : il émet déjà 5 DOMAIN_EVENT vers le front et expose une commande Tauri `cancel_resume(agentId) -> bool`.\n\nCONTRAT WIRE (DTO sérialisés camelCase, source = crates/app-tauri/src/events.rs) :\n- `agentRateLimited` { agentId: string, resetsAt?: number /* epoch-ms, absent si inconnu */ }\n- `agentResumeScheduled` { agentId: string, fireAt: number /* epoch-ms, échéance du réveil */ } ⚠️ champ wire = `fireAtMs` → vérifie le nom exact sérialisé (camelCase de `fire_at_ms` = `fireAtMs`) ; idem `resetsAtMs` pour les autres. Aligne-toi sur le JSON réel.\n- `agentResumeCancelled` { agentId: string }\n- `agentResumed` { agentId: string }\n- `agentRateLimitSuspected` { agentId: string, resetsAt?: number }\n(Vérifie les noms de champs exacts dans events.rs : `resets_at_ms`→`resetsAtMs`, `fire_at_ms`→`fireAtMs`. Ne devine pas, lis le fichier.)\n\nÀ FAIRE :\n1) Ajouter les 5 variantes au union `DomainEvent` de `src/domain/index.ts` (mêmes noms `type` que le wire), avec les champs exacts.\n2) Exposer la commande `cancel_resume` dans le port gateway approprié (cf. `src/ports/index.ts`) + son implémentation dans l'adapter système (`src/adapters/system.ts`) ET le mock (`src/adapters/mock/index.ts`). RÈGLE ARCHI STRICTE : les composants ne touchent JAMAIS `invoke()` ni `@tauri-apps/api` — tout passe par les gateways/ports (hexagonal §1.3). Suis le patron d'une commande existante (ex. `interruptAgent` si elle existe, sinon une autre commande agent).\n3) Tracker l'état limite par agent dans `useAgents` (view-model hook). Inspire-toi du patron existant `delegationSourceByRequester` qui se peuple depuis les events. État par agent : { limitedUntil?: number, resumeFireAt?: number, suspected?: bool }. Mises à jour :\n - `agentRateLimited` → limité (badge « limité jusqu'à HH:MM » si resetsAt connu, sinon « limité »).\n - `agentResumeScheduled` → arme le compte à rebours jusqu'à fireAt + bouton « Annuler la reprise ».\n - `agentResumeCancelled` → retire le compte à rebours (reste limité, sans reprise auto).\n - `agentResumed` → efface tout l'état limite de l'agent.\n - `agentRateLimitSuspected` → filet humain : signaler que la limite est détectée sans heure fiable et demander à l'utilisateur (au minimum un état/affichage « heure inconnue » ; le formulaire de saisie d'heure peut être un placeholder documenté si le backend n'a pas encore de commande pour l'enregistrer — dans ce cas, expose juste l'état et un TODO clair, NE fabrique pas de commande inexistante).\n4) UI : badge limite + compte à rebours + bouton « Annuler la reprise » (appelle `cancel_resume` via le gateway) dans la vue agents/terminal cohérente avec le style existant. Composant testable (gateways mockés).\n\nNE PAS committer toi-même (l'agent Git tranche les commits). NE PAS toucher au backend Rust. Reste cohérent avec le style TS/React existant. Quand c'est implémenté, QA écrira/exécutera les tests — donc structure pour la testabilité (logique d'état dans le hook, pas dans le JSX).\n\nRends-moi via idea_reply : la liste des fichiers modifiés/créés, les noms de champs wire exacts que tu as constatés dans events.rs, et tout point de friction (notamment sur le filet humain niveau 3 si aucune commande backend n'existe pour enregistrer l'heure saisie)."}
|
||||
{"id":"a37b2aac-87f7-476d-8606-7250d15dd852","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781676130924,"source":{"kind":"agent","agentId":"af7f86da-76bc-48e1-9900-71f45a624800"},"role":"response","text":"LS7-front terminé. Typecheck `tsc --noEmit` propre + 39 tests agents existants verts (aucune régression). Non commité (Git tranche).\n\n## Noms de champs wire constatés (lus dans crates/app-tauri/src/events.rs, `DomainEventDto`, serde camelCase + skip_serializing_if sur les Option)\n- `agentRateLimited` → { `agentId`: string, `resetsAtMs`?: number /* absent si null */ }\n- `agentResumeScheduled` → { `agentId`: string, `fireAtMs`: number /* toujours présent */ }\n- `agentResumeCancelled` → { `agentId`: string }\n- `agentResumed` → { `agentId`: string }\n- `agentRateLimitSuspected` → { `agentId`: string, `resetsAtMs`?: number /* absent si null */ }\n\n⚠️ Confirmé : ce sont bien `resetsAtMs` / `fireAtMs` (suffixe `Ms`), PAS `resetsAt`/`fireAt` comme l'énoncé du ticket le suggérait. Je me suis aligné sur le JSON réel. Vérifié aussi par le test backend `rate_limited_relays_to_dto_and_wire` qui asserte `json[\"resetsAtMs\"]`.\n\nCommande Tauri `cancel_resume` (commands.rs:1381) : signature `cancel_resume(agent_id: String) -> Result<bool, _>` — argument **nu** `agentId` (PAS d'enveloppe `{ request }` contrairement à interrupt_agent/delegation_delivered). Adapter aligné en conséquence. Renvoie `true` ssi une reprise était armée et a été désarmée.\n\n## Fichiers modifiés\n1. `frontend/src/domain/index.ts` — 5 variantes ajoutées au union `DomainEvent` (mêmes `type` que le wire, champs exacts `resetsAtMs?`/`fireAtMs`).\n2. `frontend/src/ports/index.ts` — `cancelResume(agentId: string): Promise<boolean>` ajouté à `InputGateway` (port choisi : c'est le gateway des contrôles agent hors-bande, cohérent avec `interrupt`).\n3. `frontend/src/adapters/input.ts` — `TauriInputGateway.cancelResume` → `invoke(\"cancel_resume\", { agentId })` (arg nu, typé `<boolean>`).\n4. `frontend/src/adapters/mock/index.ts` — `MockInputGateway.cancelResume` : enregistre dans `cancelledResumes: string[]`, renvoie `cancelResumeResult` (défaut `true`, flippable par les tests).\n5. `frontend/src/features/agents/useAgents.ts` — état `limitByAgent: Record<string, AgentLimitState>` (interface exportée `{ limitedUntil?, resumeFireAt?, suspected? }`), peuplé depuis les 5 events dans l'abonnement `onDomainEvent` existant (même patron que `delegationSourceByRequester`) :\n - `agentRateLimited` → `{ limitedUntil: resetsAtMs, suspected: false }` (conserve un `resumeFireAt` éventuel).\n - `agentResumeScheduled` → arme `resumeFireAt = fireAtMs`.\n - `agentResumeCancelled` → retire `resumeFireAt`, reste limité.\n - `agentResumed` → supprime l'entrée (efface tout).\n - `agentRateLimitSuspected` → `{ limitedUntil: resetsAtMs, suspected: true }`.\n - Action `cancelResume(agentId)` exposée : retrait optimiste du compte à rebours + appel `input.cancelResume`, renvoie le verdict backend.\n\n## Fichier créé\n6. `frontend/src/features/agents/AgentLimitBadge.tsx` — composant présentational + 2 helpers purs exportés (`formatResetTime(epochMs)` → `HH:MM`, `formatCountdown(remainingMs)` → `Xm Ys`/`Ys`, clampé à 0). Badge « limité jusqu'à HH:MM » / « limité », compte à rebours vivant (clock locale 1 s armée seulement si `resumeFireAt`), bouton « Annuler la reprise ». Câblé dans `AgentsPanel.tsx` (import + rendu conditionnel sous chaque ligne d'agent quand `vm.limitByAgent[a.id]` existe).\n\n## Point de friction — filet humain niveau 3 (§21.1)\nConformément à la consigne, je n'ai **fabriqué aucune commande inexistante**. Le seul point d'entrée backend disponible est `cancel_resume` ; **il n'existe pas de commande pour enregistrer une heure de reprise saisie par l'utilisateur**. Donc pour `agentRateLimitSuspected` sans `resetsAtMs`, le badge affiche l'état « limité · heure inconnue — reprise à préciser » (état + `role=\"note\"`), sans formulaire de saisie. Un `TODO(LS-front)` documenté est posé dans `AgentLimitBadge.tsx` : câbler un formulaire d'heure dès que le backend exposera une commande pour persister l'instant choisi. Si tu veux ce filet pleinement fonctionnel, il faut une tâche backend (ex. `set_resume_at(agentId, epochMs)`) à cadrer avec Architect/DevBackend — je m'y connecterai côté front.\n\nPrêt pour QA (logique d'état dans le hook + helpers purs isolés du JSX, mocks en place avec `cancelledResumes`/`cancelResumeResult`)."}
|
||||
{"id":"f21219de-033a-4b3e-8e8b-366534112a95","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781676945137,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Deux livrables distincts sur `feature/agent-session-limits` (Git en fera 2 commits séparés — ne commit pas toi-même).\n\n═══ LIVRABLE 1 — LS8-front : formulaire de saisie d'heure (filet humain niveau 3) ═══\nLe backend expose maintenant une commande Tauri `set_resume_at(agentId: string, resetsAtMs: number) -> void` (argument nu `{ agentId, resetsAtMs }`, comme `cancel_resume`). Elle arme la MÊME reprise annulable que l'auto et réémet `agentResumeScheduled` — donc une fois appelée, ton badge bascule TOUT SEUL de « heure inconnue » vers l'état nominal « limité jusqu'à HH:MM » + compte à rebours + bouton Annuler (déjà câblés en LS7). Aucun nouvel événement à consommer.\n\nÀ FAIRE :\n1. Port : ajoute `setResumeAt(agentId: string, resetsAtMs: number): Promise<void>` à `InputGateway` (`src/ports/index.ts`), à côté de `cancelResume`.\n2. Adapter Tauri (`src/adapters/input.ts`) : `setResumeAt` → `invoke(\"set_resume_at\", { agentId, resetsAtMs })`.\n3. Mock (`src/adapters/mock/index.ts`) : `MockInputGateway.setResumeAt` enregistre dans un tableau (ex. `resumeArmings: { agentId, resetsAtMs }[]`) pour les tests ; suis le patron de `cancelledResumes`.\n4. Hook `useAgents` : expose une action `setResumeAt(agentId, resetsAtMs)` qui délègue au port (pas de mutation optimiste nécessaire — l'event `agentResumeScheduled` rebasculera l'état). \n5. UI `AgentLimitBadge.tsx` : sur l'état SUSPECTED SANS heure (`suspected === true` && pas de `limitedUntil`/`resumeFireAt`), remplace le TODO/placeholder par un petit formulaire : un input heure (type `time` ou `datetime-local`) + bouton « Programmer la reprise ». À la soumission : convertir la saisie en epoch-ms (si input `time` seul = aujourd'hui à HH:MM ; si déjà passé dans la journée, le backend clampe à maintenant ⇒ reprise immédiate, donc pas besoin de validation stricte côté front, mais documente le comportement). Appeler l'action `setResumeAt(agentId, epochMs)`. Garde un helper pur exporté pour la conversion (ex. `timeInputToEpochMs(value, now)`) pour la testabilité. Style cohérent avec le reste du badge ; désactive pendant `busy`.\n6. Retire le `TODO(LS-front)` posé en LS7 (le filet est maintenant complet).\n\n═══ LIVRABLE 2 — fix test préexistant (hygiène, commit séparé) ═══\n`src/adapters/mock/mock.test.ts:16` asserte « thirteen gateways » mais il y en a 14 (la gateway `permission` existe déjà dans HEAD). Mets le compteur à jour (14, et le libellé texte si présent) pour que `npx vitest run src/adapters/mock/` repasse vert. NE change RIEN d'autre dans ce fichier ; vérifie juste que le compte réel est bien 14 avant de figer le nombre.\n\nLance `npx vitest run src/features/agents/ src/adapters/mock/` + `tsc --noEmit` et RENDS-MOI via idea_reply : fichiers modifiés/créés par livrable, le helper de conversion exact, le résultat des deux commandes (typecheck + tests), et le nombre réel de gateways constaté. Structure pour testabilité (logique dans le hook + helpers purs). NE COMMIT PAS — Git tranche, en 2 commits séparés (LS8-front / fix-test)."}
|
||||
{"id":"50c3e999-c6a3-4d8b-aef3-b6c273ed9afc","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781677152871,"source":{"kind":"agent","agentId":"af7f86da-76bc-48e1-9900-71f45a624800"},"role":"response","text":"Les deux livrables sont prêts. NON commité (Git tranche, 2 commits séparés).\n\n## Résultats des commandes\n- `npx tsc --noEmit` : **propre** (0 erreur).\n- `npx vitest run src/features/agents/ src/adapters/mock/` : **109 tests / 8 fichiers, tous verts**.\n\n## Nombre réel de gateways constaté : **14**\nLe `Gateways` (ports/index.ts) en a 14 ; l'array du test en listait 13 — il manquait `permission` (présente dans HEAD). Compteur + libellé + array corrigés.\n\n## Helper de conversion exact (pur, exporté, testé)\n```ts\n// AgentLimitBadge.tsx\nexport function timeInputToEpochMs(value: string, now: number): number | null {\n const match = /^(\\d{1,2}):(\\d{2})$/.exec(value.trim());\n if (!match) return null;\n const hours = Number(match[1]);\n const minutes = Number(match[2]);\n if (hours > 23 || minutes > 59) return null;\n const d = new Date(now);\n d.setHours(hours, minutes, 0, 0);\n return d.getTime();\n}\n```\nComportement documenté : input `time` seul ⇒ aujourd'hui à HH:MM (même jour calendaire que `now`). Une heure déjà passée renvoie un instant < now **sans validation stricte** — le backend clampe à maintenant ⇒ reprise immédiate (commenté dans le code et couvert par un test). Empty/malformé/`25:00`/`12:60` ⇒ `null` (saisie incomplète ignorée).\n\n═══ LIVRABLE 1 — LS8-front (commit « LS8-front ») ═══\nFichiers modifiés :\n1. `frontend/src/ports/index.ts` — `setResumeAt(agentId, resetsAtMs): Promise<void>` ajouté à `InputGateway`, à côté de `cancelResume`.\n2. `frontend/src/adapters/input.ts` — `setResumeAt` → `invoke(\"set_resume_at\", { agentId, resetsAtMs })` (args nus, comme cancel_resume).\n3. `frontend/src/adapters/mock/index.ts` — `MockInputGateway.setResumeAt` enregistre dans `resumeArmings: { agentId, resetsAtMs }[]` (patron `cancelledResumes`).\n4. `frontend/src/features/agents/useAgents.ts` — action `setResumeAt(agentId, resetsAtMs)` exposée, délègue au port, **aucune mutation optimiste** (l'event `agentResumeScheduled` rebascule l'état via l'abonnement existant).\n5. `frontend/src/features/agents/AgentLimitBadge.tsx` — état SUSPECTED sans heure (`suspected===true && !limitedUntil && !resumeFireAt`) : le placeholder/TODO LS7 est remplacé par un **formulaire** (input `type=\"time\"` aria-label `resume time` + bouton `Programmer la reprise` aria-label `schedule resume`, désactivés pendant `busy`/saisie vide). Soumission → `timeInputToEpochMs` → `onSetResumeAt(epochMs)`. Une fois armé, le badge bascule **tout seul** vers « limité jusqu'à HH:MM » + compte à rebours + Annuler (chaîne LS7 inchangée). TODO(LS-front) **retiré**.\n6. `frontend/src/features/agents/AgentsPanel.tsx` — passe `onSetResumeAt={(ms) => void vm.setResumeAt(a.id, ms)}` au badge.\n7. `frontend/src/features/agents/AgentLimitBadge.test.tsx` — **test QA préexistant (LS7) mis à jour au nouveau contrat** (prop requise `onSetResumeAt`, le cas « suspected sans heure » teste maintenant le formulaire au lieu de l'ancien texte placeholder) + ajout couverture LS8 : `timeInputToEpochMs` (jour calendaire, instant passé, null malformé), soumission du formulaire ⇒ `onSetResumeAt` appelé avec l'epoch-ms attendu, bouton désactivé si vide. *(Sans cette MAJ le typecheck cassait — la prop est devenue obligatoire et le rendu du cas suspected a changé. Aucune logique de prod altérée, seulement l'alignement du test.)*\n\n═══ LIVRABLE 2 — fix test (commit « fix-test » séparé) ═══\nFichier modifié :\n- `frontend/src/adapters/mock/mock.test.ts` — « thirteen » → « fourteen », `\"permission\"` inséré dans l'array attendu (ordre alphabétique). **Rien d'autre touché** dans ce fichier.\n\n## Point de friction / note\nPetit ajustement non listé mais nécessaire : le `<form>` portait initialement le même `aria-label=\"schedule resume\"` que son bouton submit, ce qui faisait matcher 2 éléments avec `getByLabelText`. J'ai renommé le label du form en `\"resume time form\"` (le bouton garde `schedule resume`). Aucune incidence fonctionnelle.\n\nLe filet humain niveau 3 est désormais **complet** : détection → formulaire de saisie → arme la reprise annulable via `set_resume_at` → bascule auto vers l'état nominal. Prêt pour QA (logique d'état dans le hook, helpers purs isolés, mocks instrumentés `resumeArmings`/`cancelledResumes`)."}
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
---
|
||||
upTo: 6df9f7b7-fcc4-4b87-94db-33dce2d76f13
|
||||
objective: Nouvelle feature cadrée par Architect, prête à démarrer le développement : « Gestion des limites de session des agents » (détection hiérarchique des limites de session + reprise auto annulable). C'est
|
||||
---
|
||||
**Objectif :** Nouvelle feature cadrée par Architect, prête à démarrer le développement : « Gestion des limites de session des agents » (détection hiérarchique des limites de session + reprise auto annulable). C'est
|
||||
|
||||
- **Response:** Décision (agent Git) — branche retenue : **`feature/agent-session-limits`**, créée **depuis `develop`**. **Raisonnement :** - Nouvelle feature indépendante (LS1→LS8) → conformément au modèle `main ← develop ← feature/*`, elle se développe sur sa propre branche `feature/*`. - Pas besoin de (re)créer `develop` : il existe déjà et est à jour (post-release 0.1.0, `git diff main develop` vide → contenu identique). C'est donc la bonne base de départ. - Nom dérivé du sujet, kebab-case : `feature/agent-session-limits`. **État git après manœuvre (LOCAL, aucun push) :** - Branche courante : `feature/agent-session-limits` @ `401c18a` (= base `develop`). - `develop` @ `401c18a` (inchangé) ← `main` @ `9a8a89f` (tag `v0.1.0`, inchangé). - `feature/agent-skills` @ `ef101db` (autre feature, intacte). **Note :** le working tree porte déjà des changements non commités hérités de l'état courant — notamment `ARCHITECTURE.md` (cadrage Architect) et un nouveau `.ideai/memory/session-limit-handling-design.md`, plus du runtime `.ideai/` (conversations/MEMORY.md). Ils n'ont pas été commités : DevBackend/QA les intégreront dans les commits de la feature au fil des lots. Le cadrage Architect (ARCHITECTURE.md + memory design) pourra faire le **premier commit** de la branche avant LS1 si tu le souhaites — dis-moi si tu veux que je le commite maintenant. Dev peut démarrer LS1 (domaine) sur `feature/agent-session-limits`. Après chaque lot vert, reviens vers moi pour décider d'un éventuel commit/merge. Aucune intégration vers `develop` tant que les tests des lots concernés ne sont pas verts (règle d'or).
|
||||
- **Prompt:** Feu vert pour le premier commit de la branche feature/agent-session-limits : commite le cadrage AVANT que le dev (LS1) ne démarre, pour isoler proprement le cadrage du code. Contenu à committer (cadrage uniquement) : ARCHITECTURE.md (nouvelle §21) + .ideai/memory/session-limit-handling-design.md + la mise à jour de .ideai/memory/MEMORY.md (pointeur). N'inclus PAS le runtime .ideai/ non pertinent (conversations, layouts, agents.json…) s'il pollue le diff — limite le commit aux fichiers du cadrage. Message de commit conforme au style du dépôt (français, conventionnel). Périmètre LOCAL uniquement — aucun push. Confirme le hash du commit et l'état git après.
|
||||
- **Response:** Cadrage committé sur `feature/agent-session-limits` — premier commit de la branche, isolé du code. **Commit : `fa5b826`** `docs(session-limits): cadrage Architect — gestion des limites de session des agents` **Contenu (cadrage uniquement, 3 fichiers / +212 lignes) :** - `ARCHITECTURE.md` (+185, nouvelle §21) - `.ideai/memory/session-limit-handling-design.md` (nouveau, design validé) - `.ideai/memory/MEMORY.md` (+1, pointeur) Le runtime `.ideai/` non pertinent (conversations, layouts, agents.json…) a été **exclu** du diff comme demandé. **État git après commit (LOCAL, aucun push) :** - `feature/agent-session-limits` @ `fa5b826` ← `develop` @ `401c18a` ← `main` @ `9a8a89f` (tag `v0.1.0`). - Working tree : reste uniquement du runtime `.ideai/conversations/…` non commité (bruit, ignoré pour le cadrage). La branche est prête : le dev peut démarrer **LS1 (domaine)**. Je reviendrai dans la boucle pour committer chaque lot vert.
|
||||
- **Prompt:** LS1 (couche domaine) de la feature « limites de session » est VERT : DevBackend a livré le code pur, QA a écrit 24 tests, `cargo test -p domain` = 165 passed / 0 failed, zéro régression. Commite LS1 sur feature/agent-session-limits (commit suivant après le cadrage fa5b826). Périmètre LOCAL uniquement, aucun push. Fichiers de prod (domaine) modifiés/créés par DevBackend : - crates/domain/src/ports.rs (variante ReplyEvent::RateLimited) - crates/domain/src/readiness.rs (variante ReadinessSignal::RateLimited + classify) - crates/domain/src/session_limit.rs (NOUVEAU : SessionLimit, ResumePlan, RateLimitSource, plan_resume) - crates/domain/src/profile.rs (RateLimitPattern + champ + builder) - crates/domain/src/events.rs (5 variantes DomainEvent) - crates/domain/src/lib.rs (mod + re-exports) Plus les tests QA dans ces mêmes fichiers (#[cfg(test)]). N'inclus PAS le runtime .ideai/ non pertinent. Message conventionnel en français. IMPORTANT — décision de merge : NE merge PAS vers develop maintenant. Le workspace complet (`cargo build --workspace`) NE COMPILE PAS tant que LS2 n'a pas ajouté l'arm `ReplyEvent::RateLimited` côté infra (match exhaustifs). On ne merge feature/* → develop que sur une tranche qui compile de bout en bout (cible : LS1+LS2+LS4 = niveau 1 Claude complet). Confirme juste le hash du commit LS1 et l'état git.
|
||||
- **Response:** LS1 (couche domaine) committé sur `feature/agent-session-limits`, après le cadrage `fa5b826`. **Commit : `0bf1eb3`** `feat(session-limits): LS1 — couche domaine (détection + plan de reprise)` **Contenu (6 fichiers domaine, prod + tests inline / +647 lignes) :** - `crates/domain/src/session_limit.rs` (nouveau) - `crates/domain/src/ports.rs` (ReplyEvent::RateLimited) - `crates/domain/src/readiness.rs` (ReadinessSignal::RateLimited + classify) - `crates/domain/src/profile.rs` (RateLimitPattern + champ + builder) - `crates/domain/src/events.rs` (5 variantes DomainEvent) - `crates/domain/src/lib.rs` (mod + re-exports) Runtime `.ideai/` non pertinent **exclu**. QA : `cargo test -p domain` = 165 passed / 0 failed, zéro régression. **Décision de merge : PAS de merge vers `develop`** — conforme à ta consigne. Le workspace ne compile pas tant que LS2 n'a pas ajouté l'arm `ReplyEvent::RateLimited` côté infra (match exhaustifs). On n'intègre `feature/* → develop` que sur une tranche qui compile de bout en bout (cible : LS1+LS2+LS4 = niveau 1 Claude complet). Je reste sur la branche feature. **État git (LOCAL, aucun push) :** ``` feature/agent-session-limits @ 0bf1eb3 (HEAD) └ fa5b826 docs cadrage └ 401c18a (develop) ← main @ 9a8a89f (tag v0.1.0) ``` Working tree : seul du runtime `.ideai/conversations/…` non commité (ignoré). Dev peut enchaîner sur LS2.
|
||||
- **Prompt:** LS2 (adapter Claude niveau 1, infra) est VERT : `cargo test -p infrastructure` = 188 passed / 0 failed, zéro régression. Commite LS2 sur feature/agent-session-limits (après LS1 0bf1eb3). LOCAL uniquement, aucun push. Fichier de prod modifié : crates/infrastructure/src/session/claude.rs (parse_event émet ReplyEvent::RateLimited ; nouvelle fonction pure parse_reset_ms + helpers ; parseur ISO maison ; doccomments T4). Tests QA : dans crates/infrastructure/src/session/mod.rs (#[cfg(test)]) — 26 nouveaux tests + 2 tests existants mis à jour vers le nouveau contrat ; et crates/infrastructure/src/session/conformance.rs (#[cfg(test)] : RateLimited ajouté aux événements non terminaux autorisés). Exclu le runtime .ideai/. Message conventionnel français. Décision merge : TOUJOURS PAS de merge vers develop (le workspace ne compile pas encore — 2 erreurs résiduelles attendues dans app-tauri, relèvent de LS7/LS8 ; et la réconciliation T4 reste à faire en LS4). On vise la tranche LS1+LS2+LS3+LS4 = niveau 1 Claude de bout en bout qui compile, pour le premier merge. Confirme le hash LS2 et l'état git.
|
||||
- **Response:** LS2 (adapter Claude niveau 1, infra) committé sur `feature/agent-session-limits`, après LS1 `0bf1eb3`. **Commit : `a1755e5`** `feat(session-limits): LS2 — adapter Claude niveau 1 (infra)` **Contenu (3 fichiers infra, prod + tests inline / +545 lignes) :** - `crates/infrastructure/src/session/claude.rs` (prod : parse_event → ReplyEvent::RateLimited, parse_reset_ms + helpers, parseur ISO maison, doccomments T4) - `crates/infrastructure/src/session/mod.rs` (26 nouveaux tests QA + 2 alignés) - `crates/infrastructure/src/session/conformance.rs` (RateLimited en non-terminal autorisé) Runtime `.ideai/` **exclu**. QA : `cargo test -p infrastructure` = 188 passed / 0 failed, zéro régression. **Décision de merge : TOUJOURS PAS de merge vers `develop`** — conforme. Le workspace ne compile pas encore (2 erreurs résiduelles attendues dans app-tauri → LS7/LS8 ; réconciliation T4 → LS4). Premier merge visé sur la tranche **LS1+LS2+LS3+LS4 = niveau 1 Claude de bout en bout qui compile**. **État git (LOCAL, aucun push) :** ``` feature/agent-session-limits @ a1755e5 (HEAD) └ 0bf1eb3 LS1 domaine └ fa5b826 cadrage └ 401c18a (develop) ← main @ 9a8a89f (tag v0.1.0) ``` Working tree : seul du runtime `.ideai/` non commité (ignoré). Dev peut enchaîner sur LS3.
|
||||
- **Prompt:** LS3 (port Scheduler + adapter TokioScheduler) est VERT : `cargo test -p infrastructure` = 195 passed / 0 failed (7 nouveaux tests scheduler, relancés 3× sans flaky), LS1/LS2 toujours verts. Builds domaine + infra OK 0 warning. Le workspace complet ne compile toujours pas (2 erreurs résiduelles inchangées dans app-tauri : chat.rs:185 → LS8, events.rs:261 → LS7 ; la réconciliation T4 reste en LS4). C'est TOI qui décides quoi et quand committer/merger. Je te livre l'état, tu tranches. Fichiers de prod LS3 : - crates/domain/src/ids.rs (ScheduleId via typed_id!) - crates/domain/src/ports.rs (trait Scheduler + enum ScheduledTask) - crates/domain/src/lib.rs (re-exports) - crates/infrastructure/src/scheduler/mod.rs (NOUVEAU : TokioScheduler) - crates/infrastructure/src/lib.rs (pub mod scheduler + re-export) Tests QA : dans crates/infrastructure/src/scheduler/mod.rs (#[cfg(test)]). Runtime .ideai/ à exclure du diff. Décide : faut-il committer LS3 maintenant (et avec quel message) ? Et concernant le merge vers develop — rappel du contexte : le premier merge envisagé portait sur la tranche LS1+LS2+LS3+LS4 (niveau 1 Claude de bout en bout qui compile), or le workspace ne compile pas encore (LS4 + app-tauri manquants). Dis-moi ta décision (commit LS3 sur la feature ? attendre la tranche compilable pour merger ? autre ?) et exécute-la. Confirme hash + état git.
|
||||
- **Response:** Décision (agent Git) : **commit LS3 sur la feature, PAS de merge vers `develop`.** **Pourquoi :** chaque lot vert mérite son commit atomique sur la branche feature (traçabilité, bissection facile), mais le merge `feature/* → develop` exige une tranche qui **compile de bout en bout** — règle d'or. Or le workspace ne compile toujours pas (réconciliation T4 en LS4 + 2 erreurs résiduelles app-tauri : `chat.rs:185`→LS8, `events.rs:261`→LS7). Donc on continue d'accumuler sur la feature ; premier merge maintenu sur la tranche compilable (LS1+LS2+LS3+LS4 niveau 1 Claude, voire jusqu'à LS7/LS8 si la compilation workspace n'est verte qu'à ce moment-là). **Commit : `253310b`** `feat(session-limits): LS3 — port Scheduler + adapter TokioScheduler` **Contenu (5 fichiers prod + tests inline / +389 lignes) :** - domaine : `ports.rs` (trait Scheduler + enum ScheduledTask), `ids.rs` (ScheduleId), `lib.rs` (re-exports) - infra : `scheduler/mod.rs` (nouveau, TokioScheduler), `lib.rs` (pub mod + re-export) Runtime `.ideai/` **exclu**. QA : `cargo test -p infrastructure` = 195 passed / 0 failed (7 tests scheduler, 3× sans flaky) ; LS1/LS2 verts ; builds domaine+infra 0 warning. **État git (LOCAL, aucun push) :** ``` feature/agent-session-limits @ 253310b (HEAD) └ a1755e5 LS2 adapter Claude └ 0bf1eb3 LS1 domaine └ fa5b826 cadrage └ 401c18a (develop) ← main @ 9a8a89f (tag v0.1.0) ``` Dev peut enchaîner sur LS4 (service application + réconciliation T4). Je tranche le merge dès que le workspace compile.
|
||||
- **Prompt:** LS4 (couche application : SessionLimitService + réconciliation T4) est VERT : `cargo test -p application` = tous binaires verts / 0 failed (16 nouveaux tests), zéro régression confirmée (drain_with_readiness_lot1 7/7, send_blocking_d1 9/9, reste du crate vert). Builds domaine+infra+application 0 warning. Le workspace complet ne compile TOUJOURS pas (inchangé : app-tauri chat.rs:185 → LS8, events.rs:261 → LS7). C'est toi qui décides quoi/quand committer/merger. État livré : Fichiers de prod LS4 : - crates/application/src/agent/session_limit.rs (NOUVEAU : SessionLimitService + port AgentResumer + const RESUME_PROMPT) - crates/application/src/agent/structured.rs (réconciliation T4 : enum TurnOutcome + drain_with_readiness_outcome ; signatures historiques préservées) - crates/application/src/agent/mod.rs (mod + re-exports) - crates/application/src/lib.rs (re-exports) Tests QA : crates/application/tests/session_limit_service.rs (9) + crates/application/tests/session_limit_t4.rs (7) (NOUVEAUX). Runtime .ideai/ à exclure. Décide et exécute (commit LS4 sur la feature ? message ? merge ou pas ?). Pour mémoire : le workspace ne compile de bout en bout qu'après LS7 (events app-tauri) + LS8 (chat.rs) ; le premier merge vers develop reste donc subordonné à ta règle d'or « tranche qui compile ». Confirme hash + état git.
|
||||
- **Response:** Décision (agent Git) : **commit LS4 sur la feature, PAS de merge vers `develop`.** **Commit : `9000b4d`** `feat(session-limits): LS4 — service application + réconciliation T4` **Contenu (6 fichiers, 4 prod + 2 tests / +987 lignes) :** - `crates/application/src/agent/session_limit.rs` (nouveau : SessionLimitService + port AgentResumer + RESUME_PROMPT) - `crates/application/src/agent/structured.rs` (T4 : enum TurnOutcome + drain_with_readiness_outcome, signatures historiques préservées) - `crates/application/src/agent/mod.rs` + `crates/application/src/lib.rs` (modules/re-exports) - `crates/application/tests/session_limit_service.rs` (9) + `crates/application/tests/session_limit_t4.rs` (7) — nouveaux Runtime `.ideai/` **exclu**. QA : `cargo test -p application` tous binaires verts / 0 failed (16 nouveaux) ; zéro régression (drain_with_readiness_lot1 7/7, send_blocking_d1 9/9) ; builds domaine+infra+application 0 warning. **Décision de merge : toujours NON.** La tranche LS1→LS4 est verte par crate, mais `cargo build --workspace` échoue encore (app-tauri : `chat.rs:185`→LS8, `events.rs:261`→LS7). Ma règle d'or impose une tranche qui **compile de bout en bout** avant tout `feature/* → develop`. Comme tu me l'as confirmé, le workspace ne sera vert qu'après LS7+LS8 → **le premier merge se fera donc sur LS1→LS8** (et non LS1→LS4 comme envisagé initialement). On accumule jusque-là sur la branche feature. **État git (LOCAL, aucun push) :** ``` feature/agent-session-limits @ 9000b4d (HEAD) └ 253310b LS3 · a1755e5 LS2 · 0bf1eb3 LS1 · fa5b826 cadrage └ 401c18a (develop) ← main @ 9a8a89f (tag v0.1.0) ``` Dev peut enchaîner sur LS5 (parser regex). Je tranche le merge dès `cargo build --workspace` vert.
|
||||
- **Prompt:** LS5 (parser regex niveau 2, infra) est VERT : `cargo test -p infrastructure` = 221 passed / 0 failed (26 nouveaux), 0 warning, LS2/LS3 toujours verts (refactor timeparse non régressif). C'est toi qui décides commit/merge. Fichiers de prod LS5 : - crates/infrastructure/Cargo.toml (dépendance regex = "1", résout 1.12.3 déjà dans Cargo.lock) - crates/infrastructure/src/ratelimit/mod.rs (NOUVEAU : RateLimitParser + applies) - crates/infrastructure/src/timeparse.rs (NOUVEAU : parsing d'heure pur partagé, factorisé depuis LS2) - crates/infrastructure/src/session/claude.rs (délègue à timeparse — refactor) - crates/infrastructure/src/lib.rs (pub mod ratelimit + timeparse, re-export) Modifs de TEST QA : crates/infrastructure/src/ratelimit/mod.rs (#[cfg(test)] 15 tests), crates/infrastructure/src/timeparse.rs (#[cfg(test)] 11 tests), crates/infrastructure/src/scheduler/mod.rs (2 warnings irrefutable if let corrigés dans le code de test). NOTE : Cargo.lock peut être inchangé (regex déjà verrouillée) — inclus-le seulement s'il bouge. Runtime .ideai/ à exclure. Décide et exécute (commit LS5 ? message ? merge ?). Pour mémoire le workspace ne compile de bout en bout qu'après LS7+LS8. Confirme hash + état git.
|
||||
- **Prompt:** Feature session-limits, suite. Project root: /home/anthony/Documents/Projects/IdeA. Deux lots sont terminés et VERTS (workspace compile, `cargo test --workspace` tout vert, clippy propre sur le nouveau code) mais NON committés : - LS5 — détecteur niveau 2 déclaratif + parsing temps partagé. Fichiers : nouveau `crates/infrastructure/src/ratelimit/mod.rs` (RateLimitParser, regex confiné infra), nouveau `crates/infrastructure/src/timeparse.rs` (parsing d'heures pur, partagé niveau 1/2), modifs `crates/infrastructure/src/session/claude.rs` (refactor vers timeparse, ~-121 lignes), `crates/infrastructure/src/scheduler/mod.rs`, `crates/infrastructure/src/lib.rs` (exports), `crates/infrastructure/Cargo.toml` (dép `regex`), `Cargo.lock`. - LS6 — câblage des 5 nouvelles variantes DomainEvent (AgentRateLimited/ResumeScheduled/ResumeCancelled/Resumed/RateLimitSuspected) + ReplyEvent::RateLimited vers le front. Fichiers : `crates/app-tauri/src/events.rs` (DTO miroir + bras From + test relais), `crates/app-tauri/src/chat.rs` (chunk_from_event). Note : l'arbre contient aussi des fichiers runtime .ideai/ modifiés (conversations/*/handoff.md, log.jsonl, layouts.json, memory/) et un nouveau memory `git-owns-commit-merge-decisions.md` — à toi de juger ce qui entre ou non dans le(s) commit(s) applicatif(s) (rappel : les LS précédents committaient uniquement le code applicatif, pas le bruit runtime). La branche courante est détachée (HEAD), pas sur develop. À toi de décider : topologie de branche, découpage en un ou deux commits (LS5 puis LS6, ou groupés), messages dans le style des commits feat(session-limits) existants, et si un merge feature/* → develop doit suivre. Périmètre LOCAL uniquement (aucun push). Exécute ta décision et rends via idea_reply le détail (branche, sha, messages, état final de `git status`).
|
||||
- **Response:** Fait. Décisions et exécution : **Branche** — Contrairement à ce qui était annoncé, HEAD n'était pas détachée : on était bien sur `feature/agent-session-limits` (la branche de la feature). Aucune création/bascule nécessaire, le dev se poursuit dessus. Rien à corriger. **Découpage — 2 commits atomiques** (LS5 puis LS6, séparés car deux intentions distinctes : couche infra de détection vs câblage événementiel vers le front) : 1. `98bfcf4` — feat(session-limits): LS5 — détecteur niveau 2 déclaratif + parsing temps partagé Fichiers : `crates/infrastructure/src/ratelimit/mod.rs` (nouv.), `crates/infrastructure/src/timeparse.rs` (nouv.), `crates/infrastructure/src/session/claude.rs`, `crates/infrastructure/src/scheduler/mod.rs`, `crates/infrastructure/src/lib.rs`, `crates/infrastructure/Cargo.toml`, `Cargo.lock`. 2. `ea94e75` — feat(session-limits): LS6 — câblage des événements de limite vers le front Fichiers : `crates/app-tauri/src/events.rs`, `crates/app-tauri/src/chat.rs`. Messages en français, style Conventional Commits cohérent avec LS1→LS4, corps expliquant le pourquoi, co-author inclus. **Bruit runtime exclu** — Non committé, comme pour les LS précédents : `.ideai/conversations/*` (handoff.md, log.jsonl), `.ideai/layouts.json`, `.ideai/memory/` (dont MEMORY.md et le nouveau `git-owns-commit-merge-decisions.md`), et la nouvelle conversation `6bc594e8-…`. Ce sont des artefacts d'exécution, pas du code applicatif. **Merge feature/* → develop : NON.** La feature session-limits est livrée par incréments (série LS en cours) ; LS5/LS6 sont verts mais internes (détecteur niveau 2 + relais d'événements) sans surface produit complète. On reste sur `feature/agent-session-limits`. Le merge vers `develop` se fera quand la feature sera terminée et toujours verte. **Périmètre** — 100% local, aucun push. **État final `git status`** : working tree propre côté applicatif ; ne subsistent que les fichiers runtime `.ideai/` modifiés/non suivis (volontairement hors commit). Derniers commits : ea94e75 LS6 / 98bfcf4 LS5 / 9000b4d LS4.
|
||||
- **Prompt:** Slice LS7 (backend / câblage app-tauri) de la feature session-limits terminée et verte. On est sur la branche feature/agent-session-limits (HEAD détaché ? `git status` montre HEAD), dernier commit ea94e75 (LS6). Contenu à committer (tout est vert, aucun warning) : - crates/application/src/agent/lifecycle.rs : `LaunchAgentOutput.profile` exposé (None sur réattache/idempotent, Some sur lancement effectif). - crates/application/src/terminal/registry.rs : `StructuredSessions::meta_for_session()` (lookup agent/node par SessionId pour le tap niveau 1). - crates/app-tauri/src/state.rs : `ResumeContext`/`ResumeContexts`, `AppAgentResumer` (impl port `AgentResumer` au-dessus de LaunchAgent), instanciation+câblage du `SessionLimitService` (TokioScheduler + drain des réveils) dans `AppState::build`. - crates/app-tauri/src/commands.rs : taps niveau 1 (agent_send) et niveau 2 (launch_agent, parser regex confiné), alimentation de `resume_contexts`, nouvelle commande `cancel_resume`. - crates/app-tauri/src/lib.rs : enregistrement de `cancel_resume` dans le handler. - crates/app-tauri/Cargo.toml : dépendance `async-trait`. - crates/app-tauri/tests/session_limit_wiring.rs : 2 tests d'intégration (composition) — verts. - crates/app-tauri/tests/dto_agents.rs + dto_chat.rs : ajustement `profile: None` dans les fixtures. - crates/application/tests/structured_registry_d1.rs : test de `meta_for_session`. Hors périmètre code (à NE PAS committer dans ce commit applicatif) : .ideai/conversations/**, .ideai/layouts.json, .ideai/memory/** — ce sont des artefacts runtime/mémoire. Questions : 1) Faut-il committer cette slice LS7-backend maintenant, et sous quel message ? 2) Reste-t-on sur feature/agent-session-limits ? 3) Décides-tu d'un merge vers develop, ou on attend LS7-front (React/TS, badge + compte à rebours + filet humain) qui complète la surface produit ? Tranche et exécute le commit applicatif si tu juges que oui.
|
||||
- **Response:** Décision Git — LS7 backend committée, on reste en feature, pas de merge maintenant. État initial : on était bien **sur la branche** `feature/agent-session-limits` (pas de HEAD détaché — le « HEAD » de ton `git status` était juste l'en-tête francisé), HEAD = ea94e75 (LS6). 1) ✅ Commit fait. Hash **9df5923** : `feat(session-limits): LS7 — câblage backend app-tauri (taps niveaux 1&2 + reprise annulable)` - 10 fichiers, +406/−3, dont la création de `tests/session_limit_wiring.rs`. - Périmètre strictement applicatif : lifecycle.rs, registry.rs, state.rs, commands.rs, lib.rs, Cargo.toml + les 4 fichiers de tests. - **Exclus comme demandé** (restés non-stagés, ce sont des artefacts runtime/mémoire) : `.ideai/conversations/**`, `.ideai/layouts.json`, `.ideai/memory/**` (dont le nouveau `git-owns-commit-merge-decisions.md` et le `6bc594e8…/` non suivi). Commit atomique = code seulement. 2) ✅ On **reste sur `feature/agent-session-limits`**. La feature n'est pas terminée côté produit (il manque LS7-front), donc on continue dessus. 3) ❌ **Pas de merge vers `develop` maintenant.** Raison : la slice est verte mais la surface produit session-limits est **incomplète sans LS7-front** (badge + compte à rebours + filet humain). Merger le backend seul livrerait dans `develop` une mécanique de reprise annulable sans son UI de contrôle/annulation visible — état intermédiaire que je ne veux pas figer dans l'intégration. **On attend LS7-front.** Une fois le front implémenté et la suite complète verte, reviens vers moi : je rebase si besoin sur `develop`, puis merge `--no-ff` la feature entière d'un coup (backend + front cohérents). Prochaine étape côté toi : enchaîner LS7-front (React/TS) sur cette même branche. Quand c'est vert, ping-moi pour le merge d'intégration.
|
||||
- **Prompt:** Slice LS7-front (React/TS, UI limites de session) terminée et VERTE sur `feature/agent-session-limits`. État tests : `npx vitest run src/features/agents/` → 4 fichiers, 63 tests passés (39 existants + 24 nouveaux), 0 régression. Typecheck `tsc --noEmit` propre. Fichiers à committer (frontend/ uniquement) : - src/domain/index.ts — 5 variantes ajoutées au union DomainEvent (agentRateLimited/ResumeScheduled/ResumeCancelled/Resumed/RateLimitSuspected). - src/ports/index.ts — `cancelResume(agentId)` ajouté à InputGateway. - src/adapters/input.ts — TauriInputGateway.cancelResume → invoke("cancel_resume", { agentId }). - src/adapters/mock/index.ts — MockInputGateway.cancelResume (cancelledResumes/cancelResumeResult). - src/features/agents/useAgents.ts — état limitByAgent + action cancelResume. - src/features/agents/AgentLimitBadge.tsx — NOUVEAU (badge + compte à rebours + bouton Annuler + helpers purs). - src/features/agents/AgentsPanel.tsx — câblage du badge. - src/features/agents/useAgentsLimits.test.tsx — NOUVEAU (13 tests, QA). - src/features/agents/AgentLimitBadge.test.tsx — NOUVEAU (11 tests, QA). Hors périmètre (NE PAS committer) : .ideai/** (artefacts runtime/mémoire). Aucun fichier backend touché. NOTE : il existe un test PRÉEXISTANT rouge SANS rapport avec cette slice — `src/adapters/mock/mock.test.ts:16` asserte « thirteen gateways » alors qu'il y en a 14 (gateway `permission` déjà présente dans HEAD committé eca2ba9). Compteur périmé, pas causé par LS7-front. À toi de voir si tu veux que je fasse corriger ce compteur (tâche séparée DevFrontend) avant/après — dis-moi. Questions : 1) Committe la slice LS7-front maintenant ? Sous quel message ? 2) Le merge --no-ff de la feature complète vers develop : on attend que je tranche le dernier point de scope (filet humain niveau 3 — voir consultation Architect en cours : peut nécessiter une slice backend+front supplémentaire `set_resume_at`), donc NE merge pas encore vers develop — confirme que tu te limites au commit de cette slice et qu'on garde le merge pour quand je te recontacte « feature complète ». Tranche et exécute le commit.
|
||||
- **Response:** Décision Git — LS7-front committée, pas de merge develop (j'attends ton « feature complète »). 1) ✅ Commit fait. Hash **4fad042** : `feat(session-limits): LS7-front — UI limites de session (badge + compte à rebours + filet humain)` - 9 fichiers, +702/−1, dont 3 créations (AgentLimitBadge.tsx + ses 2 fichiers de tests). - Périmètre strictement frontend : domain/index.ts, ports/index.ts, adapters/input.ts, adapters/mock/index.ts, useAgents.ts, AgentLimitBadge.tsx, AgentsPanel.tsx + les 2 tests. - **Exclus comme demandé** (non-stagés) : `.ideai/**` (conversations, layouts, memory) et `node_modules/` (non suivi — au passage, il traîne en untracked ; à gitignorer dans une tâche d'hygiène séparée si ce n'est pas déjà couvert, mais hors périmètre ici). Aucun fichier backend touché. Commit atomique = frontend seulement. 2) ✅ Confirmé : **je me limite à ce commit, pas de merge vers `develop`.** Je garde le `--no-ff` d'intégration de la feature complète pour quand tu me recontactes « feature complète », une fois tranché le scope du filet humain niveau 3 (slice `set_resume_at` éventuelle, cf. consultation Architect). Concernant le **test préexistant rouge** `mock.test.ts:16` (« thirteen gateways » → 14 réelles, gateway `permission` déjà dans HEAD eca2ba9) : compteur périmé sans rapport avec LS7. Mon avis Git : à corriger **avant le merge d'intégration**, en **commit séparé** (`fix(test): …` ou `chore(test): …`), pour ne pas merger `develop` avec une suite rouge. Lance une tâche DevFrontend dédiée quand tu veux ; tu peux l'enchaîner maintenant ou la grouper avec l'éventuelle slice `set_resume_at` — dans tous les cas elle aura son propre commit, et je ne mergerai pas tant que toute la suite n'est pas verte. État branche : `feature/agent-session-limits` → … ea94e75 (LS6) · 9df5923 (LS7 back) · **4fad042 (LS7 front, HEAD)**.
|
||||
- **Prompt:** Feature session-limits COMPLÈTE (3 niveaux) et verte. Reste à committer 2 slices, puis on peut envisager le merge d'intégration `feature/agent-session-limits → develop` que tu gardais en attente. État branche actuel : … 9df5923 (LS7 back) · 4fad042 (LS7 front, HEAD). Travail NON committé depuis, à committer maintenant en 2 commits séparés : ═══ COMMIT A — LS8-backend (filet humain niveau 3, backend) ═══ Implémenté par DevBackend, testé par QA (application: 15 tests session_limit_service / app-tauri: 4 wiring, + régressions vertes, 0 failed). Fichiers : - crates/application/src/agent/session_limit.rs — refactor privé `arm_scheduled` (param `resets_at_ms` brut ajouté) partagé par `on_rate_limited` + nouvelle `pub fn confirm_human_resume(agent_id, node_id, conversation_id, resets_at_ms: i64)` (source Human, réutilise la branche Scheduled, annulable). - crates/app-tauri/src/commands.rs — nouvelle commande `set_resume_at(agent_id, resets_at_ms) -> Result<(), ErrorDto>` (résout node_id via node_for_agent + conversation_id best-effort, NOT_FOUND si pas de cellule vivante). - crates/app-tauri/src/lib.rs — `set_resume_at` enregistrée après `cancel_resume`. - crates/application/tests/session_limit_service.rs — +6 tests (QA). - crates/app-tauri/tests/session_limit_wiring.rs — +2 tests (QA). Aucun événement nouveau (réutilise AgentRateLimited + AgentResumeScheduled). ═══ COMMIT B — LS8-front + fix test (DevFrontend a demandé 2 commits ; à toi de voir si tu sépares ou regroupes) ═══ LS8-front (typecheck propre, 109 tests verts) : - frontend/src/ports/index.ts — `setResumeAt(agentId, resetsAtMs)` sur InputGateway. - frontend/src/adapters/input.ts — `setResumeAt` → invoke("set_resume_at", { agentId, resetsAtMs }). - frontend/src/adapters/mock/index.ts — MockInputGateway.setResumeAt (resumeArmings[]). - frontend/src/features/agents/useAgents.ts — action setResumeAt (sans mutation optimiste). - frontend/src/features/agents/AgentLimitBadge.tsx — formulaire de saisie d'heure sur l'état suspected sans heure + helper pur `timeInputToEpochMs` ; TODO LS7 retiré. - frontend/src/features/agents/AgentsPanel.tsx — câblage onSetResumeAt. - frontend/src/features/agents/AgentLimitBadge.test.tsx — tests mis à jour au nouveau contrat + couverture LS8. Fix test hygiène (DevFrontend recommande commit SÉPARÉ) : - frontend/src/adapters/mock/mock.test.ts — compteur gateways 13→14 (« thirteen »→« fourteen » + `permission` dans l'array). Vérifié : 14 gateways réelles. Hors périmètre (NE PAS committer) : .ideai/** (runtime/mémoire), node_modules/ (untracked — DevFrontend signalait qu'il traîne, à gitignorer dans une tâche d'hygiène séparée, hors périmètre ici). QUESTIONS : 1) Committe COMMIT A puis les commits front (LS8-front + fix-test, séparés ou groupés selon ton jugement). Donne-moi les hashes + messages. 2) La suite complète est maintenant verte (Rust: domain/application/app-tauri ; front: agents + adapters/mock 109 tests) et la surface produit session-limits est complète sur les 3 niveaux. Décides-tu MAINTENANT le merge `--no-ff` vers develop (rebase si besoin) ? Si oui, exécute-le et donne-moi le résultat. Si tu veux un dernier garde-fou (ex. run de la suite complète des deux côtés avant merge), dis-le et je le fais lancer.
|
||||
- **Response:** ✅ 3 commits + merge d'intégration faits. La feature session-limits est intégrée dans `develop`. ═══ 1) Les 3 commits (sur feature/agent-session-limits) ═══ J'ai séparé le fix-test de la feature (hygiène isolée, comme recommandé par DevFrontend) → 3 commits : - **c480d28** `feat(session-limits): LS8-backend — filet humain niveau 3 (set_resume_at)` (session_limit.rs + commands.rs + lib.rs + tests service/wiring ; 5 fichiers, +394/−18) - **5d9dd32** `feat(session-limits): LS8-front — filet humain niveau 3 (saisie d'heure de reprise)` (ports/input/mock + useAgents + AgentLimitBadge(.tsx/.test) + AgentsPanel ; 7 fichiers, +222/−26) - **3f3504e** `fix(test): corrige le compteur de gateways du mock (13 → 14)` (mock.test.ts isolé ; 1 fichier, +2/−1) Artefacts `.ideai/**` et `node_modules/` exclus comme demandé. ═══ 2) Merge `--no-ff` → develop : OUI, exécuté ═══ Décision : la surface produit est complète sur les 3 niveaux et tout est vert des deux côtés → je merge. Pas de rebase nécessaire : `feature` était **12 ahead / 0 behind** `develop` (aucune divergence, base linéaire). - Merge commit : **d7041c5** `merge(session-limits): intégration de la feature limites de session (3 niveaux)` — stratégie ort, **0 conflit**, 47 fichiers / +5157/−45 (cumul LS2→LS8). - `develop` est maintenant à **d7041c5**, contenant toute la chaîne LS2 (a1755e5) → LS8 (3f3504e). - La branche `feature/agent-session-limits` est **conservée** (pas supprimée) : je la garde tant que tu n'as pas confirmé que l'intégration est définitivement validée. Dis-moi si tu veux que je la supprime maintenant. Note d'exécution : les artefacts runtime `.ideai/**` non committés bloquaient le switch de branche → je les ai **stashés le temps du merge puis restaurés** (stash poppé, working tree intact, rien perdu). Aucun de ces artefacts n'est entré dans les commits applicatifs ni dans le merge. ⚠️ Périmètre **strictement local** : aucun `push`. `develop` local est en avance de 58 commits sur `origin/develop` — je ne pousse rien sans ta validation explicite. Prochaines options possibles, à ta main : suppression de `feature/agent-session-limits`, et/ou push de `develop` vers origin (action sortante → nécessite ton feu vert).
|
||||
@ -0,0 +1,21 @@
|
||||
{"id":"28eded2d-7fd7-48cf-bc4a-c38a89ed06e3","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781612485902,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Nouvelle feature cadrée par Architect, prête à démarrer le développement : « Gestion des limites de session des agents » (détection hiérarchique des limites de session + reprise auto annulable). C'est toi qui décides de la topologie de branche — je te sollicite, tu tranches.\n\nContexte dépôt : HEAD actuel sur le commit de release 0.1.0 (9a8a89f « release(0.1.0) : intégration de develop »). Modèle de branches du projet : main (release) ← develop (intégration) ← feature/* (une par feature).\n\nLa feature sera livrée en lots ordonnés LS1→LS8 (domaine → adapter Claude → port Scheduler → service application → parser regex → filet humain → app-tauri → frontend), chacun passant par le cycle dev/test (tests verts avant de continuer). État en mémoire uniquement, aucun schéma de persistance modifié.\n\nDécide et exécute (périmètre LOCAL uniquement — aucun push) : faut-il créer une branche feature/* (et depuis quelle base — develop ?), faut-il d'abord (re)créer/checkout develop depuis main, ou rester en place ? Donne-moi le nom de branche retenu et confirme l'état git après ta manœuvre."}
|
||||
{"id":"4309abd6-8ded-4641-9a46-af523f0f1af6","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781612520039,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"Décision (agent Git) — branche retenue : **`feature/agent-session-limits`**, créée **depuis `develop`**.\n\n**Raisonnement :**\n- Nouvelle feature indépendante (LS1→LS8) → conformément au modèle `main ← develop ← feature/*`, elle se développe sur sa propre branche `feature/*`.\n- Pas besoin de (re)créer `develop` : il existe déjà et est à jour (post-release 0.1.0, `git diff main develop` vide → contenu identique). C'est donc la bonne base de départ.\n- Nom dérivé du sujet, kebab-case : `feature/agent-session-limits`.\n\n**État git après manœuvre (LOCAL, aucun push) :**\n- Branche courante : `feature/agent-session-limits` @ `401c18a` (= base `develop`).\n- `develop` @ `401c18a` (inchangé) ← `main` @ `9a8a89f` (tag `v0.1.0`, inchangé).\n- `feature/agent-skills` @ `ef101db` (autre feature, intacte).\n\n**Note :** le working tree porte déjà des changements non commités hérités de l'état courant — notamment `ARCHITECTURE.md` (cadrage Architect) et un nouveau `.ideai/memory/session-limit-handling-design.md`, plus du runtime `.ideai/` (conversations/MEMORY.md). Ils n'ont pas été commités : DevBackend/QA les intégreront dans les commits de la feature au fil des lots. Le cadrage Architect (ARCHITECTURE.md + memory design) pourra faire le **premier commit** de la branche avant LS1 si tu le souhaites — dis-moi si tu veux que je le commite maintenant.\n\nDev peut démarrer LS1 (domaine) sur `feature/agent-session-limits`. Après chaque lot vert, reviens vers moi pour décider d'un éventuel commit/merge. Aucune intégration vers `develop` tant que les tests des lots concernés ne sont pas verts (règle d'or)."}
|
||||
{"id":"8edd3bc3-3024-46b6-929b-2aac1c1f7fdc","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781612678980,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Feu vert pour le premier commit de la branche feature/agent-session-limits : commite le cadrage AVANT que le dev (LS1) ne démarre, pour isoler proprement le cadrage du code.\n\nContenu à committer (cadrage uniquement) : ARCHITECTURE.md (nouvelle §21) + .ideai/memory/session-limit-handling-design.md + la mise à jour de .ideai/memory/MEMORY.md (pointeur).\n\nN'inclus PAS le runtime .ideai/ non pertinent (conversations, layouts, agents.json…) s'il pollue le diff — limite le commit aux fichiers du cadrage. Message de commit conforme au style du dépôt (français, conventionnel). Périmètre LOCAL uniquement — aucun push. Confirme le hash du commit et l'état git après."}
|
||||
{"id":"bdcd087a-9481-4a06-a8a9-20881cb93077","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781612714298,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"Cadrage committé sur `feature/agent-session-limits` — premier commit de la branche, isolé du code.\n\n**Commit : `fa5b826`**\n`docs(session-limits): cadrage Architect — gestion des limites de session des agents`\n\n**Contenu (cadrage uniquement, 3 fichiers / +212 lignes) :**\n- `ARCHITECTURE.md` (+185, nouvelle §21)\n- `.ideai/memory/session-limit-handling-design.md` (nouveau, design validé)\n- `.ideai/memory/MEMORY.md` (+1, pointeur)\n\nLe runtime `.ideai/` non pertinent (conversations, layouts, agents.json…) a été **exclu** du diff comme demandé.\n\n**État git après commit (LOCAL, aucun push) :**\n- `feature/agent-session-limits` @ `fa5b826` ← `develop` @ `401c18a` ← `main` @ `9a8a89f` (tag `v0.1.0`).\n- Working tree : reste uniquement du runtime `.ideai/conversations/…` non commité (bruit, ignoré pour le cadrage).\n\nLa branche est prête : le dev peut démarrer **LS1 (domaine)**. Je reviendrai dans la boucle pour committer chaque lot vert."}
|
||||
{"id":"45759b66-4412-4756-aa61-9f2388dae1b4","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781613219445,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"LS1 (couche domaine) de la feature « limites de session » est VERT : DevBackend a livré le code pur, QA a écrit 24 tests, `cargo test -p domain` = 165 passed / 0 failed, zéro régression.\n\nCommite LS1 sur feature/agent-session-limits (commit suivant après le cadrage fa5b826). Périmètre LOCAL uniquement, aucun push.\n\nFichiers de prod (domaine) modifiés/créés par DevBackend :\n- crates/domain/src/ports.rs (variante ReplyEvent::RateLimited)\n- crates/domain/src/readiness.rs (variante ReadinessSignal::RateLimited + classify)\n- crates/domain/src/session_limit.rs (NOUVEAU : SessionLimit, ResumePlan, RateLimitSource, plan_resume)\n- crates/domain/src/profile.rs (RateLimitPattern + champ + builder)\n- crates/domain/src/events.rs (5 variantes DomainEvent)\n- crates/domain/src/lib.rs (mod + re-exports)\nPlus les tests QA dans ces mêmes fichiers (#[cfg(test)]).\n\nN'inclus PAS le runtime .ideai/ non pertinent. Message conventionnel en français.\n\nIMPORTANT — décision de merge : NE merge PAS vers develop maintenant. Le workspace complet (`cargo build --workspace`) NE COMPILE PAS tant que LS2 n'a pas ajouté l'arm `ReplyEvent::RateLimited` côté infra (match exhaustifs). On ne merge feature/* → develop que sur une tranche qui compile de bout en bout (cible : LS1+LS2+LS4 = niveau 1 Claude complet). Confirme juste le hash du commit LS1 et l'état git."}
|
||||
{"id":"6f923008-d23b-4c0f-8118-0b0bd82baeb1","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781613250449,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"LS1 (couche domaine) committé sur `feature/agent-session-limits`, après le cadrage `fa5b826`.\n\n**Commit : `0bf1eb3`**\n`feat(session-limits): LS1 — couche domaine (détection + plan de reprise)`\n\n**Contenu (6 fichiers domaine, prod + tests inline / +647 lignes) :**\n- `crates/domain/src/session_limit.rs` (nouveau)\n- `crates/domain/src/ports.rs` (ReplyEvent::RateLimited)\n- `crates/domain/src/readiness.rs` (ReadinessSignal::RateLimited + classify)\n- `crates/domain/src/profile.rs` (RateLimitPattern + champ + builder)\n- `crates/domain/src/events.rs` (5 variantes DomainEvent)\n- `crates/domain/src/lib.rs` (mod + re-exports)\n\nRuntime `.ideai/` non pertinent **exclu**.\nQA : `cargo test -p domain` = 165 passed / 0 failed, zéro régression.\n\n**Décision de merge : PAS de merge vers `develop`** — conforme à ta consigne. Le workspace ne compile pas tant que LS2 n'a pas ajouté l'arm `ReplyEvent::RateLimited` côté infra (match exhaustifs). On n'intègre `feature/* → develop` que sur une tranche qui compile de bout en bout (cible : LS1+LS2+LS4 = niveau 1 Claude complet). Je reste sur la branche feature.\n\n**État git (LOCAL, aucun push) :**\n```\nfeature/agent-session-limits @ 0bf1eb3 (HEAD)\n └ fa5b826 docs cadrage\n └ 401c18a (develop) ← main @ 9a8a89f (tag v0.1.0)\n```\nWorking tree : seul du runtime `.ideai/conversations/…` non commité (ignoré). Dev peut enchaîner sur LS2."}
|
||||
{"id":"8cd389e5-7f84-4737-af3a-207438d2fc62","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781613878608,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"LS2 (adapter Claude niveau 1, infra) est VERT : `cargo test -p infrastructure` = 188 passed / 0 failed, zéro régression. Commite LS2 sur feature/agent-session-limits (après LS1 0bf1eb3). LOCAL uniquement, aucun push.\n\nFichier de prod modifié : crates/infrastructure/src/session/claude.rs (parse_event émet ReplyEvent::RateLimited ; nouvelle fonction pure parse_reset_ms + helpers ; parseur ISO maison ; doccomments T4).\nTests QA : dans crates/infrastructure/src/session/mod.rs (#[cfg(test)]) — 26 nouveaux tests + 2 tests existants mis à jour vers le nouveau contrat ; et crates/infrastructure/src/session/conformance.rs (#[cfg(test)] : RateLimited ajouté aux événements non terminaux autorisés).\n\nExclu le runtime .ideai/. Message conventionnel français.\n\nDécision merge : TOUJOURS PAS de merge vers develop (le workspace ne compile pas encore — 2 erreurs résiduelles attendues dans app-tauri, relèvent de LS7/LS8 ; et la réconciliation T4 reste à faire en LS4). On vise la tranche LS1+LS2+LS3+LS4 = niveau 1 Claude de bout en bout qui compile, pour le premier merge. Confirme le hash LS2 et l'état git."}
|
||||
{"id":"38531764-1e1c-414c-93a9-266cf6847444","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781613904809,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"LS2 (adapter Claude niveau 1, infra) committé sur `feature/agent-session-limits`, après LS1 `0bf1eb3`.\n\n**Commit : `a1755e5`**\n`feat(session-limits): LS2 — adapter Claude niveau 1 (infra)`\n\n**Contenu (3 fichiers infra, prod + tests inline / +545 lignes) :**\n- `crates/infrastructure/src/session/claude.rs` (prod : parse_event → ReplyEvent::RateLimited, parse_reset_ms + helpers, parseur ISO maison, doccomments T4)\n- `crates/infrastructure/src/session/mod.rs` (26 nouveaux tests QA + 2 alignés)\n- `crates/infrastructure/src/session/conformance.rs` (RateLimited en non-terminal autorisé)\n\nRuntime `.ideai/` **exclu**.\nQA : `cargo test -p infrastructure` = 188 passed / 0 failed, zéro régression.\n\n**Décision de merge : TOUJOURS PAS de merge vers `develop`** — conforme. Le workspace ne compile pas encore (2 erreurs résiduelles attendues dans app-tauri → LS7/LS8 ; réconciliation T4 → LS4). Premier merge visé sur la tranche **LS1+LS2+LS3+LS4 = niveau 1 Claude de bout en bout qui compile**.\n\n**État git (LOCAL, aucun push) :**\n```\nfeature/agent-session-limits @ a1755e5 (HEAD)\n └ 0bf1eb3 LS1 domaine\n └ fa5b826 cadrage\n └ 401c18a (develop) ← main @ 9a8a89f (tag v0.1.0)\n```\nWorking tree : seul du runtime `.ideai/` non commité (ignoré). Dev peut enchaîner sur LS3."}
|
||||
{"id":"49eb0b0d-ee02-4497-b676-127436f10222","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781615248432,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"LS3 (port Scheduler + adapter TokioScheduler) est VERT : `cargo test -p infrastructure` = 195 passed / 0 failed (7 nouveaux tests scheduler, relancés 3× sans flaky), LS1/LS2 toujours verts. Builds domaine + infra OK 0 warning. Le workspace complet ne compile toujours pas (2 erreurs résiduelles inchangées dans app-tauri : chat.rs:185 → LS8, events.rs:261 → LS7 ; la réconciliation T4 reste en LS4).\n\nC'est TOI qui décides quoi et quand committer/merger. Je te livre l'état, tu tranches.\n\nFichiers de prod LS3 :\n- crates/domain/src/ids.rs (ScheduleId via typed_id!)\n- crates/domain/src/ports.rs (trait Scheduler + enum ScheduledTask)\n- crates/domain/src/lib.rs (re-exports)\n- crates/infrastructure/src/scheduler/mod.rs (NOUVEAU : TokioScheduler)\n- crates/infrastructure/src/lib.rs (pub mod scheduler + re-export)\nTests QA : dans crates/infrastructure/src/scheduler/mod.rs (#[cfg(test)]).\nRuntime .ideai/ à exclure du diff.\n\nDécide : faut-il committer LS3 maintenant (et avec quel message) ? Et concernant le merge vers develop — rappel du contexte : le premier merge envisagé portait sur la tranche LS1+LS2+LS3+LS4 (niveau 1 Claude de bout en bout qui compile), or le workspace ne compile pas encore (LS4 + app-tauri manquants). Dis-moi ta décision (commit LS3 sur la feature ? attendre la tranche compilable pour merger ? autre ?) et exécute-la. Confirme hash + état git."}
|
||||
{"id":"acc7dd08-37bf-422e-8962-00778f60de6c","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781615283463,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"Décision (agent Git) : **commit LS3 sur la feature, PAS de merge vers `develop`.**\n\n**Pourquoi :** chaque lot vert mérite son commit atomique sur la branche feature (traçabilité, bissection facile), mais le merge `feature/* → develop` exige une tranche qui **compile de bout en bout** — règle d'or. Or le workspace ne compile toujours pas (réconciliation T4 en LS4 + 2 erreurs résiduelles app-tauri : `chat.rs:185`→LS8, `events.rs:261`→LS7). Donc on continue d'accumuler sur la feature ; premier merge maintenu sur la tranche compilable (LS1+LS2+LS3+LS4 niveau 1 Claude, voire jusqu'à LS7/LS8 si la compilation workspace n'est verte qu'à ce moment-là).\n\n**Commit : `253310b`**\n`feat(session-limits): LS3 — port Scheduler + adapter TokioScheduler`\n\n**Contenu (5 fichiers prod + tests inline / +389 lignes) :**\n- domaine : `ports.rs` (trait Scheduler + enum ScheduledTask), `ids.rs` (ScheduleId), `lib.rs` (re-exports)\n- infra : `scheduler/mod.rs` (nouveau, TokioScheduler), `lib.rs` (pub mod + re-export)\n\nRuntime `.ideai/` **exclu**.\nQA : `cargo test -p infrastructure` = 195 passed / 0 failed (7 tests scheduler, 3× sans flaky) ; LS1/LS2 verts ; builds domaine+infra 0 warning.\n\n**État git (LOCAL, aucun push) :**\n```\nfeature/agent-session-limits @ 253310b (HEAD)\n └ a1755e5 LS2 adapter Claude\n └ 0bf1eb3 LS1 domaine\n └ fa5b826 cadrage\n └ 401c18a (develop) ← main @ 9a8a89f (tag v0.1.0)\n```\nDev peut enchaîner sur LS4 (service application + réconciliation T4). Je tranche le merge dès que le workspace compile."}
|
||||
{"id":"0465aea2-3a1c-486a-9afb-4ad04f91d6d0","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781628910047,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"LS4 (couche application : SessionLimitService + réconciliation T4) est VERT : `cargo test -p application` = tous binaires verts / 0 failed (16 nouveaux tests), zéro régression confirmée (drain_with_readiness_lot1 7/7, send_blocking_d1 9/9, reste du crate vert). Builds domaine+infra+application 0 warning. Le workspace complet ne compile TOUJOURS pas (inchangé : app-tauri chat.rs:185 → LS8, events.rs:261 → LS7).\n\nC'est toi qui décides quoi/quand committer/merger. État livré :\n\nFichiers de prod LS4 :\n- crates/application/src/agent/session_limit.rs (NOUVEAU : SessionLimitService + port AgentResumer + const RESUME_PROMPT)\n- crates/application/src/agent/structured.rs (réconciliation T4 : enum TurnOutcome + drain_with_readiness_outcome ; signatures historiques préservées)\n- crates/application/src/agent/mod.rs (mod + re-exports)\n- crates/application/src/lib.rs (re-exports)\nTests QA : crates/application/tests/session_limit_service.rs (9) + crates/application/tests/session_limit_t4.rs (7) (NOUVEAUX).\nRuntime .ideai/ à exclure.\n\nDécide et exécute (commit LS4 sur la feature ? message ? merge ou pas ?). Pour mémoire : le workspace ne compile de bout en bout qu'après LS7 (events app-tauri) + LS8 (chat.rs) ; le premier merge vers develop reste donc subordonné à ta règle d'or « tranche qui compile ». Confirme hash + état git."}
|
||||
{"id":"f88d6a5a-0e2b-4cd8-bb09-aa4534bfb453","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781628940614,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"Décision (agent Git) : **commit LS4 sur la feature, PAS de merge vers `develop`.**\n\n**Commit : `9000b4d`**\n`feat(session-limits): LS4 — service application + réconciliation T4`\n\n**Contenu (6 fichiers, 4 prod + 2 tests / +987 lignes) :**\n- `crates/application/src/agent/session_limit.rs` (nouveau : SessionLimitService + port AgentResumer + RESUME_PROMPT)\n- `crates/application/src/agent/structured.rs` (T4 : enum TurnOutcome + drain_with_readiness_outcome, signatures historiques préservées)\n- `crates/application/src/agent/mod.rs` + `crates/application/src/lib.rs` (modules/re-exports)\n- `crates/application/tests/session_limit_service.rs` (9) + `crates/application/tests/session_limit_t4.rs` (7) — nouveaux\n\nRuntime `.ideai/` **exclu**.\nQA : `cargo test -p application` tous binaires verts / 0 failed (16 nouveaux) ; zéro régression (drain_with_readiness_lot1 7/7, send_blocking_d1 9/9) ; builds domaine+infra+application 0 warning.\n\n**Décision de merge : toujours NON.** La tranche LS1→LS4 est verte par crate, mais `cargo build --workspace` échoue encore (app-tauri : `chat.rs:185`→LS8, `events.rs:261`→LS7). Ma règle d'or impose une tranche qui **compile de bout en bout** avant tout `feature/* → develop`. Comme tu me l'as confirmé, le workspace ne sera vert qu'après LS7+LS8 → **le premier merge se fera donc sur LS1→LS8** (et non LS1→LS4 comme envisagé initialement). On accumule jusque-là sur la branche feature.\n\n**État git (LOCAL, aucun push) :**\n```\nfeature/agent-session-limits @ 9000b4d (HEAD)\n └ 253310b LS3 · a1755e5 LS2 · 0bf1eb3 LS1 · fa5b826 cadrage\n └ 401c18a (develop) ← main @ 9a8a89f (tag v0.1.0)\n```\nDev peut enchaîner sur LS5 (parser regex). Je tranche le merge dès `cargo build --workspace` vert."}
|
||||
{"id":"dedd197e-7de5-4e64-8217-54be77e1e653","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781629554554,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"LS5 (parser regex niveau 2, infra) est VERT : `cargo test -p infrastructure` = 221 passed / 0 failed (26 nouveaux), 0 warning, LS2/LS3 toujours verts (refactor timeparse non régressif). C'est toi qui décides commit/merge.\n\nFichiers de prod LS5 :\n- crates/infrastructure/Cargo.toml (dépendance regex = \"1\", résout 1.12.3 déjà dans Cargo.lock)\n- crates/infrastructure/src/ratelimit/mod.rs (NOUVEAU : RateLimitParser + applies)\n- crates/infrastructure/src/timeparse.rs (NOUVEAU : parsing d'heure pur partagé, factorisé depuis LS2)\n- crates/infrastructure/src/session/claude.rs (délègue à timeparse — refactor)\n- crates/infrastructure/src/lib.rs (pub mod ratelimit + timeparse, re-export)\nModifs de TEST QA : crates/infrastructure/src/ratelimit/mod.rs (#[cfg(test)] 15 tests), crates/infrastructure/src/timeparse.rs (#[cfg(test)] 11 tests), crates/infrastructure/src/scheduler/mod.rs (2 warnings irrefutable if let corrigés dans le code de test).\nNOTE : Cargo.lock peut être inchangé (regex déjà verrouillée) — inclus-le seulement s'il bouge.\nRuntime .ideai/ à exclure.\n\nDécide et exécute (commit LS5 ? message ? merge ?). Pour mémoire le workspace ne compile de bout en bout qu'après LS7+LS8. Confirme hash + état git."}
|
||||
{"id":"050c6d11-d9bb-4e0f-8bc8-cebae970cbe3","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781632920754,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Feature session-limits, suite. Project root: /home/anthony/Documents/Projects/IdeA. Deux lots sont terminés et VERTS (workspace compile, `cargo test --workspace` tout vert, clippy propre sur le nouveau code) mais NON committés :\n\n- LS5 — détecteur niveau 2 déclaratif + parsing temps partagé. Fichiers : nouveau `crates/infrastructure/src/ratelimit/mod.rs` (RateLimitParser, regex confiné infra), nouveau `crates/infrastructure/src/timeparse.rs` (parsing d'heures pur, partagé niveau 1/2), modifs `crates/infrastructure/src/session/claude.rs` (refactor vers timeparse, ~-121 lignes), `crates/infrastructure/src/scheduler/mod.rs`, `crates/infrastructure/src/lib.rs` (exports), `crates/infrastructure/Cargo.toml` (dép `regex`), `Cargo.lock`.\n- LS6 — câblage des 5 nouvelles variantes DomainEvent (AgentRateLimited/ResumeScheduled/ResumeCancelled/Resumed/RateLimitSuspected) + ReplyEvent::RateLimited vers le front. Fichiers : `crates/app-tauri/src/events.rs` (DTO miroir + bras From + test relais), `crates/app-tauri/src/chat.rs` (chunk_from_event).\n\nNote : l'arbre contient aussi des fichiers runtime .ideai/ modifiés (conversations/*/handoff.md, log.jsonl, layouts.json, memory/) et un nouveau memory `git-owns-commit-merge-decisions.md` — à toi de juger ce qui entre ou non dans le(s) commit(s) applicatif(s) (rappel : les LS précédents committaient uniquement le code applicatif, pas le bruit runtime).\n\nLa branche courante est détachée (HEAD), pas sur develop. À toi de décider : topologie de branche, découpage en un ou deux commits (LS5 puis LS6, ou groupés), messages dans le style des commits feat(session-limits) existants, et si un merge feature/* → develop doit suivre. Périmètre LOCAL uniquement (aucun push). Exécute ta décision et rends via idea_reply le détail (branche, sha, messages, état final de `git status`)."}
|
||||
{"id":"8f8eec4e-104d-424d-b061-39fc93ad1bde","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781632975720,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"Fait. Décisions et exécution :\n\n**Branche** — Contrairement à ce qui était annoncé, HEAD n'était pas détachée : on était bien sur `feature/agent-session-limits` (la branche de la feature). Aucune création/bascule nécessaire, le dev se poursuit dessus. Rien à corriger.\n\n**Découpage — 2 commits atomiques** (LS5 puis LS6, séparés car deux intentions distinctes : couche infra de détection vs câblage événementiel vers le front) :\n\n1. `98bfcf4` — feat(session-limits): LS5 — détecteur niveau 2 déclaratif + parsing temps partagé\n Fichiers : `crates/infrastructure/src/ratelimit/mod.rs` (nouv.), `crates/infrastructure/src/timeparse.rs` (nouv.), `crates/infrastructure/src/session/claude.rs`, `crates/infrastructure/src/scheduler/mod.rs`, `crates/infrastructure/src/lib.rs`, `crates/infrastructure/Cargo.toml`, `Cargo.lock`.\n\n2. `ea94e75` — feat(session-limits): LS6 — câblage des événements de limite vers le front\n Fichiers : `crates/app-tauri/src/events.rs`, `crates/app-tauri/src/chat.rs`.\n\nMessages en français, style Conventional Commits cohérent avec LS1→LS4, corps expliquant le pourquoi, co-author inclus.\n\n**Bruit runtime exclu** — Non committé, comme pour les LS précédents : `.ideai/conversations/*` (handoff.md, log.jsonl), `.ideai/layouts.json`, `.ideai/memory/` (dont MEMORY.md et le nouveau `git-owns-commit-merge-decisions.md`), et la nouvelle conversation `6bc594e8-…`. Ce sont des artefacts d'exécution, pas du code applicatif.\n\n**Merge feature/* → develop : NON.** La feature session-limits est livrée par incréments (série LS en cours) ; LS5/LS6 sont verts mais internes (détecteur niveau 2 + relais d'événements) sans surface produit complète. On reste sur `feature/agent-session-limits`. Le merge vers `develop` se fera quand la feature sera terminée et toujours verte.\n\n**Périmètre** — 100% local, aucun push.\n\n**État final `git status`** : working tree propre côté applicatif ; ne subsistent que les fichiers runtime `.ideai/` modifiés/non suivis (volontairement hors commit). Derniers commits :\nea94e75 LS6 / 98bfcf4 LS5 / 9000b4d LS4."}
|
||||
{"id":"9c73943b-e258-4c2b-a124-f2eafffe3c5a","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781675740301,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Slice LS7 (backend / câblage app-tauri) de la feature session-limits terminée et verte. On est sur la branche feature/agent-session-limits (HEAD détaché ? `git status` montre HEAD), dernier commit ea94e75 (LS6).\n\nContenu à committer (tout est vert, aucun warning) :\n- crates/application/src/agent/lifecycle.rs : `LaunchAgentOutput.profile` exposé (None sur réattache/idempotent, Some sur lancement effectif).\n- crates/application/src/terminal/registry.rs : `StructuredSessions::meta_for_session()` (lookup agent/node par SessionId pour le tap niveau 1).\n- crates/app-tauri/src/state.rs : `ResumeContext`/`ResumeContexts`, `AppAgentResumer` (impl port `AgentResumer` au-dessus de LaunchAgent), instanciation+câblage du `SessionLimitService` (TokioScheduler + drain des réveils) dans `AppState::build`.\n- crates/app-tauri/src/commands.rs : taps niveau 1 (agent_send) et niveau 2 (launch_agent, parser regex confiné), alimentation de `resume_contexts`, nouvelle commande `cancel_resume`.\n- crates/app-tauri/src/lib.rs : enregistrement de `cancel_resume` dans le handler.\n- crates/app-tauri/Cargo.toml : dépendance `async-trait`.\n- crates/app-tauri/tests/session_limit_wiring.rs : 2 tests d'intégration (composition) — verts.\n- crates/app-tauri/tests/dto_agents.rs + dto_chat.rs : ajustement `profile: None` dans les fixtures.\n- crates/application/tests/structured_registry_d1.rs : test de `meta_for_session`.\n\nHors périmètre code (à NE PAS committer dans ce commit applicatif) : .ideai/conversations/**, .ideai/layouts.json, .ideai/memory/** — ce sont des artefacts runtime/mémoire.\n\nQuestions : 1) Faut-il committer cette slice LS7-backend maintenant, et sous quel message ? 2) Reste-t-on sur feature/agent-session-limits ? 3) Décides-tu d'un merge vers develop, ou on attend LS7-front (React/TS, badge + compte à rebours + filet humain) qui complète la surface produit ? Tranche et exécute le commit applicatif si tu juges que oui."}
|
||||
{"id":"42185422-903a-4c25-8139-b63748f6dd73","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781675787986,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"Décision Git — LS7 backend committée, on reste en feature, pas de merge maintenant.\n\nÉtat initial : on était bien **sur la branche** `feature/agent-session-limits` (pas de HEAD détaché — le « HEAD » de ton `git status` était juste l'en-tête francisé), HEAD = ea94e75 (LS6).\n\n1) ✅ Commit fait. Hash **9df5923** :\n`feat(session-limits): LS7 — câblage backend app-tauri (taps niveaux 1&2 + reprise annulable)`\n- 10 fichiers, +406/−3, dont la création de `tests/session_limit_wiring.rs`.\n- Périmètre strictement applicatif : lifecycle.rs, registry.rs, state.rs, commands.rs, lib.rs, Cargo.toml + les 4 fichiers de tests.\n- **Exclus comme demandé** (restés non-stagés, ce sont des artefacts runtime/mémoire) : `.ideai/conversations/**`, `.ideai/layouts.json`, `.ideai/memory/**` (dont le nouveau `git-owns-commit-merge-decisions.md` et le `6bc594e8…/` non suivi). Commit atomique = code seulement.\n\n2) ✅ On **reste sur `feature/agent-session-limits`**. La feature n'est pas terminée côté produit (il manque LS7-front), donc on continue dessus.\n\n3) ❌ **Pas de merge vers `develop` maintenant.** Raison : la slice est verte mais la surface produit session-limits est **incomplète sans LS7-front** (badge + compte à rebours + filet humain). Merger le backend seul livrerait dans `develop` une mécanique de reprise annulable sans son UI de contrôle/annulation visible — état intermédiaire que je ne veux pas figer dans l'intégration. **On attend LS7-front.** Une fois le front implémenté et la suite complète verte, reviens vers moi : je rebase si besoin sur `develop`, puis merge `--no-ff` la feature entière d'un coup (backend + front cohérents).\n\nProchaine étape côté toi : enchaîner LS7-front (React/TS) sur cette même branche. Quand c'est vert, ping-moi pour le merge d'intégration."}
|
||||
{"id":"4d281c8d-2a92-45b8-ac22-9a8fac5aafde","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781676394147,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Slice LS7-front (React/TS, UI limites de session) terminée et VERTE sur `feature/agent-session-limits`.\n\nÉtat tests : `npx vitest run src/features/agents/` → 4 fichiers, 63 tests passés (39 existants + 24 nouveaux), 0 régression. Typecheck `tsc --noEmit` propre.\n\nFichiers à committer (frontend/ uniquement) :\n- src/domain/index.ts — 5 variantes ajoutées au union DomainEvent (agentRateLimited/ResumeScheduled/ResumeCancelled/Resumed/RateLimitSuspected).\n- src/ports/index.ts — `cancelResume(agentId)` ajouté à InputGateway.\n- src/adapters/input.ts — TauriInputGateway.cancelResume → invoke(\"cancel_resume\", { agentId }).\n- src/adapters/mock/index.ts — MockInputGateway.cancelResume (cancelledResumes/cancelResumeResult).\n- src/features/agents/useAgents.ts — état limitByAgent + action cancelResume.\n- src/features/agents/AgentLimitBadge.tsx — NOUVEAU (badge + compte à rebours + bouton Annuler + helpers purs).\n- src/features/agents/AgentsPanel.tsx — câblage du badge.\n- src/features/agents/useAgentsLimits.test.tsx — NOUVEAU (13 tests, QA).\n- src/features/agents/AgentLimitBadge.test.tsx — NOUVEAU (11 tests, QA).\n\nHors périmètre (NE PAS committer) : .ideai/** (artefacts runtime/mémoire). Aucun fichier backend touché.\n\nNOTE : il existe un test PRÉEXISTANT rouge SANS rapport avec cette slice — `src/adapters/mock/mock.test.ts:16` asserte « thirteen gateways » alors qu'il y en a 14 (gateway `permission` déjà présente dans HEAD committé eca2ba9). Compteur périmé, pas causé par LS7-front. À toi de voir si tu veux que je fasse corriger ce compteur (tâche séparée DevFrontend) avant/après — dis-moi.\n\nQuestions : 1) Committe la slice LS7-front maintenant ? Sous quel message ? 2) Le merge --no-ff de la feature complète vers develop : on attend que je tranche le dernier point de scope (filet humain niveau 3 — voir consultation Architect en cours : peut nécessiter une slice backend+front supplémentaire `set_resume_at`), donc NE merge pas encore vers develop — confirme que tu te limites au commit de cette slice et qu'on garde le merge pour quand je te recontacte « feature complète ». Tranche et exécute le commit."}
|
||||
{"id":"21dcba39-7c5a-4191-8ddb-f13ab7eed54e","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781676433750,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"Décision Git — LS7-front committée, pas de merge develop (j'attends ton « feature complète »).\n\n1) ✅ Commit fait. Hash **4fad042** :\n`feat(session-limits): LS7-front — UI limites de session (badge + compte à rebours + filet humain)`\n- 9 fichiers, +702/−1, dont 3 créations (AgentLimitBadge.tsx + ses 2 fichiers de tests).\n- Périmètre strictement frontend : domain/index.ts, ports/index.ts, adapters/input.ts, adapters/mock/index.ts, useAgents.ts, AgentLimitBadge.tsx, AgentsPanel.tsx + les 2 tests.\n- **Exclus comme demandé** (non-stagés) : `.ideai/**` (conversations, layouts, memory) et `node_modules/` (non suivi — au passage, il traîne en untracked ; à gitignorer dans une tâche d'hygiène séparée si ce n'est pas déjà couvert, mais hors périmètre ici). Aucun fichier backend touché. Commit atomique = frontend seulement.\n\n2) ✅ Confirmé : **je me limite à ce commit, pas de merge vers `develop`.** Je garde le `--no-ff` d'intégration de la feature complète pour quand tu me recontactes « feature complète », une fois tranché le scope du filet humain niveau 3 (slice `set_resume_at` éventuelle, cf. consultation Architect).\n\nConcernant le **test préexistant rouge** `mock.test.ts:16` (« thirteen gateways » → 14 réelles, gateway `permission` déjà dans HEAD eca2ba9) : compteur périmé sans rapport avec LS7. Mon avis Git : à corriger **avant le merge d'intégration**, en **commit séparé** (`fix(test): …` ou `chore(test): …`), pour ne pas merger `develop` avec une suite rouge. Lance une tâche DevFrontend dédiée quand tu veux ; tu peux l'enchaîner maintenant ou la grouper avec l'éventuelle slice `set_resume_at` — dans tous les cas elle aura son propre commit, et je ne mergerai pas tant que toute la suite n'est pas verte.\n\nÉtat branche : `feature/agent-session-limits` → … ea94e75 (LS6) · 9df5923 (LS7 back) · **4fad042 (LS7 front, HEAD)**."}
|
||||
{"id":"8a9caf1e-94cf-4d45-905a-caab1363aa99","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781678668760,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Feature session-limits COMPLÈTE (3 niveaux) et verte. Reste à committer 2 slices, puis on peut envisager le merge d'intégration `feature/agent-session-limits → develop` que tu gardais en attente.\n\nÉtat branche actuel : … 9df5923 (LS7 back) · 4fad042 (LS7 front, HEAD). Travail NON committé depuis, à committer maintenant en 2 commits séparés :\n\n═══ COMMIT A — LS8-backend (filet humain niveau 3, backend) ═══\nImplémenté par DevBackend, testé par QA (application: 15 tests session_limit_service / app-tauri: 4 wiring, + régressions vertes, 0 failed).\nFichiers :\n- crates/application/src/agent/session_limit.rs — refactor privé `arm_scheduled` (param `resets_at_ms` brut ajouté) partagé par `on_rate_limited` + nouvelle `pub fn confirm_human_resume(agent_id, node_id, conversation_id, resets_at_ms: i64)` (source Human, réutilise la branche Scheduled, annulable).\n- crates/app-tauri/src/commands.rs — nouvelle commande `set_resume_at(agent_id, resets_at_ms) -> Result<(), ErrorDto>` (résout node_id via node_for_agent + conversation_id best-effort, NOT_FOUND si pas de cellule vivante).\n- crates/app-tauri/src/lib.rs — `set_resume_at` enregistrée après `cancel_resume`.\n- crates/application/tests/session_limit_service.rs — +6 tests (QA).\n- crates/app-tauri/tests/session_limit_wiring.rs — +2 tests (QA).\nAucun événement nouveau (réutilise AgentRateLimited + AgentResumeScheduled).\n\n═══ COMMIT B — LS8-front + fix test (DevFrontend a demandé 2 commits ; à toi de voir si tu sépares ou regroupes) ═══\nLS8-front (typecheck propre, 109 tests verts) :\n- frontend/src/ports/index.ts — `setResumeAt(agentId, resetsAtMs)` sur InputGateway.\n- frontend/src/adapters/input.ts — `setResumeAt` → invoke(\"set_resume_at\", { agentId, resetsAtMs }).\n- frontend/src/adapters/mock/index.ts — MockInputGateway.setResumeAt (resumeArmings[]).\n- frontend/src/features/agents/useAgents.ts — action setResumeAt (sans mutation optimiste).\n- frontend/src/features/agents/AgentLimitBadge.tsx — formulaire de saisie d'heure sur l'état suspected sans heure + helper pur `timeInputToEpochMs` ; TODO LS7 retiré.\n- frontend/src/features/agents/AgentsPanel.tsx — câblage onSetResumeAt.\n- frontend/src/features/agents/AgentLimitBadge.test.tsx — tests mis à jour au nouveau contrat + couverture LS8.\nFix test hygiène (DevFrontend recommande commit SÉPARÉ) :\n- frontend/src/adapters/mock/mock.test.ts — compteur gateways 13→14 (« thirteen »→« fourteen » + `permission` dans l'array). Vérifié : 14 gateways réelles.\n\nHors périmètre (NE PAS committer) : .ideai/** (runtime/mémoire), node_modules/ (untracked — DevFrontend signalait qu'il traîne, à gitignorer dans une tâche d'hygiène séparée, hors périmètre ici).\n\nQUESTIONS : 1) Committe COMMIT A puis les commits front (LS8-front + fix-test, séparés ou groupés selon ton jugement). Donne-moi les hashes + messages. 2) La suite complète est maintenant verte (Rust: domain/application/app-tauri ; front: agents + adapters/mock 109 tests) et la surface produit session-limits est complète sur les 3 niveaux. Décides-tu MAINTENANT le merge `--no-ff` vers develop (rebase si besoin) ? Si oui, exécute-le et donne-moi le résultat. Si tu veux un dernier garde-fou (ex. run de la suite complète des deux côtés avant merge), dis-le et je le fais lancer."}
|
||||
{"id":"6df9f7b7-fcc4-4b87-94db-33dce2d76f13","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781678766649,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"✅ 3 commits + merge d'intégration faits. La feature session-limits est intégrée dans `develop`.\n\n═══ 1) Les 3 commits (sur feature/agent-session-limits) ═══\nJ'ai séparé le fix-test de la feature (hygiène isolée, comme recommandé par DevFrontend) → 3 commits :\n\n- **c480d28** `feat(session-limits): LS8-backend — filet humain niveau 3 (set_resume_at)`\n (session_limit.rs + commands.rs + lib.rs + tests service/wiring ; 5 fichiers, +394/−18)\n- **5d9dd32** `feat(session-limits): LS8-front — filet humain niveau 3 (saisie d'heure de reprise)`\n (ports/input/mock + useAgents + AgentLimitBadge(.tsx/.test) + AgentsPanel ; 7 fichiers, +222/−26)\n- **3f3504e** `fix(test): corrige le compteur de gateways du mock (13 → 14)`\n (mock.test.ts isolé ; 1 fichier, +2/−1)\n\nArtefacts `.ideai/**` et `node_modules/` exclus comme demandé.\n\n═══ 2) Merge `--no-ff` → develop : OUI, exécuté ═══\nDécision : la surface produit est complète sur les 3 niveaux et tout est vert des deux côtés → je merge. Pas de rebase nécessaire : `feature` était **12 ahead / 0 behind** `develop` (aucune divergence, base linéaire).\n\n- Merge commit : **d7041c5** `merge(session-limits): intégration de la feature limites de session (3 niveaux)` — stratégie ort, **0 conflit**, 47 fichiers / +5157/−45 (cumul LS2→LS8).\n- `develop` est maintenant à **d7041c5**, contenant toute la chaîne LS2 (a1755e5) → LS8 (3f3504e).\n- La branche `feature/agent-session-limits` est **conservée** (pas supprimée) : je la garde tant que tu n'as pas confirmé que l'intégration est définitivement validée. Dis-moi si tu veux que je la supprime maintenant.\n\nNote d'exécution : les artefacts runtime `.ideai/**` non committés bloquaient le switch de branche → je les ai **stashés le temps du merge puis restaurés** (stash poppé, working tree intact, rien perdu). Aucun de ces artefacts n'est entré dans les commits applicatifs ni dans le merge.\n\n⚠️ Périmètre **strictement local** : aucun `push`. `develop` local est en avance de 58 commits sur `origin/develop` — je ne pousse rien sans ta validation explicite.\n\nProchaines options possibles, à ta main : suppression de `feature/agent-session-limits`, et/ou push de `develop` vers origin (action sortante → nécessite ton feu vert)."}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -10,28 +10,99 @@
|
||||
"root": {
|
||||
"type": "split",
|
||||
"node": {
|
||||
"id": "2ea41e76-262d-4dc3-a674-348ceac9b543",
|
||||
"id": "bd3dfce4-d31b-44d4-bd1e-b299b00d3cce",
|
||||
"direction": "row",
|
||||
"children": [
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"type": "split",
|
||||
"node": {
|
||||
"id": "14d20dcb-f243-4381-a130-2d4d3d224f42",
|
||||
"session": "4e60d6c2-6be6-499c-a296-577ce1e5ec62",
|
||||
"agent": "a6ced819-b893-4213-b003-9e9dc79b9641",
|
||||
"agentWasRunning": true
|
||||
"id": "cd60658c-8c2d-4ef1-9149-073230350696",
|
||||
"direction": "column",
|
||||
"children": [
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "14d20dcb-f243-4381-a130-2d4d3d224f42",
|
||||
"session": "adb24405-26ad-4920-a114-d27a21d9022a",
|
||||
"agent": "a6ced819-b893-4213-b003-9e9dc79b9641",
|
||||
"agentWasRunning": true
|
||||
}
|
||||
},
|
||||
"weight": 1.1910967
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "8d2c2720-b971-4792-8c0a-b28581eea8a8",
|
||||
"session": "5def4f66-2921-4ddc-b9a2-be4958788081"
|
||||
}
|
||||
},
|
||||
"weight": 0.80890334
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"type": "split",
|
||||
"node": {
|
||||
"id": "71f5cc81-9bef-4b2b-b7dc-2e0a5a4eafcb",
|
||||
"session": "bb08a08e-71f3-4ab2-842c-71a8da5c74b4",
|
||||
"agent": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"
|
||||
"id": "80fa1a60-60d7-4442-8614-fb055ea97e93",
|
||||
"direction": "column",
|
||||
"children": [
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "fbcfacb2-ad0b-46cc-9b53-8199d23f9324",
|
||||
"session": "462f69bc-7957-437d-9096-e79b57af1223",
|
||||
"agent": "73c853d1-c0fd-463b-ad17-1d24fefa371f",
|
||||
"agentWasRunning": true
|
||||
}
|
||||
},
|
||||
"weight": 0.80238867
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"type": "split",
|
||||
"node": {
|
||||
"id": "8318f2b8-55e1-4fff-88ba-9891bd85c8a0",
|
||||
"direction": "column",
|
||||
"children": [
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "07698ab4-0700-4205-966b-a50b1e73ec2b",
|
||||
"session": "f1780cb6-91c3-47d7-9081-1bcbdb96ba92",
|
||||
"agent": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5",
|
||||
"agentWasRunning": true
|
||||
}
|
||||
},
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "f31fa71f-c55e-4597-8ea0-caa9775531ff",
|
||||
"session": "b04d11d9-0359-48a6-94ec-bd1d40eb6277",
|
||||
"agent": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5",
|
||||
"agentWasRunning": true
|
||||
}
|
||||
},
|
||||
"weight": 1.0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"weight": 1.1976112
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"weight": 1.0
|
||||
@ -40,6 +111,19 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a1958c6d-901f-4fbc-a621-d026aa6b2b62",
|
||||
"name": "Git Graph",
|
||||
"kind": "gitGraph",
|
||||
"tree": {
|
||||
"root": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "7a088843-00e7-4ea0-9676-8c4acb1f6d1d"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -5,3 +5,5 @@
|
||||
- [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.
|
||||
- [git-owns-commit-merge-decisions](git-owns-commit-merge-decisions.md) — Ne jamais demander a l'utilisateur s'il faut committer/merger/brancher : l'agent Git tranche toute la topologie du depot.
|
||||
|
||||
12
.ideai/memory/git-owns-commit-merge-decisions.md
Normal file
12
.ideai/memory/git-owns-commit-merge-decisions.md
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
name: git-owns-commit-merge-decisions
|
||||
description: Ne jamais demander a l'utilisateur s'il faut committer/merger/brancher — c'est l'agent Git qui tranche toute la topologie du depot.
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Je ne dois PLUS demander a l'utilisateur s'il faut committer, merger, OU gerer les branches (creer/checkout/switch/rebase/supprimer une branche, ni quoi, ni quand). TOUTE la gestion des branches et la topologie du depot est decidee par l'agent **Git** — c'est LUI qui tranche.
|
||||
|
||||
**Why:** l'utilisateur a explicitement (CLAUDE.md §2.4 + confirme en session le 2026-06-17) defini Git comme garant du depot local : commits, merges, rebases ET toute la gestion de branches sont SA decision, pas celle de Main ni de l'utilisateur. Demander a l'utilisateur court-circuite ce role et le fait perdre du temps.
|
||||
|
||||
**How to apply:** quand un lot est vert, je delegue a Git (`idea_ask_agent` target=Git) en lui livrant l'etat (fichiers, tests verts, etat de compilation) et c'est Git qui decide/execute commit, merge, ET la branche (creer une `feature/*`, switch, rebase, supprimer apres merge…). Avant de demarrer une nouvelle feature, je passe la main a Git pour la decision de branche. En cas de doute sur le versioning/branching, je demande a **Git**, jamais a l'utilisateur. Les actions SORTANTES (push/publication) restent la seule chose soumise a validation explicite de l'utilisateur. Voir [[session-limit-handling-design]].
|
||||
38
.ideai/memory/session-limit-handling-design.md
Normal file
38
.ideai/memory/session-limit-handling-design.md
Normal file
@ -0,0 +1,38 @@
|
||||
---
|
||||
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).
|
||||
|
||||
Avancement (committe sur `feature/agent-session-limits`) :
|
||||
- LS1 domaine, LS2 adapter Claude niveau 1, LS3 port Scheduler + TokioScheduler, LS4 service application + reconciliation T4 : DONE.
|
||||
- LS5 (98bfcf4) detecteur niveau 2 declaratif `RateLimitParser` (regex confinee infra) + module `timeparse` pur partage niveau 1/2 (epoch s/ms, RFC3339, heure murale avec passage de minuit) : DONE, 221 tests infra verts.
|
||||
- LS6 (ea94e75) cablage : 5 variantes `DomainEvent` (AgentRateLimited/ResumeScheduled/ResumeCancelled/Resumed/RateLimitSuspected) + `ReplyEvent::RateLimited` relayees en `DomainEventDto` (events.rs) ; `chunk_from_event` traite RateLimited -> None (non terminal, comme Heartbeat) : DONE, workspace vert.
|
||||
- LS7-backend (9df5923) cablage app-tauri : `LaunchAgentOutput.profile` expose (lifecycle.rs), `StructuredSessions::meta_for_session` (registry.rs, lookup N1), `ResumeContext`/`AppAgentResumer` (impl port `AgentResumer` au-dessus de LaunchAgent) + instanciation/drain du `SessionLimitService` (TokioScheduler) dans `AppState::build` (state.rs), taps N1 (agent_send) & N2 (launch_agent, parser regex confine) + commande `cancel_resume` (commands.rs/lib.rs), dep `async-trait`. 2 tests d'integration (session_limit_wiring.rs) verts, suites app-tauri+application vertes. Git : reste sur feature/agent-session-limits, PAS de merge develop avant LS7-front.
|
||||
- LS7-front (4fad042) : 5 variantes au union `DomainEvent` (src/domain/index.ts) ; etat `limitByAgent` dans useAgents (patron `delegationSourceByRequester`) ; `AgentLimitBadge.tsx` (badge « limite jusqu'a HH:MM » + compte a rebours + bouton « Annuler la reprise » -> port `InputGateway.cancelResume` -> commande `cancel_resume`) ; cable dans AgentsPanel. 24 tests front. Champs wire reels = suffixe Ms : `resetsAtMs`/`fireAtMs` (PAS `resetsAt`/`fireAt`).
|
||||
- LS8 (filet humain niveau 3, decision Architect = B « demander = armer, pas juste informer ») :
|
||||
- backend (c480d28) : `SessionLimitService::confirm_human_resume(agent, node, conv, resets_at_ms)` (source `Human`, refactor privé `arm_scheduled` partagé avec `on_rate_limited`, reutilise branche Scheduled, annulable, AUCUN evenement nouveau) ; commande `set_resume_at(agent_id, resets_at_ms)` (resout node_id via `node_for_agent` + conv best-effort, NOT_FOUND si pas de cellule vivante). 15+4 tests (clamp passe, dedup croise, parite auto/humain).
|
||||
- front (5d9dd32) : `InputGateway.setResumeAt` -> `set_resume_at` ; formulaire de saisie d'heure sur l'etat suspected-sans-heure (helper pur `timeInputToEpochMs`) -> arme la reprise -> badge bascule auto via `agentResumeScheduled`. TODO LS7 retire.
|
||||
- fix hygiene (3f3504e) : compteur gateways mock 13->14 (`permission`).
|
||||
- STATUT : FEATURE TERMINEE, 3 niveaux complets, tout vert (Rust domain/application/app-tauri + front agents/adapters 109 tests). MERGEE --no-ff dans `develop` (merge d7041c5, 0 conflit). Branche `feature/agent-session-limits` conservee (non supprimee). Etat = EN MEMOIRE uniquement (pas de persistance SessionLimit, cf. decision verrouillee). Aucun push (develop local en avance sur origin ; action sortante en attente de validation).
|
||||
10
.ideai/skills/index.json
Normal file
10
.ideai/skills/index.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": [
|
||||
{
|
||||
"id": "a72dac60-641c-4417-b0d7-94b8539f817a",
|
||||
"name": "build-appimage",
|
||||
"contentHash": "77cb33b978b242f6"
|
||||
}
|
||||
]
|
||||
}
|
||||
32
.ideai/skills/md/a72dac60-641c-4417-b0d7-94b8539f817a.md
Normal file
32
.ideai/skills/md/a72dac60-641c-4417-b0d7-94b8539f817a.md
Normal file
@ -0,0 +1,32 @@
|
||||
# Build de l'AppImage IdeA (Linux)
|
||||
|
||||
Commande **validée bout-en-bout** (2026-06-17, build exit 0, artefact 106M produit) pour reconstruire l'AppImage Linux d'IdeA. À utiliser à chaque fois qu'un correctif backend/front doit être rendu actif dans l'app (rappel : **le binaire qui tourne = l'AppImage installée, pas les sources** — un correctif n'est actif qu'après rebuild + remplacement + relance d'IdeA).
|
||||
|
||||
## Procédure (2 étapes, depuis le project root `/home/anthony/Documents/Projects/IdeA`)
|
||||
|
||||
### 1. Build du frontend (génère `frontend/dist/`)
|
||||
```bash
|
||||
npm --prefix frontend run build
|
||||
```
|
||||
|
||||
### 2. Bundle Tauri AppImage (depuis `crates/app-tauri/`)
|
||||
```bash
|
||||
cd crates/app-tauri
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ../../frontend/node_modules/.bin/tauri build --bundles appimage
|
||||
```
|
||||
|
||||
## Pourquoi ces options (ne pas les retirer)
|
||||
- `--bundles appimage` : **exclut NSIS** (installeur Windows) — sinutile et cassant sur Linux.
|
||||
- `APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1` : **workaround FUSE obligatoire**. Sans ça, l'étape finale `linuxdeploy` (elle-même une AppImage montée via FUSE) échoue avec `failed to run linuxdeploy`. Le compile Rust réussit avant ce point ; seul le bundling casse (l'`AppDir` est généré mais pas le `.AppImage`).
|
||||
|
||||
## Artefact produit
|
||||
```
|
||||
target/release/bundle/appimage/IdeA_0.1.0_amd64.AppImage
|
||||
```
|
||||
(Le build est long : compile Rust release ~1 min + bundling. Lancer en arrière-plan.)
|
||||
|
||||
## Étape suivante = MANUELLE (ne PAS l'automatiser sans demander)
|
||||
Pour rendre le correctif actif, il faut **remplacer l'AppImage installée** `/home/anthony/Documents/IdeA_0.1.0_amd64.AppImage` par l'artefact, puis **relancer IdeA**. ⚠️ Relancer IdeA **tue l'orchestrateur en cours** (le serveur qui héberge la session active et les ponts MCP) : à faire par l'utilisateur quand il est prêt, pas en pleine session multi-agents. Garder un backup de l'ancienne AppImage avant remplacement (cf. convention `.old-<raison>`).
|
||||
|
||||
## Piège env (si on lance un binaire ensuite)
|
||||
La session shell hérite des variables de l'AppImage montée (`APPDIR`, `LD_LIBRARY_PATH`, `PYTHONHOME` → `/tmp/.mount_IdeA_*`). Pour lancer un binaire app-tauri compilé sans crash WebKit, partir d'un env propre (`env -i PATH=/usr/bin:/bin HOME=$HOME XDG_RUNTIME_DIR=/run/user/1000 …`) et préférer `jq` à `python3`.
|
||||
185
ARCHITECTURE.md
185
ARCHITECTURE.md
@ -2160,4 +2160,189 @@ La décision frontière l'évite : **la frontière propre est jugée côté fron
|
||||
|
||||
---
|
||||
|
||||
## 21. Gestion des limites de session des agents — détection hiérarchique + reprise auto annulable (cadrage 2026-06-16)
|
||||
|
||||
> Cadrage produit verrouillé avec l'utilisateur le 2026-06-16. **Étape 1 du cycle §3 — AUCUN code.**
|
||||
> Besoin : IdeA doit savoir **quand** un agent est en limite de session **et jusqu'à quand**, puis lui
|
||||
> demander de **reprendre où il en était** une fois la limite levée. Priorités : (1) **SOLIDE** (marche
|
||||
> même pour un novice, ~100 % des cas) ; (2) **sans dépendance au modèle** autant que possible.
|
||||
|
||||
### 21.0 État du terrain (lu dans le code, pas présumé)
|
||||
|
||||
- `domain/src/ports.rs` — `ReplyEvent` = `{ TextDelta, ToolActivity, Heartbeat, Final }`. **Aujourd'hui un
|
||||
`rate_limit_event` Claude est réduit à `Heartbeat`** (`infrastructure/src/session/claude.rs:90`,
|
||||
commentaire « fenêtre de limite de débit ») : l'info `rate_limit_info.resetsAt` est **lue puis jetée**.
|
||||
- `domain/src/readiness.rs` — `ReadinessSignal` = `{ TurnEnded, ExplicitReply, PromptReady, Stalled, TimedOut }`.
|
||||
`Stalled`/`TimedOut` sont **réservés au lot 2** (vocabulaire présent, **non produits** par `classify`).
|
||||
- `domain/src/input.rs` — deux axes d'état **orthogonaux** déjà posés : `AgentBusyState{Idle,Busy}` et
|
||||
`AgentLiveness{Alive,Stalled}`. La limite de session sera un **3ᵉ axe orthogonal**.
|
||||
- Reprise déjà câblée et **model-agnostique** : pivot `conversation_id` du moteur (`LeafCell.conversation_id`)
|
||||
+ `SessionPlan::{None,Assign,Resume}` (`ports.rs`) + `build_spawn_line` (`session/claude.rs:200`, `--resume`)
|
||||
+ `ListResumableAgents` (`application/agent/resume.rs`) + `SessionInspector` (`inspector/claude.rs`, « dernier sujet »).
|
||||
- `domain/src/ports.rs` — `Clock::now_millis() -> i64` (**épochе-millis**). **Il n'existe AUCUN port de
|
||||
minuterie** (pas de « réveille-moi à T »). C'est le seul vrai manque.
|
||||
|
||||
### 21.1 Décisions d'architecture (tranchées)
|
||||
|
||||
1. **Détecteur HIÉRARCHIQUE** calqué sur la hiérarchie de readiness existante. Trois niveaux, *premier
|
||||
qui matche gagne*, jamais d'inaction silencieuse :
|
||||
- **Niveau 1 — structuré (solide)** : l'adapter structuré extrait limite + reset du flux machine. Pour
|
||||
Claude, lire `rate_limit_info.resetsAt` **au lieu de le jeter**.
|
||||
- **Niveau 2 — déclaratif (configurable)** : champ de profil `rate_limit_pattern` (motif + capture de
|
||||
l'heure) pour agents **PTY/TUI sans adapter structuré**, dans la lignée des profils déclaratifs §9.
|
||||
- **Niveau 3 — filet humain** : rien ne matche **mais** l'agent est `Stalled` (lot 2) ⇒ IdeA **demande**
|
||||
à l'utilisateur. Garantit le « 100 % même pour un novice » : jamais d'inaction silencieuse.
|
||||
2. **Model-agnostic tenu AU DOMAINE.** Le domaine ne connaît qu'un fait neutre : « limité, reset à T (peut-
|
||||
être) ». Tout savoir spécifique modèle (forme du `rate_limit_event`, regex d'une TUI, parsing d'une heure
|
||||
locale) reste **confiné aux adapters/profils** (philosophie §9).
|
||||
3. **État EN MÉMOIRE uniquement** (décidé). **Aucune persistance** de `SessionLimit`, **aucun nouveau store**,
|
||||
**aucun schéma `.ideai/` modifié**. Conséquence assumée : le réveil auto ne joue que tant qu'IdeA reste
|
||||
ouvert ; IDE fermé/rouvert après le reset ⇒ le chemin **existant** `ListResumableAgents`
|
||||
(`agent_was_running`/`conversation_id`) prend le relais (popup de reprise, pas d'auto).
|
||||
4. **Reprise AUTOMATIQUE à l'heure de reset, ANNULABLE** (fenêtre + notification UI). Réutilise
|
||||
`SessionPlan::Resume` + un **prompt de reprise court** ; `--resume` porte tout l'historique (zéro
|
||||
reconstruction manuelle).
|
||||
5. **Un seul nouveau port** : `Scheduler` (minuterie one-shot annulable). Tout le reste **réutilise**
|
||||
l'existant (`Clock`, `EventBus`, `AgentSession`/`Factory`, `LaunchAgent`, `InputMediator`).
|
||||
|
||||
### 21.2 Tensions avec l'archi hexagonale existante — et leur résolution (à lire avant de coder)
|
||||
|
||||
| # | Tension (proposition initiale) | Résolution retenue |
|
||||
|---|---|---|
|
||||
| **T1** | `RateLimited { until: Option<Instant> }`. `std::time::Instant` est **monotone, non sérialisable, non horloge murale**, et **absent du domaine** (qui parle `i64` époche-millis via `Clock`). | **Abandonner `Instant`** → `resets_at_ms: Option<i64>` (époche-millis), homogène avec `Clock::now_millis` et `AgentBusyState.since_ms`, serde-friendly. **Déviation assumée de la proposition.** |
|
||||
| **T2** | `rate_limit_pattern` = **regex + capture**. Or le domaine est **dépendance-zéro** ; `prompt_ready_pattern` a été délibérément choisi **littéral** pour éviter la dépendance `regex`. | Le **domaine ne stocke que de la donnée** : `RateLimitPattern { pattern, reset_capture, time_format }` (chaînes). Le **moteur regex + le parsing d'heure vivent en infra** (un composant `RateLimitParser` / le watcher PTY). `regex` est ajouté au `Cargo.toml` de **`infrastructure` uniquement**. Domaine pur préservé (§1.4 / §9). |
|
||||
| **T3** | « armer un réveil sur le port `Clock` ». `Clock` ne sait que **donner l'heure**, pas **réveiller**. | Nouveau port **`Scheduler`** (ISP : minuterie fine, une responsabilité). `Clock` reste pour « maintenant ». |
|
||||
| **T4** | `RateLimited` comme événement de tour. Le contrat `ReplyStream` dit **« seul `Final` est terminal »** et `claude.rs` **rompt** la boucle au `Final`. | `ReplyEvent::RateLimited` est **non terminal** (comme `Heartbeat`) : il s'intercale, le flux continue jusqu'à `Final` **ou** clôture. **Point d'intégration** : un tour clos **sans `Final`** *parce que* limité ne doit **pas** devenir `AgentSessionError::Io` (« flux clos sans Final ») — `drain_bounded` doit traiter « clos + `RateLimited` vu » comme une **fin gracieuse limitée**, pas une erreur. |
|
||||
| **T5** | Niveau 3 « si agent `Stalled` ». Or `Stalled` est **réservé** au lot 2 (non produit aujourd'hui). | Le **niveau 3 dépend du lot 2** (détection de stagnation). À livrer **après** ; d'ici là, niveaux 1+2 couvrent le structuré et le PTY configuré. **Dépendance explicitée** dans le découpage. |
|
||||
|
||||
### 21.3 Modèle de domaine (ajouts purs, I/O-free)
|
||||
|
||||
- **`ReplyEvent::RateLimited { resets_at_ms: Option<i64> }`** (`ports.rs`) — non terminal, model-agnostique.
|
||||
- **`ReadinessSignal::RateLimited { resets_at_ms: Option<i64> }`** (`readiness.rs`) — l'enum **reste `Copy`**
|
||||
(`Option<i64>` est `Copy`). `ReadinessPolicy::classify(ReplyEvent::RateLimited{..})` ⇒
|
||||
`Some(ReadinessSignal::RateLimited{..})` (seul ajout au `match`).
|
||||
- **`domain/src/session_limit.rs` (NOUVEAU)** :
|
||||
- `SessionLimit { resets_at_ms: Option<i64>, detected_at_ms: i64, source: RateLimitSource }` (VO).
|
||||
- `RateLimitSource { Structured, Pattern, Human }` (traçabilité du niveau, pour l'UI).
|
||||
- **fonction pure** `plan_resume(now_ms, &SessionLimit) -> ResumePlan` avec
|
||||
`ResumePlan { fire_at_ms: i64 }` (si `resets_at_ms` absent ⇒ pas de plan auto ⇒ filet humain).
|
||||
Toute la logique de calendrier est **pure et testable sans I/O** (cf. `LayoutTree`, §7.2).
|
||||
- **`AgentProfile.rate_limit_pattern: Option<RateLimitPattern>`** + `with_rate_limit_pattern` (builder),
|
||||
`#[serde(default, skip_serializing_if = "Option::is_none")]` ⇒ **zéro régression** de sérialisation
|
||||
(mêmes tests que `liveness`/`prompt_ready_pattern`). `RateLimitPattern` = **donnée** (cf. T2), validée a
|
||||
minima (motif non vide) par un constructeur *parse-don't-validate*.
|
||||
- **`events.rs`** : `AgentRateLimited { agent_id, resets_at_ms: Option<i64> }`,
|
||||
`AgentResumeScheduled { agent_id, fire_at_ms }`, `AgentResumeCancelled { agent_id }`,
|
||||
`AgentResumed { agent_id }`, `AgentRateLimitSuspected { agent_id }` (niveau 3). Discrets, basse fréquence.
|
||||
|
||||
### 21.4 Le port `Scheduler` (frontière domaine — le seul nouveau port)
|
||||
|
||||
```rust
|
||||
/// Minuterie one-shot **annulable** (ARCHITECTURE §21). `Clock` dit l'heure ; ce
|
||||
/// port *réveille* à une échéance absolue. In-memory (aucune persistance, §21.1-3).
|
||||
pub trait Scheduler: Send + Sync {
|
||||
/// Arme un réveil à `deadline_ms` (époche-ms) qui, à échéance, **pousse** `task`
|
||||
/// vers le drain applicatif. Renvoie un id annulable.
|
||||
fn arm(&self, deadline_ms: i64, task: ScheduledTask) -> ScheduleId;
|
||||
/// Annule un réveil armé (idempotent ; `false` si déjà tiré/inconnu).
|
||||
fn cancel(&self, id: ScheduleId) -> bool;
|
||||
}
|
||||
|
||||
/// Intention model-agnostique exécutée à l'échéance (donnée pure, pas de closure
|
||||
/// traversant la frontière). Calqué sur l'esprit du dispatch orchestrateur §14.3.
|
||||
pub enum ScheduledTask {
|
||||
ResumeAgent { agent_id: AgentId, node_id: NodeId, conversation_id: Option<String> },
|
||||
}
|
||||
```
|
||||
|
||||
- **Pourquoi un port plutôt qu'un `tokio::sleep` direct** : garder l'**application testable sans temps réel**
|
||||
(un `Scheduler` fake déclenche à la demande) et l'inversion de dépendance (§1.2-D). `Clock` **reste** le
|
||||
port d'« heure courante ».
|
||||
- **Adapter** : `TokioScheduler` (`infrastructure/src/scheduler/`) — `tokio::time::sleep_until` + table de
|
||||
`JoinHandle` (abort = `cancel`) ; à l'échéance il **pousse `task`** dans un `mpsc` fourni à la construction
|
||||
(le drain est côté application). **Direction des dépendances respectée** : infra → canal → application
|
||||
(exactement le motif du watcher orchestrateur §14.3, qui draine un dispatch unique).
|
||||
|
||||
### 21.5 Application — service de limite & reprise
|
||||
|
||||
- **`application/src/agent/session_limit.rs` (NOUVEAU)** : `SessionLimitService`, qui compose **uniquement
|
||||
des ports/use-cases existants** + `Scheduler` :
|
||||
- `on_rate_limited(agent, SessionLimit)` : enregistre une entrée **en mémoire**, `plan_resume`, `Scheduler::
|
||||
arm(fire_at_ms, ResumeAgent{..})`, publie `AgentRateLimited` + `AgentResumeScheduled`.
|
||||
- `cancel_resume(agent)` : `Scheduler::cancel`, publie `AgentResumeCancelled` (la fenêtre annulable UI).
|
||||
- `resume_now(agent)` / drain de `ScheduledTask::ResumeAgent` : **compose `LaunchAgent` / `AgentSessionFactory::
|
||||
start` avec `SessionPlan::Resume{conversation_id}`** + **prompt de reprise court** ; publie `AgentResumed`.
|
||||
- `confirm_manual(agent, resets_at_ms)` (niveau 3) : même chemin que niveau 1 avec `source = Human`.
|
||||
- **`application/src/agent/structured.rs`** : dans `drain_with_readiness`, réagir à
|
||||
`ReadinessSignal::RateLimited{resets_at_ms}` ⇒ `SessionLimitService::on_rate_limited`. **Réconcilier T4** :
|
||||
un flux clos **sans `Final`** alors qu'un `RateLimited` a été vu ⇒ **pas** d'`Io`, mais une issue « limité »
|
||||
(l'agent reste vivant ; le service arme la reprise). Le `mark_idle`/FIFO reste piloté par `Final`/timeout.
|
||||
- **Niveau 3 (filet humain)** : à brancher **après le lot 2** — quand le détecteur de stagnation passe
|
||||
`Alive→Stalled` et qu'**aucune** `SessionLimit` n'est connue pour l'agent, le service publie
|
||||
`AgentRateLimitSuspected` ⇒ l'UI demande (jamais d'auto sans heure).
|
||||
|
||||
### 21.6 Infrastructure — où vit chaque détail modèle
|
||||
|
||||
- **Niveau 1** — `infrastructure/src/session/claude.rs` `parse_event` : `rate_limit_event` ⇒ lire
|
||||
`rate_limit_info.resetsAt`, le **normaliser en époche-ms** (ISO-8601/époch → `i64`) et émettre
|
||||
`ReplyEvent::RateLimited{resets_at_ms}` (non terminal) **au lieu** du `Heartbeat` actuel. Ajuster la boucle
|
||||
`send` (le `break 'lines` reste sur `Final` ; `RateLimited` ne rompt pas). `session/codex.rs` : mapper
|
||||
l'équivalent Codex **s'il existe**, sinon s'en remettre au niveau 2.
|
||||
- **Niveau 2** — `infrastructure/src/ratelimit/` (NOUVEAU) `RateLimitParser` : compile le
|
||||
`rate_limit_pattern` du profil (**`regex`, dépendance infra seulement**, cf. T2), observe le flux PTY du
|
||||
handle lié (réutilise l'armement du watcher de prompt, `MediatedInbox`), et à un match calcule
|
||||
`resets_at_ms` (capture d'heure + `Clock` + date du jour) puis émet `ReplyEvent::RateLimited` /
|
||||
notifie le service. **Aucune** regex ne franchit la frontière domaine.
|
||||
- **Port `Scheduler`** — `infrastructure/src/scheduler/` `TokioScheduler` (cf. §21.4).
|
||||
- `infrastructure/src/clock/` — **inchangé** (réutilisé pour « maintenant » et le calcul d'heure de reset).
|
||||
|
||||
### 21.7 Présentation (app-tauri + frontend)
|
||||
|
||||
- **`app-tauri`** (composition root) : instancier `TokioScheduler` + `SessionLimitService`, démarrer le
|
||||
**drain des `ScheduledTask`** (boucle de fond, jumelle du watcher orchestrateur). Commandes :
|
||||
`cancel_agent_resume`, `resume_agent_now`, `confirm_agent_rate_limit{resets_at_ms}`. Relais des nouveaux
|
||||
`DomainEvent` en events IPC **camelCase** (`agentRateLimited`, `agentResumeScheduled`, …).
|
||||
- **`frontend`** (`features/agents` + `features/terminals`) : **badge « limité jusqu'à HH:MM »** + compte à
|
||||
rebours + bouton **« Annuler la reprise »** (fenêtre annulable) ; **dialogue du filet humain** (niveau 3 :
|
||||
« limite détectée mais heure inconnue — reprendre à ? »). Étendre un `gateway` UI (port TS) + son adapter
|
||||
Tauri + les mocks (§1.3). Le « dernier sujet » du `SessionInspector` enrichit la notification.
|
||||
|
||||
### 21.8 Conformité hexagonale & SOLID
|
||||
|
||||
- **Domaine pur** : `SessionLimit`/`plan_resume`/`classify` testables **sans I/O ni temps réel** (T1 époche-ms,
|
||||
T2 regex hors domaine). **S** : `SessionLimitService` = une intention (détecter→planifier→reprendre) ;
|
||||
`Scheduler` = une responsabilité (minuter). **O** : ajouter un moteur niveau 1 = un adapter ; ajouter une
|
||||
TUI niveau 2 = **de la donnée** (`rate_limit_pattern`), pas de code. **L** : tout `Scheduler` (réel/fake)
|
||||
substituable. **I** : `Scheduler` minimal (`arm`/`cancel`), distinct de `Clock`. **D** : l'application
|
||||
reçoit `Arc<dyn Scheduler>` injecté au composition root.
|
||||
|
||||
### 21.9 Découpage en LOTS testables (cycle dev↔QA §3) — ordonné
|
||||
|
||||
| Lot | Couche | Contenu | Vert quand |
|
||||
|---|---|---|---|
|
||||
| **LS1** | domaine | `ReplyEvent::RateLimited` ; `ReadinessSignal::RateLimited` + `classify` ; `session_limit.rs` (VO + `plan_resume`) ; `events.rs` (5 variantes) ; `profile.rate_limit_pattern` + builder | tests purs : `classify` mappe RateLimited ; `plan_resume` (avec/sans `resets_at`) ; round-trip serde profil (clé omise si `None`, legacy→`None`) ; `ReadinessSignal` reste `Copy` |
|
||||
| **LS2** | infra (niv. 1) | `claude.rs` `parse_event` extrait `resetsAt`→époche-ms→`RateLimited` ; boucle `send` (pas de rupture sur RateLimited) ; idem Codex si applicable | conformance : une ligne `rate_limit_event` ⇒ `RateLimited{Some(ms)}` ; un tour `rate_limit_event`+`result` ⇒ `[…,RateLimited,Final]` ; sans `resetsAt` ⇒ `RateLimited{None}` |
|
||||
| **LS3** | domaine+infra | port `Scheduler` + `ScheduledTask` ; `TokioScheduler` (arm/cancel + push mpsc) | `arm` tire la tâche à l'échéance (deadline courte) ; `cancel` empêche le tir ; `cancel` post-tir = `false` |
|
||||
| **LS4** | application | `SessionLimitService` (on_rate_limited/cancel/resume_now/drain) ; `structured.rs` réconcilie T4 | mocks `Scheduler`+`AgentSession` : `on_rate_limited` arme + publie ; `cancel` annule ; drain compose `SessionPlan::Resume{id}` + prompt ; « clos sans Final + RateLimited » ⇒ **pas** d'`Io` |
|
||||
| **LS5** | infra (niv. 2) | `ratelimit/RateLimitParser` (regex, dép. infra) + armement sur le watcher PTY ; conso `rate_limit_pattern` | sur une sortie TUI échantillon : match ⇒ `resets_at_ms` calculé ; profil sans motif ⇒ aucun faux positif |
|
||||
| **LS6** | app+front | **niveau 3** (après lot 2) : `Stalled` sans limite connue ⇒ `AgentRateLimitSuspected` + `confirm_agent_rate_limit` | mock : transition `Stalled` sans `SessionLimit` ⇒ 1 `AgentRateLimitSuspected` ; `confirm_manual` arme comme niv. 1 |
|
||||
| **LS7** | app-tauri | wiring scheduler+service+drain ; commandes `cancel`/`resume_now`/`confirm` ; relais events camelCase | `cargo build`/tests commands ; events mappés ; drain démarré à open/create_project |
|
||||
| **LS8** | frontend | badge « limité jusqu'à HH:MM » + countdown + Annuler ; dialogue filet humain ; gateway+adapter+mocks | Vitest avec gateway mock : badge sur `agentRateLimited` ; Annuler appelle `cancel_agent_resume` ; dialogue sur `agentRateLimitSuspected` |
|
||||
|
||||
**Ordre** : LS1 → (LS2 ∥ LS3) → LS4 → LS5 → **LS6 (gate : lot 2 stagnation livré)** → LS7 → LS8.
|
||||
**LS1+LS2+LS4** donnent déjà le niveau 1 de bout en bout (Claude) : le cœur de valeur est atteint tôt.
|
||||
|
||||
### 21.10 Points ouverts (spikes)
|
||||
|
||||
1. **Format de `resetsAt`** (Claude) : époch vs ISO-8601 vs durée relative — à vérifier sur un vrai
|
||||
`rate_limit_event` (spike LS2). Le domaine ne voit que des **époche-ms** quoi qu'il arrive.
|
||||
2. **Heure locale → époche** (niveau 2) : une capture « 15:00 » est une **heure murale locale** ⇒ composer
|
||||
avec la date du jour + fuseau via `Clock` ; gérer le passage de minuit (reset « demain »). Confiné infra.
|
||||
3. **Codex** : existe-t-il un signal structuré de limite dans `codex exec --json` ? Sinon niveau 2 obligatoire
|
||||
pour Codex (spike LS2).
|
||||
4. **Double détection** : niveau 1 **et** niveau 2 pourraient matcher le même épisode ⇒ le service
|
||||
**dédoublonne par agent** (une `SessionLimit` vivante par agent ; le second signal rafraîchit, n'empile pas).
|
||||
|
||||
---
|
||||
|
||||
*Document maintenu par l'Agent Architecture — base du jalon « cadrage architecture » avant tout code applicatif.*
|
||||
|
||||
9
Cargo.lock
generated
9
Cargo.lock
generated
@ -69,7 +69,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "app-tauri"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"application",
|
||||
"async-trait",
|
||||
@ -88,7 +88,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "application"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"domain",
|
||||
@ -880,7 +880,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "domain"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"serde",
|
||||
@ -1936,7 +1936,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "infrastructure"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"application",
|
||||
"async-trait",
|
||||
@ -1946,6 +1946,7 @@ dependencies = [
|
||||
"landlock",
|
||||
"notify",
|
||||
"portable-pty",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "app-tauri"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
@ -32,6 +32,8 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
# `AppAgentResumer` implements the application's async `AgentResumer` port (LS7).
|
||||
async-trait = { workspace = true }
|
||||
# Cross-OS local IPC for the MCP loopback transport (M5a): Unix domain socket
|
||||
# (Linux/macOS) + Windows named pipe behind one async API, no network port. Pulled
|
||||
# in only here (the transport is an app-tauri/infra concern); its `tokio` feature
|
||||
|
||||
@ -178,8 +178,10 @@ impl ChatBridge {
|
||||
///
|
||||
/// Returns `None` for [`ReplyEvent::Heartbeat`]: a heartbeat is a non-terminal
|
||||
/// liveness proof (readiness/heartbeat lot 1) with **no chat content**, so it maps
|
||||
/// to no wire chunk — the pump simply skips it. Every content-bearing event still
|
||||
/// maps to exactly one chunk.
|
||||
/// to no wire chunk — the pump simply skips it. Likewise [`ReplyEvent::RateLimited`]
|
||||
/// is non-terminal and content-free (ports §21.2-T4): the UI badge comes from the
|
||||
/// `DomainEvent::AgentRateLimited` bus, not the chat stream, so it maps to `None`
|
||||
/// too. Every content-bearing event still maps to exactly one chunk.
|
||||
#[must_use]
|
||||
pub fn chunk_from_event(event: ReplyEvent) -> Option<ReplyChunk> {
|
||||
match event {
|
||||
@ -187,5 +189,6 @@ pub fn chunk_from_event(event: ReplyEvent) -> Option<ReplyChunk> {
|
||||
ReplyEvent::ToolActivity { label } => Some(ReplyChunk::ToolActivity { label }),
|
||||
ReplyEvent::Final { content } => Some(ReplyChunk::Final { content }),
|
||||
ReplyEvent::Heartbeat => None,
|
||||
ReplyEvent::RateLimited { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1137,6 +1137,11 @@ pub async fn launch_agent(
|
||||
// artefacts from previous AppImages do not survive into this activation.
|
||||
state.reconcile_claude_run_dirs(&project).await;
|
||||
|
||||
// Session-limit resume context (LS7, §21.5): record the Project + cell size keyed by
|
||||
// agent so an auto-resume (which only carries agent/node/conversation) can recompose a
|
||||
// full `LaunchAgentInput`. Cloned here — `project` is moved into the launch below.
|
||||
let resume_project = project.clone();
|
||||
|
||||
let output = state
|
||||
.launch_agent
|
||||
.execute(LaunchAgentInput {
|
||||
@ -1153,7 +1158,21 @@ pub async fn launch_agent(
|
||||
.await
|
||||
.map_err(ErrorDto::from)?;
|
||||
|
||||
if let Ok(mut contexts) = state.resume_contexts.lock() {
|
||||
contexts.insert(
|
||||
agent_id,
|
||||
crate::state::ResumeContext {
|
||||
project: resume_project,
|
||||
rows: request.rows,
|
||||
cols: request.cols,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let session_id = output.session.id;
|
||||
// Host cell of the freshly (re)launched session — the pivot the level-2 tap reports
|
||||
// to the service alongside the agent.
|
||||
let host_node_id = output.session.node_id;
|
||||
|
||||
// §17.4/§17.6: a structured launch routes to an `AgentSession`, registered
|
||||
// **only** in `StructuredSessions` — its id is never a live PTY. Such a cell is
|
||||
@ -1166,6 +1185,18 @@ pub async fn launch_agent(
|
||||
// Register the xterm output channel for this session.
|
||||
let gen = state.pty_bridge.register(session_id, on_output);
|
||||
|
||||
// Level-2 session-limit detector (§21, niveau 2 — PTY/TUI sans adapter
|
||||
// structuré). Selection rule (§21.10-4) is the single infra source `applies`:
|
||||
// arm a parser **only** for a profile without a structured adapter that declares
|
||||
// a `rate_limit_pattern` (anti-double-détection avec le niveau 1). A `None`
|
||||
// profile (reattach/idempotent) or an invalid regex ⇒ no parser, jamais de panique.
|
||||
let rate_limit_parser = output
|
||||
.profile
|
||||
.as_ref()
|
||||
.filter(|p| infrastructure::ratelimit::applies(p))
|
||||
.and_then(|p| p.rate_limit_pattern.as_ref())
|
||||
.and_then(infrastructure::RateLimitParser::new);
|
||||
|
||||
// Subscribe to the PTY's byte stream and pump it to the channel.
|
||||
// The stream is a blocking iterator; it runs on a dedicated OS thread and
|
||||
// ends when the PTY hits EOF or this attach is superseded.
|
||||
@ -1173,8 +1204,30 @@ pub async fn launch_agent(
|
||||
match state.pty_port.subscribe_output(&handle) {
|
||||
Ok(stream) => {
|
||||
let bridge: std::sync::Arc<PtyBridge> = std::sync::Arc::clone(&state.pty_bridge);
|
||||
// Captured for the level-2 tap (moved into the pump thread).
|
||||
let service = std::sync::Arc::clone(&state.session_limit_service);
|
||||
let detect_clock = std::sync::Arc::new(infrastructure::SystemClock::new());
|
||||
let conversation_id = request.conversation_id.clone();
|
||||
std::thread::spawn(move || {
|
||||
for chunk in stream {
|
||||
// §21.10-4 tap (best-effort par fragment) : avant de consommer le
|
||||
// fragment, on cherche le motif de limite. Une détection arme la
|
||||
// reprise via le service (détecter→planifier). Dormant si pas de
|
||||
// parser. La fragmentation PTV (motif coupé entre deux fragments)
|
||||
// est un raté best-effort connu pour LS7.
|
||||
if let Some(parser) = &rate_limit_parser {
|
||||
let text = String::from_utf8_lossy(&chunk);
|
||||
if let Some(limit) =
|
||||
parser.detect(&text, domain::ports::Clock::now_millis(&*detect_clock))
|
||||
{
|
||||
service.on_rate_limited(
|
||||
agent_id,
|
||||
host_node_id,
|
||||
conversation_id.clone(),
|
||||
limit.resets_at_ms,
|
||||
);
|
||||
}
|
||||
}
|
||||
if !bridge.send_output(&session_id, chunk) {
|
||||
break;
|
||||
}
|
||||
@ -1279,8 +1332,23 @@ pub async fn agent_send(
|
||||
// `Final` event (or stream end / superseded channel), then detaches *only its
|
||||
// own* generation so a concurrent re-attach is never torn down.
|
||||
let bridge = std::sync::Arc::clone(&state.chat_bridge);
|
||||
// Level-1 session-limit tap (§21, niveau 1 — structuré). Resolve the agent + host
|
||||
// cell of this session once; a `RateLimited` turn event then feeds the service
|
||||
// (détecter→planifier). DORMANT en composition B-2 (aucune session structurée
|
||||
// vivante) mais câblé pour forward-compat. `conversation_id` non disponible ici ⇒
|
||||
// `None` (acceptable LS7).
|
||||
let service = std::sync::Arc::clone(&state.session_limit_service);
|
||||
let meta = state.structured_sessions.meta_for_session(&sid);
|
||||
std::thread::spawn(move || {
|
||||
for event in stream {
|
||||
// Non-terminal, sans contenu chat : un signal de limite alimente le service
|
||||
// (le badge UI vient du bus `AgentRateLimited`, pas du flux chat) puis on
|
||||
// continue à drainer comme pour un battement.
|
||||
if let domain::ports::ReplyEvent::RateLimited { resets_at_ms } = &event {
|
||||
if let Some((agent_id, node_id)) = meta {
|
||||
service.on_rate_limited(agent_id, node_id, None, *resets_at_ms);
|
||||
}
|
||||
}
|
||||
// Heartbeats carry no chat content (readiness/heartbeat lot 1) ⇒ no wire
|
||||
// chunk; skip them while still draining so the turn runs to its `Final`.
|
||||
let Some(chunk) = crate::chat::chunk_from_event(event) else {
|
||||
@ -1298,6 +1366,79 @@ pub async fn agent_send(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `cancel_resume` — annule la **reprise automatique** armée pour un agent limité
|
||||
/// (ARCHITECTURE §21.1-4, fenêtre annulable).
|
||||
///
|
||||
/// Délègue à [`SessionLimitService::cancel_resume`](application::SessionLimitService::cancel_resume) :
|
||||
/// désarme le réveil et, **seulement si** l'annulation a réussi (le réveil n'avait pas
|
||||
/// encore tiré), publie `AgentResumeCancelled`. Renvoie `true` ssi une reprise a
|
||||
/// effectivement été annulée (`false` si aucune n'était armée, ou si elle venait de
|
||||
/// tirer — auquel cas la reprise suit son cours).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] (`INVALID` for a malformed agent id).
|
||||
#[tauri::command]
|
||||
pub async fn cancel_resume(
|
||||
agent_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<bool, ErrorDto> {
|
||||
let id = parse_agent_id(&agent_id)?;
|
||||
Ok(state.session_limit_service.cancel_resume(id))
|
||||
}
|
||||
|
||||
/// `set_resume_at` — **filet humain niveau 3** (ARCHITECTURE §21.1) : l'utilisateur a
|
||||
/// saisi l'heure de reset d'un agent en limite **suspectée** (rien n'a matché
|
||||
/// automatiquement). Arme la **même** reprise annulable que les niveaux 1/2.
|
||||
///
|
||||
/// Le front ne dispose que de l'`agent_id` ; on résout côté backend :
|
||||
/// - `node_id` : la cellule vivante hébergeant l'agent, cherchée dans la registry
|
||||
/// structurée puis dans la registry terminal ([`StructuredSessions::node_for_agent`]
|
||||
/// / [`TerminalSessions::node_for_agent`]). Sans cellule vivante, la saisie n'a pas de
|
||||
/// cible ⇒ `NOT_FOUND`.
|
||||
/// - `conversation_id` : best-effort via la session structurée de l'agent
|
||||
/// ([`AgentSession::conversation_id`]) ; `None` toléré (reprise en mode dégradé).
|
||||
///
|
||||
/// Délègue ensuite à
|
||||
/// [`SessionLimitService::confirm_human_resume`](application::SessionLimitService::confirm_human_resume),
|
||||
/// qui réémet la paire `AgentRateLimited{Some}` + `AgentResumeScheduled` déjà relayée au
|
||||
/// front. Aucun nouvel événement.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] (`INVALID` for a malformed agent id, `NOT_FOUND` if no live
|
||||
/// cell hosts the agent).
|
||||
#[tauri::command]
|
||||
pub async fn set_resume_at(
|
||||
agent_id: String,
|
||||
resets_at_ms: i64,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
let id = parse_agent_id(&agent_id)?;
|
||||
|
||||
// Résolution agent→cellule : la registry des sessions vivantes est la source de
|
||||
// vérité. On regarde d'abord le structuré (qui porte aussi le `conversation_id`),
|
||||
// puis le terminal (PTY).
|
||||
let node_id = state
|
||||
.structured_sessions
|
||||
.node_for_agent(&id)
|
||||
.or_else(|| state.terminal_sessions.node_for_agent(&id))
|
||||
.ok_or_else(|| {
|
||||
ErrorDto::from(AppError::NotFound(format!(
|
||||
"aucune cellule vivante pour l'agent {id}"
|
||||
)))
|
||||
})?;
|
||||
|
||||
// `conversation_id` best-effort : seule une session structurée vivante l'expose.
|
||||
let conversation_id = state
|
||||
.structured_sessions
|
||||
.session_for_agent(&id)
|
||||
.and_then(|s| s.conversation_id());
|
||||
|
||||
state
|
||||
.session_limit_service
|
||||
.confirm_human_resume(id, node_id, conversation_id, resets_at_ms);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `interrupt_agent` — the **Interrompre** path (cadrage C4 §4.2).
|
||||
///
|
||||
/// Routes to [`OrchestratorService::interrupt_agent`], which `preempt`s the agent's
|
||||
|
||||
@ -205,6 +205,52 @@ pub enum DomainEventDto {
|
||||
/// Whether the in-process ONNX capability is compiled in.
|
||||
vector_onnx_enabled: bool,
|
||||
},
|
||||
/// An agent entered a **session/rate limit** (ARCHITECTURE §21). Low-frequency,
|
||||
/// model-agnostic badge: carries only the neutral "limited, maybe resets at T"
|
||||
/// fact. The frontend badges "limité jusqu'à HH:MM".
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentRateLimited {
|
||||
/// Agent id (UUID string).
|
||||
agent_id: String,
|
||||
/// Reset instant in **epoch-milliseconds**; `null` ⇒ unknown (no auto-resume).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
resets_at_ms: Option<i64>,
|
||||
},
|
||||
/// An **auto-resume** was armed for a rate-limited agent (ARCHITECTURE §21). The
|
||||
/// frontend shows the countdown + the "Annuler la reprise" button (cancellable
|
||||
/// window).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentResumeScheduled {
|
||||
/// Agent id (UUID string).
|
||||
agent_id: String,
|
||||
/// Wake-up deadline in **epoch-milliseconds**.
|
||||
fire_at_ms: i64,
|
||||
},
|
||||
/// An agent's **auto-resume** was **cancelled** (ARCHITECTURE §21): the user
|
||||
/// clicked "Annuler la reprise". The frontend removes the countdown.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentResumeCancelled {
|
||||
/// Agent id (UUID string).
|
||||
agent_id: String,
|
||||
},
|
||||
/// An agent was effectively **resumed** after a limit (ARCHITECTURE §21): the
|
||||
/// wake-up fired (or immediate resume). The frontend clears the "limited" state.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentResumed {
|
||||
/// Agent id (UUID string).
|
||||
agent_id: String,
|
||||
},
|
||||
/// **Human net (level 3)**: a session limit is **suspected** without any reliable
|
||||
/// reset time (ARCHITECTURE §21.1). IdeA never resumes blind: this asks the front
|
||||
/// to **prompt the user** ("limit detected but time unknown — resume at?").
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentRateLimitSuspected {
|
||||
/// Agent id (UUID string).
|
||||
agent_id: String,
|
||||
/// Reset instant in **epoch-milliseconds** if an estimate exists, else `null`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
resets_at_ms: Option<i64>,
|
||||
},
|
||||
/// Raw PTY output (normally routed to a per-session channel, not here).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PtyOutput {
|
||||
@ -377,6 +423,33 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
vector_http_enabled: *vector_http_enabled,
|
||||
vector_onnx_enabled: *vector_onnx_enabled,
|
||||
},
|
||||
DomainEvent::AgentRateLimited {
|
||||
agent_id,
|
||||
resets_at_ms,
|
||||
} => Self::AgentRateLimited {
|
||||
agent_id: agent_id.to_string(),
|
||||
resets_at_ms: *resets_at_ms,
|
||||
},
|
||||
DomainEvent::AgentResumeScheduled {
|
||||
agent_id,
|
||||
fire_at_ms,
|
||||
} => Self::AgentResumeScheduled {
|
||||
agent_id: agent_id.to_string(),
|
||||
fire_at_ms: *fire_at_ms,
|
||||
},
|
||||
DomainEvent::AgentResumeCancelled { agent_id } => Self::AgentResumeCancelled {
|
||||
agent_id: agent_id.to_string(),
|
||||
},
|
||||
DomainEvent::AgentResumed { agent_id } => Self::AgentResumed {
|
||||
agent_id: agent_id.to_string(),
|
||||
},
|
||||
DomainEvent::AgentRateLimitSuspected {
|
||||
agent_id,
|
||||
resets_at_ms,
|
||||
} => Self::AgentRateLimitSuspected {
|
||||
agent_id: agent_id.to_string(),
|
||||
resets_at_ms: *resets_at_ms,
|
||||
},
|
||||
DomainEvent::PtyOutput { session_id, bytes } => Self::PtyOutput {
|
||||
session_id: session_id.to_string(),
|
||||
bytes: bytes.clone(),
|
||||
@ -450,4 +523,19 @@ mod tests {
|
||||
assert_eq!(json["type"], "agentLivenessChanged");
|
||||
assert_eq!(json["liveness"], "alive");
|
||||
}
|
||||
|
||||
/// LS6 : un `AgentRateLimited` du domaine se relaie en DTO portant le même agent
|
||||
/// et l'heure de reset (époche-ms), et se sérialise en `"agentRateLimited"` avec
|
||||
/// `resetsAtMs` — le fait neutre que le front badge « limité jusqu'à HH:MM ».
|
||||
#[test]
|
||||
fn rate_limited_relays_to_dto_and_wire() {
|
||||
let dto = DomainEventDto::from(&DomainEvent::AgentRateLimited {
|
||||
agent_id: agent(11),
|
||||
resets_at_ms: Some(1_700_000_000_000),
|
||||
});
|
||||
let json = serde_json::to_value(&dto).expect("serialisable");
|
||||
assert_eq!(json["type"], "agentRateLimited");
|
||||
assert_eq!(json["agentId"], agent(11).to_string());
|
||||
assert_eq!(json["resetsAtMs"], 1_700_000_000_000_i64);
|
||||
}
|
||||
}
|
||||
|
||||
@ -165,6 +165,8 @@ pub fn run() {
|
||||
commands::launch_agent,
|
||||
commands::change_agent_profile,
|
||||
commands::agent_send,
|
||||
commands::cancel_resume,
|
||||
commands::set_resume_at,
|
||||
commands::interrupt_agent,
|
||||
commands::delegation_delivered,
|
||||
commands::set_front_attached,
|
||||
|
||||
@ -12,9 +12,11 @@ use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::{
|
||||
AssignSkillToAgent, ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab,
|
||||
AgentResumer, AppError, AssignSkillToAgent, ChangeAgentProfile, CheckEmbedderSuggestion,
|
||||
CloseProject, CloseTab,
|
||||
CloseTerminal, ConfigureProfiles, CreateAgentFromScratch, CreateAgentFromTemplate,
|
||||
CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent,
|
||||
LaunchAgentInput, SessionLimitService,
|
||||
DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate,
|
||||
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
||||
FirstRunState, GetMemory, GetProjectPermissions, GitBranches, GitCheckout, GitCommit, GitGraph,
|
||||
@ -35,7 +37,7 @@ use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector,
|
||||
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
|
||||
MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore, ProjectStore,
|
||||
PtyPort, SkillStore, TemplateStore,
|
||||
PtyPort, ScheduledTask, Scheduler, SkillStore, TemplateStore,
|
||||
};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||
@ -54,7 +56,7 @@ use infrastructure::{
|
||||
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||
OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory, SystemClock,
|
||||
SystemMillisClock, TokioBroadcastEventBus, UuidGenerator, VectorMemoryRecall,
|
||||
SystemMillisClock, TokioBroadcastEventBus, TokioScheduler, UuidGenerator, VectorMemoryRecall,
|
||||
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
|
||||
VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
@ -127,6 +129,108 @@ impl application::ProviderSessionProvider for AppProviderSessionProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// Contexte minimal de **relance** d'un agent (LS7, ARCHITECTURE §21.5).
|
||||
///
|
||||
/// [`AgentResumer::resume`] et [`ScheduledTask::ResumeAgent`] ne portent **pas** le
|
||||
/// `Project` ni la taille de la cellule, alors que [`LaunchAgentInput`] les exige.
|
||||
/// La commande `launch_agent` (seul endroit où ces faits sont en main) alimente ce
|
||||
/// contexte par `agent_id` ; [`AppAgentResumer`] le relit à l'échéance pour
|
||||
/// recomposer un lancement complet.
|
||||
#[derive(Clone)]
|
||||
pub struct ResumeContext {
|
||||
/// Le projet hôte de l'agent (pour recomposer `LaunchAgentInput`).
|
||||
pub project: Project,
|
||||
/// Hauteur de la cellule au dernier lancement (lignes PTY).
|
||||
pub rows: u16,
|
||||
/// Largeur de la cellule au dernier lancement (colonnes PTY).
|
||||
pub cols: u16,
|
||||
}
|
||||
|
||||
/// Registre partagé `agent_id → ResumeContext` (composition root ↔ commande
|
||||
/// `launch_agent`). Le **même** `Arc` est injecté dans [`AppAgentResumer`] et conservé
|
||||
/// sur [`AppState`] pour que la commande l'alimente à chaque lancement.
|
||||
pub type ResumeContexts = Arc<Mutex<HashMap<AgentId, ResumeContext>>>;
|
||||
|
||||
/// Implémente le port applicatif [`AgentResumer`] (LS7) **par-dessus** le mécanisme de
|
||||
/// lancement existant ([`LaunchAgent`]).
|
||||
///
|
||||
/// À l'échéance d'une limite de session, [`SessionLimitService::execute_resume`]
|
||||
/// délègue ici : on relit le [`ResumeContext`] alimenté par `launch_agent`, on
|
||||
/// recompose un [`LaunchAgentInput`] (avec le `conversation_id` de la cellule ⇒
|
||||
/// `LaunchAgent` applique [`domain::ports::SessionPlan::Resume`]), puis on transmet le
|
||||
/// `resume_prompt` comme **premier tour** via le portail d'entrée unique
|
||||
/// ([`InputMediator`](domain::input::InputMediator)) — jamais un write PTY brut
|
||||
/// (ARCHITECTURE §20). Sans contexte connu (resume « à l'aveugle »), on échoue
|
||||
/// proprement (l'erreur empêche `AgentResumed` d'être publié), jamais de panique.
|
||||
struct AppAgentResumer {
|
||||
/// Le **même** `Arc<LaunchAgent>` que la commande `launch_agent` (relance/réattache).
|
||||
launch_agent: Arc<LaunchAgent>,
|
||||
/// Portail d'entrée unique : injecte le prompt de reprise comme premier tour.
|
||||
input_mediator: Arc<dyn domain::input::InputMediator>,
|
||||
/// Contexte de relance alimenté par la commande `launch_agent`.
|
||||
contexts: ResumeContexts,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AgentResumer for AppAgentResumer {
|
||||
async fn resume(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
node_id: domain::NodeId,
|
||||
conversation_id: Option<String>,
|
||||
resume_prompt: &str,
|
||||
) -> Result<(), AppError> {
|
||||
// Repli propre (jamais de panique) : sans contexte de relance connu, on ne
|
||||
// reprend pas à l'aveugle. L'erreur remonte ⇒ `AgentResumed` n'est pas publié.
|
||||
let ctx = self
|
||||
.contexts
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|m| m.get(&agent_id).cloned());
|
||||
let Some(ctx) = ctx else {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"resume context for agent {agent_id}"
|
||||
)));
|
||||
};
|
||||
|
||||
// Recompose la déclaration MCP réelle (même recette que la commande
|
||||
// `launch_agent`) pour que l'agent repris retrouve ses outils `idea_*`.
|
||||
let mcp_runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime {
|
||||
exe,
|
||||
endpoint: crate::mcp_endpoint::mcp_endpoint(&ctx.project.id)
|
||||
.as_cli_arg()
|
||||
.to_owned(),
|
||||
project_id: ctx.project.id.as_uuid().simple().to_string(),
|
||||
requester: agent_id.to_string(),
|
||||
});
|
||||
|
||||
self.launch_agent
|
||||
.execute(LaunchAgentInput {
|
||||
project: ctx.project,
|
||||
agent_id,
|
||||
rows: ctx.rows,
|
||||
cols: ctx.cols,
|
||||
node_id: Some(node_id),
|
||||
conversation_id,
|
||||
mcp_runtime,
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Premier tour de reprise par le **portail d'entrée** (pas de write brut, §20) :
|
||||
// l'enqueue publie `DelegationReady`, la cellule l'écrit quand le prompt est prêt.
|
||||
// On ne corrèle aucune réponse (reprise, pas une délégation) ⇒ on lâche le
|
||||
// `PendingReply`.
|
||||
let ticket = domain::mailbox::Ticket::new(
|
||||
domain::mailbox::TicketId::new_random(),
|
||||
"IdeA",
|
||||
resume_prompt,
|
||||
);
|
||||
let _ = self.input_mediator.enqueue(agent_id, ticket);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the IPC layer needs at runtime, managed by Tauri.
|
||||
///
|
||||
/// Use cases are stored behind `Arc` so handlers clone cheaply. The concrete
|
||||
@ -339,6 +443,15 @@ pub struct AppState {
|
||||
/// stopped on close, exactly like the watcher, via the same `Mutex`-guarded
|
||||
/// per-project registry.
|
||||
pub mcp_servers: Mutex<HashMap<ProjectId, McpServerHandle>>,
|
||||
/// Service de gestion des **limites de session** des agents (ARCHITECTURE §21) :
|
||||
/// détecte → planifie → reprend, et annule. Alimenté par les taps niveau 1
|
||||
/// (structuré, `agent_send`) et niveau 2 (PTY, `launch_agent`) ; sa reprise auto est
|
||||
/// annulable via la commande `cancel_resume`.
|
||||
pub session_limit_service: Arc<SessionLimitService>,
|
||||
/// Registre `agent_id → ResumeContext` (LS7) partagé avec [`AppAgentResumer`] :
|
||||
/// la commande `launch_agent` y dépose le `Project`/taille du dernier lancement pour
|
||||
/// que la reprise auto puisse recomposer un `LaunchAgentInput` complet.
|
||||
pub resume_contexts: ResumeContexts,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@ -910,6 +1023,45 @@ impl AppState {
|
||||
});
|
||||
}
|
||||
let input_mediator = Arc::clone(&mediated_inbox) as Arc<dyn domain::input::InputMediator>;
|
||||
|
||||
// --- Limites de session des agents (ARCHITECTURE §21, LS7) ---
|
||||
// Service pur-ports « détecter → planifier → reprendre » câblé sur l'existant :
|
||||
// l'horloge système, le bus partagé, un `TokioScheduler` (minuterie one-shot
|
||||
// annulable) dont les tâches échues sont drainées plus bas, et un `AppAgentResumer`
|
||||
// qui relance via le *même* `LaunchAgent` que la commande `launch_agent`.
|
||||
let resume_contexts: ResumeContexts = Arc::new(Mutex::new(HashMap::new()));
|
||||
let (resume_tx, mut resume_rx) = tokio::sync::mpsc::unbounded_channel::<ScheduledTask>();
|
||||
let scheduler = Arc::new(TokioScheduler::new(
|
||||
resume_tx,
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
)) as Arc<dyn Scheduler>;
|
||||
let resumer = Arc::new(AppAgentResumer {
|
||||
launch_agent: Arc::clone(&launch_agent),
|
||||
input_mediator: Arc::clone(&input_mediator),
|
||||
contexts: Arc::clone(&resume_contexts),
|
||||
}) as Arc<dyn AgentResumer>;
|
||||
let session_limit_service = Arc::new(SessionLimitService::new(
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
scheduler,
|
||||
Arc::clone(&events_port),
|
||||
resumer,
|
||||
));
|
||||
// Drain du scheduler (§21.5-b) : à chaque réveil tiré, `TokioScheduler` pousse une
|
||||
// `ScheduledTask::ResumeAgent` ; on l'exécute via le service (relance + AgentResumed).
|
||||
// Patron `sweep_stalled` : `tauri::async_runtime::spawn` (pas `tokio::spawn` — `build`
|
||||
// tourne dans le hook `setup` sans runtime ambiant). Détaché ⇒ vit autant que l'app.
|
||||
{
|
||||
let service = Arc::clone(&session_limit_service);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
while let Some(task) = resume_rx.recv().await {
|
||||
if let Err(e) = service.execute_resume(task).await {
|
||||
// Best-effort : une relance qui échoue ne fige pas le drain.
|
||||
eprintln!("[session-limit] reprise auto échouée : {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Registre des conversations par paire (cadrage C3) : un fil par paire, session
|
||||
// vivante keyée par conversation (lève l'ambiguïté session/agent).
|
||||
let conversation_registry = Arc::new(InMemoryConversationRegistry::new())
|
||||
@ -1051,6 +1203,8 @@ impl AppState {
|
||||
orchestrator_service,
|
||||
orchestrator_watchers: Mutex::new(HashMap::new()),
|
||||
mcp_servers: Mutex::new(HashMap::new()),
|
||||
session_limit_service,
|
||||
resume_contexts,
|
||||
move_tab,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "IdeA",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"identifier": "app.idea.ide",
|
||||
"build": {
|
||||
"frontendDist": "../../frontend/dist",
|
||||
|
||||
@ -309,6 +309,7 @@ fn launch_agent_output_maps_to_terminal_session_dto() {
|
||||
assigned_conversation_id: None,
|
||||
engine_session_id: None,
|
||||
structured: None,
|
||||
profile: None,
|
||||
};
|
||||
let dto = TerminalSessionDto::from(out);
|
||||
|
||||
@ -354,6 +355,7 @@ fn launch_agent_output_propagates_assigned_conversation_id() {
|
||||
assigned_conversation_id: Some("conv-xyz".to_owned()),
|
||||
engine_session_id: None,
|
||||
structured: None,
|
||||
profile: None,
|
||||
};
|
||||
let dto = TerminalSessionDto::from(out);
|
||||
|
||||
|
||||
@ -157,6 +157,7 @@ fn launch_output_with_structured_descriptor_derives_chat_cell_kind() {
|
||||
assigned_conversation_id: None,
|
||||
engine_session_id: None,
|
||||
structured: Some(descriptor),
|
||||
profile: None,
|
||||
};
|
||||
let dto = TerminalSessionDto::from(out);
|
||||
assert_eq!(dto.cell_kind, CellKind::Chat);
|
||||
@ -173,6 +174,7 @@ fn launch_output_without_structured_descriptor_derives_pty_cell_kind() {
|
||||
assigned_conversation_id: None,
|
||||
engine_session_id: None,
|
||||
structured: None,
|
||||
profile: None,
|
||||
};
|
||||
let dto = TerminalSessionDto::from(out);
|
||||
assert_eq!(dto.cell_kind, CellKind::Pty, "no descriptor ⇒ pty");
|
||||
@ -207,6 +209,7 @@ fn pty_launch_output_serialises_cellkind_pty_without_breaking_existing_keys() {
|
||||
assigned_conversation_id: None,
|
||||
engine_session_id: None,
|
||||
structured: None,
|
||||
profile: None,
|
||||
};
|
||||
let v = serde_json::to_value(TerminalSessionDto::from(out)).unwrap();
|
||||
assert_eq!(v["sessionId"], sid.to_string());
|
||||
|
||||
167
crates/app-tauri/tests/session_limit_wiring.rs
Normal file
167
crates/app-tauri/tests/session_limit_wiring.rs
Normal file
@ -0,0 +1,167 @@
|
||||
//! Integration test for the **session-limit** wiring in the composition root
|
||||
//! (ARCHITECTURE §21, LS7).
|
||||
//!
|
||||
//! Proves that [`AppState`] actually *constructs and wires* the
|
||||
//! `SessionLimitService` (the gap LS7 closes: the LS1–LS4 domain/application
|
||||
//! machinery existed but was never instantiated at runtime). The detect→plan→
|
||||
//! resume→cancel logic itself is covered by the application/infrastructure unit
|
||||
//! tests (LS3/LS4); here we assert the **composition** the commands rely on:
|
||||
//! the service is present, `on_rate_limited` arms a cancellable resume that emits
|
||||
//! the domain events on the real bus, and `cancel_resume` is a clean no-op for an
|
||||
//! unknown agent (never a panic).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use app_tauri_lib::state::AppState;
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ids::NodeId;
|
||||
use domain::AgentId;
|
||||
use infrastructure::UuidGenerator;
|
||||
use domain::ports::IdGenerator;
|
||||
|
||||
fn temp_path(tag: &str) -> PathBuf {
|
||||
let ids = UuidGenerator::new();
|
||||
std::env::temp_dir().join(format!("idea-sl-test-{tag}-{}", ids.new_uuid()))
|
||||
}
|
||||
|
||||
fn agent(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn node(n: u128) -> NodeId {
|
||||
NodeId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
/// `cancel_resume` on an agent with no armed resume returns `false` (and never
|
||||
/// panics): the service is wired and behaves as a clean no-op.
|
||||
#[tokio::test]
|
||||
async fn cancel_resume_is_a_clean_noop_for_unknown_agent() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
assert!(
|
||||
!state.session_limit_service.cancel_resume(agent(1)),
|
||||
"no armed resume ⇒ cancel returns false"
|
||||
);
|
||||
}
|
||||
|
||||
/// A structured rate-limit signal with a **future** reset arms a cancellable
|
||||
/// resume: the wired service publishes `AgentRateLimited` then
|
||||
/// `AgentResumeScheduled` on the **real** event bus, and `cancel_resume` then
|
||||
/// publishes `AgentResumeCancelled` and returns `true`.
|
||||
#[tokio::test]
|
||||
async fn on_rate_limited_arms_a_cancellable_resume_over_the_real_bus() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
let mut rx = state.event_bus.raw_receiver();
|
||||
|
||||
// Reset far in the future so the scheduler does NOT fire before we cancel.
|
||||
let resets_at_ms = i64::MAX;
|
||||
state
|
||||
.session_limit_service
|
||||
.on_rate_limited(agent(7), node(70), None, Some(resets_at_ms));
|
||||
|
||||
// The two arming events, in order, on the bus.
|
||||
let mut saw_rate_limited = false;
|
||||
let mut saw_scheduled = false;
|
||||
for _ in 0..32 {
|
||||
match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
|
||||
Ok(Ok(DomainEvent::AgentRateLimited { agent_id, .. })) if agent_id == agent(7) => {
|
||||
saw_rate_limited = true;
|
||||
}
|
||||
Ok(Ok(DomainEvent::AgentResumeScheduled { agent_id, .. })) if agent_id == agent(7) => {
|
||||
saw_scheduled = true;
|
||||
break;
|
||||
}
|
||||
Ok(Ok(_)) => continue,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
assert!(saw_rate_limited, "AgentRateLimited relayed on the bus");
|
||||
assert!(saw_scheduled, "AgentResumeScheduled relayed on the bus");
|
||||
|
||||
// The window is cancellable: cancel succeeds and emits AgentResumeCancelled.
|
||||
assert!(
|
||||
state.session_limit_service.cancel_resume(agent(7)),
|
||||
"an armed resume is cancellable"
|
||||
);
|
||||
let mut saw_cancelled = false;
|
||||
for _ in 0..32 {
|
||||
match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
|
||||
Ok(Ok(DomainEvent::AgentResumeCancelled { agent_id })) if agent_id == agent(7) => {
|
||||
saw_cancelled = true;
|
||||
break;
|
||||
}
|
||||
Ok(Ok(_)) => continue,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
assert!(saw_cancelled, "AgentResumeCancelled relayed on the bus");
|
||||
}
|
||||
|
||||
/// `set_resume_at` (filet humain niveau 3) résout l'agent→cellule dans les registries
|
||||
/// des sessions vivantes ; **sans cellule vivante**, la résolution échoue ⇒ la commande
|
||||
/// renvoie `NOT_FOUND` sans armer quoi que ce soit (pas d'armement orphelin).
|
||||
///
|
||||
/// La commande `#[tauri::command]` exige une `State<AppState>` non constructible hors
|
||||
/// runtime Tauri ; on couvre donc ici sa **précondition exacte** : sur un `AppState`
|
||||
/// neuf (aucune session vivante), aucune registry ne résout l'agent — c'est précisément
|
||||
/// le `None` qui produit le `NOT_FOUND` dans `set_resume_at` (cf. commands.rs:1420-1428).
|
||||
#[tokio::test]
|
||||
async fn set_resume_at_resolves_no_cell_for_an_agent_without_a_live_session() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
let unknown = agent(99);
|
||||
|
||||
assert!(
|
||||
state.structured_sessions.node_for_agent(&unknown).is_none(),
|
||||
"aucune session structurée vivante ⇒ pas de cellule"
|
||||
);
|
||||
assert!(
|
||||
state.terminal_sessions.node_for_agent(&unknown).is_none(),
|
||||
"aucune session terminal vivante ⇒ pas de cellule"
|
||||
);
|
||||
// Conjonction = la branche `ok_or_else(NotFound)` de set_resume_at est prise :
|
||||
// la saisie d'heure n'a pas de cible ⇒ aucun armement orphelin.
|
||||
let resolved = state
|
||||
.structured_sessions
|
||||
.node_for_agent(&unknown)
|
||||
.or_else(|| state.terminal_sessions.node_for_agent(&unknown));
|
||||
assert!(resolved.is_none(), "set_resume_at ⇒ NOT_FOUND (aucun armement orphelin)");
|
||||
}
|
||||
|
||||
/// Parité runtime du filet humain : `confirm_human_resume` (la délégation de
|
||||
/// `set_resume_at`) arme une reprise annulable sur le **vrai** bus — `AgentRateLimited`
|
||||
/// puis `AgentResumeScheduled` — exactement comme la branche auto `on_rate_limited`.
|
||||
#[tokio::test]
|
||||
async fn confirm_human_resume_arms_a_cancellable_resume_over_the_real_bus() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
let mut rx = state.event_bus.raw_receiver();
|
||||
|
||||
// Reset très loin dans le futur : le scheduler ne tire pas avant l'annulation.
|
||||
let resets_at_ms = i64::MAX;
|
||||
state
|
||||
.session_limit_service
|
||||
.confirm_human_resume(agent(8), node(80), None, resets_at_ms);
|
||||
|
||||
let mut saw_rate_limited = false;
|
||||
let mut saw_scheduled = false;
|
||||
for _ in 0..32 {
|
||||
match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
|
||||
Ok(Ok(DomainEvent::AgentRateLimited { agent_id, .. })) if agent_id == agent(8) => {
|
||||
saw_rate_limited = true;
|
||||
}
|
||||
Ok(Ok(DomainEvent::AgentResumeScheduled { agent_id, .. })) if agent_id == agent(8) => {
|
||||
saw_scheduled = true;
|
||||
break;
|
||||
}
|
||||
Ok(Ok(_)) => continue,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
assert!(saw_rate_limited, "AgentRateLimited relayed on the bus (humain)");
|
||||
assert!(saw_scheduled, "AgentResumeScheduled relayed on the bus (humain)");
|
||||
|
||||
// Annulable par la même voie que l'auto.
|
||||
assert!(
|
||||
state.session_limit_service.cancel_resume(agent(8)),
|
||||
"an armed human resume is cancellable"
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "application"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
@ -936,6 +936,15 @@ pub struct LaunchAgentOutput {
|
||||
/// avec les use cases A/B et le câblage qui ne lisent que `session` /
|
||||
/// `assigned_conversation_id`.
|
||||
pub structured: Option<StructuredSessionDescriptor>,
|
||||
/// Le **profil résolu** de l'agent lors d'un (re)lancement *effectif* (LS7,
|
||||
/// ARCHITECTURE §21.10-4). `LaunchAgent::execute` le résout déjà en interne (zéro
|
||||
/// I/O supplémentaire) ; on l'expose pour que le câblage app-tauri décide d'armer —
|
||||
/// ou non — le détecteur de limite **niveau 2** (PTY) via la règle de sélection
|
||||
/// `infrastructure::ratelimit::applies` (anti-double-détection N1/N2), sans
|
||||
/// reconstruire la frontière infra côté domaine. `None` sur une **réattache**
|
||||
/// (rebind/idempotent) où aucun profil n'est re-résolu — le câblage n'arme alors
|
||||
/// rien (best-effort, le tour est sans nouvelle session).
|
||||
pub profile: Option<AgentProfile>,
|
||||
}
|
||||
|
||||
/// Registry mapping each [`ProjectorKey`] to its concrete
|
||||
@ -1331,6 +1340,8 @@ impl LaunchAgent {
|
||||
assigned_conversation_id: None,
|
||||
engine_session_id: None,
|
||||
structured: None,
|
||||
// Réattache (rebind de vue) : aucun profil re-résolu.
|
||||
profile: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1350,6 +1361,8 @@ impl LaunchAgent {
|
||||
assigned_conversation_id: None,
|
||||
engine_session_id: None,
|
||||
structured: None,
|
||||
// Idempotent (pas de respawn) : aucun profil re-résolu.
|
||||
profile: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1388,6 +1401,8 @@ impl LaunchAgent {
|
||||
node_id,
|
||||
conversation_id: existing.conversation_id(),
|
||||
}),
|
||||
// Réattache structurée (rebind de vue) : aucun profil re-résolu.
|
||||
profile: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1605,6 +1620,9 @@ impl LaunchAgent {
|
||||
// Chemin PTY/terminal brut : pas de session moteur structurée à cacher.
|
||||
engine_session_id: None,
|
||||
structured: None,
|
||||
// Lancement effectif : profil résolu exposé pour la sélection du détecteur
|
||||
// de limite niveau 2 (§21.10-4) côté câblage.
|
||||
profile: Some(profile.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
@ -1690,6 +1708,9 @@ impl LaunchAgent {
|
||||
node_id,
|
||||
conversation_id: engine_session_id,
|
||||
}),
|
||||
// Lancement structuré effectif : profil résolu exposé (le tap niveau 2 PTY ne
|
||||
// l'arme pas — `applies` est faux pour un profil structuré, §21.10-4).
|
||||
profile: Some(profile.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -10,13 +10,17 @@ mod catalogue;
|
||||
mod inspect;
|
||||
mod lifecycle;
|
||||
mod resume;
|
||||
mod session_limit;
|
||||
mod structured;
|
||||
mod usecases;
|
||||
|
||||
pub(crate) use lifecycle::unique_md_path;
|
||||
pub(crate) use lifecycle::ReattachDecision;
|
||||
|
||||
pub use structured::{drain_with_readiness, send_blocking};
|
||||
pub use session_limit::{AgentResumer, SessionLimitService, RESUME_PROMPT};
|
||||
pub use structured::{
|
||||
drain_with_readiness, drain_with_readiness_outcome, send_blocking, TurnOutcome,
|
||||
};
|
||||
|
||||
pub use catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles};
|
||||
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
|
||||
|
||||
281
crates/application/src/agent/session_limit.rs
Normal file
281
crates/application/src/agent/session_limit.rs
Normal file
@ -0,0 +1,281 @@
|
||||
//! [`SessionLimitService`] — orchestration applicative des **limites de session**
|
||||
//! des agents (ARCHITECTURE §21.5) : **détecter → planifier → reprendre**, et
|
||||
//! **annuler**.
|
||||
//!
|
||||
//! Service **pur-ports** (SOLID/hexagonal) : il ne dépend que de traits du domaine
|
||||
//! ([`Clock`], [`Scheduler`], [`EventBus`]) et d'un port applicatif de reprise
|
||||
//! ([`AgentResumer`], implémenté au composition root en LS7 par-dessus `LaunchAgent`).
|
||||
//! Aucune dépendance vers un adapter concret ⇒ entièrement testable avec des fakes.
|
||||
//!
|
||||
//! # État en mémoire uniquement (§21.1-3)
|
||||
//!
|
||||
//! La seule mémoire du service est une table `agent_id → ScheduleId` des reprises
|
||||
//! **armées** (pour pouvoir les annuler). Aucune persistance : à un redémarrage
|
||||
//! d'IdeA le chemin `ListResumableAgents` existant prend le relais.
|
||||
//!
|
||||
//! # Dédoublonnage par agent (§21.10-4)
|
||||
//!
|
||||
//! Un agent n'a qu'**une** reprise armée à la fois : un second signal de limite
|
||||
//! **rafraîchit** l'armement (annule l'ancien, arme le nouveau) au lieu d'empiler.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::ids::{AgentId, NodeId, ScheduleId};
|
||||
use domain::ports::{Clock, EventBus, ScheduledTask, Scheduler};
|
||||
use domain::session_limit::{plan_resume, RateLimitSource, ResumePlan, SessionLimit};
|
||||
use domain::DomainEvent;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Prompt **court** envoyé à l'agent au moment de la reprise automatique (§21.5).
|
||||
///
|
||||
/// `--resume` (via [`domain::ports::SessionPlan::Resume`]) porte déjà tout
|
||||
/// l'historique : ce prompt n'a qu'à **réamorcer** le tour, pas reconstruire le
|
||||
/// contexte. Volontairement neutre et model-agnostique.
|
||||
pub const RESUME_PROMPT: &str =
|
||||
"La limite de session est levée. Reprends là où tu t'étais arrêté.";
|
||||
|
||||
/// Port applicatif de **reprise d'un agent** (frontière implémentée au composition
|
||||
/// root, LS7). Calqué sur les autres traits-passerelles de l'application
|
||||
/// ([`crate::agent::HandoffProvider`], [`crate::agent::ProviderSessionProvider`]) :
|
||||
/// l'app-tauri le branche par-dessus le mécanisme de lancement existant
|
||||
/// (`LaunchAgent` + `AgentSessionFactory`) avec [`domain::ports::SessionPlan::Resume`].
|
||||
///
|
||||
/// Le service ne sait **pas** relancer un agent lui-même (cela exige le `Project`, le
|
||||
/// profil, le contexte préparé, le PTY… que seul `LaunchAgent` résout) ; il délègue
|
||||
/// donc à ce port, en restant testable avec un fake.
|
||||
#[async_trait]
|
||||
pub trait AgentResumer: Send + Sync {
|
||||
/// Relance/réattache l'agent `agent_id` dans sa cellule `node_id`, en reprenant la
|
||||
/// conversation moteur `conversation_id` (`SessionPlan::Resume` côté lancement) et
|
||||
/// en lui transmettant `resume_prompt` comme premier tour.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] si la relance échoue (profil/contexte introuvable, échec de
|
||||
/// démarrage de session…). Le service propage l'erreur sans publier `AgentResumed`.
|
||||
async fn resume(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
node_id: NodeId,
|
||||
conversation_id: Option<String>,
|
||||
resume_prompt: &str,
|
||||
) -> Result<(), AppError>;
|
||||
}
|
||||
|
||||
/// Service d'orchestration des limites de session (§21.5).
|
||||
pub struct SessionLimitService {
|
||||
clock: Arc<dyn Clock>,
|
||||
scheduler: Arc<dyn Scheduler>,
|
||||
events: Arc<dyn EventBus>,
|
||||
resumer: Arc<dyn AgentResumer>,
|
||||
/// Reprises **armées** non encore tirées : `agent_id → ScheduleId` (en mémoire).
|
||||
armed: Mutex<HashMap<AgentId, ScheduleId>>,
|
||||
}
|
||||
|
||||
impl SessionLimitService {
|
||||
/// Construit le service depuis ses ports injectés (composition root).
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
clock: Arc<dyn Clock>,
|
||||
scheduler: Arc<dyn Scheduler>,
|
||||
events: Arc<dyn EventBus>,
|
||||
resumer: Arc<dyn AgentResumer>,
|
||||
) -> Self {
|
||||
Self {
|
||||
clock,
|
||||
scheduler,
|
||||
events,
|
||||
resumer,
|
||||
armed: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// **(a) Détection → planification.** À partir d'un signal de limite
|
||||
/// (`ReadinessSignal::RateLimited`/`TurnOutcome::RateLimited`) pour `agent_id` dans
|
||||
/// la cellule `node_id`, construit un [`SessionLimit`] (source `Structured`),
|
||||
/// calcule le plan via [`plan_resume`] et agit :
|
||||
///
|
||||
/// - [`ResumePlan::Scheduled`] ⇒ publie `AgentRateLimited`, **arme** la reprise via
|
||||
/// [`Scheduler::arm`] (dédoublonnée : un éventuel armement antérieur du même agent
|
||||
/// est annulé d'abord, §21.10-4), mémorise le [`ScheduleId`], puis publie
|
||||
/// `AgentResumeScheduled`.
|
||||
/// - [`ResumePlan::HumanFallback`] (heure de reset inconnue) ⇒ publie
|
||||
/// `AgentRateLimited{None}` puis `AgentRateLimitSuspected{None}` (filet humain ;
|
||||
/// la confirmation UI est LS6/LS8 — ici on émet seulement l'événement).
|
||||
pub fn on_rate_limited(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
node_id: NodeId,
|
||||
conversation_id: Option<String>,
|
||||
resets_at_ms: Option<i64>,
|
||||
) {
|
||||
let now = self.clock.now_millis();
|
||||
let limit = SessionLimit::new(resets_at_ms, now, RateLimitSource::Structured);
|
||||
|
||||
match plan_resume(now, &limit, conversation_id) {
|
||||
ResumePlan::Scheduled {
|
||||
fire_at_ms,
|
||||
conversation_id,
|
||||
} => {
|
||||
self.arm_scheduled(agent_id, fire_at_ms, node_id, conversation_id, resets_at_ms);
|
||||
}
|
||||
ResumePlan::HumanFallback => {
|
||||
self.events.publish(DomainEvent::AgentRateLimited {
|
||||
agent_id,
|
||||
resets_at_ms: None,
|
||||
});
|
||||
self.events.publish(DomainEvent::AgentRateLimitSuspected {
|
||||
agent_id,
|
||||
resets_at_ms: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **(d) Filet humain (§21.1 niveau 3).** L'utilisateur a saisi l'heure de reset
|
||||
/// pour un agent en limite **suspectée** (rien n'a matché automatiquement). On
|
||||
/// construit une [`SessionLimit`] de source [`RateLimitSource::Human`], on calcule
|
||||
/// le plan via [`plan_resume`] et on **arme exactement la même reprise** que la
|
||||
/// branche auto : mêmes événements (`AgentRateLimited{Some}` + `AgentResumeScheduled`),
|
||||
/// même dédoublonnage, même annulabilité via [`Self::cancel_resume`].
|
||||
///
|
||||
/// L'heure saisie est traitée par le domaine sans privilège particulier : un reset
|
||||
/// déjà passé est clampé à `now` par [`plan_resume`] ⇒ reprise immédiate. Le cas
|
||||
/// [`ResumePlan::HumanFallback`] est ici inatteignable (`resets_at_ms` est toujours
|
||||
/// `Some`) ; on le traite en no-op défensif pour rester total.
|
||||
pub fn confirm_human_resume(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
node_id: NodeId,
|
||||
conversation_id: Option<String>,
|
||||
resets_at_ms: i64,
|
||||
) {
|
||||
let now = self.clock.now_millis();
|
||||
let limit = SessionLimit::new(Some(resets_at_ms), now, RateLimitSource::Human);
|
||||
|
||||
if let ResumePlan::Scheduled {
|
||||
fire_at_ms,
|
||||
conversation_id,
|
||||
} = plan_resume(now, &limit, conversation_id)
|
||||
{
|
||||
self.arm_scheduled(agent_id, fire_at_ms, node_id, conversation_id, Some(resets_at_ms));
|
||||
}
|
||||
}
|
||||
|
||||
/// Arme (ou ré-arme) une reprise **programmée** pour `agent_id`, fabrique commune aux
|
||||
/// deux entrées (auto §21.1 niveaux 1/2 et filet humain niveau 3). Séquence stricte,
|
||||
/// identique à l'origine — d'où **zéro régression** : publie `AgentRateLimited`
|
||||
/// (avec l'heure de reset connue), **dédoublonne** l'armement précédent via
|
||||
/// [`Self::disarm`] (interne, sans événement), arme le réveil via [`Scheduler::arm`],
|
||||
/// mémorise le [`ScheduleId`], puis publie `AgentResumeScheduled`.
|
||||
///
|
||||
/// `resets_at_ms` est l'heure de reset **annoncée à l'UI** (countdown) ; `fire_at_ms`
|
||||
/// est l'échéance effective (déjà clampée anti-passé par le domaine). Les deux ne
|
||||
/// coïncident que si le reset est futur — on conserve donc la sémantique d'origine en
|
||||
/// publiant l'heure de reset brute, pas l'échéance clampée.
|
||||
fn arm_scheduled(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
fire_at_ms: i64,
|
||||
node_id: NodeId,
|
||||
conversation_id: Option<String>,
|
||||
resets_at_ms: Option<i64>,
|
||||
) {
|
||||
self.events.publish(DomainEvent::AgentRateLimited {
|
||||
agent_id,
|
||||
resets_at_ms,
|
||||
});
|
||||
// Dédoublonnage (§21.10-4) : un signal de rafraîchissement annule
|
||||
// l'armement précédent (sans événement d'annulation : c'est interne).
|
||||
self.disarm(agent_id);
|
||||
let id = self.scheduler.arm(
|
||||
fire_at_ms,
|
||||
ScheduledTask::ResumeAgent {
|
||||
agent_id,
|
||||
node_id,
|
||||
conversation_id,
|
||||
},
|
||||
);
|
||||
self.armed.lock().expect("session-limit mutex sain").insert(agent_id, id);
|
||||
self.events
|
||||
.publish(DomainEvent::AgentResumeScheduled { agent_id, fire_at_ms });
|
||||
}
|
||||
|
||||
/// **(b) Exécution de la reprise.** Consomme une [`ScheduledTask::ResumeAgent`]
|
||||
/// échue (celle que `TokioScheduler` pousse dans le canal de remise ; le câblage du
|
||||
/// récepteur dans le runtime est LS7). Retire l'entrée armée (le réveil a tiré),
|
||||
/// relance l'agent via [`AgentResumer`] avec [`RESUME_PROMPT`], puis publie
|
||||
/// `AgentResumed`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] propagée par [`AgentResumer::resume`] (la relance a échoué) ; dans
|
||||
/// ce cas `AgentResumed` n'est **pas** publié.
|
||||
pub async fn execute_resume(&self, task: ScheduledTask) -> Result<(), AppError> {
|
||||
let ScheduledTask::ResumeAgent {
|
||||
agent_id,
|
||||
node_id,
|
||||
conversation_id,
|
||||
} = task;
|
||||
|
||||
// Le réveil a tiré : l'entrée armée n'a plus lieu d'être (qu'on réussisse ou non).
|
||||
self.disarm(agent_id);
|
||||
|
||||
self.resumer
|
||||
.resume(agent_id, node_id, conversation_id, RESUME_PROMPT)
|
||||
.await?;
|
||||
|
||||
self.events.publish(DomainEvent::AgentResumed { agent_id });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// **(c) Annulation.** Désarme la reprise auto de `agent_id` (socle du « annulable »).
|
||||
/// Retrouve le [`ScheduleId`], appelle [`Scheduler::cancel`] et, **seulement si**
|
||||
/// l'annulation a réussi, retire l'entrée et publie `AgentResumeCancelled`.
|
||||
///
|
||||
/// Renvoie `true` ssi la reprise a effectivement été annulée.
|
||||
///
|
||||
/// # Course « cancel pile au tir » (vigilance QA, LS3)
|
||||
/// Sous runtime multi-thread, le réveil peut tirer **pile** au moment de l'annulation :
|
||||
/// [`Scheduler::cancel`] renvoie alors `false` (déjà tiré). Dans ce cas on **ne
|
||||
/// publie pas** `AgentResumeCancelled` et on **laisse l'entrée** (l'`execute_resume`
|
||||
/// en cours la retirera) : la reprise **suit son cours**, cohérent et sans
|
||||
/// événement trompeur.
|
||||
pub fn cancel_resume(&self, agent_id: AgentId) -> bool {
|
||||
let id = self
|
||||
.armed
|
||||
.lock()
|
||||
.expect("session-limit mutex sain")
|
||||
.get(&agent_id)
|
||||
.copied();
|
||||
let Some(id) = id else {
|
||||
return false; // aucune reprise armée pour cet agent.
|
||||
};
|
||||
|
||||
if self.scheduler.cancel(id) {
|
||||
self.armed.lock().expect("session-limit mutex sain").remove(&agent_id);
|
||||
self.events
|
||||
.publish(DomainEvent::AgentResumeCancelled { agent_id });
|
||||
true
|
||||
} else {
|
||||
// Déjà tiré : la reprise suivra son cours, pas d'événement d'annulation.
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Retire (best-effort) l'armement de `agent_id` et annule le réveil sous-jacent
|
||||
/// s'il existe. Usage interne (rafraîchissement / nettoyage post-tir) — **ne publie
|
||||
/// aucun événement** (contrairement à [`Self::cancel_resume`]).
|
||||
fn disarm(&self, agent_id: AgentId) {
|
||||
let previous = self
|
||||
.armed
|
||||
.lock()
|
||||
.expect("session-limit mutex sain")
|
||||
.remove(&agent_id);
|
||||
if let Some(id) = previous {
|
||||
self.scheduler.cancel(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -19,6 +19,31 @@ use domain::ids::AgentId;
|
||||
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent};
|
||||
use domain::readiness::{ReadinessPolicy, ReadinessSignal};
|
||||
|
||||
/// Issue d'un tour drainé (ARCHITECTURE §21.2-T4, réconciliation de la limite de
|
||||
/// session avec le contrat « seul `Final` est terminal »).
|
||||
///
|
||||
/// Un tour se termine de deux façons **gracieuses** :
|
||||
/// - [`TurnOutcome::Completed`] : le flux a rendu son [`ReplyEvent::Final`] — fin
|
||||
/// déterministe normale (cas historique, contenu agrégé) ;
|
||||
/// - [`TurnOutcome::RateLimited`] : le flux s'est **clos sans `Final`** *parce que*
|
||||
/// l'agent est entré en **limite de session** (un [`ReplyEvent::RateLimited`] a été
|
||||
/// observé dans le tour). Ce n'est **pas** une erreur (§21.2-T4) : l'agent reste
|
||||
/// vivant, le service de limite (lot LS4) arme la reprise.
|
||||
///
|
||||
/// Un flux clos **sans `Final` ET sans `RateLimited`** reste une **erreur**
|
||||
/// [`AgentSessionError::Io`] (tour réellement interrompu) — comportement inchangé.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TurnOutcome {
|
||||
/// Tour terminé normalement par un `Final` ; porte le contenu agrégé.
|
||||
Completed(String),
|
||||
/// Tour clos sur une **limite de session** (sans `Final`) ; porte l'heure de
|
||||
/// reset éventuelle (époche-ms) telle que vue dans le dernier `RateLimited`.
|
||||
RateLimited {
|
||||
/// Instant de reset en époche-ms (`None` ⇒ heure inconnue ⇒ filet humain).
|
||||
resets_at_ms: Option<i64>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Envoie `prompt` à la session vivante puis **draine le flux de réponse jusqu'au
|
||||
/// [`ReplyEvent::Final`]**, et retourne son contenu agrégé.
|
||||
///
|
||||
@ -36,14 +61,23 @@ use domain::readiness::{ReadinessPolicy, ReadinessSignal};
|
||||
/// - [`AgentSessionError::Io`]/[`AgentSessionError::Decode`] remontées par `send`
|
||||
/// (échec de communication / décodage de la sortie structurée) ;
|
||||
/// - [`AgentSessionError::Io`] si le flux se termine **sans** `Final` (tour
|
||||
/// interrompu) ;
|
||||
/// interrompu) — y compris un tour clos sur une **limite de session** (le
|
||||
/// rendez-vous synchrone n'a pas de contenu à rendre ; cf. [`TurnOutcome`] et la
|
||||
/// variante riche [`drain_with_readiness_outcome`] pour exploiter la limite) ;
|
||||
/// - [`AgentSessionError::Timeout`] si `timeout` expire avant le `Final`.
|
||||
pub async fn send_blocking(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<String, AgentSessionError> {
|
||||
drain_bounded_events(session, prompt, timeout, |_event| {}, |_signal| {}).await
|
||||
match drain_bounded_events(session, prompt, timeout, |_event| {}, |_signal| {}).await? {
|
||||
TurnOutcome::Completed(content) => Ok(content),
|
||||
// Le rendez-vous synchrone (ask) attend un contenu : un tour limité n'en a pas
|
||||
// ⇒ on conserve le comportement historique (erreur), sans casser le contrat.
|
||||
TurnOutcome::RateLimited { .. } => Err(AgentSessionError::Io(
|
||||
"le tour s'est clos en limite de session, sans contenu Final".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Comme [`send_blocking`], mais **branche la readiness** : à chaque événement du
|
||||
@ -61,7 +95,9 @@ pub async fn send_blocking(
|
||||
///
|
||||
/// # Errors
|
||||
/// Identiques à [`send_blocking`] (échec `send`/décodage, flux clos sans `Final`,
|
||||
/// timeout).
|
||||
/// timeout). Un tour clos sur une **limite de session** ⇒ [`AgentSessionError::Io`]
|
||||
/// **ici** (signature historique `Result<String>`, zéro régression pour l'appelant
|
||||
/// orchestrateur) ; utilise [`drain_with_readiness_outcome`] pour exploiter la limite.
|
||||
pub async fn drain_with_readiness(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
@ -69,6 +105,38 @@ pub async fn drain_with_readiness(
|
||||
mediator: &dyn InputMediator,
|
||||
agent: AgentId,
|
||||
) -> Result<String, AgentSessionError> {
|
||||
match drain_with_readiness_outcome(session, prompt, timeout, mediator, agent).await? {
|
||||
TurnOutcome::Completed(content) => Ok(content),
|
||||
TurnOutcome::RateLimited { .. } => Err(AgentSessionError::Io(
|
||||
"le tour s'est clos en limite de session, sans contenu Final".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Variante **riche** de [`drain_with_readiness`] : même branchement readiness, mais
|
||||
/// retourne le [`TurnOutcome`] complet au lieu de réduire la limite à une erreur.
|
||||
///
|
||||
/// C'est le point d'entrée du chemin **conscient de la limite** (réconciliation
|
||||
/// §21.2-T4) : sur un tour clos sans `Final` mais ayant vu un
|
||||
/// [`ReplyEvent::RateLimited`], il renvoie `Ok(`[`TurnOutcome::RateLimited`]`)` (fin
|
||||
/// gracieuse) plutôt qu'une `Io`. Le drain applicatif / le `SessionLimitService`
|
||||
/// (LS4) consomment cette issue pour armer la reprise. Le câblage de ce chemin sur
|
||||
/// le tour délégué de l'orchestrateur est du ressort de LS7.
|
||||
///
|
||||
/// **Readiness inchangée** : `mark_idle` reste piloté **uniquement** par le `Final`
|
||||
/// (`TurnEnded`) — un `RateLimited` ne marque **pas** l'agent `Idle` (§21.5 : « le
|
||||
/// `mark_idle`/FIFO reste piloté par `Final`/timeout »).
|
||||
///
|
||||
/// # Errors
|
||||
/// Comme [`drain_with_readiness`], **sauf** qu'un tour limité n'est plus une erreur
|
||||
/// (il devient [`TurnOutcome::RateLimited`]).
|
||||
pub async fn drain_with_readiness_outcome(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
timeout: Option<Duration>,
|
||||
mediator: &dyn InputMediator,
|
||||
agent: AgentId,
|
||||
) -> Result<TurnOutcome, AgentSessionError> {
|
||||
// `on_signal` ne reçoit QUE les événements terminaux (le `Final` ⇒ `TurnEnded`) :
|
||||
// la readiness ne classe pas les non-terminaux. Pour le **battement** de vivacité
|
||||
// (lot 2) on a besoin de notifier le médiateur à CHAQUE événement non terminal
|
||||
@ -84,6 +152,8 @@ pub async fn drain_with_readiness(
|
||||
}
|
||||
},
|
||||
|signal| {
|
||||
// Seul le `Final` marque `Idle` : un `RateLimited` ne fait PAS avancer la
|
||||
// FIFO (la reprise est gérée par le service de limite, §21.5).
|
||||
if signal == ReadinessSignal::TurnEnded {
|
||||
mediator.mark_idle(agent);
|
||||
}
|
||||
@ -106,7 +176,7 @@ async fn drain_bounded_events(
|
||||
timeout: Option<Duration>,
|
||||
on_event: impl FnMut(&ReplyEvent),
|
||||
on_signal: impl FnMut(ReadinessSignal),
|
||||
) -> Result<String, AgentSessionError> {
|
||||
) -> Result<TurnOutcome, AgentSessionError> {
|
||||
match timeout {
|
||||
Some(dur) => match tokio::time::timeout(
|
||||
dur,
|
||||
@ -126,18 +196,27 @@ async fn drain_bounded_events(
|
||||
///
|
||||
/// Le flux ([`domain::ports::ReplyStream`]) est un itérateur synchrone et borné :
|
||||
/// après le `Final` il ne produit plus rien. On le parcourt donc simplement
|
||||
/// jusqu'à rencontrer le `Final` (et on retourne son contenu) ; si le flux
|
||||
/// s'épuise avant, c'est un tour interrompu → erreur [`AgentSessionError::Io`].
|
||||
/// jusqu'à rencontrer le `Final` (et on retourne son contenu) ; si le flux s'épuise
|
||||
/// avant, l'issue dépend de ce qu'on a vu (réconciliation §21.2-T4) :
|
||||
/// - un [`ReplyEvent::RateLimited`] a été observé ⇒ fin **gracieuse**
|
||||
/// [`TurnOutcome::RateLimited`] (l'agent est limité, pas en erreur) ;
|
||||
/// - sinon ⇒ tour réellement interrompu → erreur [`AgentSessionError::Io`]
|
||||
/// (comportement **inchangé**).
|
||||
///
|
||||
/// Chaque événement est classé par [`ReadinessPolicy`] et le signal éventuel est
|
||||
/// remonté à `on_signal` (le `Final` ⇒ [`ReadinessSignal::TurnEnded`]). Deltas,
|
||||
/// activités et heartbeats sont non terminaux ⇒ ignorés par le rendez-vous synchrone.
|
||||
/// remonté à `on_signal` (le `Final` ⇒ [`ReadinessSignal::TurnEnded`] ; un
|
||||
/// `RateLimited` ⇒ [`ReadinessSignal::RateLimited`]). Deltas, activités et heartbeats
|
||||
/// sont non terminaux ⇒ ignorés par le rendez-vous synchrone.
|
||||
async fn drain_to_final(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
mut on_event: impl FnMut(&ReplyEvent),
|
||||
mut on_signal: impl FnMut(ReadinessSignal),
|
||||
) -> Result<String, AgentSessionError> {
|
||||
) -> Result<TurnOutcome, AgentSessionError> {
|
||||
// Mémorise la dernière limite vue (§21.2-T4) : `Some(resets_at_ms)` dès qu'un
|
||||
// `RateLimited` traverse le flux. Sert UNIQUEMENT au cas « clos sans Final » —
|
||||
// un `Final` ultérieur l'emporte toujours (le tour a réellement abouti).
|
||||
let mut last_rate_limit: Option<Option<i64>> = None;
|
||||
let stream = session.send(prompt).await?;
|
||||
for event in stream {
|
||||
// Battement de vivacité (lot 2) : notifié pour CHAQUE événement brut, avant le
|
||||
@ -146,14 +225,23 @@ async fn drain_to_final(
|
||||
if let Some(signal) = ReadinessPolicy::classify(&event) {
|
||||
on_signal(signal);
|
||||
}
|
||||
if let ReplyEvent::Final { content } = event {
|
||||
return Ok(content);
|
||||
match event {
|
||||
ReplyEvent::Final { content } => return Ok(TurnOutcome::Completed(content)),
|
||||
ReplyEvent::RateLimited { resets_at_ms } => last_rate_limit = Some(resets_at_ms),
|
||||
// TextDelta / ToolActivity / Heartbeat : non terminaux, ignorés ici.
|
||||
ReplyEvent::TextDelta { .. }
|
||||
| ReplyEvent::ToolActivity { .. }
|
||||
| ReplyEvent::Heartbeat => {}
|
||||
}
|
||||
// TextDelta / ToolActivity / Heartbeat : non terminaux, ignorés ici.
|
||||
}
|
||||
Err(AgentSessionError::Io(
|
||||
"le flux de réponse s'est terminé sans événement Final".to_string(),
|
||||
))
|
||||
// Flux clos sans `Final` : fin gracieuse SI une limite a été vue (§21.2-T4),
|
||||
// sinon erreur comme avant.
|
||||
match last_rate_limit {
|
||||
Some(resets_at_ms) => Ok(TurnOutcome::RateLimited { resets_at_ms }),
|
||||
None => Err(AgentSessionError::Io(
|
||||
"le flux de réponse s'est terminé sans événement Final".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@ -29,8 +29,9 @@ pub mod terminal;
|
||||
pub mod window;
|
||||
|
||||
pub use agent::{
|
||||
drain_with_readiness, reference_profile_id, reference_profiles, selectable_reference_profiles,
|
||||
send_blocking, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput,
|
||||
drain_with_readiness, drain_with_readiness_outcome, reference_profile_id, reference_profiles,
|
||||
selectable_reference_profiles, send_blocking, AgentResumer, ChangeAgentProfile,
|
||||
ChangeAgentProfileInput, ChangeAgentProfileOutput,
|
||||
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch,
|
||||
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile,
|
||||
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
|
||||
@ -41,8 +42,8 @@ pub use agent::{
|
||||
ProfileAvailability,
|
||||
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
|
||||
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput,
|
||||
SaveProfileOutput, StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
|
||||
AGENT_MEMORY_RECALL_BUDGET,
|
||||
SaveProfileOutput, SessionLimitService, StructuredSessionDescriptor, TurnOutcome,
|
||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, RESUME_PROMPT,
|
||||
};
|
||||
pub use conversation::RecordTurn;
|
||||
pub use embedder::{
|
||||
|
||||
@ -380,6 +380,22 @@ impl StructuredSessions {
|
||||
})
|
||||
}
|
||||
|
||||
/// Résout les coordonnées `(agent_id, node_id)` d'une session structurée par son
|
||||
/// [`SessionId`] (LS7, tap niveau 1 des limites de session, §21.10).
|
||||
///
|
||||
/// Jumeau « inverse » de [`Self::live_agents`] : là où `live_agents` énumère tout,
|
||||
/// celui-ci fait un lookup direct par id. Le pump structuré (`agent_send`) n'a en
|
||||
/// main que le `SessionId` du tour ; ce lookup lui rend l'agent et la cellule à
|
||||
/// passer à [`SessionLimitService::on_rate_limited`](crate::SessionLimitService) sur
|
||||
/// un signal `RateLimited`. `None` si l'id n'est pas (ou plus) une session vivante.
|
||||
#[must_use]
|
||||
pub fn meta_for_session(&self, id: &SessionId) -> Option<(AgentId, NodeId)> {
|
||||
self.entries
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|m| m.get(id).map(|e| (e.agent_id, e.node_id)))
|
||||
}
|
||||
|
||||
/// Liste chaque agent IA vivant, sa cellule hôte et son id de session.
|
||||
///
|
||||
/// Jumeau de [`TerminalSessions::live_agents`] : un tuple
|
||||
|
||||
641
crates/application/tests/session_limit_service.rs
Normal file
641
crates/application/tests/session_limit_service.rs
Normal file
@ -0,0 +1,641 @@
|
||||
//! LS4 — tests unitaires QA du `SessionLimitService` (ARCHITECTURE §21.5),
|
||||
//! **100 % fakes** des ports : `Clock` fixe, `Scheduler` enregistreur/contrôlable,
|
||||
//! `EventBus` espion, `AgentResumer` espion/contrôlable.
|
||||
//!
|
||||
//! Couvre les trois responsabilités du service :
|
||||
//! - (a) détection → planification (`on_rate_limited`) : armement + ordre des events ;
|
||||
//! - (b) exécution de la reprise (`execute_resume`) : prompt, event, propagation d'erreur ;
|
||||
//! - (c) annulation (`cancel_resume`) : contrat anti-course (cancel `false` ⇒ pas d'event).
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use application::{AgentResumer, AppError, SessionLimitService, RESUME_PROMPT};
|
||||
use domain::ids::{AgentId, NodeId, ScheduleId};
|
||||
use domain::ports::{Clock, EventBus, EventStream, ScheduledTask, Scheduler};
|
||||
use domain::DomainEvent;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn nid(n: u128) -> NodeId {
|
||||
NodeId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fakes des ports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Horloge fixe (déterminisme) : `now` injecté.
|
||||
struct FixedClock(i64);
|
||||
impl Clock for FixedClock {
|
||||
fn now_millis(&self) -> i64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// EventBus espion : journalise les events publiés, dans l'ordre.
|
||||
#[derive(Default, Clone)]
|
||||
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
|
||||
impl SpyBus {
|
||||
fn events(&self) -> Vec<DomainEvent> {
|
||||
self.0.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
impl EventBus for SpyBus {
|
||||
fn publish(&self, event: DomainEvent) {
|
||||
self.0.lock().unwrap().push(event);
|
||||
}
|
||||
fn subscribe(&self) -> EventStream {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// Scheduler fake : enregistre chaque `arm` (deadline + task) et chaque `cancel`,
|
||||
/// distribue des `ScheduleId` déterministes, et rend un résultat de `cancel`
|
||||
/// contrôlable (pour simuler le cas « déjà tiré »).
|
||||
#[derive(Clone)]
|
||||
struct FakeScheduler {
|
||||
armed: Arc<Mutex<Vec<(i64, ScheduledTask)>>>,
|
||||
issued: Arc<Mutex<Vec<ScheduleId>>>,
|
||||
cancels: Arc<Mutex<Vec<ScheduleId>>>,
|
||||
next_id: Arc<Mutex<u128>>,
|
||||
cancel_result: Arc<AtomicBool>,
|
||||
}
|
||||
impl FakeScheduler {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
armed: Arc::new(Mutex::new(Vec::new())),
|
||||
issued: Arc::new(Mutex::new(Vec::new())),
|
||||
cancels: Arc::new(Mutex::new(Vec::new())),
|
||||
next_id: Arc::new(Mutex::new(1)),
|
||||
cancel_result: Arc::new(AtomicBool::new(true)),
|
||||
}
|
||||
}
|
||||
fn set_cancel_result(&self, v: bool) {
|
||||
self.cancel_result.store(v, Ordering::SeqCst);
|
||||
}
|
||||
fn armed(&self) -> Vec<(i64, ScheduledTask)> {
|
||||
self.armed.lock().unwrap().clone()
|
||||
}
|
||||
fn issued(&self) -> Vec<ScheduleId> {
|
||||
self.issued.lock().unwrap().clone()
|
||||
}
|
||||
fn cancels(&self) -> Vec<ScheduleId> {
|
||||
self.cancels.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
impl Scheduler for FakeScheduler {
|
||||
fn arm(&self, deadline_ms: i64, task: ScheduledTask) -> ScheduleId {
|
||||
self.armed.lock().unwrap().push((deadline_ms, task));
|
||||
let mut n = self.next_id.lock().unwrap();
|
||||
let id = ScheduleId::from_uuid(Uuid::from_u128(*n));
|
||||
*n += 1;
|
||||
self.issued.lock().unwrap().push(id);
|
||||
id
|
||||
}
|
||||
fn cancel(&self, id: ScheduleId) -> bool {
|
||||
self.cancels.lock().unwrap().push(id);
|
||||
self.cancel_result.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
/// AgentResumer fake : enregistre l'appel `resume` (et le prompt reçu), et peut
|
||||
/// être configuré pour échouer (propagation d'erreur).
|
||||
#[derive(Clone)]
|
||||
struct FakeResumer {
|
||||
calls: Arc<Mutex<Vec<(AgentId, NodeId, Option<String>, String)>>>,
|
||||
fail: Arc<AtomicBool>,
|
||||
}
|
||||
impl FakeResumer {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
calls: Arc::new(Mutex::new(Vec::new())),
|
||||
fail: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
fn set_fail(&self, v: bool) {
|
||||
self.fail.store(v, Ordering::SeqCst);
|
||||
}
|
||||
fn calls(&self) -> Vec<(AgentId, NodeId, Option<String>, String)> {
|
||||
self.calls.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl AgentResumer for FakeResumer {
|
||||
async fn resume(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
node_id: NodeId,
|
||||
conversation_id: Option<String>,
|
||||
resume_prompt: &str,
|
||||
) -> Result<(), AppError> {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((agent_id, node_id, conversation_id, resume_prompt.to_owned()));
|
||||
if self.fail.load(Ordering::SeqCst) {
|
||||
Err(AppError::Internal("reprise échouée (fake)".to_owned()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Banc d'essai : assemble le service avec ses fakes (et garde les poignées).
|
||||
struct Env {
|
||||
service: SessionLimitService,
|
||||
scheduler: FakeScheduler,
|
||||
bus: SpyBus,
|
||||
resumer: FakeResumer,
|
||||
}
|
||||
fn env_at(now_ms: i64) -> Env {
|
||||
let scheduler = FakeScheduler::new();
|
||||
let bus = SpyBus::default();
|
||||
let resumer = FakeResumer::new();
|
||||
let service = SessionLimitService::new(
|
||||
Arc::new(FixedClock(now_ms)),
|
||||
Arc::new(scheduler.clone()),
|
||||
Arc::new(bus.clone()),
|
||||
Arc::new(resumer.clone()),
|
||||
);
|
||||
Env {
|
||||
service,
|
||||
scheduler,
|
||||
bus,
|
||||
resumer,
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// (a) Détection → planification
|
||||
// ===========================================================================
|
||||
|
||||
const NOW: i64 = 1_700_000_000_000;
|
||||
|
||||
/// `on_rate_limited(Some(reset futur))` ⇒ EXACTEMENT un `arm(fire_at_ms, ResumeAgent{..})`
|
||||
/// avec `fire_at_ms == reset` (futur) ; events `AgentRateLimited` PUIS `AgentResumeScheduled`
|
||||
/// dans CET ordre.
|
||||
#[test]
|
||||
fn on_rate_limited_future_arms_and_emits_in_order() {
|
||||
let env = env_at(NOW);
|
||||
let reset = NOW + 60_000;
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(reset));
|
||||
|
||||
// Exactement un arm, avec la bonne échéance et la bonne tâche.
|
||||
let armed = env.scheduler.armed();
|
||||
assert_eq!(armed.len(), 1, "exactement un arm");
|
||||
assert_eq!(armed[0].0, reset, "fire_at_ms == reset (futur)");
|
||||
assert_eq!(
|
||||
armed[0].1,
|
||||
ScheduledTask::ResumeAgent {
|
||||
agent_id: aid(1),
|
||||
node_id: nid(2),
|
||||
conversation_id: Some("conv-1".to_owned()),
|
||||
}
|
||||
);
|
||||
|
||||
// Ordre des events : RateLimited puis ResumeScheduled.
|
||||
assert_eq!(
|
||||
env.bus.events(),
|
||||
vec![
|
||||
DomainEvent::AgentRateLimited {
|
||||
agent_id: aid(1),
|
||||
resets_at_ms: Some(reset),
|
||||
},
|
||||
DomainEvent::AgentResumeScheduled {
|
||||
agent_id: aid(1),
|
||||
fire_at_ms: reset,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Reset DÉJÀ PASSÉ ⇒ `plan_resume` clampe à `now` ⇒ `fire_at_ms == now` (jamais dans
|
||||
/// le passé) ; le `AgentRateLimited` garde l'heure brute (passée), le `ResumeScheduled`
|
||||
/// porte le `now` clampé.
|
||||
#[test]
|
||||
fn on_rate_limited_past_reset_clamps_fire_at_to_now() {
|
||||
let env = env_at(NOW);
|
||||
let past = NOW - 60_000;
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), None, Some(past));
|
||||
|
||||
let armed = env.scheduler.armed();
|
||||
assert_eq!(armed.len(), 1);
|
||||
assert_eq!(armed[0].0, NOW, "fire_at_ms clampé à now (jamais le passé)");
|
||||
|
||||
assert_eq!(
|
||||
env.bus.events(),
|
||||
vec![
|
||||
DomainEvent::AgentRateLimited {
|
||||
agent_id: aid(1),
|
||||
resets_at_ms: Some(past), // l'heure brute (passée) est conservée dans l'event
|
||||
},
|
||||
DomainEvent::AgentResumeScheduled {
|
||||
agent_id: aid(1),
|
||||
fire_at_ms: NOW, // clampé
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// `on_rate_limited(None)` ⇒ AUCUN arm ; events `AgentRateLimited{None}` puis
|
||||
/// `AgentRateLimitSuspected{None}` (filet humain).
|
||||
#[test]
|
||||
fn on_rate_limited_without_reset_is_human_fallback_no_arm() {
|
||||
let env = env_at(NOW);
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), None);
|
||||
|
||||
assert!(env.scheduler.armed().is_empty(), "aucun arm sans heure de reset");
|
||||
assert!(env.scheduler.cancels().is_empty(), "aucun cancel non plus");
|
||||
assert_eq!(
|
||||
env.bus.events(),
|
||||
vec![
|
||||
DomainEvent::AgentRateLimited {
|
||||
agent_id: aid(1),
|
||||
resets_at_ms: None,
|
||||
},
|
||||
DomainEvent::AgentRateLimitSuspected {
|
||||
agent_id: aid(1),
|
||||
resets_at_ms: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Dédoublonnage (§21.10-4) : deux `on_rate_limited` successifs pour le MÊME agent ⇒
|
||||
/// l'ancien `ScheduleId` est annulé sur le Scheduler (cancel interne), un nouveau réveil
|
||||
/// est armé, et AUCUN `AgentResumeCancelled` n'est émis (le dédoublonnage est interne).
|
||||
#[test]
|
||||
fn on_rate_limited_twice_same_agent_dedups_cancelling_previous() {
|
||||
let env = env_at(NOW);
|
||||
let reset1 = NOW + 60_000;
|
||||
let reset2 = NOW + 120_000;
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(reset1));
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(reset2));
|
||||
|
||||
// Deux arms (un par signal), ids distincts.
|
||||
let issued = env.scheduler.issued();
|
||||
assert_eq!(issued.len(), 2, "deux arms (rafraîchissement, pas empilement)");
|
||||
// Le premier id émis a été annulé par le dédoublonnage du 2ᵉ signal.
|
||||
assert_eq!(
|
||||
env.scheduler.cancels(),
|
||||
vec![issued[0]],
|
||||
"l'ancien ScheduleId est cancel-é avant de réarmer"
|
||||
);
|
||||
|
||||
// Aucun AgentResumeCancelled : le dédoublonnage est silencieux.
|
||||
let cancelled: Vec<_> = env
|
||||
.bus
|
||||
.events()
|
||||
.into_iter()
|
||||
.filter(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. }))
|
||||
.collect();
|
||||
assert!(
|
||||
cancelled.is_empty(),
|
||||
"le dédoublonnage interne n'émet PAS AgentResumeCancelled"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// (b) Exécution de la reprise
|
||||
// ===========================================================================
|
||||
|
||||
/// `execute_resume(ResumeAgent{..})` ⇒ `AgentResumer::resume` appelé avec
|
||||
/// (agent, node, conv, RESUME_PROMPT) ; `AgentResumed` publié ; l'entrée armée est
|
||||
/// retirée (un `cancel_resume` ultérieur ⇒ false).
|
||||
#[tokio::test]
|
||||
async fn execute_resume_calls_resumer_with_prompt_and_emits_resumed() {
|
||||
let env = env_at(NOW);
|
||||
// Arme d'abord (pour prouver que l'entrée est ensuite retirée).
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
|
||||
|
||||
let task = ScheduledTask::ResumeAgent {
|
||||
agent_id: aid(1),
|
||||
node_id: nid(2),
|
||||
conversation_id: Some("conv-1".to_owned()),
|
||||
};
|
||||
env.service.execute_resume(task).await.expect("resume ok");
|
||||
|
||||
// Le resumer a vu exactement l'appel attendu, avec le prompt constant.
|
||||
assert_eq!(
|
||||
env.resumer.calls(),
|
||||
vec![(aid(1), nid(2), Some("conv-1".to_owned()), RESUME_PROMPT.to_owned())]
|
||||
);
|
||||
|
||||
// AgentResumed publié.
|
||||
assert!(
|
||||
env.bus
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| *e == DomainEvent::AgentResumed { agent_id: aid(1) }),
|
||||
"AgentResumed doit être publié"
|
||||
);
|
||||
|
||||
// L'entrée armée a été retirée ⇒ cancel_resume ultérieur = false.
|
||||
assert!(
|
||||
!env.service.cancel_resume(aid(1)),
|
||||
"après execute_resume, plus rien à annuler"
|
||||
);
|
||||
}
|
||||
|
||||
/// `AgentResumer` qui échoue ⇒ l'erreur est propagée ET `AgentResumed` n'est PAS publié.
|
||||
#[tokio::test]
|
||||
async fn execute_resume_propagates_error_without_emitting_resumed() {
|
||||
let env = env_at(NOW);
|
||||
env.resumer.set_fail(true);
|
||||
|
||||
let task = ScheduledTask::ResumeAgent {
|
||||
agent_id: aid(1),
|
||||
node_id: nid(2),
|
||||
conversation_id: None,
|
||||
};
|
||||
let err = env
|
||||
.service
|
||||
.execute_resume(task)
|
||||
.await
|
||||
.expect_err("la reprise doit échouer");
|
||||
assert!(matches!(err, AppError::Internal(_)), "erreur propagée: {err:?}");
|
||||
|
||||
assert!(
|
||||
!env
|
||||
.bus
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::AgentResumed { .. })),
|
||||
"AgentResumed ne doit PAS être publié si la reprise a échoué"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// (c) Annulation
|
||||
// ===========================================================================
|
||||
|
||||
/// `cancel_resume` après un armement, Scheduler renvoyant `true` ⇒ renvoie `true` et
|
||||
/// publie `AgentResumeCancelled`.
|
||||
#[test]
|
||||
fn cancel_resume_after_arm_returns_true_and_emits_cancelled() {
|
||||
let env = env_at(NOW);
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
|
||||
let issued = env.scheduler.issued();
|
||||
|
||||
assert!(env.service.cancel_resume(aid(1)), "cancel d'un réveil armé ⇒ true");
|
||||
// Le bon ScheduleId a été passé au Scheduler.
|
||||
assert_eq!(env.scheduler.cancels(), vec![issued[0]]);
|
||||
// AgentResumeCancelled publié.
|
||||
assert!(
|
||||
env.bus
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| *e == DomainEvent::AgentResumeCancelled { agent_id: aid(1) }),
|
||||
"AgentResumeCancelled doit être publié"
|
||||
);
|
||||
}
|
||||
|
||||
/// `cancel_resume` sans armement préalable ⇒ `false`, aucun event, et le Scheduler
|
||||
/// n'est même pas sollicité.
|
||||
#[test]
|
||||
fn cancel_resume_without_arm_is_false_no_event() {
|
||||
let env = env_at(NOW);
|
||||
assert!(!env.service.cancel_resume(aid(1)));
|
||||
assert!(env.scheduler.cancels().is_empty(), "Scheduler non sollicité");
|
||||
assert!(env.bus.events().is_empty(), "aucun event");
|
||||
}
|
||||
|
||||
/// Contrat ANTI-COURSE : Scheduler renvoyant `false` (« déjà tiré ») ⇒ `cancel_resume`
|
||||
/// renvoie `false` et n'émet PAS `AgentResumeCancelled` (la reprise suit son cours).
|
||||
#[test]
|
||||
fn cancel_resume_when_scheduler_already_fired_is_false_no_event() {
|
||||
let env = env_at(NOW);
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
|
||||
// Simule un réveil déjà tiré : cancel renvoie false.
|
||||
env.scheduler.set_cancel_result(false);
|
||||
|
||||
assert!(
|
||||
!env.service.cancel_resume(aid(1)),
|
||||
"cancel pile au tir ⇒ false (la reprise suit son cours)"
|
||||
);
|
||||
// Le Scheduler a bien été sollicité (mais a répondu false).
|
||||
assert_eq!(env.scheduler.cancels().len(), 1);
|
||||
// AUCUN AgentResumeCancelled (pas d'event trompeur).
|
||||
assert!(
|
||||
!env
|
||||
.bus
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
|
||||
"pas d'AgentResumeCancelled quand le réveil a déjà tiré"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// (d/LS8) Filet humain niveau 3 — `confirm_human_resume`
|
||||
// ===========================================================================
|
||||
|
||||
/// (a) Heure FUTURE saisie par l'humain ⇒ EXACTEMENT un `arm(fire_at_ms, ResumeAgent{..})`
|
||||
/// avec `fire_at_ms == resets_at_ms` (futur), un `ScheduleId` armé, et les events
|
||||
/// `AgentRateLimited{Some}` PUIS `AgentResumeScheduled` dans cet ordre — parité auto.
|
||||
#[test]
|
||||
fn confirm_human_resume_future_arms_and_emits_in_order() {
|
||||
let env = env_at(NOW);
|
||||
let reset = NOW + 90_000;
|
||||
env.service
|
||||
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), reset);
|
||||
|
||||
// Exactement un arm, bonne échéance, bonne tâche.
|
||||
let armed = env.scheduler.armed();
|
||||
assert_eq!(armed.len(), 1, "exactement un arm");
|
||||
assert_eq!(armed[0].0, reset, "fire_at_ms == reset (futur)");
|
||||
assert_eq!(
|
||||
armed[0].1,
|
||||
ScheduledTask::ResumeAgent {
|
||||
agent_id: aid(1),
|
||||
node_id: nid(2),
|
||||
conversation_id: Some("conv-1".to_owned()),
|
||||
}
|
||||
);
|
||||
// Un ScheduleId a bien été émis (armement actif).
|
||||
assert_eq!(env.scheduler.issued().len(), 1, "un ScheduleId armé");
|
||||
|
||||
// Ordre des events : RateLimited puis ResumeScheduled.
|
||||
assert_eq!(
|
||||
env.bus.events(),
|
||||
vec![
|
||||
DomainEvent::AgentRateLimited {
|
||||
agent_id: aid(1),
|
||||
resets_at_ms: Some(reset),
|
||||
},
|
||||
DomainEvent::AgentResumeScheduled {
|
||||
agent_id: aid(1),
|
||||
fire_at_ms: reset,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// (b) Heure PASSÉE saisie par l'humain (`resets_at_ms < now`) ⇒ clamp anti-passé :
|
||||
/// `fire_at_ms == now` (reprise quasi-immédiate). L'event `AgentRateLimited` conserve
|
||||
/// l'heure brute (passée) ; `AgentResumeScheduled` porte le `now` clampé.
|
||||
#[test]
|
||||
fn confirm_human_resume_past_reset_clamps_fire_at_to_now() {
|
||||
let env = env_at(NOW);
|
||||
let past = NOW - 30_000;
|
||||
env.service
|
||||
.confirm_human_resume(aid(1), nid(2), None, past);
|
||||
|
||||
let armed = env.scheduler.armed();
|
||||
assert_eq!(armed.len(), 1);
|
||||
assert_eq!(armed[0].0, NOW, "fire_at_ms clampé à now (jamais le passé)");
|
||||
|
||||
assert_eq!(
|
||||
env.bus.events(),
|
||||
vec![
|
||||
DomainEvent::AgentRateLimited {
|
||||
agent_id: aid(1),
|
||||
resets_at_ms: Some(past), // heure brute (passée) conservée dans l'event
|
||||
},
|
||||
DomainEvent::AgentResumeScheduled {
|
||||
agent_id: aid(1),
|
||||
fire_at_ms: NOW, // clampé
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// (c) DÉDOUBLONNAGE CROISÉ — `confirm_human_resume` APRÈS un `on_rate_limited` déjà
|
||||
/// armé pour le même agent ⇒ l'armement auto précédent est annulé (cancel interne du
|
||||
/// 1er ScheduleId), un seul réveil reste actif, et AUCUN `AgentResumeCancelled` n'est
|
||||
/// émis (dédoublonnage silencieux). On prouve l'unicité de l'armement actif : un
|
||||
/// `cancel_resume` réussit une fois (true), un second échoue (false).
|
||||
#[test]
|
||||
fn confirm_human_resume_after_auto_dedups_single_active_arm() {
|
||||
let env = env_at(NOW);
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
|
||||
env.service
|
||||
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 120_000);
|
||||
|
||||
let issued = env.scheduler.issued();
|
||||
assert_eq!(issued.len(), 2, "deux arms (auto puis humain), pas d'empilement");
|
||||
assert_eq!(
|
||||
env.scheduler.cancels(),
|
||||
vec![issued[0]],
|
||||
"le ScheduleId auto précédent est cancel-é avant de réarmer (humain)"
|
||||
);
|
||||
|
||||
// Aucun AgentResumeCancelled (dédoublonnage interne, silencieux).
|
||||
assert!(
|
||||
!env
|
||||
.bus
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
|
||||
"le dédoublonnage croisé n'émet PAS AgentResumeCancelled"
|
||||
);
|
||||
|
||||
// Unicité de l'armement actif : un seul cancel_resume aboutit.
|
||||
assert!(env.service.cancel_resume(aid(1)), "un armement actif unique ⇒ true");
|
||||
assert!(
|
||||
!env.service.cancel_resume(aid(1)),
|
||||
"plus aucun armement après le premier cancel ⇒ false (une seule entrée)"
|
||||
);
|
||||
}
|
||||
|
||||
/// (c-inverse) `on_rate_limited` APRÈS un `confirm_human_resume` déjà armé pour le même
|
||||
/// agent ⇒ symétrique : l'armement humain précédent est annulé (cancel interne), un seul
|
||||
/// réveil reste actif, pas d'`AgentResumeCancelled`.
|
||||
#[test]
|
||||
fn auto_after_confirm_human_resume_dedups_single_active_arm() {
|
||||
let env = env_at(NOW);
|
||||
env.service
|
||||
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 60_000);
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 120_000));
|
||||
|
||||
let issued = env.scheduler.issued();
|
||||
assert_eq!(issued.len(), 2, "deux arms (humain puis auto), pas d'empilement");
|
||||
assert_eq!(
|
||||
env.scheduler.cancels(),
|
||||
vec![issued[0]],
|
||||
"le ScheduleId humain précédent est cancel-é avant de réarmer (auto)"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!env
|
||||
.bus
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
|
||||
"le dédoublonnage croisé n'émet PAS AgentResumeCancelled"
|
||||
);
|
||||
|
||||
assert!(env.service.cancel_resume(aid(1)), "un armement actif unique ⇒ true");
|
||||
assert!(
|
||||
!env.service.cancel_resume(aid(1)),
|
||||
"plus aucun armement après le premier cancel ⇒ false (une seule entrée)"
|
||||
);
|
||||
}
|
||||
|
||||
/// (d) ANNULABILITÉ — `cancel_resume` après `confirm_human_resume` ⇒ renvoie `true` et
|
||||
/// publie `AgentResumeCancelled` (l'armement humain s'annule par la MÊME voie que l'auto).
|
||||
#[test]
|
||||
fn cancel_resume_after_confirm_human_resume_returns_true_and_emits_cancelled() {
|
||||
let env = env_at(NOW);
|
||||
env.service
|
||||
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 60_000);
|
||||
let issued = env.scheduler.issued();
|
||||
|
||||
assert!(
|
||||
env.service.cancel_resume(aid(1)),
|
||||
"cancel d'un réveil humain armé ⇒ true"
|
||||
);
|
||||
assert_eq!(env.scheduler.cancels(), vec![issued[0]]);
|
||||
assert!(
|
||||
env.bus
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| *e == DomainEvent::AgentResumeCancelled { agent_id: aid(1) }),
|
||||
"AgentResumeCancelled doit être publié"
|
||||
);
|
||||
}
|
||||
|
||||
/// (e) PARITÉ auto/humain — à reset (futur) IDENTIQUE, `confirm_human_resume` produit
|
||||
/// EXACTEMENT la même séquence d'events et le même `arm` (échéance + tâche) qu'`on_rate_limited`
|
||||
/// dans son cas Scheduled. La source (Human vs Structured) n'a aucun effet observable.
|
||||
#[test]
|
||||
fn confirm_human_resume_is_event_for_event_identical_to_auto_scheduled() {
|
||||
let reset = NOW + 60_000;
|
||||
|
||||
let auto = env_at(NOW);
|
||||
auto.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(reset));
|
||||
|
||||
let human = env_at(NOW);
|
||||
human
|
||||
.service
|
||||
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), reset);
|
||||
|
||||
// Même séquence d'events.
|
||||
assert_eq!(
|
||||
human.bus.events(),
|
||||
auto.bus.events(),
|
||||
"parité auto/humain : même séquence d'events à reset identique"
|
||||
);
|
||||
// Même armement (échéance + tâche).
|
||||
assert_eq!(
|
||||
human.scheduler.armed(),
|
||||
auto.scheduler.armed(),
|
||||
"parité auto/humain : même arm (fire_at_ms + ResumeAgent{{..}})"
|
||||
);
|
||||
}
|
||||
205
crates/application/tests/session_limit_t4.rs
Normal file
205
crates/application/tests/session_limit_t4.rs
Normal file
@ -0,0 +1,205 @@
|
||||
//! LS4 — réconciliation §21.2-T4 au niveau applicatif : `drain_with_readiness_outcome`
|
||||
//! traduit un tour clos par une **limite de session** (un `RateLimited` vu, pas de
|
||||
//! `Final`) en `Ok(TurnOutcome::RateLimited{..})` — une **fin gracieuse**, pas une
|
||||
//! erreur. **100 % fakes** (fake `AgentSession` scriptable + fake `InputMediator`).
|
||||
//!
|
||||
//! On vérifie AUSSI la NON-RÉGRESSION des signatures historiques `Result<String>` :
|
||||
//! - `drain_with_readiness` (et `send_blocking`) ⇒ un tour limité reste une `Io`
|
||||
//! (zéro régression pour l'appelant orchestrateur) ;
|
||||
//! - un flux clos SANS `Final` ET SANS `RateLimited` reste une `Io` (tour tronqué).
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use application::{drain_with_readiness, drain_with_readiness_outcome, send_blocking, TurnOutcome};
|
||||
use domain::ids::AgentId;
|
||||
use domain::input::{AgentBusyState, InputMediator};
|
||||
use domain::mailbox::{PendingReply, Ticket};
|
||||
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
|
||||
use domain::SessionId;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake AgentSession : `send` rejoue une liste fixe d'événements.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct FakeSession {
|
||||
events: Vec<ReplyEvent>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl AgentSession for FakeSession {
|
||||
fn id(&self) -> SessionId {
|
||||
SessionId::from_uuid(Uuid::from_u128(1))
|
||||
}
|
||||
fn conversation_id(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||
Ok(Box::new(self.events.clone().into_iter()))
|
||||
}
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake InputMediator : journalise mark_idle / mark_alive (pour prouver que la
|
||||
// readiness reste pilotée par le Final, pas par un RateLimited).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingMediator {
|
||||
calls: Mutex<Vec<&'static str>>,
|
||||
}
|
||||
impl InputMediator for RecordingMediator {
|
||||
fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply {
|
||||
PendingReply::new(Box::pin(std::future::pending()))
|
||||
}
|
||||
fn preempt(&self, _agent: AgentId) {}
|
||||
fn mark_idle(&self, _agent: AgentId) {
|
||||
self.calls.lock().unwrap().push("idle");
|
||||
}
|
||||
fn mark_alive(&self, _agent: AgentId) {
|
||||
self.calls.lock().unwrap().push("alive");
|
||||
}
|
||||
fn busy_state(&self, _agent: AgentId) -> AgentBusyState {
|
||||
AgentBusyState::Idle
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// drain_with_readiness_outcome — issue riche T4
|
||||
// ===========================================================================
|
||||
|
||||
/// `[RateLimited{Some(t)}]` sans Final ⇒ `Ok(TurnOutcome::RateLimited{Some(t)})`
|
||||
/// (fin gracieuse, PAS d'Err).
|
||||
#[tokio::test]
|
||||
async fn outcome_rate_limited_some_without_final_is_graceful() {
|
||||
let session = FakeSession {
|
||||
events: vec![ReplyEvent::RateLimited {
|
||||
resets_at_ms: Some(1_700_000_000_000),
|
||||
}],
|
||||
};
|
||||
let mediator = RecordingMediator::default();
|
||||
let out = drain_with_readiness_outcome(&session, "go", None, &mediator, aid(1))
|
||||
.await
|
||||
.expect("un tour limité est une fin gracieuse, pas une erreur");
|
||||
assert_eq!(
|
||||
out,
|
||||
TurnOutcome::RateLimited {
|
||||
resets_at_ms: Some(1_700_000_000_000)
|
||||
}
|
||||
);
|
||||
// Readiness : un RateLimited ne marque PAS Idle (piloté par Final uniquement).
|
||||
assert!(
|
||||
!mediator.calls.lock().unwrap().contains(&"idle"),
|
||||
"un RateLimited ne doit pas marquer l'agent Idle"
|
||||
);
|
||||
}
|
||||
|
||||
/// `[RateLimited{None}]` sans Final ⇒ `Ok(TurnOutcome::RateLimited{None})`.
|
||||
#[tokio::test]
|
||||
async fn outcome_rate_limited_none_without_final_is_graceful() {
|
||||
let session = FakeSession {
|
||||
events: vec![ReplyEvent::RateLimited { resets_at_ms: None }],
|
||||
};
|
||||
let mediator = RecordingMediator::default();
|
||||
let out = drain_with_readiness_outcome(&session, "go", None, &mediator, aid(1))
|
||||
.await
|
||||
.expect("fin gracieuse");
|
||||
assert_eq!(out, TurnOutcome::RateLimited { resets_at_ms: None });
|
||||
}
|
||||
|
||||
/// `[.., RateLimited, Final]` ⇒ `Ok(TurnOutcome::Completed(contenu))` : le Final
|
||||
/// l'emporte toujours (le tour a réellement abouti).
|
||||
#[tokio::test]
|
||||
async fn outcome_rate_limited_then_final_is_completed() {
|
||||
let session = FakeSession {
|
||||
events: vec![
|
||||
ReplyEvent::TextDelta { text: "a".into() },
|
||||
ReplyEvent::RateLimited {
|
||||
resets_at_ms: Some(42),
|
||||
},
|
||||
ReplyEvent::Final {
|
||||
content: "fini".into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
let mediator = RecordingMediator::default();
|
||||
let out = drain_with_readiness_outcome(&session, "go", None, &mediator, aid(1))
|
||||
.await
|
||||
.expect("ok");
|
||||
assert_eq!(out, TurnOutcome::Completed("fini".to_owned()));
|
||||
// Le Final marque bien Idle.
|
||||
assert!(mediator.calls.lock().unwrap().contains(&"idle"));
|
||||
}
|
||||
|
||||
/// `[TextDelta]` seul (ni Final ni RateLimited) ⇒ `Err(Io)` INCHANGÉ : un vrai flux
|
||||
/// tronqué reste une erreur (non-régression critique).
|
||||
#[tokio::test]
|
||||
async fn outcome_truncated_stream_without_final_or_ratelimit_is_io_error() {
|
||||
let session = FakeSession {
|
||||
events: vec![ReplyEvent::TextDelta { text: "a".into() }],
|
||||
};
|
||||
let mediator = RecordingMediator::default();
|
||||
let err = drain_with_readiness_outcome(&session, "go", None, &mediator, aid(1))
|
||||
.await
|
||||
.expect_err("flux tronqué sans limite ⇒ erreur");
|
||||
assert!(matches!(err, AgentSessionError::Io(_)), "vu: {err:?}");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Non-régression des signatures historiques Result<String>
|
||||
// ===========================================================================
|
||||
|
||||
/// `drain_with_readiness` (signature historique) : un tour limité reste `Err(Io)`
|
||||
/// (zéro régression pour l'appelant orchestrateur).
|
||||
#[tokio::test]
|
||||
async fn drain_with_readiness_rate_limited_is_io_error() {
|
||||
let session = FakeSession {
|
||||
events: vec![ReplyEvent::RateLimited {
|
||||
resets_at_ms: Some(1),
|
||||
}],
|
||||
};
|
||||
let mediator = RecordingMediator::default();
|
||||
let err = drain_with_readiness(&session, "go", None, &mediator, aid(1))
|
||||
.await
|
||||
.expect_err("limite ⇒ Io sur la signature historique");
|
||||
assert!(matches!(err, AgentSessionError::Io(_)), "vu: {err:?}");
|
||||
}
|
||||
|
||||
/// `send_blocking` (signature historique) : un tour limité reste `Err(Io)`.
|
||||
#[tokio::test]
|
||||
async fn send_blocking_rate_limited_is_io_error() {
|
||||
let session = FakeSession {
|
||||
events: vec![ReplyEvent::RateLimited { resets_at_ms: None }],
|
||||
};
|
||||
let err = send_blocking(&session, "go", None)
|
||||
.await
|
||||
.expect_err("limite ⇒ Io");
|
||||
assert!(matches!(err, AgentSessionError::Io(_)), "vu: {err:?}");
|
||||
}
|
||||
|
||||
/// `drain_with_readiness` nominal : `[.., Final]` ⇒ le contenu, et `mark_idle` au Final.
|
||||
#[tokio::test]
|
||||
async fn drain_with_readiness_nominal_still_completes() {
|
||||
let session = FakeSession {
|
||||
events: vec![
|
||||
ReplyEvent::TextDelta { text: "a".into() },
|
||||
ReplyEvent::Final {
|
||||
content: "fini".into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
let mediator = RecordingMediator::default();
|
||||
let content = drain_with_readiness(&session, "go", None, &mediator, aid(1))
|
||||
.await
|
||||
.expect("ok");
|
||||
assert_eq!(content, "fini");
|
||||
assert!(mediator.calls.lock().unwrap().contains(&"idle"));
|
||||
}
|
||||
@ -106,6 +106,24 @@ fn structured_insert_resolve_and_remove() {
|
||||
assert!(reg.session_for_agent(&a).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_meta_for_session_resolves_agent_and_node() {
|
||||
// LS7 (§21.10) : le tap niveau 1 n'a que le `SessionId` du tour et doit retrouver
|
||||
// l'agent + la cellule hôte à passer au service de limite. Lookup direct par id.
|
||||
let reg = StructuredSessions::new();
|
||||
let s = sid(1);
|
||||
let a = aid(10);
|
||||
let n = nid(100);
|
||||
reg.insert(fake(s), a, n);
|
||||
|
||||
assert_eq!(reg.meta_for_session(&s), Some((a, n)));
|
||||
|
||||
// Id inconnu (ou retiré) ⇒ None (jamais de panique sur une session morte).
|
||||
assert_eq!(reg.meta_for_session(&sid(999)), None);
|
||||
reg.remove(&s);
|
||||
assert_eq!(reg.meta_for_session(&s), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_live_agents_lists_triples() {
|
||||
let reg = StructuredSessions::new();
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "domain"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
@ -209,6 +209,55 @@ pub enum DomainEvent {
|
||||
/// Target profile's submit delay in ms. `None` ⇒ front default (~60 ms).
|
||||
submit_delay_ms: Option<u32>,
|
||||
},
|
||||
/// Un agent vient d'entrer en **limite de session/débit** (ARCHITECTURE §21).
|
||||
/// Publié quand le service de limite enregistre une nouvelle `SessionLimit` (niveau
|
||||
/// 1 structuré ou niveau 2 motif). Balise discrète, basse fréquence, relayée au
|
||||
/// front pour afficher le badge « limité jusqu'à HH:MM ». Model-agnostique : ne
|
||||
/// porte que le fait neutre « limité, reset à T (peut-être) ».
|
||||
AgentRateLimited {
|
||||
/// L'agent entré en limite.
|
||||
agent_id: AgentId,
|
||||
/// Instant de reset en **époche-millisecondes**. `None` ⇒ heure inconnue
|
||||
/// (pas de reprise auto, filet humain).
|
||||
resets_at_ms: Option<i64>,
|
||||
},
|
||||
/// Une **reprise automatique** a été armée pour un agent limité (ARCHITECTURE §21).
|
||||
/// Publié après que le service a calculé le plan ([`crate::session_limit::plan_resume`])
|
||||
/// et armé le `Scheduler`. Relayé au front pour afficher le compte à rebours + le
|
||||
/// bouton « Annuler la reprise » (fenêtre annulable).
|
||||
AgentResumeScheduled {
|
||||
/// L'agent dont la reprise est programmée.
|
||||
agent_id: AgentId,
|
||||
/// Échéance du réveil en **époche-millisecondes**.
|
||||
fire_at_ms: i64,
|
||||
},
|
||||
/// La **reprise automatique** d'un agent a été **annulée** (ARCHITECTURE §21) :
|
||||
/// l'utilisateur a cliqué « Annuler la reprise » dans la fenêtre annulable. Relayé
|
||||
/// au front pour retirer le compte à rebours.
|
||||
AgentResumeCancelled {
|
||||
/// L'agent dont la reprise a été annulée.
|
||||
agent_id: AgentId,
|
||||
},
|
||||
/// Un agent a effectivement été **relancé** après une limite (ARCHITECTURE §21) :
|
||||
/// le réveil a tiré (ou reprise immédiate), l'agent a redémarré via
|
||||
/// [`crate::ports::SessionPlan::Resume`] avec un prompt de reprise court. Relayé au
|
||||
/// front pour effacer l'état « limité ».
|
||||
AgentResumed {
|
||||
/// L'agent relancé.
|
||||
agent_id: AgentId,
|
||||
},
|
||||
/// **Filet humain (niveau 3)** : une limite de session est **suspectée** sans
|
||||
/// qu'aucune heure de reset fiable ne soit connue (ARCHITECTURE §21.1 niveau 3) —
|
||||
/// typiquement un agent passé `Stalled` (lot 2) sans `SessionLimit` connue. IdeA ne
|
||||
/// reprend **jamais** à l'aveugle : ce signal demande au front de **solliciter
|
||||
/// l'utilisateur** (« limite détectée mais heure inconnue — reprendre à ? »).
|
||||
AgentRateLimitSuspected {
|
||||
/// L'agent dont la limite est suspectée.
|
||||
agent_id: AgentId,
|
||||
/// Instant de reset en **époche-millisecondes** si une estimation existe,
|
||||
/// sinon `None` (l'utilisateur fournira l'heure).
|
||||
resets_at_ms: Option<i64>,
|
||||
},
|
||||
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
|
||||
PtyOutput {
|
||||
/// The session.
|
||||
@ -217,3 +266,104 @@ pub enum DomainEvent {
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn agent(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
// -- §21 : constructibilité + égalité PartialEq des 5 variantes --------------
|
||||
|
||||
#[test]
|
||||
fn agent_rate_limited_constructs_and_compares() {
|
||||
let ev = DomainEvent::AgentRateLimited {
|
||||
agent_id: agent(1),
|
||||
resets_at_ms: Some(1_700_000_000_000),
|
||||
};
|
||||
assert_eq!(
|
||||
ev,
|
||||
DomainEvent::AgentRateLimited {
|
||||
agent_id: agent(1),
|
||||
resets_at_ms: Some(1_700_000_000_000),
|
||||
}
|
||||
);
|
||||
// Une heure différente ⇒ inégaux.
|
||||
assert_ne!(
|
||||
ev,
|
||||
DomainEvent::AgentRateLimited {
|
||||
agent_id: agent(1),
|
||||
resets_at_ms: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_resume_scheduled_constructs_and_compares() {
|
||||
let ev = DomainEvent::AgentResumeScheduled {
|
||||
agent_id: agent(2),
|
||||
fire_at_ms: 1_700_000_000_000,
|
||||
};
|
||||
assert_eq!(
|
||||
ev,
|
||||
DomainEvent::AgentResumeScheduled {
|
||||
agent_id: agent(2),
|
||||
fire_at_ms: 1_700_000_000_000,
|
||||
}
|
||||
);
|
||||
assert_ne!(
|
||||
ev,
|
||||
DomainEvent::AgentResumeScheduled {
|
||||
agent_id: agent(2),
|
||||
fire_at_ms: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_resume_cancelled_constructs_and_compares() {
|
||||
let ev = DomainEvent::AgentResumeCancelled { agent_id: agent(3) };
|
||||
assert_eq!(ev, DomainEvent::AgentResumeCancelled { agent_id: agent(3) });
|
||||
assert_ne!(ev, DomainEvent::AgentResumeCancelled { agent_id: agent(4) });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_resumed_constructs_and_compares() {
|
||||
let ev = DomainEvent::AgentResumed { agent_id: agent(5) };
|
||||
assert_eq!(ev, DomainEvent::AgentResumed { agent_id: agent(5) });
|
||||
assert_ne!(ev, DomainEvent::AgentResumed { agent_id: agent(6) });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_rate_limit_suspected_constructs_and_compares() {
|
||||
let ev = DomainEvent::AgentRateLimitSuspected {
|
||||
agent_id: agent(7),
|
||||
resets_at_ms: None,
|
||||
};
|
||||
assert_eq!(
|
||||
ev,
|
||||
DomainEvent::AgentRateLimitSuspected {
|
||||
agent_id: agent(7),
|
||||
resets_at_ms: None,
|
||||
}
|
||||
);
|
||||
assert_ne!(
|
||||
ev,
|
||||
DomainEvent::AgentRateLimitSuspected {
|
||||
agent_id: agent(7),
|
||||
resets_at_ms: Some(42),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_session_limit_variants_are_not_equal() {
|
||||
// Les variantes ne se confondent pas entre elles malgré des champs proches.
|
||||
assert_ne!(
|
||||
DomainEvent::AgentResumeCancelled { agent_id: agent(8) },
|
||||
DomainEvent::AgentResumed { agent_id: agent(8) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,3 +92,10 @@ typed_id!(
|
||||
/// Identifies a node in a [`crate::layout::LayoutTree`].
|
||||
NodeId
|
||||
);
|
||||
typed_id!(
|
||||
/// Identifies one armed one-shot wake-up of a [`crate::ports::Scheduler`]
|
||||
/// (ARCHITECTURE §21.4). Opaque, cancellable handle returned by
|
||||
/// [`crate::ports::Scheduler::arm`] and consumed by
|
||||
/// [`crate::ports::Scheduler::cancel`].
|
||||
ScheduleId
|
||||
);
|
||||
|
||||
@ -50,6 +50,7 @@ pub mod profile;
|
||||
pub mod project;
|
||||
pub mod readiness;
|
||||
pub mod sandbox;
|
||||
pub mod session_limit;
|
||||
pub mod remote;
|
||||
pub mod skill;
|
||||
pub mod template;
|
||||
@ -64,8 +65,8 @@ mod validation;
|
||||
pub use error::DomainError;
|
||||
|
||||
pub use ids::{
|
||||
AgentId, LayoutId, NodeId, ProfileId, ProjectId, SessionId, SkillId, TabId, TemplateId,
|
||||
WindowId,
|
||||
AgentId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId, TabId,
|
||||
TemplateId, WindowId,
|
||||
};
|
||||
|
||||
pub use project::{Project, ProjectPath};
|
||||
@ -78,7 +79,7 @@ pub use template::{AgentTemplate, TemplateVersion};
|
||||
|
||||
pub use profile::{
|
||||
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, LivenessStrategy,
|
||||
McpServerWiring, SessionStrategy,
|
||||
McpServerWiring, RateLimitPattern, SessionStrategy,
|
||||
};
|
||||
|
||||
pub use mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId};
|
||||
@ -92,6 +93,8 @@ pub use input::{AgentBusyState, AgentLiveness, InputMediator, InputSource};
|
||||
|
||||
pub use readiness::{ReadinessPolicy, ReadinessSignal};
|
||||
|
||||
pub use session_limit::{plan_resume, ResumePlan, RateLimitSource, SessionLimit};
|
||||
|
||||
pub use conversation_log::{
|
||||
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
|
||||
ProviderSessionStore, TurnId, TurnRole,
|
||||
@ -142,6 +145,6 @@ pub use ports::{
|
||||
FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator,
|
||||
MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PermissionStore,
|
||||
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle,
|
||||
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, SpawnSpec, StoreError,
|
||||
TemplateStore,
|
||||
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, SpawnSpec,
|
||||
StoreError, TemplateStore,
|
||||
};
|
||||
|
||||
@ -29,7 +29,7 @@ use thiserror::Error;
|
||||
|
||||
use crate::agent::AgentManifest;
|
||||
use crate::events::DomainEvent;
|
||||
use crate::ids::{AgentId, SessionId};
|
||||
use crate::ids::{AgentId, NodeId, ScheduleId, SessionId};
|
||||
use crate::markdown::MarkdownDoc;
|
||||
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
||||
use crate::permission::ProjectPermissions;
|
||||
@ -225,6 +225,26 @@ pub enum ReplyEvent {
|
||||
/// jusqu'au [`ReplyEvent::Final`]. Un tour comporte ≥0 `Heartbeat`, jamais
|
||||
/// d'obligation d'en émettre.
|
||||
Heartbeat,
|
||||
/// **Limite de session/débit atteinte** (model-agnostique, ARCHITECTURE §21) :
|
||||
/// l'adapter structuré a observé que le moteur a suspendu l'agent pour cause de
|
||||
/// quota (Claude `rate_limit_event` → `rate_limit_info.resetsAt`, ou équivalent).
|
||||
/// Porte un fait neutre : « limité, reset à T (peut-être) ». Aucun détail propre
|
||||
/// à une CLI ne franchit la frontière (forme du `rate_limit_event`, format de
|
||||
/// l'heure…) — tout cela reste confiné à l'adapter (cf. §21.2-T2).
|
||||
///
|
||||
/// **Jamais terminal** (cf. §21.2-T4) : exactement comme [`ReplyEvent::Heartbeat`],
|
||||
/// un `RateLimited` **ne clôt pas** le flux — il s'intercale et le flux continue
|
||||
/// jusqu'au [`ReplyEvent::Final`] **ou** jusqu'à une clôture du flux. Conséquence
|
||||
/// pour les consommateurs : un tour clos **sans `Final`** parce que limité doit
|
||||
/// être traité comme une **fin gracieuse limitée** (et non comme une erreur « flux
|
||||
/// clos sans Final »), dès lors qu'un `RateLimited` a été vu dans le tour.
|
||||
RateLimited {
|
||||
/// Instant de réinitialisation de la limite, en **époche-millisecondes**
|
||||
/// (homogène avec [`Clock::now_millis`]). `None` quand le moteur n'a pas
|
||||
/// fourni d'heure de reset exploitable ⇒ pas de reprise auto possible (filet
|
||||
/// humain, §21.1 niveau 3). Jamais une `Instant` monotone (cf. §21.2-T1).
|
||||
resets_at_ms: Option<i64>,
|
||||
},
|
||||
/// **Événement terminal déterministe** d'un tour : l'adapter l'émet quand il a
|
||||
/// lu le message `result` documenté de la CLI. Porte le contenu final agrégé.
|
||||
/// Après `Final`, le flux se termine (plus aucun événement).
|
||||
@ -242,6 +262,34 @@ pub enum ReplyEvent {
|
||||
/// clôt le flux ; deltas, activités et heartbeats sont tous non terminaux.
|
||||
pub type ReplyStream = Box<dyn Iterator<Item = ReplyEvent> + Send>;
|
||||
|
||||
/// Intention **model-agnostique** exécutée à l'échéance d'un réveil de
|
||||
/// [`Scheduler`] (ARCHITECTURE §21.4).
|
||||
///
|
||||
/// **Donnée pure, pas de closure** : à l'image du dispatch de l'orchestrateur
|
||||
/// (§14.3, où une requête est une *donnée* validée puis dispatchée), une tâche
|
||||
/// programmée est une **valeur** qui franchit la frontière, jamais un effet ni une
|
||||
/// fonction. C'est ce qui garde le port [`Scheduler`] sans aucune dépendance vers
|
||||
/// l'application : l'adapter ne fait que **remettre** cette donnée à un drain
|
||||
/// applicatif, qui seul l'exécute.
|
||||
///
|
||||
/// Enum **extensible** : d'autres intentions programmées pourront s'ajouter sans
|
||||
/// toucher au port (Open/Closed).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ScheduledTask {
|
||||
/// Reprendre un agent à l'heure de reset de sa limite de session (§21) : relancer
|
||||
/// via [`SessionPlan::Resume`] avec un prompt de reprise court (logique côté
|
||||
/// application, lot LS4). Porte exactement le pivot de reprise model-agnostique.
|
||||
ResumeAgent {
|
||||
/// L'agent à reprendre.
|
||||
agent_id: AgentId,
|
||||
/// La cellule (nœud du layout) qui héberge sa session.
|
||||
node_id: NodeId,
|
||||
/// Id de conversation du moteur à reprendre (`None` ⇒ reprise dégradée sans
|
||||
/// id, comme le reste du chemin de reprise §15).
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-port error types
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -1246,3 +1294,50 @@ pub trait IdGenerator: Send + Sync {
|
||||
/// Returns a fresh UUID.
|
||||
fn new_uuid(&self) -> uuid::Uuid;
|
||||
}
|
||||
|
||||
/// Minuterie **one-shot annulable** (ARCHITECTURE §21.4) — le **seul** port neuf de
|
||||
/// la feature « limites de session ». Là où [`Clock`] dit *quelle heure il est*, ce
|
||||
/// port *réveille* à une échéance absolue donnée.
|
||||
///
|
||||
/// # Interface Segregation (§21.8-I)
|
||||
/// Volontairement minimal — `arm` / `cancel`, rien d'autre — et **distinct** de
|
||||
/// [`Clock`] : un consommateur qui n'a besoin que de « réveille-moi à T » ne dépend
|
||||
/// pas d'un store ni d'une horloge.
|
||||
///
|
||||
/// # Synchrone (pas d'`async_trait`)
|
||||
/// Comme [`Clock`], [`IdGenerator`] et [`EventBus`], ce port est **non bloquant** :
|
||||
/// `arm` ne fait qu'enregistrer une minuterie (l'attente réelle se passe en tâche de
|
||||
/// fond dans l'adapter) et `cancel` ne fait qu'annuler — aucun `await` au point
|
||||
/// d'appel. On le garde donc en `fn` simple, sans payer le boxing d'`async_trait`.
|
||||
///
|
||||
/// # Remise de la tâche échue — dispatch par DONNÉE (§14.3, §21.4)
|
||||
/// Le port **n'exécute jamais** la reprise (aucune dépendance vers l'application).
|
||||
/// À l'échéance, l'adapter **pousse la [`ScheduledTask`] (une valeur)** dans un canal
|
||||
/// fourni à sa construction, que l'application **draine** et exécute — exactement le
|
||||
/// patron du watcher d'orchestrateur, qui dispatche une requête-donnée vers
|
||||
/// l'`OrchestratorService`. Aucune closure ne franchit la frontière domaine.
|
||||
///
|
||||
/// # État en mémoire (§21.1-3)
|
||||
/// Aucune persistance : un réveil armé ne survit pas à un redémarrage d'IdeA (le
|
||||
/// chemin `ListResumableAgents` existant prend alors le relais).
|
||||
///
|
||||
/// # Substituabilité (Liskov)
|
||||
/// Tout adapter (le `TokioScheduler` réel comme un fake de test qui tire à la
|
||||
/// demande) respecte le même contrat : `arm` rend un id annulable ; `cancel` renvoie
|
||||
/// `true` ssi il a effectivement désarmé un réveil non encore tiré.
|
||||
pub trait Scheduler: Send + Sync {
|
||||
/// Arme une minuterie one-shot à `deadline_ms` (**époche-millisecondes absolues**,
|
||||
/// cohérent avec [`Clock::now_millis`] et `ResumePlan::Scheduled.fire_at_ms`). À
|
||||
/// l'échéance, l'adapter remet `task` au drain applicatif (cf. doc du trait).
|
||||
/// Renvoie un [`ScheduleId`] annulable.
|
||||
///
|
||||
/// `deadline_ms` **≤ now** ⇒ déclenchement **au plus tôt** (immédiat). Le clamp
|
||||
/// anti-passé est déjà assuré en amont par `plan_resume` (domaine) ; ce contrat ne
|
||||
/// fait que garantir qu'une échéance passée ne « se perd » pas.
|
||||
fn arm(&self, deadline_ms: i64, task: ScheduledTask) -> ScheduleId;
|
||||
|
||||
/// Annule un réveil armé **non encore tiré**. Idempotent et **sans erreur** :
|
||||
/// renvoie `true` s'il a effectivement été désarmé, `false` s'il était inconnu ou
|
||||
/// déjà tiré. C'est ce qui sous-tend la « reprise auto **annulable** » (§21.1-4).
|
||||
fn cancel(&self, id: ScheduleId) -> bool;
|
||||
}
|
||||
|
||||
@ -174,6 +174,64 @@ impl LivenessStrategy {
|
||||
}
|
||||
}
|
||||
|
||||
/// Motif déclaratif de détection d'une **limite de session/débit** pour un agent
|
||||
/// **PTY/TUI sans adapter structuré** (ARCHITECTURE §21, niveau 2 de détection).
|
||||
///
|
||||
/// Donnée **pure** (pas de code par CLI — Open/Closed, §9), calquée sur la
|
||||
/// philosophie de [`AgentProfile::prompt_ready_pattern`] mais **plus riche** : là où
|
||||
/// le retour-de-prompt est une simple sous-chaîne littérale, la limite de session a
|
||||
/// besoin d'**extraire une heure de reset** dans la sortie. Le domaine **ne stocke
|
||||
/// que la donnée** (chaînes) ; le **moteur regex et le parsing d'heure vivent en
|
||||
/// infrastructure** (composant `RateLimitParser`, dépendance `regex` ajoutée au seul
|
||||
/// `Cargo.toml` d'`infrastructure`, cf. §21.2-T2). **Aucune** regex ni heure parsée
|
||||
/// ne franchit la frontière domaine : l'infra émet un
|
||||
/// [`crate::ports::ReplyEvent::RateLimited`] avec une époche-ms normalisée.
|
||||
///
|
||||
/// Invariant (garanti par le constructeur) : `pattern` est non vide.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RateLimitPattern {
|
||||
/// Le **motif brut** (interprété comme une regex par l'infra) recherché dans la
|
||||
/// sortie PTY pour reconnaître l'épisode de limite. Le domaine ne le compile
|
||||
/// jamais — il le transporte tel quel jusqu'à l'adapter (§21.2-T2).
|
||||
pub pattern: String,
|
||||
/// Nom du **groupe de capture** (ou indication équivalente) d'où l'infra extrait
|
||||
/// l'heure de reset. `None` ⇒ le motif détecte la limite **sans** heure de reset
|
||||
/// ⇒ [`crate::ports::ReplyEvent::RateLimited`]`{ resets_at_ms: None }` (filet
|
||||
/// humain). Donnée opaque au domaine.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reset_capture: Option<String>,
|
||||
/// **Format d'heure** (ex. style `strftime`) que l'infra utilise pour parser la
|
||||
/// chaîne capturée par `reset_capture` en une heure murale, qu'elle compose
|
||||
/// ensuite avec la date du jour + le fuseau via `Clock` pour obtenir une
|
||||
/// époche-ms (§21.10-2). `None` ⇒ l'infra applique sa stratégie de parsing par
|
||||
/// défaut. Donnée opaque au domaine.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub time_format: Option<String>,
|
||||
}
|
||||
|
||||
impl RateLimitPattern {
|
||||
/// Construit un motif validé (parse-don't-validate, comme
|
||||
/// [`SessionStrategy::new`] / [`LivenessStrategy::new`]).
|
||||
///
|
||||
/// # Errors
|
||||
/// Renvoie [`DomainError::EmptyField`] (`"rateLimitPattern.pattern"`) si
|
||||
/// `pattern` est vide.
|
||||
pub fn new(
|
||||
pattern: impl Into<String>,
|
||||
reset_capture: Option<String>,
|
||||
time_format: Option<String>,
|
||||
) -> Result<Self, DomainError> {
|
||||
let pattern = pattern.into();
|
||||
crate::validation::non_empty(&pattern, "rateLimitPattern.pattern")?;
|
||||
Ok(Self {
|
||||
pattern,
|
||||
reset_capture,
|
||||
time_format,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapter d'**exécution structurée** qui pilote un profil IA (ARCHITECTURE §17).
|
||||
///
|
||||
/// Déclaratif, Open/Closed (comme [`EmbedderStrategy`]) : un profil déclare quel
|
||||
@ -553,6 +611,21 @@ pub struct AgentProfile {
|
||||
/// un profil sans cette clé sérialise exactement comme avant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub liveness: Option<LivenessStrategy>,
|
||||
/// Motif déclaratif de détection d'une **limite de session/débit** (ARCHITECTURE
|
||||
/// §21, niveau 2). `None` (défaut, et valeur des profils existants) ⇒ **aucune**
|
||||
/// détection par motif : seuls les agents structurés (niveau 1) ou le filet
|
||||
/// humain (niveau 3) couvrent la limite. `Some(_)` ⇒ pour un agent **PTY/TUI sans
|
||||
/// adapter structuré**, IdeA observe la sortie et émet un
|
||||
/// [`crate::ports::ReplyEvent::RateLimited`] sur match.
|
||||
///
|
||||
/// Le domaine ne porte que la **donnée** ([`RateLimitPattern`]) ; le moteur regex
|
||||
/// + le parsing d'heure vivent en infra (§21.2-T2) — domaine dépendance-zéro
|
||||
/// préservé.
|
||||
///
|
||||
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation :
|
||||
/// un profil sans cette clé sérialise exactement comme avant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rate_limit_pattern: Option<RateLimitPattern>,
|
||||
/// Séquence de soumission écrite **après** le texte d'une délégation pour la
|
||||
/// faire valider par la CLI (§20.3, fix Bug 1). Le portail d'écriture (front)
|
||||
/// écrit d'abord le texte (sans `\n`, pour esquiver la détection de paste de
|
||||
@ -726,6 +799,7 @@ impl AgentProfile {
|
||||
mcp: None,
|
||||
prompt_ready_pattern: None,
|
||||
liveness: None,
|
||||
rate_limit_pattern: None,
|
||||
submit_sequence: None,
|
||||
submit_delay_ms: None,
|
||||
projector: None,
|
||||
@ -768,6 +842,15 @@ impl AgentProfile {
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe le [`RateLimitPattern`] de détection de limite (§21, niveau 2)
|
||||
/// et renvoie le profil. Laisse [`AgentProfile::new`] stable (zéro régression
|
||||
/// d'appel) : les profils sans détection par motif ne l'appellent simplement pas.
|
||||
#[must_use]
|
||||
pub fn with_rate_limit_pattern(mut self, pattern: RateLimitPattern) -> Self {
|
||||
self.rate_limit_pattern = Some(pattern);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe la [`Self::submit_sequence`] (§20.3, fix Bug 1) et renvoie le
|
||||
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
||||
/// profils qui s'en remettent au défaut `"\r"` ne l'appellent simplement pas.
|
||||
@ -1366,4 +1449,96 @@ mod mcp_tests {
|
||||
"transport expected; got: {toml}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- §21 : rate_limit_pattern (détection de limite par motif, niveau 2) ------
|
||||
|
||||
#[test]
|
||||
fn rate_limit_pattern_new_rejects_empty_pattern() {
|
||||
let err = RateLimitPattern::new("", None, None).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, DomainError::EmptyField { field } if field == "rateLimitPattern.pattern"),
|
||||
"un motif vide doit être rejeté; got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_pattern_new_accepts_non_empty_pattern() {
|
||||
let p = RateLimitPattern::new(
|
||||
"rate limit.*resets at (?P<reset>.+)",
|
||||
Some("reset".to_owned()),
|
||||
Some("%H:%M".to_owned()),
|
||||
)
|
||||
.expect("valid pattern");
|
||||
assert_eq!(p.pattern, "rate limit.*resets at (?P<reset>.+)");
|
||||
assert_eq!(p.reset_capture.as_deref(), Some("reset"));
|
||||
assert_eq!(p.time_format.as_deref(), Some("%H:%M"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_default_has_no_rate_limit_pattern() {
|
||||
// Profils existants (via `new`) : aucun motif de limite.
|
||||
assert!(profile_without_mcp().rate_limit_pattern.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_without_rate_limit_pattern_omits_key_in_json() {
|
||||
let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
|
||||
assert!(
|
||||
!json.contains("rateLimitPattern"),
|
||||
"a profile without a rate-limit pattern must NOT serialise the key (zero regression); got: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_json_without_rate_limit_pattern_deserialises_to_none() {
|
||||
// JSON produit avant l'existence du champ : aucune clé `rateLimitPattern`.
|
||||
let legacy = r#"{
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"name": "Dev",
|
||||
"command": "claude",
|
||||
"args": [],
|
||||
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
|
||||
"detect": null,
|
||||
"cwdTemplate": "{agentRunDir}"
|
||||
}"#;
|
||||
let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
|
||||
assert!(profile.rate_limit_pattern.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_rate_limit_pattern_sets_and_round_trips_camel_case() {
|
||||
let pattern = RateLimitPattern::new(
|
||||
"limit reached, resets (?P<resetAt>.+)",
|
||||
Some("resetAt".to_owned()),
|
||||
Some("%-I%p".to_owned()),
|
||||
)
|
||||
.expect("valid pattern");
|
||||
let profile = profile_without_mcp().with_rate_limit_pattern(pattern.clone());
|
||||
assert_eq!(profile.rate_limit_pattern, Some(pattern));
|
||||
|
||||
let json = serde_json::to_string(&profile).expect("serialise");
|
||||
assert!(json.contains("rateLimitPattern"), "key present: {json}");
|
||||
// camelCase respecté sur les champs de RateLimitPattern.
|
||||
assert!(json.contains("resetCapture"), "camelCase field resetCapture: {json}");
|
||||
assert!(json.contains("timeFormat"), "camelCase field timeFormat: {json}");
|
||||
|
||||
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
|
||||
assert_eq!(profile, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_pattern_omits_unset_optional_fields_in_json() {
|
||||
// reset_capture / time_format à None ⇒ leurs clés sont omises.
|
||||
let pattern = RateLimitPattern::new("rate limited", None, None).expect("valid pattern");
|
||||
let json = serde_json::to_string(&pattern).expect("serialise");
|
||||
assert!(json.contains("\"pattern\""), "pattern field present: {json}");
|
||||
assert!(
|
||||
!json.contains("resetCapture"),
|
||||
"an unset resetCapture must be omitted; got: {json}"
|
||||
);
|
||||
assert!(
|
||||
!json.contains("timeFormat"),
|
||||
"an unset timeFormat must be omitted; got: {json}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,6 +49,20 @@ pub enum ReadinessSignal {
|
||||
/// Le garde-fou de durée de tour a expiré. Place réservée au **lot 2** — non
|
||||
/// produit dans ce lot.
|
||||
TimedOut,
|
||||
/// L'agent est en **limite de session/débit** (ARCHITECTURE §21) : le moteur l'a
|
||||
/// suspendu pour cause de quota. Déduit de [`ReplyEvent::RateLimited`] par
|
||||
/// [`ReadinessPolicy::classify`]. **Pas terminal** au sens « fin de tour » : il ne
|
||||
/// marque pas le tour comme `TurnEnded` (l'agent n'a pas rendu son `Final`) — il
|
||||
/// signale un 3ᵉ axe d'état orthogonal (« limité jusqu'à T »), que l'application
|
||||
/// traduit en planification de reprise.
|
||||
///
|
||||
/// `Copy` préservé : `Option<i64>` est `Copy`, donc l'enum le reste (cf. §21.3).
|
||||
RateLimited {
|
||||
/// Instant de reset en **époche-millisecondes** (cf.
|
||||
/// [`ReplyEvent::RateLimited::resets_at_ms`]). `None` ⇒ pas d'heure connue
|
||||
/// (filet humain, pas de reprise auto).
|
||||
resets_at_ms: Option<i64>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Politique **pure** de classification d'un événement de tour en
|
||||
@ -63,6 +77,11 @@ impl ReadinessPolicy {
|
||||
///
|
||||
/// - [`ReplyEvent::Final`] ⇒ `Some(`[`ReadinessSignal::TurnEnded`]`)` : seul
|
||||
/// événement terminal, il signe la fin de tour déterministe.
|
||||
/// - [`ReplyEvent::RateLimited`] ⇒
|
||||
/// `Some(`[`ReadinessSignal::RateLimited`]`{..})` : **non terminal** (l'agent
|
||||
/// n'a pas rendu son `Final`), mais porteur d'un signal exploitable par
|
||||
/// l'application (planifier la reprise à `resets_at_ms`). L'heure de reset est
|
||||
/// propagée telle quelle.
|
||||
/// - [`ReplyEvent::TextDelta`] / [`ReplyEvent::ToolActivity`] /
|
||||
/// [`ReplyEvent::Heartbeat`] ⇒ `None` : tous **non terminaux** (le flux
|
||||
/// continue). Un heartbeat prouve la vivacité mais ne termine pas le tour.
|
||||
@ -70,6 +89,11 @@ impl ReadinessPolicy {
|
||||
pub const fn classify(event: &ReplyEvent) -> Option<ReadinessSignal> {
|
||||
match event {
|
||||
ReplyEvent::Final { .. } => Some(ReadinessSignal::TurnEnded),
|
||||
ReplyEvent::RateLimited { resets_at_ms } => {
|
||||
Some(ReadinessSignal::RateLimited {
|
||||
resets_at_ms: *resets_at_ms,
|
||||
})
|
||||
}
|
||||
ReplyEvent::TextDelta { .. }
|
||||
| ReplyEvent::ToolActivity { .. }
|
||||
| ReplyEvent::Heartbeat => None,
|
||||
@ -108,4 +132,51 @@ mod tests {
|
||||
"un heartbeat prouve la vivacité mais ne termine JAMAIS le tour"
|
||||
);
|
||||
}
|
||||
|
||||
// -- §21 : RateLimited ⇒ ReadinessSignal::RateLimited (heure propagée) -------
|
||||
|
||||
#[test]
|
||||
fn rate_limited_with_known_reset_classifies_and_propagates_time() {
|
||||
let ev = ReplyEvent::RateLimited {
|
||||
resets_at_ms: Some(1_700_000_000_000),
|
||||
};
|
||||
assert_eq!(
|
||||
ReadinessPolicy::classify(&ev),
|
||||
Some(ReadinessSignal::RateLimited {
|
||||
resets_at_ms: Some(1_700_000_000_000),
|
||||
}),
|
||||
"l'heure de reset doit être propagée telle quelle"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_without_reset_classifies_with_none() {
|
||||
let ev = ReplyEvent::RateLimited { resets_at_ms: None };
|
||||
assert_eq!(
|
||||
ReadinessPolicy::classify(&ev),
|
||||
Some(ReadinessSignal::RateLimited { resets_at_ms: None }),
|
||||
"heure inconnue ⇒ None propagé (filet humain en aval)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_is_not_classified_as_turn_ended() {
|
||||
// Non terminal : un RateLimited ne marque JAMAIS le tour comme fini.
|
||||
let signal = ReadinessPolicy::classify(&ReplyEvent::RateLimited {
|
||||
resets_at_ms: Some(42),
|
||||
});
|
||||
assert_ne!(signal, Some(ReadinessSignal::TurnEnded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_signal_is_copy() {
|
||||
// Test de compilation : `ReadinessSignal` reste `Copy` (cf. §21.3). Si le
|
||||
// type cessait d'être `Copy`, l'usage après le `copy` ci-dessous (move
|
||||
// implicite) ne compilerait plus.
|
||||
let sig = ReadinessSignal::RateLimited {
|
||||
resets_at_ms: Some(7),
|
||||
};
|
||||
let copy = sig; // copie implicite, pas un move
|
||||
assert_eq!(sig, copy);
|
||||
}
|
||||
}
|
||||
|
||||
227
crates/domain/src/session_limit.rs
Normal file
227
crates/domain/src/session_limit.rs
Normal file
@ -0,0 +1,227 @@
|
||||
//! Limite de session/débit d'un agent — value object pur + calcul de reprise
|
||||
//! (ARCHITECTURE §21).
|
||||
//!
|
||||
//! Objet **pur** (aucune I/O, aucun temps réel, aucune dépendance externe) : il
|
||||
//! capture le **fait neutre** « cet agent est limité, reset à T (peut-être) » et
|
||||
//! calcule, à partir de l'heure courante, le **plan de reprise** correspondant.
|
||||
//! Tout savoir spécifique modèle (forme du `rate_limit_event` Claude, regex d'une
|
||||
//! TUI, parsing d'une heure locale) reste **confiné aux adapters/profils** (§21.2).
|
||||
//!
|
||||
//! La limite de session est un **3ᵉ axe d'état orthogonal** aux deux axes déjà
|
||||
//! posés dans [`crate::input`] ([`crate::input::AgentBusyState`] et
|
||||
//! [`crate::input::AgentLiveness`]) : un agent peut être `Idle`/`Busy`,
|
||||
//! `Alive`/`Stalled`, **et** limité jusqu'à une certaine heure.
|
||||
//!
|
||||
//! # Pourquoi des époche-millisecondes ?
|
||||
//!
|
||||
//! Toutes les heures manipulées ici sont des **i64 époche-millisecondes**,
|
||||
//! homogènes avec [`crate::ports::Clock::now_millis`] et
|
||||
//! [`crate::input::AgentBusyState`] : sérialisables, comparables, sans dépendance
|
||||
//! à `std::time::Instant` (monotone, non sérialisable — cf. §21.2-T1).
|
||||
|
||||
/// D'où provient la détection de la limite — utile pour la traçabilité et l'UI
|
||||
/// (afficher « limite structurée » vs « confirmée par l'utilisateur »).
|
||||
///
|
||||
/// Calqué sur la hiérarchie de détection à trois niveaux de §21.1.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RateLimitSource {
|
||||
/// **Niveau 1** : extraite du flux structuré de l'adapter (le plus solide ;
|
||||
/// Claude `rate_limit_info.resetsAt`, équivalent Codex…).
|
||||
Structured,
|
||||
/// **Niveau 2** : détectée par un motif déclaratif de profil
|
||||
/// ([`crate::profile::RateLimitPattern`]) sur la sortie PTY d'un agent TUI.
|
||||
Pattern,
|
||||
/// **Niveau 3** : confirmée par l'utilisateur (filet humain) quand rien n'a
|
||||
/// matché automatiquement mais que l'agent semble bloqué.
|
||||
Human,
|
||||
}
|
||||
|
||||
/// Value object **pur** : une limite de session/débit détectée pour un agent
|
||||
/// (ARCHITECTURE §21.3).
|
||||
///
|
||||
/// Immuable, sans I/O, trivialement testable. Vit **en mémoire uniquement**
|
||||
/// (§21.1-3) : aucune persistance, aucun store. Un agent n'a qu'**une** limite
|
||||
/// vivante à la fois ; un second signal la **rafraîchit** plutôt que de l'empiler
|
||||
/// (dédoublonnage assuré côté application, §21.10-4).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SessionLimit {
|
||||
/// Instant de reset en **époche-millisecondes**. `None` quand aucune heure de
|
||||
/// reset exploitable n'a été obtenue ⇒ pas de reprise auto possible (filet
|
||||
/// humain, §21.1 niveau 3).
|
||||
pub resets_at_ms: Option<i64>,
|
||||
/// Instant de **détection** de la limite, en époche-millisecondes (utile à l'UI
|
||||
/// et au diagnostic ; non requis pour le calcul de reprise).
|
||||
pub detected_at_ms: i64,
|
||||
/// Niveau de détection ayant produit cette limite.
|
||||
pub source: RateLimitSource,
|
||||
}
|
||||
|
||||
impl SessionLimit {
|
||||
/// Construit une limite de session. Pur, sans validation : tous les états
|
||||
/// (`resets_at_ms` présent ou absent, n'importe quelle source) sont légitimes.
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
resets_at_ms: Option<i64>,
|
||||
detected_at_ms: i64,
|
||||
source: RateLimitSource,
|
||||
) -> Self {
|
||||
Self {
|
||||
resets_at_ms,
|
||||
detected_at_ms,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
/// Vrai si une heure de reset exploitable est connue (⇒ reprise auto possible).
|
||||
/// Faux ⇒ filet humain (l'application demande l'heure à l'utilisateur).
|
||||
#[must_use]
|
||||
pub const fn has_known_reset(&self) -> bool {
|
||||
self.resets_at_ms.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Plan de reprise calculé par [`plan_resume`] (ARCHITECTURE §21.3).
|
||||
///
|
||||
/// Donnée **pure** : ne porte que ce dont l'application a besoin pour armer la
|
||||
/// reprise, jamais de closure ni de port (cf. l'esprit du dispatch orchestrateur
|
||||
/// §14.3 — une **intention** model-agnostique, pas un effet).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ResumePlan {
|
||||
/// Reprise **automatique programmée** : armer un réveil à `fire_at_ms` qui
|
||||
/// relancera l'agent via [`crate::ports::SessionPlan::Resume`].
|
||||
Scheduled {
|
||||
/// Échéance du réveil en **époche-millisecondes**. Vaut l'heure de reset,
|
||||
/// **clampée à `now_ms`** si le reset est déjà passé (on ne programme jamais
|
||||
/// une échéance dans le passé : reprise immédiate).
|
||||
fire_at_ms: i64,
|
||||
/// Identifiant de conversation du moteur à reprendre (pivot model-agnostique,
|
||||
/// porté tel quel jusqu'à [`crate::ports::SessionPlan::Resume`]). `None` ⇒
|
||||
/// reprise en mode dégradé (sans id), comme le reste du chemin de reprise.
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// Aucune reprise automatique possible : l'heure de reset est inconnue
|
||||
/// (`SessionLimit::resets_at_ms == None`) ⇒ **filet humain** (§21.1 niveau 3).
|
||||
/// L'application demandera l'heure à l'utilisateur plutôt que d'agir à l'aveugle
|
||||
/// (jamais d'inaction silencieuse, jamais d'auto sans heure).
|
||||
HumanFallback,
|
||||
}
|
||||
|
||||
/// Calcule le **plan de reprise** d'un agent limité, à partir de l'heure courante
|
||||
/// (ARCHITECTURE §21.3). Fonction **pure** : aucune I/O, aucun temps réel — `now_ms`
|
||||
/// est injecté (cf. les fonctions pures de `LayoutTree`, §7.2), ce qui la rend
|
||||
/// trivialement testable.
|
||||
///
|
||||
/// - `limit.resets_at_ms == Some(t)` ⇒ [`ResumePlan::Scheduled`] avec
|
||||
/// `fire_at_ms = max(t, now_ms)` (on ne programme jamais dans le passé : un reset
|
||||
/// déjà écoulé ⇒ reprise immédiate) et l'`conversation_id` fourni propagé tel quel.
|
||||
/// - `limit.resets_at_ms == None` ⇒ [`ResumePlan::HumanFallback`] (filet humain).
|
||||
#[must_use]
|
||||
pub fn plan_resume(
|
||||
now_ms: i64,
|
||||
limit: &SessionLimit,
|
||||
conversation_id: Option<String>,
|
||||
) -> ResumePlan {
|
||||
match limit.resets_at_ms {
|
||||
Some(resets_at_ms) => ResumePlan::Scheduled {
|
||||
fire_at_ms: resets_at_ms.max(now_ms),
|
||||
conversation_id,
|
||||
},
|
||||
None => ResumePlan::HumanFallback,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const NOW: i64 = 1_700_000_000_000;
|
||||
|
||||
fn limit(resets_at_ms: Option<i64>) -> SessionLimit {
|
||||
SessionLimit::new(resets_at_ms, NOW, RateLimitSource::Structured)
|
||||
}
|
||||
|
||||
// -- plan_resume : reset futur ⇒ Scheduled à l'heure de reset ----------------
|
||||
|
||||
#[test]
|
||||
fn future_reset_schedules_at_reset_time_with_conversation_id() {
|
||||
let reset = NOW + 60_000;
|
||||
let plan = plan_resume(NOW, &limit(Some(reset)), Some("conv-42".to_owned()));
|
||||
assert_eq!(
|
||||
plan,
|
||||
ResumePlan::Scheduled {
|
||||
fire_at_ms: reset,
|
||||
conversation_id: Some("conv-42".to_owned()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// -- plan_resume : clamp anti-passé ------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn past_reset_is_clamped_to_now_never_in_the_past() {
|
||||
let past = NOW - 60_000;
|
||||
let plan = plan_resume(NOW, &limit(Some(past)), None);
|
||||
match plan {
|
||||
ResumePlan::Scheduled { fire_at_ms, .. } => {
|
||||
assert_eq!(fire_at_ms, NOW, "un reset déjà passé ⇒ reprise immédiate (now)");
|
||||
}
|
||||
other => panic!("attendu Scheduled, obtenu {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_exactly_now_fires_at_now() {
|
||||
let plan = plan_resume(NOW, &limit(Some(NOW)), None);
|
||||
assert_eq!(
|
||||
plan,
|
||||
ResumePlan::Scheduled {
|
||||
fire_at_ms: NOW,
|
||||
conversation_id: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// -- plan_resume : pas d'heure ⇒ filet humain --------------------------------
|
||||
|
||||
#[test]
|
||||
fn unknown_reset_falls_back_to_human() {
|
||||
let plan = plan_resume(NOW, &limit(None), Some("conv-42".to_owned()));
|
||||
assert_eq!(plan, ResumePlan::HumanFallback);
|
||||
}
|
||||
|
||||
// -- conversation_id propagé tel quel (Some et None) -------------------------
|
||||
|
||||
#[test]
|
||||
fn conversation_id_some_is_propagated_into_scheduled() {
|
||||
let reset = NOW + 1;
|
||||
let plan = plan_resume(NOW, &limit(Some(reset)), Some("c".to_owned()));
|
||||
assert_eq!(
|
||||
plan,
|
||||
ResumePlan::Scheduled {
|
||||
fire_at_ms: reset,
|
||||
conversation_id: Some("c".to_owned()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversation_id_none_is_propagated_into_scheduled() {
|
||||
let reset = NOW + 1;
|
||||
let plan = plan_resume(NOW, &limit(Some(reset)), None);
|
||||
assert_eq!(
|
||||
plan,
|
||||
ResumePlan::Scheduled {
|
||||
fire_at_ms: reset,
|
||||
conversation_id: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// -- SessionLimit::has_known_reset -------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn has_known_reset_is_true_with_some_and_false_with_none() {
|
||||
assert!(limit(Some(NOW)).has_known_reset());
|
||||
assert!(!limit(None).has_known_reset());
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "infrastructure"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
@ -20,6 +20,11 @@ async-trait = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
# Moteur regex du détecteur de limite de session niveau 2 (ARCHITECTURE §21.2-T2) :
|
||||
# le DOMAINE ne porte que la donnée du motif (`RateLimitPattern`) ; le moteur regex
|
||||
# vit ICI, jamais dans `domain` (qui reste dépendance-zéro). Version alignée sur
|
||||
# celle déjà présente dans le `Cargo.lock` (transitive).
|
||||
regex = "1"
|
||||
portable-pty = "0.9"
|
||||
git2 = { workspace = true }
|
||||
|
||||
|
||||
@ -27,11 +27,14 @@ pub mod orchestrator;
|
||||
pub mod permission;
|
||||
pub mod process;
|
||||
pub mod pty;
|
||||
pub mod ratelimit;
|
||||
pub mod remote;
|
||||
pub mod runtime;
|
||||
pub mod sandbox;
|
||||
pub mod scheduler;
|
||||
pub mod session;
|
||||
pub mod store;
|
||||
pub mod timeparse;
|
||||
|
||||
pub use clock::SystemClock;
|
||||
pub use conversation::InMemoryConversationRegistry;
|
||||
@ -58,7 +61,9 @@ pub use remote::{remote_host, LocalHost};
|
||||
pub use runtime::CliAgentRuntime;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use sandbox::LandlockSandbox;
|
||||
pub use ratelimit::RateLimitParser;
|
||||
pub use sandbox::{default_enforcer, NoopSandbox};
|
||||
pub use scheduler::TokioScheduler;
|
||||
pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory};
|
||||
#[cfg(feature = "vector-onnx")]
|
||||
pub use store::OnnxEmbedder;
|
||||
|
||||
384
crates/infrastructure/src/ratelimit/mod.rs
Normal file
384
crates/infrastructure/src/ratelimit/mod.rs
Normal file
@ -0,0 +1,384 @@
|
||||
//! Détecteur de limite de session **niveau 2 — déclaratif** (ARCHITECTURE §21,
|
||||
//! niveau 2 de la hiérarchie §21.1). Pour les agents **PTY/TUI sans adapter
|
||||
//! structuré**, on n'a pas de flux machine : on **observe la sortie texte** et on y
|
||||
//! cherche le motif déclaré par le profil ([`RateLimitPattern`], LS1).
|
||||
//!
|
||||
//! # Frontière T2 respectée
|
||||
//!
|
||||
//! Le **domaine** ne porte que la *donnée* du motif ; le **moteur regex** et le
|
||||
//! **parsing d'heure** vivent **ici** (§21.2-T2). Aucune `regex` ne franchit la
|
||||
//! frontière domaine : ce module produit un [`SessionLimit`] (valeur domaine pure).
|
||||
//!
|
||||
//! # Robustesse (« solide même pour un novice »)
|
||||
//!
|
||||
//! Une regex **invalide** dans un profil mal configuré ⇒ [`RateLimitParser::new`]
|
||||
//! renvoie `None` (pas de détecteur) : **jamais** de panique ni d'erreur fatale. Un
|
||||
//! profil pourri ne doit pas faire tomber IdeA.
|
||||
//!
|
||||
//! # Anti-double-détection niveau 1 / niveau 2 (§21.10-4)
|
||||
//!
|
||||
//! Le niveau 2 ne s'applique **qu'aux agents sans adapter structuré** : voir
|
||||
//! [`applies`]. Un agent qui a un `structured_adapter` détecte sa limite par le
|
||||
//! niveau 1 (flux machine, `session/claude.rs`) ; on ne lui arme **pas** de parser
|
||||
//! niveau 2 ⇒ pas de double détection. C'est la **règle de sélection** que le câblage
|
||||
//! (LS7) doit appliquer pour décider d'instancier — ou non — un [`RateLimitParser`].
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use domain::profile::{AgentProfile, RateLimitPattern};
|
||||
use domain::session_limit::{RateLimitSource, SessionLimit};
|
||||
|
||||
use crate::timeparse;
|
||||
|
||||
/// Règle de sélection (§21.10-4) : le détecteur niveau 2 ne s'applique qu'aux agents
|
||||
/// **sans adapter structuré** (sinon le niveau 1 détecte déjà) **et** dont le profil
|
||||
/// déclare un `rate_limit_pattern`. Source **unique** de la règle anti-double-
|
||||
/// détection, à appeler par le câblage PTY (LS7) avant d'instancier un parser.
|
||||
#[must_use]
|
||||
pub fn applies(profile: &AgentProfile) -> bool {
|
||||
profile.structured_adapter.is_none() && profile.rate_limit_pattern.is_some()
|
||||
}
|
||||
|
||||
/// Stratégie d'interprétation de l'heure capturée, déduite **une seule fois** du
|
||||
/// champ `time_format` du profil (donnée opaque au domaine, comprise ici).
|
||||
///
|
||||
/// Jeux de jetons reconnus (insensibles à la casse) ; tout le reste ⇒ [`Self::Auto`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ResetTimeFormat {
|
||||
/// `None`/inconnu : best-effort **absolu** (epoch entier/float, ou ISO-8601).
|
||||
Auto,
|
||||
/// `"epoch_s"` / `"epoch_seconds"` / `"unix_s"` : entier epoch en **secondes**.
|
||||
EpochSeconds,
|
||||
/// `"epoch_ms"` / `"epoch_millis"` / `"unix_ms"` : entier epoch en **ms**.
|
||||
EpochMillis,
|
||||
/// `"iso8601"` / `"rfc3339"` / `"iso"` : chaîne ISO-8601 absolue.
|
||||
Iso8601,
|
||||
/// `"relative_s"` / `"relative_seconds"` / `"duration_s"` / `"retry_after_s"` :
|
||||
/// **délai** en secondes ⇒ `now + delta`.
|
||||
RelativeSeconds,
|
||||
/// `"relative_ms"` / `"relative_millis"` : **délai** en ms ⇒ `now + delta`.
|
||||
RelativeMillis,
|
||||
/// `"wall"` / `"wall_clock"` / `"local"` / `"hh:mm"` : **heure murale locale**
|
||||
/// (« 3pm », « 15:00 ») ⇒ aujourd'hui à cette heure, demain si déjà passée.
|
||||
WallClock,
|
||||
}
|
||||
|
||||
impl ResetTimeFormat {
|
||||
/// Déduit la stratégie depuis le `time_format` déclaratif (ou `Auto` si absent).
|
||||
fn from_opt(time_format: Option<&str>) -> Self {
|
||||
let Some(raw) = time_format else {
|
||||
return Self::Auto;
|
||||
};
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"epoch_s" | "epoch_seconds" | "unix_s" => Self::EpochSeconds,
|
||||
"epoch_ms" | "epoch_millis" | "unix_ms" => Self::EpochMillis,
|
||||
"iso8601" | "rfc3339" | "iso" => Self::Iso8601,
|
||||
"relative_s" | "relative_seconds" | "duration_s" | "retry_after_s" => {
|
||||
Self::RelativeSeconds
|
||||
}
|
||||
"relative_ms" | "relative_millis" => Self::RelativeMillis,
|
||||
"wall" | "wall_clock" | "local" | "hh:mm" => Self::WallClock,
|
||||
_ => Self::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit la chaîne capturée en époche-ms selon la stratégie, en référence à
|
||||
/// `now_ms` pour les heures relatives/murales. `None` si la capture est
|
||||
/// inexploitable (⇒ détection sans heure ⇒ filet humain en aval).
|
||||
fn resolve(self, captured: &str, now_ms: i64) -> Option<i64> {
|
||||
let c = captured.trim();
|
||||
match self {
|
||||
Self::Auto => timeparse::parse_absolute_ms(c),
|
||||
Self::EpochSeconds => c.parse::<i64>().ok().map(|s| s.saturating_mul(1000)),
|
||||
Self::EpochMillis => c.parse::<i64>().ok(),
|
||||
Self::Iso8601 => timeparse::parse_rfc3339_to_ms(c),
|
||||
Self::RelativeSeconds => c
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.map(|d| now_ms + (d * 1000.0) as i64),
|
||||
Self::RelativeMillis => c.parse::<i64>().ok().map(|d| now_ms + d),
|
||||
Self::WallClock => {
|
||||
let (h, m, s) = timeparse::parse_wall_clock(c)?;
|
||||
Some(timeparse::wall_clock_to_ms(now_ms, h, m, s))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Détecteur niveau 2 : un **motif regex compilé une fois** + sa stratégie d'heure.
|
||||
///
|
||||
/// À instancier **par agent** (au câblage PTY, LS7) à partir de son
|
||||
/// `rate_limit_pattern`, puis à nourrir avec chaque fragment de sortie via
|
||||
/// [`detect`](Self::detect). La compilation (coûteuse) est faite **une seule fois**
|
||||
/// à la construction, pas à chaque fragment.
|
||||
pub struct RateLimitParser {
|
||||
/// Motif compilé recherché dans la sortie.
|
||||
regex: Regex,
|
||||
/// Nom (ou index décimal) du groupe de capture d'où extraire l'heure de reset.
|
||||
/// `None` ⇒ détection **sans** extraction d'heure (⇒ `resets_at_ms: None`).
|
||||
reset_capture: Option<String>,
|
||||
/// Stratégie d'interprétation de la capture, pré-calculée.
|
||||
time_format: ResetTimeFormat,
|
||||
}
|
||||
|
||||
impl RateLimitParser {
|
||||
/// Compile le motif du profil. `None` si la regex est **invalide** (profil mal
|
||||
/// configuré) — robustesse : jamais de panique, l'agent tourne juste sans
|
||||
/// détection niveau 2.
|
||||
#[must_use]
|
||||
pub fn new(pattern: &RateLimitPattern) -> Option<Self> {
|
||||
let regex = Regex::new(&pattern.pattern).ok()?;
|
||||
Some(Self {
|
||||
regex,
|
||||
reset_capture: pattern.reset_capture.clone(),
|
||||
time_format: ResetTimeFormat::from_opt(pattern.time_format.as_deref()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Analyse un fragment de sortie texte.
|
||||
///
|
||||
/// - **pas de match** ⇒ `None` (pas de limite) ;
|
||||
/// - **match** ⇒ `Some(SessionLimit)` avec `source = Pattern`,
|
||||
/// `detected_at_ms = now_ms`, et `resets_at_ms` extrait du groupe de capture
|
||||
/// `reset_capture` interprété selon `time_format` — `None` si pas de capture,
|
||||
/// groupe absent, ou heure inexploitable (détection **utile sans heure** ⇒ filet
|
||||
/// humain en aval, §21).
|
||||
#[must_use]
|
||||
pub fn detect(&self, text: &str, now_ms: i64) -> Option<SessionLimit> {
|
||||
let caps = self.regex.captures(text)?;
|
||||
let resets_at_ms = self.reset_capture.as_deref().and_then(|name| {
|
||||
// Groupe nommé en priorité ; repli sur un index décimal si `name` en est un.
|
||||
let raw = caps
|
||||
.name(name)
|
||||
.or_else(|| name.parse::<usize>().ok().and_then(|i| caps.get(i)))?;
|
||||
self.time_format.resolve(raw.as_str(), now_ms)
|
||||
});
|
||||
Some(SessionLimit::new(resets_at_ms, now_ms, RateLimitSource::Pattern))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
|
||||
|
||||
/// Instant de référence (epoch-ms) : 2023-11-14T22:13:20Z = 1_700_000_000 s.
|
||||
const NOW: i64 = 1_700_000_000_000;
|
||||
/// Début de journée UTC du 2023-11-14 (= 19675 × 86_400_000), pour les heures murales.
|
||||
const DAY_START: i64 = 1_699_920_000_000;
|
||||
|
||||
fn pattern(
|
||||
pat: &str,
|
||||
reset_capture: Option<&str>,
|
||||
time_format: Option<&str>,
|
||||
) -> RateLimitPattern {
|
||||
RateLimitPattern::new(
|
||||
pat.to_owned(),
|
||||
reset_capture.map(str::to_owned),
|
||||
time_format.map(str::to_owned),
|
||||
)
|
||||
.expect("motif valide (non vide)")
|
||||
}
|
||||
|
||||
// -- new : robustesse regex ----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn new_returns_none_on_invalid_regex() {
|
||||
// Parenthèse ouvrante non fermée ⇒ regex invalide ⇒ None (jamais de panique).
|
||||
let p = pattern("rate limit (", None, None);
|
||||
assert!(RateLimitParser::new(&p).is_none());
|
||||
}
|
||||
|
||||
// -- detect : pas de match -----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn detect_returns_none_when_pattern_does_not_match() {
|
||||
let parser = RateLimitParser::new(&pattern("rate limit reached", None, None)).unwrap();
|
||||
assert!(parser.detect("tout va bien", NOW).is_none());
|
||||
}
|
||||
|
||||
// -- detect : match sans reset_capture -----------------------------------
|
||||
|
||||
#[test]
|
||||
fn detect_match_without_reset_capture_has_no_time() {
|
||||
let parser = RateLimitParser::new(&pattern("rate limit reached", None, None)).unwrap();
|
||||
let limit = parser.detect("oops: rate limit reached!", NOW).expect("détecté");
|
||||
assert_eq!(limit.resets_at_ms, None);
|
||||
assert_eq!(limit.detected_at_ms, NOW);
|
||||
assert_eq!(limit.source, RateLimitSource::Pattern);
|
||||
}
|
||||
|
||||
// -- detect : formats ABSOLUS --------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn detect_epoch_seconds_format() {
|
||||
let parser = RateLimitParser::new(&pattern(
|
||||
r"resets at (?P<reset>\d+)",
|
||||
Some("reset"),
|
||||
Some("epoch_s"),
|
||||
))
|
||||
.unwrap();
|
||||
let limit = parser
|
||||
.detect("limit, resets at 1700000000 ok", NOW)
|
||||
.expect("détecté");
|
||||
assert_eq!(limit.resets_at_ms, Some(1_700_000_000_000));
|
||||
assert_eq!(limit.source, RateLimitSource::Pattern);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_epoch_millis_format() {
|
||||
let parser = RateLimitParser::new(&pattern(
|
||||
r"resets at (?P<reset>\d+)",
|
||||
Some("reset"),
|
||||
Some("epoch_ms"),
|
||||
))
|
||||
.unwrap();
|
||||
let limit = parser
|
||||
.detect("resets at 1700000000000", NOW)
|
||||
.expect("détecté");
|
||||
assert_eq!(limit.resets_at_ms, Some(1_700_000_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_iso8601_format() {
|
||||
let parser = RateLimitParser::new(&pattern(
|
||||
r"resets at (?P<reset>\S+)",
|
||||
Some("reset"),
|
||||
Some("iso8601"),
|
||||
))
|
||||
.unwrap();
|
||||
let limit = parser
|
||||
.detect("resets at 2023-11-14T22:13:20Z", NOW)
|
||||
.expect("détecté");
|
||||
assert_eq!(limit.resets_at_ms, Some(1_700_000_000_000));
|
||||
}
|
||||
|
||||
// -- detect : format RELATIF ---------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn detect_relative_seconds_format_uses_now() {
|
||||
let parser = RateLimitParser::new(&pattern(
|
||||
r"retry after (?P<reset>\d+)s",
|
||||
Some("reset"),
|
||||
Some("relative_s"),
|
||||
))
|
||||
.unwrap();
|
||||
let limit = parser.detect("retry after 600s", NOW).expect("détecté");
|
||||
assert_eq!(limit.resets_at_ms, Some(NOW + 600_000));
|
||||
assert_eq!(limit.source, RateLimitSource::Pattern);
|
||||
}
|
||||
|
||||
// -- detect : format MURAL (passage de minuit) ---------------------------
|
||||
|
||||
/// « 3pm » alors qu'il est 10 h ⇒ 15 h AUJOURD'HUI (même jour UTC).
|
||||
#[test]
|
||||
fn detect_wall_clock_same_day_when_future() {
|
||||
let parser = RateLimitParser::new(&pattern(
|
||||
r"resets at (?P<reset>.+)",
|
||||
Some("reset"),
|
||||
Some("wall"),
|
||||
))
|
||||
.unwrap();
|
||||
let now_10h = DAY_START + 10 * 3_600_000; // 10:00 UTC ce jour-là
|
||||
let limit = parser.detect("resets at 3pm", now_10h).expect("détecté");
|
||||
// 15:00 le même jour.
|
||||
assert_eq!(limit.resets_at_ms, Some(DAY_START + 15 * 3_600_000));
|
||||
}
|
||||
|
||||
/// « 3pm » alors qu'il est 16 h ⇒ 15 h DEMAIN (déjà passé ⇒ +24 h).
|
||||
#[test]
|
||||
fn detect_wall_clock_next_day_when_past() {
|
||||
let parser = RateLimitParser::new(&pattern(
|
||||
r"resets at (?P<reset>.+)",
|
||||
Some("reset"),
|
||||
Some("wall"),
|
||||
))
|
||||
.unwrap();
|
||||
let now_16h = DAY_START + 16 * 3_600_000; // 16:00 UTC
|
||||
let limit = parser.detect("resets at 3pm", now_16h).expect("détecté");
|
||||
// 15:00 le LENDEMAIN.
|
||||
assert_eq!(
|
||||
limit.resets_at_ms,
|
||||
Some(DAY_START + 15 * 3_600_000 + 86_400_000)
|
||||
);
|
||||
}
|
||||
|
||||
// -- detect : match mais capture inexploitable ⇒ détection sans heure -----
|
||||
|
||||
#[test]
|
||||
fn detect_match_with_missing_capture_group_has_no_time() {
|
||||
// reset_capture nommé "reset" mais le motif ne définit aucun groupe "reset".
|
||||
let parser =
|
||||
RateLimitParser::new(&pattern("rate limited", Some("reset"), Some("epoch_s"))).unwrap();
|
||||
let limit = parser.detect("rate limited", NOW).expect("détecté");
|
||||
assert_eq!(limit.resets_at_ms, None);
|
||||
assert_eq!(limit.source, RateLimitSource::Pattern);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_match_with_unparsable_value_has_no_time() {
|
||||
// Groupe présent mais valeur non numérique pour un format epoch_s ⇒ None.
|
||||
let parser = RateLimitParser::new(&pattern(
|
||||
r"reset (?P<reset>\w+)",
|
||||
Some("reset"),
|
||||
Some("epoch_s"),
|
||||
))
|
||||
.unwrap();
|
||||
let limit = parser.detect("reset abc", NOW).expect("détecté");
|
||||
assert_eq!(limit.resets_at_ms, None);
|
||||
}
|
||||
|
||||
// -- detect : appelable plusieurs fois (regex compilé une seule fois) -----
|
||||
|
||||
#[test]
|
||||
fn detect_can_be_called_multiple_times() {
|
||||
let parser = RateLimitParser::new(&pattern(
|
||||
r"resets at (?P<reset>\d+)",
|
||||
Some("reset"),
|
||||
Some("epoch_s"),
|
||||
))
|
||||
.unwrap();
|
||||
let a = parser.detect("resets at 1700000000", NOW).expect("1er détecté");
|
||||
assert!(parser.detect("rien ici", NOW).is_none());
|
||||
let b = parser.detect("resets at 1700000000", NOW).expect("2e détecté");
|
||||
assert_eq!(a.resets_at_ms, b.resets_at_ms);
|
||||
assert_eq!(a.resets_at_ms, Some(1_700_000_000_000));
|
||||
}
|
||||
|
||||
// -- applies(profile) : règle anti-double-détection (§21.10-4) ------------
|
||||
|
||||
fn base_profile() -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
ProfileId::from_uuid(uuid::Uuid::nil()),
|
||||
"Dev",
|
||||
"claude",
|
||||
Vec::new(),
|
||||
ContextInjection::convention_file("CLAUDE.md").expect("valide"),
|
||||
None,
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.expect("profil valide")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_false_for_structured_profile_even_with_pattern() {
|
||||
let profile = base_profile()
|
||||
.with_structured_adapter(StructuredAdapter::Claude)
|
||||
.with_rate_limit_pattern(pattern("rate limit", None, None));
|
||||
assert!(!applies(&profile), "un agent structuré détecte par le niveau 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_true_for_pty_profile_with_pattern() {
|
||||
let profile = base_profile().with_rate_limit_pattern(pattern("rate limit", None, None));
|
||||
assert!(applies(&profile));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_false_for_pty_profile_without_pattern() {
|
||||
assert!(!applies(&base_profile()));
|
||||
}
|
||||
}
|
||||
297
crates/infrastructure/src/scheduler/mod.rs
Normal file
297
crates/infrastructure/src/scheduler/mod.rs
Normal file
@ -0,0 +1,297 @@
|
||||
//! [`TokioScheduler`] — adapter du port [`domain::ports::Scheduler`] (ARCHITECTURE
|
||||
//! §21.4), minuterie **one-shot annulable** en mémoire.
|
||||
//!
|
||||
//! # Rôle
|
||||
//!
|
||||
//! Armer un réveil à une **échéance absolue** (époche-ms) qui, à expiration, **remet
|
||||
//! une [`ScheduledTask`] (donnée pure) au drain applicatif** via un canal `mpsc`
|
||||
//! fourni à la construction — exactement le patron du watcher d'orchestrateur
|
||||
//! (§14.3) : l'adapter dispatche une *donnée*, il **n'exécute jamais** la reprise
|
||||
//! lui-même (aucune dépendance vers l'application). `cancel` désarme un réveil non
|
||||
//! encore tiré (c'est ce qui rend la « reprise auto annulable », §21.1-4).
|
||||
//!
|
||||
//! # En mémoire uniquement (§21.1-3)
|
||||
//!
|
||||
//! Aucune persistance : les réveils armés ne survivent pas à un redémarrage d'IdeA.
|
||||
//! La table `id → JoinHandle` vit derrière un `Mutex` (thread-safe). L'attente réelle
|
||||
//! se fait dans une tâche tokio de fond ; `cancel` l'`abort()`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use domain::ids::ScheduleId;
|
||||
use domain::ports::{Clock, Scheduler, ScheduledTask};
|
||||
|
||||
/// Adapter tokio du port [`Scheduler`] (§21.4). Construire avec [`TokioScheduler::new`].
|
||||
///
|
||||
/// Thread-safe et clonable-en-`Arc` : injecté au composition root comme
|
||||
/// `Arc<dyn Scheduler>` (LS7, hors périmètre de ce lot).
|
||||
pub struct TokioScheduler {
|
||||
/// Canal de **remise** des tâches échues : à l'échéance, la tâche de fond y pousse
|
||||
/// la [`ScheduledTask`]. Le drain (application, LS4) en est l'autre bout. Non borné
|
||||
/// pour ne **jamais** perdre/retarder une reprise (événements rares, basse
|
||||
/// fréquence) ni bloquer la tâche de fond.
|
||||
tx: UnboundedSender<ScheduledTask>,
|
||||
/// Horloge injectée (déterminisme/testabilité) pour traduire l'échéance absolue en
|
||||
/// délai relatif (`deadline_ms - now`).
|
||||
clock: Arc<dyn Clock>,
|
||||
/// Table `id → handle` des réveils armés non encore tirés. `cancel` y retire +
|
||||
/// `abort()` le handle.
|
||||
handles: Arc<Mutex<HashMap<ScheduleId, JoinHandle<()>>>>,
|
||||
}
|
||||
|
||||
impl TokioScheduler {
|
||||
/// Construit l'adapter. `tx` est le bout **émetteur** du canal de remise (le
|
||||
/// drain applicatif détient le récepteur) ; `clock` fournit « maintenant » pour
|
||||
/// calculer le délai d'attente.
|
||||
#[must_use]
|
||||
pub fn new(tx: UnboundedSender<ScheduledTask>, clock: Arc<dyn Clock>) -> Self {
|
||||
Self {
|
||||
tx,
|
||||
clock,
|
||||
handles: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Scheduler for TokioScheduler {
|
||||
fn arm(&self, deadline_ms: i64, task: ScheduledTask) -> ScheduleId {
|
||||
let id = ScheduleId::new_random();
|
||||
|
||||
// Délai relatif : une échéance déjà passée (≤ now) ⇒ délai nul ⇒ déclenchement
|
||||
// au plus tôt (contrat du port). `saturating_sub` + `max(0)` évitent tout
|
||||
// débordement/négatif avant le cast non signé.
|
||||
let now = self.clock.now_millis();
|
||||
let delay = Duration::from_millis(deadline_ms.saturating_sub(now).max(0) as u64);
|
||||
|
||||
let tx = self.tx.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
tokio::time::sleep(delay).await;
|
||||
// Remet la DONNÉE au drain applicatif (jamais d'exécution ici). Une erreur
|
||||
// d'envoi signifie que le récepteur a été lâché (IdeA s'arrête) ⇒ ignorée.
|
||||
let _ = tx.send(task);
|
||||
});
|
||||
|
||||
let mut map = self.handles.lock().expect("scheduler mutex sain");
|
||||
// Élague au passage les réveils déjà tirés (handles terminés) pour borner la
|
||||
// table sans course : sous le même verrou, `is_finished()` est un fait stable.
|
||||
map.retain(|_, h| !h.is_finished());
|
||||
map.insert(id, handle);
|
||||
id
|
||||
}
|
||||
|
||||
fn cancel(&self, id: ScheduleId) -> bool {
|
||||
let mut map = self.handles.lock().expect("scheduler mutex sain");
|
||||
match map.remove(&id) {
|
||||
// Inconnu (jamais armé, ou déjà élagué après tir) ⇒ rien à annuler.
|
||||
None => false,
|
||||
// Déjà tiré mais encore présent ⇒ pas une annulation (la tâche est partie).
|
||||
Some(handle) if handle.is_finished() => false,
|
||||
// Armé et non tiré ⇒ on l'abort : la reprise n'aura pas lieu.
|
||||
Some(handle) => {
|
||||
handle.abort();
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::mpsc::error::TryRecvError;
|
||||
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver};
|
||||
use tokio::time::timeout;
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::ids::{AgentId, NodeId, ScheduleId};
|
||||
use domain::ports::{Clock, Scheduler, ScheduledTask};
|
||||
|
||||
use super::TokioScheduler;
|
||||
use crate::SystemClock;
|
||||
|
||||
/// Délai d'ARMEMENT court (les réveils doivent tomber vite pour ne pas ralentir
|
||||
/// la suite).
|
||||
const SHORT_MS: i64 = 50;
|
||||
/// Timeout de RÉCEPTION généreux (anti-flaky) : on attend bien plus longtemps que
|
||||
/// le délai d'armement pour absorber l'aléa d'ordonnancement sans jamais expirer
|
||||
/// à tort.
|
||||
const RECV_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Construit un scheduler + le bout récepteur du canal de remise + l'horloge réelle
|
||||
/// (partagée, pour calculer des échéances cohérentes avec celle qu'`arm` lira).
|
||||
fn make() -> (TokioScheduler, UnboundedReceiver<ScheduledTask>, Arc<dyn Clock>) {
|
||||
let (tx, rx) = unbounded_channel();
|
||||
let clock: Arc<dyn Clock> = Arc::new(SystemClock::new());
|
||||
(TokioScheduler::new(tx, clock.clone()), rx, clock)
|
||||
}
|
||||
|
||||
/// Une `ScheduledTask` identifiable par son `conversation_id` (pour distinguer les
|
||||
/// tâches concurrentes à l'arrivée).
|
||||
fn task_with(conv: &str) -> ScheduledTask {
|
||||
ScheduledTask::ResumeAgent {
|
||||
agent_id: AgentId::from_uuid(Uuid::from_u128(1)),
|
||||
node_id: NodeId::from_uuid(Uuid::from_u128(2)),
|
||||
conversation_id: Some(conv.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `arm` à `now + SHORT_MS` ⇒ la tâche EXACTE arrive (sous timeout de sécurité) ;
|
||||
/// et AVANT l'échéance, le canal est vide (try_recv juste après arm).
|
||||
#[tokio::test]
|
||||
async fn arm_fires_after_deadline_with_exact_task() {
|
||||
let (sched, mut rx, clock) = make();
|
||||
let t = task_with("c-fire");
|
||||
sched.arm(clock.now_millis() + SHORT_MS, t.clone());
|
||||
|
||||
// Juste après arm : rien n'est encore tiré (échéance dans le futur).
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Err(TryRecvError::Empty)),
|
||||
"aucune tâche ne doit arriver AVANT l'échéance"
|
||||
);
|
||||
|
||||
let got = timeout(RECV_TIMEOUT, rx.recv())
|
||||
.await
|
||||
.expect("le réveil doit tirer sous le timeout de sécurité")
|
||||
.expect("le canal de remise doit rester ouvert");
|
||||
assert_eq!(got, t, "la tâche reçue doit être exactement celle armée");
|
||||
}
|
||||
|
||||
/// `cancel` AVANT l'échéance empêche le tir : on arme un délai court, on annule
|
||||
/// aussitôt (large marge avant les 50 ms), puis on attend 4× l'échéance — la tâche
|
||||
/// NE doit JAMAIS arriver. Prouve une absence qui, SANS l'annulation, se serait
|
||||
/// produite (le délai court aurait tiré bien avant la fin de l'attente).
|
||||
#[tokio::test]
|
||||
async fn cancel_before_deadline_prevents_fire() {
|
||||
let (sched, mut rx, clock) = make();
|
||||
let id = sched.arm(clock.now_millis() + SHORT_MS, task_with("c-cancel"));
|
||||
assert!(sched.cancel(id), "cancel d'un réveil armé non tiré ⇒ true");
|
||||
|
||||
// Attend bien au-delà de l'échéance (4×) : si l'annulation avait échoué, la
|
||||
// tâche serait déjà là.
|
||||
tokio::time::sleep(Duration::from_millis((SHORT_MS as u64) * 4)).await;
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Err(TryRecvError::Empty)),
|
||||
"après cancel, aucune tâche ne doit être remise"
|
||||
);
|
||||
|
||||
// L'id a été retiré de la table : un second cancel ne trouve plus rien.
|
||||
assert!(!sched.cancel(id), "un id déjà annulé/retiré ⇒ false");
|
||||
}
|
||||
|
||||
/// `cancel` d'un id JAMAIS armé ⇒ false (rien à désarmer).
|
||||
#[tokio::test]
|
||||
async fn cancel_unknown_id_is_false() {
|
||||
let (sched, _rx, _clock) = make();
|
||||
assert!(!sched.cancel(ScheduleId::new_random()));
|
||||
}
|
||||
|
||||
/// `cancel` APRÈS le tir ⇒ false : on arme court, on attend la réception (donc le
|
||||
/// tir a eu lieu), puis on annule — la tâche est déjà partie, rien à désarmer.
|
||||
#[tokio::test]
|
||||
async fn cancel_after_fire_is_false() {
|
||||
let (sched, mut rx, clock) = make();
|
||||
let id = sched.arm(clock.now_millis() + SHORT_MS, task_with("c-after"));
|
||||
|
||||
let _ = timeout(RECV_TIMEOUT, rx.recv())
|
||||
.await
|
||||
.expect("doit tirer")
|
||||
.expect("canal ouvert");
|
||||
// Laisse l'exécuteur finaliser l'état du JoinHandle (is_finished) après l'envoi.
|
||||
for _ in 0..8 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
|
||||
assert!(
|
||||
!sched.cancel(id),
|
||||
"annuler un réveil déjà tiré ne désarme rien ⇒ false"
|
||||
);
|
||||
}
|
||||
|
||||
/// Échéance DÉJÀ PASSÉE (`now - 1000`) ⇒ tir quasi-immédiat (délai nul) : la tâche
|
||||
/// arrive sous un timeout court.
|
||||
#[tokio::test]
|
||||
async fn past_deadline_fires_immediately() {
|
||||
let (sched, mut rx, clock) = make();
|
||||
let t = task_with("c-past");
|
||||
sched.arm(clock.now_millis() - 1000, t.clone());
|
||||
|
||||
let got = timeout(Duration::from_millis(500), rx.recv())
|
||||
.await
|
||||
.expect("une échéance passée doit tirer au plus tôt")
|
||||
.expect("canal ouvert");
|
||||
assert_eq!(got, t);
|
||||
}
|
||||
|
||||
/// Plusieurs `arm` concurrents (même échéance) : chacun tire indépendamment, on
|
||||
/// reçoit les TROIS tâches (ensemble, sans dépendre de l'ordre d'arrivée).
|
||||
#[tokio::test]
|
||||
async fn multiple_concurrent_arms_all_fire() {
|
||||
let (sched, mut rx, clock) = make();
|
||||
let deadline = clock.now_millis() + SHORT_MS;
|
||||
let ids: Vec<_> = ["a", "b", "c"]
|
||||
.iter()
|
||||
.map(|c| sched.arm(deadline, task_with(c)))
|
||||
.collect();
|
||||
// Les ids armés sont distincts (pas de collision).
|
||||
assert_ne!(ids[0], ids[1]);
|
||||
assert_ne!(ids[1], ids[2]);
|
||||
assert_ne!(ids[0], ids[2]);
|
||||
|
||||
let mut got = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let task = timeout(RECV_TIMEOUT, rx.recv())
|
||||
.await
|
||||
.expect("chaque réveil doit tirer")
|
||||
.expect("canal ouvert");
|
||||
// `ScheduledTask` est mono-variante : déstructuration directe (pas de `if let`).
|
||||
let ScheduledTask::ResumeAgent {
|
||||
conversation_id, ..
|
||||
} = task;
|
||||
got.push(conversation_id.expect("conv présent"));
|
||||
}
|
||||
got.sort();
|
||||
assert_eq!(got, vec!["a".to_owned(), "b".to_owned(), "c".to_owned()]);
|
||||
}
|
||||
|
||||
/// `cancel` ciblé parmi plusieurs réveils concurrents : seul l'annulé ne tire pas ;
|
||||
/// les autres arrivent normalement. Prouve l'indépendance des handles (pas de fuite,
|
||||
/// pas d'annulation collatérale).
|
||||
#[tokio::test]
|
||||
async fn cancel_one_among_many_leaves_others_firing() {
|
||||
let (sched, mut rx, clock) = make();
|
||||
let deadline = clock.now_millis() + SHORT_MS;
|
||||
let keep1 = sched.arm(deadline, task_with("keep-1"));
|
||||
let drop_id = sched.arm(deadline, task_with("dropped"));
|
||||
let keep2 = sched.arm(deadline, task_with("keep-2"));
|
||||
let _ = (keep1, keep2);
|
||||
|
||||
assert!(sched.cancel(drop_id), "le réveil ciblé est annulé ⇒ true");
|
||||
|
||||
// Les deux survivants arrivent ; l'annulé jamais.
|
||||
let mut got = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let task = timeout(RECV_TIMEOUT, rx.recv())
|
||||
.await
|
||||
.expect("les survivants doivent tirer")
|
||||
.expect("canal ouvert");
|
||||
let ScheduledTask::ResumeAgent {
|
||||
conversation_id, ..
|
||||
} = task;
|
||||
got.push(conversation_id.expect("conv présent"));
|
||||
}
|
||||
got.sort();
|
||||
assert_eq!(got, vec!["keep-1".to_owned(), "keep-2".to_owned()]);
|
||||
// Plus rien derrière (l'annulé n'a pas tiré).
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Err(TryRecvError::Empty)),
|
||||
"le réveil annulé ne doit jamais être remis"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -51,7 +51,11 @@ pub struct ParsedLine {
|
||||
/// ⇒ capture le `session_id` (= id de conversation pour la reprise) **et** émet un
|
||||
/// [`ReplyEvent::Heartbeat`] (preuve de vivacité non terminale : la CLI a démarré).
|
||||
/// - `{"type":"rate_limit_event","rate_limit_info":{…},"session_id":"…"}`
|
||||
/// ⇒ [`ReplyEvent::Heartbeat`] (pas de contenu, mais le moteur est vivant).
|
||||
/// ⇒ [`ReplyEvent::RateLimited`] (ARCHITECTURE §21, niveau 1 structuré) : l'heure
|
||||
/// de reset est extraite de `rate_limit_info` par [`parse_reset_ms`] et normalisée
|
||||
/// en époche-ms. Sans `rate_limit_info` exploitable ⇒ `RateLimited{None}` (limite
|
||||
/// détectée, heure inconnue ⇒ filet humain en aval). **Non terminal** (comme le
|
||||
/// `Heartbeat`) : il s'intercale, le flux continue jusqu'au `Final` ou la clôture.
|
||||
/// - `{"type":"assistant","message":{"role":"assistant","content":[
|
||||
/// {"type":"text","text":"…"} | {"type":"tool_use","name":"…", …}
|
||||
/// ], …},"session_id":"…","parent_tool_use_id":null}`
|
||||
@ -85,9 +89,14 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
|
||||
// init/handshake : on capte le session_id ET on émet un battement de cœur
|
||||
// (preuve de vivacité non terminale : la CLI a démarré et répond).
|
||||
Some("system") => vec![ReplyEvent::Heartbeat],
|
||||
// Fenêtre de limite de débit : pas de contenu, mais le moteur est vivant ⇒
|
||||
// battement de cœur (readiness/heartbeat lot 1), plus ignoré.
|
||||
Some("rate_limit_event") => vec![ReplyEvent::Heartbeat],
|
||||
// Limite de session/débit (ARCHITECTURE §21, niveau 1) : on lit l'heure de
|
||||
// reset dans `rate_limit_info` (au lieu de la jeter) et on émet un
|
||||
// `RateLimited{resets_at_ms}` **non terminal**. Robuste : absence/illisibilité
|
||||
// de `rate_limit_info` ⇒ `RateLimited{None}` (jamais d'erreur), filet humain.
|
||||
Some("rate_limit_event") => {
|
||||
let resets_at_ms = value.get("rate_limit_info").and_then(parse_reset_ms);
|
||||
vec![ReplyEvent::RateLimited { resets_at_ms }]
|
||||
}
|
||||
Some("assistant") => assistant_events(&value),
|
||||
Some("result") => value
|
||||
.get("result")
|
||||
@ -139,6 +148,70 @@ fn assistant_events(value: &Value) -> Vec<ReplyEvent> {
|
||||
events
|
||||
}
|
||||
|
||||
/// **Extrait l'heure de reset d'une limite de débit** depuis l'objet
|
||||
/// `rate_limit_info` d'un `rate_limit_event` Claude, **normalisée en époche-ms**
|
||||
/// (ARCHITECTURE §21, niveau 1 structuré). Fonction **pure** (aucune I/O, aucun
|
||||
/// `now`), isolée du reste de l'adapter pour être testable sans process.
|
||||
///
|
||||
/// # Spike §21.10-1 — format réel non garanti, parsing défensif
|
||||
///
|
||||
/// Le schéma exact du champ de reset n'est **pas** documenté de façon stable. On
|
||||
/// gère donc défensivement plusieurs **noms de champ** plausibles, dans l'ordre :
|
||||
/// `resetsAt`, `resets_at`, `reset_at`, `resetAt`, `reset`. La première clé présente
|
||||
/// gagne. Sa valeur est convertie en époche-ms selon son type ([`value_to_epoch_ms`]) :
|
||||
///
|
||||
/// - **entier/float epoch en secondes** (magnitude `< 10^12`) ⇒ `× 1000` ;
|
||||
/// - **entier/float epoch en millisecondes** (magnitude `≥ 10^12`) ⇒ tel quel ;
|
||||
/// - **chaîne** : d'abord tentée comme entier/float epoch (même heuristique), sinon
|
||||
/// parsée comme **ISO-8601 / RFC3339** ([`parse_rfc3339_to_ms`]).
|
||||
///
|
||||
/// L'heuristique secondes-vs-ms repose sur le **seuil de magnitude** [`EPOCH_MS_THRESHOLD`]
|
||||
/// (`10^12`) : `10^12 ms ≈ 2001-09`, `10^12 s ≈ an 33658` — toute date plausible
|
||||
/// (1970…) tombe sans ambiguïté du bon côté.
|
||||
///
|
||||
/// # Hypothèses & limites assumées
|
||||
///
|
||||
/// - Un champ **relatif** (`retryAfter` / `retry_after`, en secondes) **n'est pas**
|
||||
/// exploité ici : le convertir en instant absolu exigerait `now`, or cette fonction
|
||||
/// est **pure et sans horloge** (cf. `Clock` confiné à l'application). Il est donc
|
||||
/// **ignoré** (documenté) ⇒ `None` ⇒ `RateLimited{None}` ⇒ filet humain. Une
|
||||
/// résolution `now + delta` pourra être ajoutée côté appelant/application (LS4) qui,
|
||||
/// lui, détient `Clock`.
|
||||
/// - Une chaîne ISO **sans fuseau** est traitée comme **UTC** (best-effort), le reset
|
||||
/// Claude étant une heure absolue. La gestion d'heure murale locale relève du
|
||||
/// niveau 2 (LS5, §21.10-2), pas d'ici.
|
||||
///
|
||||
/// Retourne `None` (jamais d'erreur) si aucune clé connue n'est présente ou si la
|
||||
/// valeur est inexploitable — l'appelant émet alors `RateLimited{None}`.
|
||||
#[must_use]
|
||||
pub fn parse_reset_ms(rate_limit_info: &Value) -> Option<i64> {
|
||||
const RESET_KEYS: [&str; 5] = ["resetsAt", "resets_at", "reset_at", "resetAt", "reset"];
|
||||
let raw = RESET_KEYS.iter().find_map(|k| rate_limit_info.get(*k))?;
|
||||
value_to_epoch_ms(raw)
|
||||
}
|
||||
|
||||
/// Convertit une [`Value`] JSON (entier / float / chaîne) en époche-ms, défensivement.
|
||||
///
|
||||
/// La conversion **générique** (heuristique secondes-vs-ms, parseur ISO-8601, algo
|
||||
/// jour-civil) est factorisée dans [`crate::timeparse`] et **partagée** avec le
|
||||
/// détecteur de niveau 2 (`ratelimit`) — pas de duplication (§21.2-T2). Seule
|
||||
/// l'extraction depuis un `serde_json::Value` (typage int/float/str) reste ici.
|
||||
fn value_to_epoch_ms(v: &Value) -> Option<i64> {
|
||||
if let Some(i) = v.as_i64() {
|
||||
return Some(crate::timeparse::int_epoch_to_ms(i));
|
||||
}
|
||||
if let Some(u) = v.as_u64() {
|
||||
return Some(crate::timeparse::int_epoch_to_ms(i64::try_from(u).ok()?));
|
||||
}
|
||||
if let Some(f) = v.as_f64() {
|
||||
return Some(crate::timeparse::float_epoch_to_ms(f));
|
||||
}
|
||||
if let Some(s) = v.as_str() {
|
||||
return crate::timeparse::parse_absolute_ms(s);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Adapter de session structurée Claude.
|
||||
///
|
||||
/// Incarnation « un `claude -p` par tour » (§17.2 (b)) : chaque `send` relance la
|
||||
@ -241,9 +314,11 @@ impl AgentSession for ClaudeSdkSession {
|
||||
captured_id = Some(id);
|
||||
}
|
||||
// Aplatit : une ligne `assistant` multi-blocs rend plusieurs événements.
|
||||
// Le `Final` est **terminal** (contrat de port) : on arrête d'émettre dès
|
||||
// qu'on l'a vu, pour qu'aucun heartbeat de fin (`rate_limit_event` tardif…)
|
||||
// ne le suive dans le flux.
|
||||
// Le `Final` est le **seul** événement terminal (contrat de port, §21-T4) :
|
||||
// on arrête d'émettre dès qu'on l'a vu. Un `RateLimited` (comme un
|
||||
// `Heartbeat`) est **non terminal** : il s'intercale et ne rompt JAMAIS la
|
||||
// boucle — un tour limité clos sans `Final` reste une fin gracieuse (le
|
||||
// traitement « limité » est en aval, application lot LS4).
|
||||
for event in parsed.events {
|
||||
let is_final = matches!(event, ReplyEvent::Final { .. });
|
||||
events.push(event);
|
||||
|
||||
@ -210,7 +210,8 @@ pub(crate) mod harness {
|
||||
other => panic!("le dernier événement doit être Final, vu: {other:?}"),
|
||||
}
|
||||
// Les événements avant le Final ne sont que des deltas / activités / heartbeats
|
||||
// (tous **non terminaux** ; le heartbeat est une preuve de vivacité, lot 1).
|
||||
// / rate-limited (tous **non terminaux** ; le heartbeat est une preuve de
|
||||
// vivacité, lot 1 ; le RateLimited s'intercale comme un heartbeat, §21-T4).
|
||||
for e in &events[..events.len() - 1] {
|
||||
assert!(
|
||||
matches!(
|
||||
@ -218,8 +219,9 @@ pub(crate) mod harness {
|
||||
ReplyEvent::TextDelta { .. }
|
||||
| ReplyEvent::ToolActivity { .. }
|
||||
| ReplyEvent::Heartbeat
|
||||
| ReplyEvent::RateLimited { .. }
|
||||
),
|
||||
"avant le Final, seuls deltas/activités/heartbeats sont permis, vu: {e:?}"
|
||||
"avant le Final, seuls deltas/activités/heartbeats/rate-limited sont permis, vu: {e:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -122,14 +122,20 @@ mod tests {
|
||||
assert_eq!(parsed.events, vec![ReplyEvent::Heartbeat]);
|
||||
}
|
||||
|
||||
/// §21 (LS2) : un `rate_limit_event` n'est PLUS un heartbeat — il porte désormais
|
||||
/// un [`ReplyEvent::RateLimited`] (niveau 1 structuré). Sans heure de reset
|
||||
/// exploitable dans `rate_limit_info` ⇒ `RateLimited{None}` (filet humain en aval).
|
||||
/// Le `session_id` reste capté.
|
||||
#[test]
|
||||
fn claude_parse_rate_limit_event_is_heartbeat() {
|
||||
fn claude_parse_rate_limit_event_without_reset_is_rate_limited_none() {
|
||||
let parsed = claude::parse_event(
|
||||
r#"{"type":"rate_limit_event","rate_limit_info":{"x":1},"session_id":"conv-123"}"#,
|
||||
)
|
||||
.expect("parse ok");
|
||||
// Plus ignoré : preuve de vivacité ⇒ heartbeat (mais session_id tout de même capté).
|
||||
assert_eq!(parsed.events, vec![ReplyEvent::Heartbeat]);
|
||||
assert_eq!(
|
||||
parsed.events,
|
||||
vec![ReplyEvent::RateLimited { resets_at_ms: None }]
|
||||
);
|
||||
assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
|
||||
}
|
||||
|
||||
@ -1286,4 +1292,351 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&cmd);
|
||||
let _ = std::fs::remove_file(&argv);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// LS2 — adapter Claude niveau 1 (§21) : `parse_reset_ms` (parseur ISO-8601
|
||||
// À LA MAIN + heuristique secondes/ms + days_from_civil) et le mapping
|
||||
// `parse_event` du `rate_limit_event` vers `ReplyEvent::RateLimited`, plus
|
||||
// la NON-TERMINALITÉ (T4). Tout passe par les fonctions pures (jamais de
|
||||
// process) sauf le test de séquence via `send()` (FakeCli).
|
||||
// =====================================================================
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::claude::parse_reset_ms;
|
||||
|
||||
// ---- parse_reset_ms : noms de champ reconnus + priorité ----------------
|
||||
|
||||
/// Les cinq noms de champ plausibles sont chacun reconnus (valeur en secondes
|
||||
/// ⇒ ×1000). Couvre `resetsAt`, `resets_at`, `reset_at`, `resetAt`, `reset`.
|
||||
#[test]
|
||||
fn parse_reset_ms_recognises_every_field_name() {
|
||||
for key in ["resetsAt", "resets_at", "reset_at", "resetAt", "reset"] {
|
||||
let info = json!({ key: 1_700_000_000_i64 });
|
||||
assert_eq!(
|
||||
parse_reset_ms(&info),
|
||||
Some(1_700_000_000_000),
|
||||
"le champ `{key}` doit être reconnu (epoch secondes ×1000)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Priorité : si plusieurs clés sont présentes, la PREMIÈRE de l'ordre
|
||||
/// (`resetsAt` avant `reset`) gagne.
|
||||
#[test]
|
||||
fn parse_reset_ms_first_known_key_wins() {
|
||||
// resetsAt (priorité 1) = 1_700_000_000 s ; reset (priorité 5) = 5 s.
|
||||
let info = json!({ "reset": 5, "resetsAt": 1_700_000_000_i64 });
|
||||
assert_eq!(
|
||||
parse_reset_ms(&info),
|
||||
Some(1_700_000_000_000),
|
||||
"resetsAt prime sur reset"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- parse_reset_ms : heuristique secondes vs millisecondes ------------
|
||||
|
||||
#[test]
|
||||
fn parse_reset_ms_integer_seconds_are_scaled_to_ms() {
|
||||
// < 10^12 ⇒ secondes ⇒ ×1000.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": 1_700_000_000_i64 })), Some(1_700_000_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_reset_ms_integer_millis_are_kept_as_is() {
|
||||
// ≥ 10^12 ⇒ déjà des millisecondes ⇒ tel quel.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": 1_700_000_000_000_i64 })), Some(1_700_000_000_000));
|
||||
}
|
||||
|
||||
/// Le SEUIL exact (10^12) : juste en-dessous ⇒ secondes (×1000) ; pile/au-dessus
|
||||
/// ⇒ millisecondes (tel quel).
|
||||
#[test]
|
||||
fn parse_reset_ms_threshold_boundary() {
|
||||
// 10^12 - 1 ⇒ secondes ⇒ ×1000.
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": 999_999_999_999_i64 })),
|
||||
Some(999_999_999_999_000)
|
||||
);
|
||||
// 10^12 pile ⇒ millisecondes ⇒ tel quel (la borne est inclusive côté ms).
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": 1_000_000_000_000_i64 })),
|
||||
Some(1_000_000_000_000)
|
||||
);
|
||||
}
|
||||
|
||||
// ---- parse_reset_ms : floats ------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_reset_ms_float_seconds_preserve_fraction() {
|
||||
// 1_700_000_000.5 s < 10^12 ⇒ ×1000 = 1_700_000_000_500 ms.
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": 1_700_000_000.5_f64 })),
|
||||
Some(1_700_000_000_500)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_reset_ms_float_millis_kept_as_is() {
|
||||
// 1.7e12 ≥ 10^12 ⇒ déjà ms ⇒ tronqué tel quel.
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": 1_700_000_000_000.0_f64 })),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
}
|
||||
|
||||
// ---- parse_reset_ms : chaînes numériques (même heuristique) ------------
|
||||
|
||||
#[test]
|
||||
fn parse_reset_ms_string_integer_uses_seconds_heuristic() {
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "1700000000" })), Some(1_700_000_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_reset_ms_string_float_uses_seconds_heuristic() {
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "1700000000.5" })), Some(1_700_000_000_500));
|
||||
}
|
||||
|
||||
// ---- parse_reset_ms : ISO-8601 / RFC3339 (parseur maison) --------------
|
||||
|
||||
/// `...Z` (UTC) : un instant rond connu. `2023-11-14T22:13:20Z` correspond à
|
||||
/// l'epoch 1_700_000_000 s ⇒ 1_700_000_000_000 ms. Recoupe le parseur ISO
|
||||
/// maison (days_from_civil + math d'heure) contre l'heuristique secondes.
|
||||
#[test]
|
||||
fn parse_reset_ms_iso_utc_z() {
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "2023-11-14T22:13:20Z" })),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
}
|
||||
|
||||
/// Offset `+hh:mm` : `2023-11-14T23:13:20+01:00` est le MÊME instant que
|
||||
/// `22:13:20Z` ⇒ doit donner exactement le même epoch-ms (offset soustrait).
|
||||
#[test]
|
||||
fn parse_reset_ms_iso_positive_offset_converts_to_utc() {
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "2023-11-14T23:13:20+01:00" })),
|
||||
Some(1_700_000_000_000),
|
||||
"+01:00 ⇒ on soustrait 1h pour revenir à l'UTC"
|
||||
);
|
||||
}
|
||||
|
||||
/// Offset `-hh:mm` : `2023-11-14T21:13:20-01:00` est aussi `22:13:20Z`.
|
||||
#[test]
|
||||
fn parse_reset_ms_iso_negative_offset_converts_to_utc() {
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "2023-11-14T21:13:20-01:00" })),
|
||||
Some(1_700_000_000_000),
|
||||
"-01:00 ⇒ on ajoute 1h pour revenir à l'UTC"
|
||||
);
|
||||
}
|
||||
|
||||
/// Offset compact `±hhmm` (sans `:`) supporté par `split_tz`.
|
||||
#[test]
|
||||
fn parse_reset_ms_iso_compact_offset() {
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "2023-11-14T23:13:20+0100" })),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
}
|
||||
|
||||
/// Fraction de seconde `.fff` : tronquée/complétée à 3 chiffres (précision ms).
|
||||
#[test]
|
||||
fn parse_reset_ms_iso_fraction_padded_and_truncated() {
|
||||
// `.5` ⇒ "500" ms.
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "1970-01-01T00:00:00.5Z" })),
|
||||
Some(500)
|
||||
);
|
||||
// `.123456` ⇒ tronqué à "123" ms.
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "1970-01-01T00:00:00.123456Z" })),
|
||||
Some(123)
|
||||
);
|
||||
// `.7` ⇒ complété à "700" ms.
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "1970-01-01T00:00:00.7Z" })),
|
||||
Some(700)
|
||||
);
|
||||
}
|
||||
|
||||
// ---- parse_reset_ms : robustesse (jamais de panic, jamais d'erreur) ----
|
||||
|
||||
#[test]
|
||||
fn parse_reset_ms_unknown_key_yields_none() {
|
||||
// Aucune clé connue ⇒ None.
|
||||
assert_eq!(parse_reset_ms(&json!({ "retryAfter": 60 })), None);
|
||||
assert_eq!(parse_reset_ms(&json!({})), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_reset_ms_non_numeric_garbage_yields_none() {
|
||||
// Valeurs inexploitables (booléen, null, tableau, objet, chaîne pourrie) ⇒ None.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": true })), None);
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": null })), None);
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": [1, 2, 3] })), None);
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": { "nested": 1 } })), None);
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "pas une date" })), None);
|
||||
}
|
||||
|
||||
/// Formes ISO **structurellement** malformées ⇒ None (pas de panic). NB : le
|
||||
/// parseur maison ne valide PAS les plages (un mois 13 / jour 99 calcule une
|
||||
/// valeur sans erreur) ; ce qui produit `None`, c'est l'ABSENCE de séparateur
|
||||
/// `T`, une composante non numérique, ou un nombre de composantes invalide.
|
||||
#[test]
|
||||
fn parse_reset_ms_invalid_iso_string_yields_none() {
|
||||
// Pas de séparateur de date/heure.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14" })), None);
|
||||
// Année non numérique.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "abcd-11-14T00:00:00Z" })), None);
|
||||
// Composante de date manquante (pas de jour).
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11T00:00:00Z" })), None);
|
||||
// Trop de composantes de date.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14-9T00:00:00Z" })), None);
|
||||
// Minute manquante dans l'heure.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14T22Z" })), None);
|
||||
}
|
||||
|
||||
// ---- days_from_civil & bissextiles (via le parseur ISO) ----------------
|
||||
|
||||
/// Référence absolue : l'époque Unix elle-même. `1970-01-01T00:00:00Z` ⇒ 0 ms
|
||||
/// (days_from_civil(1970,1,1) == 0).
|
||||
#[test]
|
||||
fn parse_reset_ms_unix_epoch_is_zero() {
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "1970-01-01T00:00:00Z" })),
|
||||
Some(0)
|
||||
);
|
||||
}
|
||||
|
||||
/// Année bissextile : le 29 février 2024 existe et donne l'epoch attendu.
|
||||
/// `2024-02-29T00:00:00Z` = 1_709_164_800 s = 1_709_164_800_000 ms (calculé à la
|
||||
/// main : 2024-01-01 = 1_704_067_200 ; +31j (janvier) ; +28j pour atteindre le 29).
|
||||
#[test]
|
||||
fn parse_reset_ms_leap_day_2024_02_29() {
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "2024-02-29T00:00:00Z" })),
|
||||
Some(1_709_164_800_000)
|
||||
);
|
||||
}
|
||||
|
||||
/// Date post-2001 connue, recoupée indépendamment : `2021-01-01T00:00:00Z`
|
||||
/// = 1_609_459_200 s = 1_609_459_200_000 ms.
|
||||
#[test]
|
||||
fn parse_reset_ms_known_post_2001_date() {
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "2021-01-01T00:00:00Z" })),
|
||||
Some(1_609_459_200_000)
|
||||
);
|
||||
}
|
||||
|
||||
// ---- parse_event : mapping rate_limit_event ----------------------------
|
||||
|
||||
/// `rate_limit_event` avec `rate_limit_info.resetsAt` exploitable ⇒
|
||||
/// `RateLimited{Some(...)}` (l'heure de reset est extraite et normalisée).
|
||||
#[test]
|
||||
fn parse_event_rate_limit_with_reset_yields_rate_limited_some() {
|
||||
let parsed = claude::parse_event(
|
||||
r#"{"type":"rate_limit_event","rate_limit_info":{"resetsAt":1700000000},"session_id":"c"}"#,
|
||||
)
|
||||
.expect("parse ok");
|
||||
assert_eq!(
|
||||
parsed.events,
|
||||
vec![ReplyEvent::RateLimited {
|
||||
resets_at_ms: Some(1_700_000_000_000)
|
||||
}]
|
||||
);
|
||||
assert_eq!(parsed.session_id.as_deref(), Some("c"));
|
||||
}
|
||||
|
||||
/// `rate_limit_event` SANS `rate_limit_info` exploitable ⇒ `RateLimited{None}`
|
||||
/// (et surtout PAS un `Heartbeat` : c'est le changement §21/LS2).
|
||||
#[test]
|
||||
fn parse_event_rate_limit_without_info_is_rate_limited_none_not_heartbeat() {
|
||||
// rate_limit_info absent.
|
||||
let absent = claude::parse_event(r#"{"type":"rate_limit_event","session_id":"c"}"#)
|
||||
.expect("parse ok");
|
||||
assert_eq!(
|
||||
absent.events,
|
||||
vec![ReplyEvent::RateLimited { resets_at_ms: None }]
|
||||
);
|
||||
assert_ne!(absent.events, vec![ReplyEvent::Heartbeat]);
|
||||
|
||||
// rate_limit_info présent mais sans clé de reset connue.
|
||||
let no_key = claude::parse_event(
|
||||
r#"{"type":"rate_limit_event","rate_limit_info":{"x":1},"session_id":"c"}"#,
|
||||
)
|
||||
.expect("parse ok");
|
||||
assert_eq!(
|
||||
no_key.events,
|
||||
vec![ReplyEvent::RateLimited { resets_at_ms: None }]
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Non-terminalité (T4) : RateLimited n'interrompt PAS ----------------
|
||||
|
||||
/// Au niveau séquence de `parse_event` : `rate_limit_event` puis `result` ⇒ la
|
||||
/// concaténation des events est `[RateLimited, Final]` — le RateLimited s'intercale
|
||||
/// et seul le Final clôt.
|
||||
#[test]
|
||||
fn parse_event_sequence_rate_limited_then_final_is_not_interrupted() {
|
||||
let rl = claude::parse_event(
|
||||
r#"{"type":"rate_limit_event","rate_limit_info":{"resetsAt":1700000000},"session_id":"c"}"#,
|
||||
)
|
||||
.expect("parse ok");
|
||||
let fin = claude::parse_event(
|
||||
r#"{"type":"result","subtype":"success","result":"fini","session_id":"c"}"#,
|
||||
)
|
||||
.expect("parse ok");
|
||||
let mut seq = rl.events;
|
||||
seq.extend(fin.events);
|
||||
assert_eq!(
|
||||
seq,
|
||||
vec![
|
||||
ReplyEvent::RateLimited {
|
||||
resets_at_ms: Some(1_700_000_000_000)
|
||||
},
|
||||
ReplyEvent::Final {
|
||||
content: "fini".to_owned()
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Au niveau `send()` (FakeCli) : init → rate_limit_event → assistant → result ⇒
|
||||
/// le flux émis est `[Heartbeat, RateLimited, TextDelta, Final]`. Le RateLimited
|
||||
/// NE rompt PAS la boucle d'émission (T4) ; seul le Final clôt — on le PROUVE
|
||||
/// bout-en-bout, pas seulement au niveau parse.
|
||||
#[tokio::test]
|
||||
async fn send_emits_rate_limited_intercalated_only_final_closes() {
|
||||
let fake = FakeCli::printing(&[
|
||||
r#"{"type":"system","subtype":"init","session_id":"rl-1","cwd":"/tmp","tools":[]}"#,
|
||||
r#"{"type":"rate_limit_event","rate_limit_info":{"resetsAt":1700000000},"session_id":"rl-1"}"#,
|
||||
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"ap"}]},"session_id":"rl-1","parent_tool_use_id":null}"#,
|
||||
r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"rl-1","num_turns":1}"#,
|
||||
]);
|
||||
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
|
||||
let events: Vec<ReplyEvent> = session.send("x").await.expect("send ok").collect();
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![
|
||||
ReplyEvent::Heartbeat,
|
||||
ReplyEvent::RateLimited {
|
||||
resets_at_ms: Some(1_700_000_000_000)
|
||||
},
|
||||
ReplyEvent::TextDelta { text: "ap".into() },
|
||||
ReplyEvent::Final {
|
||||
content: "ok".into()
|
||||
},
|
||||
],
|
||||
"RateLimited s'intercale (non terminal), seul Final clôt"
|
||||
);
|
||||
// Exactement un Final, en dernière position.
|
||||
assert_eq!(
|
||||
events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
315
crates/infrastructure/src/timeparse.rs
Normal file
315
crates/infrastructure/src/timeparse.rs
Normal file
@ -0,0 +1,315 @@
|
||||
//! Parsing d'heures **pur et dépendance-zéro**, partagé par les deux niveaux de
|
||||
//! détection de limite de session (ARCHITECTURE §21) :
|
||||
//!
|
||||
//! - **niveau 1 structuré** (`session/claude.rs`) : normaliser le `resetsAt` d'un
|
||||
//! `rate_limit_event` (entier epoch s/ms, ou chaîne ISO-8601) en époche-ms ;
|
||||
//! - **niveau 2 déclaratif** (`ratelimit`) : interpréter l'heure capturée par un
|
||||
//! `RateLimitPattern` selon son `time_format` (absolue, **murale locale**, ou
|
||||
//! **relative**).
|
||||
//!
|
||||
//! Tout est **pur** (aucune I/O, `now_ms` injecté) ⇒ testable sans process ni
|
||||
//! horloge réelle. Factorisé ici pour **ne pas dupliquer** le savoir de LS2
|
||||
//! (l'algorithme jour-civil de Howard Hinnant, l'heuristique secondes-vs-ms, le
|
||||
//! parseur RFC3339) entre les deux détecteurs (DRY, §21.2-T2).
|
||||
|
||||
/// Seuil de magnitude départageant un epoch en **secondes** d'un epoch en
|
||||
/// **millisecondes** : `10^12 ms ≈ 2001-09-09`, `10^12 s ≈ an 33658`. Tout epoch
|
||||
/// plausible (≥ 1970) de magnitude `≥ 10^12` est donc déjà des millisecondes ;
|
||||
/// en-dessous, ce sont des secondes.
|
||||
pub const EPOCH_MS_THRESHOLD: i64 = 1_000_000_000_000;
|
||||
|
||||
/// Millisecondes dans une journée (24 h), pour le calcul d'heure murale.
|
||||
pub const DAY_MS: i64 = 86_400_000;
|
||||
|
||||
/// Applique l'heuristique secondes-vs-ms à un epoch **entier**.
|
||||
#[must_use]
|
||||
pub const fn int_epoch_to_ms(n: i64) -> i64 {
|
||||
if n.abs() >= EPOCH_MS_THRESHOLD {
|
||||
n
|
||||
} else {
|
||||
n * 1000
|
||||
}
|
||||
}
|
||||
|
||||
/// Idem pour un epoch **flottant** (fraction de seconde préservée → ms).
|
||||
#[must_use]
|
||||
pub fn float_epoch_to_ms(f: f64) -> i64 {
|
||||
if f.abs() >= EPOCH_MS_THRESHOLD as f64 {
|
||||
f as i64
|
||||
} else {
|
||||
(f * 1000.0) as i64
|
||||
}
|
||||
}
|
||||
|
||||
/// Tente de lire une heure **absolue** depuis une chaîne : entier epoch, puis float
|
||||
/// epoch (heuristique s-vs-ms), puis **ISO-8601 / RFC3339**. `None` si rien n'est
|
||||
/// reconnu. C'est la branche « chaîne » réutilisée par le niveau 1
|
||||
/// (`claude::parse_reset_ms`) et le niveau 2 (`time_format` absolu).
|
||||
#[must_use]
|
||||
pub fn parse_absolute_ms(s: &str) -> Option<i64> {
|
||||
let t = s.trim();
|
||||
if let Ok(i) = t.parse::<i64>() {
|
||||
return Some(int_epoch_to_ms(i));
|
||||
}
|
||||
if let Ok(f) = t.parse::<f64>() {
|
||||
return Some(float_epoch_to_ms(f));
|
||||
}
|
||||
parse_rfc3339_to_ms(t)
|
||||
}
|
||||
|
||||
/// **Parse une chaîne ISO-8601 / RFC3339** (`YYYY-MM-DDThh:mm:ss[.fff][Z|±hh:mm]`) en
|
||||
/// époche-ms. Pur, dépendance-zéro. Best-effort : `None` sur toute forme non
|
||||
/// reconnue. Sans désignateur de fuseau ⇒ traité **UTC**.
|
||||
#[must_use]
|
||||
pub fn parse_rfc3339_to_ms(s: &str) -> Option<i64> {
|
||||
let (date, rest) = s.split_once(['T', 't', ' '])?;
|
||||
let mut dp = date.split('-');
|
||||
let year: i64 = dp.next()?.parse().ok()?;
|
||||
let month: i64 = dp.next()?.parse().ok()?;
|
||||
let day: i64 = dp.next()?.parse().ok()?;
|
||||
if dp.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (time, tz_offset_secs) = split_tz(rest)?;
|
||||
let mut tp = time.split(':');
|
||||
let hour: i64 = tp.next()?.parse().ok()?;
|
||||
let minute: i64 = tp.next()?.parse().ok()?;
|
||||
let (second, millis) = split_seconds_frac(tp.next().unwrap_or("0"))?;
|
||||
if tp.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let days = days_from_civil(year, month, day);
|
||||
let epoch_secs = days * 86_400 + hour * 3_600 + minute * 60 + second - tz_offset_secs;
|
||||
Some(epoch_secs * 1000 + millis)
|
||||
}
|
||||
|
||||
/// Convertit une **heure murale locale** `(h, m, s)` en époche-ms en référence à
|
||||
/// `now_ms`, avec **passage de minuit** (spike §21.10-2).
|
||||
///
|
||||
/// Règle : on calcule « **aujourd'hui** à `h:m:s` » sur la journée UTC contenant
|
||||
/// `now_ms` ; si cet instant est **déjà passé** (≤ `now_ms`), on prend **le
|
||||
/// lendemain** (`+ 24 h`). Ainsi « resets at 3pm » alors qu'il est 16 h vise 15 h
|
||||
/// **demain**, jamais une heure dans le passé.
|
||||
///
|
||||
/// **Limite assumée (UTC)** : faute de base de fuseaux dans le binaire (dépendance-
|
||||
/// zéro), la journée de référence est la **journée UTC**. Une heure murale d'un
|
||||
/// fuseau très décalé peut donc viser le mauvais jour de ±1 ; le passage de minuit
|
||||
/// borne l'erreur à « au plus tôt maintenant ». Un offset de fuseau explicite (champ
|
||||
/// futur du profil) lèverait cette limite — confiné infra, hors périmètre LS5.
|
||||
#[must_use]
|
||||
pub fn wall_clock_to_ms(now_ms: i64, hour: u32, minute: u32, second: u32) -> i64 {
|
||||
let day_start = now_ms - now_ms.rem_euclid(DAY_MS);
|
||||
let tod_ms = (i64::from(hour) * 3_600 + i64::from(minute) * 60 + i64::from(second)) * 1000;
|
||||
let target = day_start + tod_ms;
|
||||
if target <= now_ms {
|
||||
target + DAY_MS
|
||||
} else {
|
||||
target
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse une heure murale lâche : `"15:00"`, `"15:00:30"`, `"3pm"`, `"3:30 pm"`,
|
||||
/// `"3 PM"` → `(heure 0-23, minute, seconde)`. `None` si non reconnue. Utilisé par le
|
||||
/// niveau 2 quand `time_format` désigne une heure murale.
|
||||
#[must_use]
|
||||
pub fn parse_wall_clock(s: &str) -> Option<(u32, u32, u32)> {
|
||||
let lower = s.trim().to_ascii_lowercase();
|
||||
// Suffixe am/pm éventuel.
|
||||
let (body, meridiem) = if let Some(b) = lower.strip_suffix("am") {
|
||||
(b.trim(), Some(false))
|
||||
} else if let Some(b) = lower.strip_suffix("pm") {
|
||||
(b.trim(), Some(true))
|
||||
} else {
|
||||
(lower.as_str(), None)
|
||||
};
|
||||
|
||||
let mut parts = body.split(':');
|
||||
let mut hour: u32 = parts.next()?.trim().parse().ok()?;
|
||||
let minute: u32 = parts.next().map_or(Ok(0), |p| p.trim().parse()).ok()?;
|
||||
let second: u32 = parts.next().map_or(Ok(0), |p| p.trim().parse()).ok()?;
|
||||
if parts.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Conversion 12 h → 24 h si un méridien est présent.
|
||||
match meridiem {
|
||||
Some(true) => {
|
||||
// pm : 12pm reste 12, 1..=11pm ⇒ +12.
|
||||
if hour < 12 {
|
||||
hour += 12;
|
||||
}
|
||||
}
|
||||
Some(false) => {
|
||||
// am : 12am ⇒ 0, le reste inchangé.
|
||||
if hour == 12 {
|
||||
hour = 0;
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
if hour >= 24 || minute >= 60 || second >= 60 {
|
||||
return None;
|
||||
}
|
||||
Some((hour, minute, second))
|
||||
}
|
||||
|
||||
/// Sépare la partie heure de son **désignateur de fuseau** et renvoie l'offset en
|
||||
/// secondes (à **soustraire** de l'heure locale pour obtenir l'UTC). `Z`/`z` ⇒ 0 ;
|
||||
/// `±hh:mm` ou `±hhmm` ⇒ offset signé ; aucun désignateur ⇒ 0 (UTC best-effort).
|
||||
fn split_tz(rest: &str) -> Option<(&str, i64)> {
|
||||
if let Some(stripped) = rest.strip_suffix(['Z', 'z']) {
|
||||
return Some((stripped, 0));
|
||||
}
|
||||
if let Some(pos) = rest.rfind(['+', '-']) {
|
||||
let (time, tz) = rest.split_at(pos);
|
||||
let sign = if tz.starts_with('-') { -1 } else { 1 };
|
||||
let tz = &tz[1..];
|
||||
let (h, m) = if let Some((h, m)) = tz.split_once(':') {
|
||||
(h.parse::<i64>().ok()?, m.parse::<i64>().ok()?)
|
||||
} else if tz.len() == 4 {
|
||||
(tz[0..2].parse::<i64>().ok()?, tz[2..4].parse::<i64>().ok()?)
|
||||
} else {
|
||||
(tz.parse::<i64>().ok()?, 0)
|
||||
};
|
||||
return Some((time, sign * (h * 3_600 + m * 60)));
|
||||
}
|
||||
Some((rest, 0))
|
||||
}
|
||||
|
||||
/// Sépare `SS` ou `SS.fff…` en `(secondes, millisecondes)`. La fraction est tronquée
|
||||
/// /complétée à **3 chiffres** (précision ms).
|
||||
fn split_seconds_frac(s: &str) -> Option<(i64, i64)> {
|
||||
let Some((sec, frac)) = s.split_once('.') else {
|
||||
return Some((s.parse::<i64>().ok()?, 0));
|
||||
};
|
||||
let sec = sec.parse::<i64>().ok()?;
|
||||
let mut d3: String = frac.chars().take_while(char::is_ascii_digit).take(3).collect();
|
||||
while d3.len() < 3 {
|
||||
d3.push('0');
|
||||
}
|
||||
let millis = d3.parse::<i64>().ok()?;
|
||||
Some((sec, millis))
|
||||
}
|
||||
|
||||
/// Jours depuis l'époque Unix (1970-01-01) pour une date civile proleptique
|
||||
/// grégorienne. Algorithme de Howard Hinnant (`days_from_civil`), exact et
|
||||
/// dépendance-zéro ; gère bissextiles et siècles.
|
||||
#[must_use]
|
||||
pub const fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = (if y >= 0 { y } else { y - 399 }) / 400;
|
||||
let yoe = y - era * 400;
|
||||
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Début de journée UTC du 2023-11-14 (= 19675 × 86_400_000).
|
||||
const DAY_START: i64 = 1_699_920_000_000;
|
||||
|
||||
// -- days_from_civil ------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn days_from_civil_epoch_is_zero() {
|
||||
assert_eq!(days_from_civil(1970, 1, 1), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn days_from_civil_leap_day_2024() {
|
||||
// 2024-02-29T00:00:00Z = 1_709_164_800_000 ms = 19782 jours pleins.
|
||||
assert_eq!(days_from_civil(2024, 2, 29), 19782);
|
||||
// Cohérence avec le jour suivant (1ᵉʳ mars), preuve que le 29 février existe.
|
||||
assert_eq!(
|
||||
days_from_civil(2024, 3, 1),
|
||||
days_from_civil(2024, 2, 29) + 1
|
||||
);
|
||||
}
|
||||
|
||||
// -- parse_wall_clock -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_wall_clock_variants() {
|
||||
assert_eq!(parse_wall_clock("3pm"), Some((15, 0, 0)));
|
||||
assert_eq!(parse_wall_clock("15:00:30"), Some((15, 0, 30)));
|
||||
assert_eq!(parse_wall_clock("3:30 pm"), Some((15, 30, 0)));
|
||||
assert_eq!(parse_wall_clock("12am"), Some((0, 0, 0))); // minuit
|
||||
assert_eq!(parse_wall_clock("12pm"), Some((12, 0, 0))); // midi
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_wall_clock_rejects_invalid() {
|
||||
assert_eq!(parse_wall_clock("pas une heure"), None);
|
||||
assert_eq!(parse_wall_clock("25:00"), None); // heure hors plage
|
||||
assert_eq!(parse_wall_clock("10:75"), None); // minute hors plage
|
||||
assert_eq!(parse_wall_clock("1:2:3:4"), None); // trop de composantes
|
||||
}
|
||||
|
||||
// -- wall_clock_to_ms (passage de minuit) --------------------------------
|
||||
|
||||
#[test]
|
||||
fn wall_clock_to_ms_same_day_when_future() {
|
||||
let now_10h = DAY_START + 10 * 3_600_000;
|
||||
// 15:00 est dans le futur ⇒ même jour.
|
||||
assert_eq!(wall_clock_to_ms(now_10h, 15, 0, 0), DAY_START + 15 * 3_600_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wall_clock_to_ms_next_day_when_past() {
|
||||
let now_16h = DAY_START + 16 * 3_600_000;
|
||||
// 15:00 est déjà passé ⇒ lendemain (+24 h).
|
||||
assert_eq!(
|
||||
wall_clock_to_ms(now_16h, 15, 0, 0),
|
||||
DAY_START + 15 * 3_600_000 + DAY_MS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wall_clock_to_ms_exactly_now_rolls_to_next_day() {
|
||||
// Cas-limite : la cible == now ⇒ considérée passée ⇒ lendemain (jamais le présent).
|
||||
let now_15h = DAY_START + 15 * 3_600_000;
|
||||
assert_eq!(wall_clock_to_ms(now_15h, 15, 0, 0), now_15h + DAY_MS);
|
||||
}
|
||||
|
||||
// -- parse_absolute_ms (recoupe LS2) -------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_absolute_ms_epoch_seconds_and_millis() {
|
||||
assert_eq!(parse_absolute_ms("1700000000"), Some(1_700_000_000_000)); // s ⇒ ×1000
|
||||
assert_eq!(parse_absolute_ms("1700000000000"), Some(1_700_000_000_000)); // ms tel quel
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_absolute_ms_iso8601() {
|
||||
assert_eq!(
|
||||
parse_absolute_ms("2023-11-14T22:13:20Z"),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_absolute_ms_garbage_is_none() {
|
||||
assert_eq!(parse_absolute_ms("pas une date"), None);
|
||||
}
|
||||
|
||||
// -- parse_rfc3339_to_ms : offsets signés (cohérence niveau 1/2) ----------
|
||||
|
||||
#[test]
|
||||
fn parse_rfc3339_offsets_convert_to_utc() {
|
||||
// +01:00 et -01:00 autour de 22:13:20Z ⇒ même instant.
|
||||
assert_eq!(
|
||||
parse_rfc3339_to_ms("2023-11-14T23:13:20+01:00"),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_rfc3339_to_ms("2023-11-14T21:13:20-01:00"),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
}
|
||||
}
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "idea-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "idea-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "idea-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -38,4 +38,16 @@ export class TauriInputGateway implements InputGateway {
|
||||
request: { agentId, attached },
|
||||
});
|
||||
}
|
||||
|
||||
async cancelResume(agentId: string): Promise<boolean> {
|
||||
// `cancel_resume` takes a bare `agent_id` (camelCase `agentId`), not a
|
||||
// `request` envelope, and returns whether a pending resume was disarmed.
|
||||
return invoke<boolean>("cancel_resume", { agentId });
|
||||
}
|
||||
|
||||
async setResumeAt(agentId: string, resetsAtMs: number): Promise<void> {
|
||||
// `set_resume_at` takes bare `agent_id` + `resets_at_ms` (camelCase), like
|
||||
// `cancel_resume`; arms the cancellable resume at the chosen instant.
|
||||
await invoke("set_resume_at", { agentId, resetsAtMs });
|
||||
}
|
||||
}
|
||||
|
||||
@ -1559,6 +1559,16 @@ export class MockInputGateway implements InputGateway {
|
||||
readonly delivered: MockInputCall[] = [];
|
||||
/** All recorded front-attached reports, in order. */
|
||||
readonly frontAttached: { agentId: string; attached: boolean }[] = [];
|
||||
/** All recorded cancel-resume calls, in order. */
|
||||
readonly cancelledResumes: string[] = [];
|
||||
/** All recorded set-resume-at armings (human net, level 3), in order. */
|
||||
readonly resumeArmings: { agentId: string; resetsAtMs: number }[] = [];
|
||||
/**
|
||||
* What {@link cancelResume} resolves to (mirrors the backend `bool`: whether a
|
||||
* pending resume was disarmed). Tests can flip it to exercise the "already
|
||||
* fired / none armed" path.
|
||||
*/
|
||||
cancelResumeResult = true;
|
||||
|
||||
async interrupt(projectId: string, agentId: string): Promise<void> {
|
||||
this.interrupts.push({ projectId, agentId });
|
||||
@ -1575,6 +1585,15 @@ export class MockInputGateway implements InputGateway {
|
||||
async setFrontAttached(agentId: string, attached: boolean): Promise<void> {
|
||||
this.frontAttached.push({ agentId, attached });
|
||||
}
|
||||
|
||||
async cancelResume(agentId: string): Promise<boolean> {
|
||||
this.cancelledResumes.push(agentId);
|
||||
return this.cancelResumeResult;
|
||||
}
|
||||
|
||||
async setResumeAt(agentId: string, resetsAtMs: number): Promise<void> {
|
||||
this.resumeArmings.push({ agentId, resetsAtMs });
|
||||
}
|
||||
}
|
||||
|
||||
/** In-memory permissions gateway. */
|
||||
|
||||
@ -12,7 +12,7 @@ import { createMockGateways, MockSystemGateway } from "./index";
|
||||
const gateways: Gateways = createMockGateways();
|
||||
|
||||
describe("createMockGateways", () => {
|
||||
it("exposes all thirteen gateways", () => {
|
||||
it("exposes all fourteen gateways", () => {
|
||||
expect(Object.keys(gateways).sort()).toEqual([
|
||||
"agent",
|
||||
"embedder",
|
||||
@ -20,6 +20,7 @@ describe("createMockGateways", () => {
|
||||
"input",
|
||||
"layout",
|
||||
"memory",
|
||||
"permission",
|
||||
"profile",
|
||||
"project",
|
||||
"remote",
|
||||
|
||||
@ -36,6 +36,55 @@ export type DomainEvent =
|
||||
submitSequence?: string;
|
||||
submitDelayMs?: number;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* An agent entered a session/rate limit (ARCHITECTURE §21). Low-frequency,
|
||||
* model-agnostic. `resetsAtMs` is the reset instant in epoch-milliseconds;
|
||||
* absent ⇒ unknown (no reliable time → no auto-resume). The frontend badges
|
||||
* "limité jusqu'à HH:MM" when known, "limité" otherwise.
|
||||
*/
|
||||
type: "agentRateLimited";
|
||||
agentId: string;
|
||||
resetsAtMs?: number;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* An auto-resume was armed for a rate-limited agent (ARCHITECTURE §21).
|
||||
* `fireAtMs` is the wake-up deadline in epoch-milliseconds. The frontend
|
||||
* shows the countdown + the "Annuler la reprise" button (cancellable window).
|
||||
*/
|
||||
type: "agentResumeScheduled";
|
||||
agentId: string;
|
||||
fireAtMs: number;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* An agent's auto-resume was cancelled (ARCHITECTURE §21): the user clicked
|
||||
* "Annuler la reprise". The frontend removes the countdown but keeps the
|
||||
* "limité" state (no auto-resume will fire).
|
||||
*/
|
||||
type: "agentResumeCancelled";
|
||||
agentId: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* An agent was effectively resumed after a limit (ARCHITECTURE §21): the
|
||||
* wake-up fired (or an immediate resume). The frontend clears all limit state.
|
||||
*/
|
||||
type: "agentResumed";
|
||||
agentId: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Human net (level 3): a session limit is suspected without any reliable
|
||||
* reset time (ARCHITECTURE §21.1). IdeA never resumes blind: this asks the
|
||||
* frontend to surface an "heure inconnue" state and prompt the user.
|
||||
* `resetsAtMs` carries an estimate when one exists, else absent.
|
||||
*/
|
||||
type: "agentRateLimitSuspected";
|
||||
agentId: string;
|
||||
resetsAtMs?: number;
|
||||
}
|
||||
| { type: "templateUpdated"; templateId: string; version: number }
|
||||
| { type: "agentDriftDetected"; agentId: string; from: number; to: number }
|
||||
| { type: "agentSynced"; agentId: string; to: number }
|
||||
|
||||
234
frontend/src/features/agents/AgentLimitBadge.test.tsx
Normal file
234
frontend/src/features/agents/AgentLimitBadge.test.tsx
Normal file
@ -0,0 +1,234 @@
|
||||
/**
|
||||
* LS7/LS8-front — {@link AgentLimitBadge} presentation + its pure helpers
|
||||
* (ARCHITECTURE §21).
|
||||
*
|
||||
* Three layers:
|
||||
* - the exported pure helpers `formatCountdown` / `formatResetTime` /
|
||||
* `timeInputToEpochMs` (edge cases: 0, negative, sub-minute, multi-minute,
|
||||
* midnight, seconds-stripped, malformed/empty time input);
|
||||
* - the rendered badge across its states (limité jusqu'à HH:MM / "limité"
|
||||
* without a time / the "heure inconnue" resume-time form for a suspected limit
|
||||
* without a reset), plus the "Annuler la reprise" button wiring when a resume
|
||||
* is armed;
|
||||
* - the human-net form (LS8): submitting a time arms a resume via onSetResumeAt.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
AgentLimitBadge,
|
||||
formatCountdown,
|
||||
formatResetTime,
|
||||
timeInputToEpochMs,
|
||||
} from "./AgentLimitBadge";
|
||||
|
||||
describe("formatCountdown", () => {
|
||||
it("renders 0s for exactly zero", () => {
|
||||
expect(formatCountdown(0)).toBe("0s");
|
||||
});
|
||||
|
||||
it("clamps a negative (past) deadline to 0s", () => {
|
||||
expect(formatCountdown(-5_000)).toBe("0s");
|
||||
});
|
||||
|
||||
it("renders sub-minute durations as just seconds", () => {
|
||||
expect(formatCountdown(5_000)).toBe("5s");
|
||||
expect(formatCountdown(59_000)).toBe("59s");
|
||||
});
|
||||
|
||||
it("rounds partial seconds up (ceil)", () => {
|
||||
expect(formatCountdown(4_200)).toBe("5s");
|
||||
expect(formatCountdown(1)).toBe("1s");
|
||||
});
|
||||
|
||||
it("renders minutes + seconds past a minute", () => {
|
||||
expect(formatCountdown(60_000)).toBe("1m 0s");
|
||||
expect(formatCountdown(90_000)).toBe("1m 30s");
|
||||
expect(formatCountdown(125_000)).toBe("2m 5s");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatResetTime", () => {
|
||||
it("produces an HH:MM wall-clock label (no seconds component)", () => {
|
||||
// Same minute, +30s apart → identical label (seconds are not shown).
|
||||
const base = new Date(2026, 0, 15, 9, 5, 0).getTime();
|
||||
const plus30s = new Date(2026, 0, 15, 9, 5, 30).getTime();
|
||||
expect(formatResetTime(base)).toBe(formatResetTime(plus30s));
|
||||
// Matches the documented HH:MM contract (delegates to toLocaleTimeString).
|
||||
expect(formatResetTime(base)).toBe(
|
||||
new Date(base).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders midnight as a stable two-digit label", () => {
|
||||
const midnight = new Date(2026, 0, 15, 0, 0, 0).getTime();
|
||||
const label = formatResetTime(midnight);
|
||||
expect(label).toBe(
|
||||
new Date(midnight).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
);
|
||||
// Minutes are "00" at midnight regardless of 12h/24h locale.
|
||||
expect(label).toContain("00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeInputToEpochMs", () => {
|
||||
it("maps HH:MM to the same calendar day as `now`", () => {
|
||||
const now = new Date(2026, 0, 15, 9, 0, 0).getTime();
|
||||
const ms = timeInputToEpochMs("14:30", now);
|
||||
expect(ms).not.toBeNull();
|
||||
const d = new Date(ms!);
|
||||
expect(d.getFullYear()).toBe(2026);
|
||||
expect(d.getMonth()).toBe(0);
|
||||
expect(d.getDate()).toBe(15);
|
||||
expect(d.getHours()).toBe(14);
|
||||
expect(d.getMinutes()).toBe(30);
|
||||
expect(d.getSeconds()).toBe(0);
|
||||
expect(d.getMilliseconds()).toBe(0);
|
||||
});
|
||||
|
||||
it("returns a past instant unchanged (backend clamps to now)", () => {
|
||||
const now = new Date(2026, 0, 15, 18, 0, 0).getTime();
|
||||
const ms = timeInputToEpochMs("08:00", now);
|
||||
expect(ms).not.toBeNull();
|
||||
expect(ms!).toBeLessThan(now);
|
||||
});
|
||||
|
||||
it("returns null for empty or malformed input", () => {
|
||||
const now = Date.now();
|
||||
expect(timeInputToEpochMs("", now)).toBeNull();
|
||||
expect(timeInputToEpochMs("nope", now)).toBeNull();
|
||||
expect(timeInputToEpochMs("25:00", now)).toBeNull();
|
||||
expect(timeInputToEpochMs("12:60", now)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentLimitBadge (render)", () => {
|
||||
const noop = () => {};
|
||||
|
||||
it("shows 'limité jusqu'à HH:MM' when the reset time is known", () => {
|
||||
const at = new Date(2026, 0, 15, 14, 30, 0).getTime();
|
||||
render(
|
||||
<AgentLimitBadge
|
||||
state={{ limitedUntil: at }}
|
||||
onCancelResume={noop}
|
||||
onSetResumeAt={noop}
|
||||
/>,
|
||||
);
|
||||
const expected = `limité jusqu'à ${formatResetTime(at)}`;
|
||||
expect(screen.getByText(expected)).toBeTruthy();
|
||||
// No countdown / cancel button without an armed resume.
|
||||
expect(screen.queryByLabelText("cancel resume")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows plain 'limité' when no reset time is known", () => {
|
||||
render(
|
||||
<AgentLimitBadge state={{}} onCancelResume={noop} onSetResumeAt={noop} />,
|
||||
);
|
||||
expect(screen.getByText("limité")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the 'heure inconnue' resume-time form for a suspected limit without a time", () => {
|
||||
render(
|
||||
<AgentLimitBadge
|
||||
state={{ suspected: true }}
|
||||
onCancelResume={noop}
|
||||
onSetResumeAt={noop}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("limité")).toBeTruthy();
|
||||
expect(screen.getByText(/heure inconnue/)).toBeTruthy();
|
||||
// The human-net form: a resume-time input + a "schedule resume" button.
|
||||
expect(screen.getByLabelText("resume time")).toBeTruthy();
|
||||
expect(screen.getByLabelText("schedule resume")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT show the resume-time form when a suspected limit has a time", () => {
|
||||
const at = new Date(2026, 0, 15, 14, 30, 0).getTime();
|
||||
render(
|
||||
<AgentLimitBadge
|
||||
state={{ suspected: true, limitedUntil: at }}
|
||||
onCancelResume={noop}
|
||||
onSetResumeAt={noop}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(`limité jusqu'à ${formatResetTime(at)}`)).toBeTruthy();
|
||||
expect(screen.queryByText(/heure inconnue/)).toBeNull();
|
||||
expect(screen.queryByLabelText("resume time")).toBeNull();
|
||||
});
|
||||
|
||||
it("submitting the resume-time form arms a resume at the chosen epoch-ms", () => {
|
||||
const onSetResumeAt = vi.fn();
|
||||
render(
|
||||
<AgentLimitBadge
|
||||
state={{ suspected: true }}
|
||||
onCancelResume={noop}
|
||||
onSetResumeAt={onSetResumeAt}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("resume time"), {
|
||||
target: { value: "23:45" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("schedule resume"));
|
||||
expect(onSetResumeAt).toHaveBeenCalledTimes(1);
|
||||
const ms = onSetResumeAt.mock.calls[0][0] as number;
|
||||
const d = new Date(ms);
|
||||
expect(d.getHours()).toBe(23);
|
||||
expect(d.getMinutes()).toBe(45);
|
||||
});
|
||||
|
||||
it("does not arm a resume when the time input is empty (button disabled)", () => {
|
||||
const onSetResumeAt = vi.fn();
|
||||
render(
|
||||
<AgentLimitBadge
|
||||
state={{ suspected: true }}
|
||||
onCancelResume={noop}
|
||||
onSetResumeAt={onSetResumeAt}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText("schedule resume") as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
expect(onSetResumeAt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the countdown + calls onCancelResume when a resume is armed", () => {
|
||||
const onCancelResume = vi.fn();
|
||||
const fireAt = Date.now() + 90_000;
|
||||
render(
|
||||
<AgentLimitBadge
|
||||
state={{ limitedUntil: Date.now(), resumeFireAt: fireAt }}
|
||||
onCancelResume={onCancelResume}
|
||||
onSetResumeAt={noop}
|
||||
/>,
|
||||
);
|
||||
// Countdown label present (≈ "reprise dans 1m 30s").
|
||||
expect(screen.getByLabelText("resume countdown").textContent).toMatch(
|
||||
/reprise dans/,
|
||||
);
|
||||
|
||||
const btn = screen.getByLabelText("cancel resume");
|
||||
fireEvent.click(btn);
|
||||
expect(onCancelResume).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disables the cancel button while busy", () => {
|
||||
render(
|
||||
<AgentLimitBadge
|
||||
state={{ resumeFireAt: Date.now() + 10_000 }}
|
||||
onCancelResume={noop}
|
||||
onSetResumeAt={noop}
|
||||
busy
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText("cancel resume") as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
180
frontend/src/features/agents/AgentLimitBadge.tsx
Normal file
180
frontend/src/features/agents/AgentLimitBadge.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
/**
|
||||
* `AgentLimitBadge` — presentational badge for an agent's session-limit state
|
||||
* (ARCHITECTURE §21). All limit *state* is computed in {@link useAgents}; this
|
||||
* component only renders it and owns the 1 s countdown clock (a pure UI concern).
|
||||
*
|
||||
* It shows:
|
||||
* - "limité jusqu'à HH:MM" when the reset time is known, "limité" otherwise;
|
||||
* - a live countdown + an "Annuler la reprise" button while an auto-resume is
|
||||
* armed (`resumeFireAt`);
|
||||
* - a "heure inconnue" human-net note (level 3) when the limit is only
|
||||
* *suspected* with no reliable time — IdeA never resumes blind.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button, Input, cn } from "@/shared";
|
||||
import type { AgentLimitState } from "./useAgents";
|
||||
|
||||
/**
|
||||
* Converts a `<input type="time">` value (`"HH:MM"`) into an epoch-ms instant on
|
||||
* the same calendar day as `now`. Returns `null` for an empty/malformed value so
|
||||
* the caller can ignore an incomplete entry.
|
||||
*
|
||||
* No future/past guard on purpose: an instant earlier than `now` is left as-is —
|
||||
* the backend clamps a past time to "now" (⇒ immediate resume), so the front
|
||||
* needs no strict validation.
|
||||
*/
|
||||
export function timeInputToEpochMs(value: string, now: number): number | null {
|
||||
const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
|
||||
if (!match) return null;
|
||||
const hours = Number(match[1]);
|
||||
const minutes = Number(match[2]);
|
||||
if (hours > 23 || minutes > 59) return null;
|
||||
const d = new Date(now);
|
||||
d.setHours(hours, minutes, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
/** Formats an epoch-ms instant as a local `HH:MM` wall-clock label. */
|
||||
export function formatResetTime(epochMs: number): string {
|
||||
return new Date(epochMs).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a remaining duration (ms) as a compact countdown: `Xm Ys` (or `Ys`
|
||||
* under a minute). Clamped at zero — a past deadline reads `0s`.
|
||||
*/
|
||||
export function formatCountdown(remainingMs: number): string {
|
||||
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
|
||||
}
|
||||
|
||||
export interface AgentLimitBadgeProps {
|
||||
/** The agent's limit state (from {@link useAgents.limitByAgent}). */
|
||||
state: AgentLimitState;
|
||||
/** Invoked when the user clicks "Annuler la reprise". */
|
||||
onCancelResume: () => void;
|
||||
/**
|
||||
* Human net (level 3): invoked with the chosen wake-up instant (epoch-ms) when
|
||||
* the user submits the resume-time form on a suspected-without-time limit.
|
||||
*/
|
||||
onSetResumeAt: (resetsAtMs: number) => void;
|
||||
/** Disables the actions (e.g. while another request is in flight). */
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
export function AgentLimitBadge({
|
||||
state,
|
||||
onCancelResume,
|
||||
onSetResumeAt,
|
||||
busy = false,
|
||||
}: AgentLimitBadgeProps) {
|
||||
const { limitedUntil, resumeFireAt, suspected } = state;
|
||||
|
||||
// Tick a local clock once per second only while a resume countdown is armed,
|
||||
// so the displayed remaining time stays fresh without a global timer.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (resumeFireAt === undefined) return;
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [resumeFireAt]);
|
||||
|
||||
// Local resume-time entry (human net form). Bound to a `time` input.
|
||||
const [resumeTime, setResumeTime] = useState("");
|
||||
|
||||
// The human-net form shows only when the limit is *suspected* with neither a
|
||||
// reliable reset time nor an armed resume — IdeA must ask before resuming.
|
||||
const needsResumeTime =
|
||||
suspected === true &&
|
||||
limitedUntil === undefined &&
|
||||
resumeFireAt === undefined;
|
||||
|
||||
function handleSubmitResumeTime(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const epochMs = timeInputToEpochMs(resumeTime, Date.now());
|
||||
if (epochMs === null) return;
|
||||
onSetResumeAt(epochMs);
|
||||
setResumeTime("");
|
||||
}
|
||||
|
||||
const limitLabel =
|
||||
limitedUntil !== undefined
|
||||
? `limité jusqu'à ${formatResetTime(limitedUntil)}`
|
||||
: "limité";
|
||||
|
||||
return (
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span
|
||||
aria-label="session limit"
|
||||
title={
|
||||
suspected
|
||||
? "Limite de session détectée sans heure fiable"
|
||||
: "Limite de session atteinte"
|
||||
}
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
"bg-danger/20 text-danger",
|
||||
)}
|
||||
>
|
||||
{limitLabel}
|
||||
</span>
|
||||
|
||||
{needsResumeTime && (
|
||||
// Human net (level 3, §21.1): the limit is suspected with no reliable
|
||||
// time. IdeA never resumes blind — ask the user for a resume time, then
|
||||
// arm it via `set_resume_at`. A past time is clamped to "now" by the
|
||||
// backend (⇒ immediate resume), so no strict validation is needed here.
|
||||
<form
|
||||
onSubmit={handleSubmitResumeTime}
|
||||
className="flex items-center gap-2"
|
||||
aria-label="resume time form"
|
||||
>
|
||||
<span className="text-xs text-muted" role="note">
|
||||
heure inconnue
|
||||
</span>
|
||||
<Input
|
||||
type="time"
|
||||
aria-label="resume time"
|
||||
value={resumeTime}
|
||||
disabled={busy}
|
||||
onChange={(e) => setResumeTime(e.target.value)}
|
||||
className="h-8 w-28 text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="primary"
|
||||
aria-label="schedule resume"
|
||||
disabled={busy || resumeTime.trim() === ""}
|
||||
>
|
||||
Programmer la reprise
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{resumeFireAt !== undefined && (
|
||||
<span className="flex items-center gap-2">
|
||||
<span aria-label="resume countdown" className="text-xs text-muted">
|
||||
reprise dans {formatCountdown(resumeFireAt - now)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="cancel resume"
|
||||
disabled={busy}
|
||||
onClick={onCancelResume}
|
||||
>
|
||||
Annuler la reprise
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -21,6 +21,7 @@ import { TerminalView } from "@/features/terminals/TerminalView";
|
||||
import { useDrift } from "@/features/templates/useDrift";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { useAgents } from "./useAgents";
|
||||
import { AgentLimitBadge } from "./AgentLimitBadge";
|
||||
|
||||
export interface AgentsPanelProps {
|
||||
/** The project whose agents to manage. */
|
||||
@ -327,6 +328,8 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
// Source of this agent's last orchestration delegation (mcp vs
|
||||
// file), if any has been observed. Absent ⇒ no badge.
|
||||
const delegationSource = vm.delegationSourceByRequester[a.id];
|
||||
// Session-limit state (ARCHITECTURE §21), if the agent is limited.
|
||||
const limitState = vm.limitByAgent[a.id];
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
@ -373,6 +376,17 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
)}
|
||||
</button>
|
||||
|
||||
{limitState && (
|
||||
<AgentLimitBadge
|
||||
state={limitState}
|
||||
busy={vm.busy}
|
||||
onCancelResume={() => void vm.cancelResume(a.id)}
|
||||
onSetResumeAt={(resetsAtMs) =>
|
||||
void vm.setResumeAt(a.id, resetsAtMs)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{/* Profile hot-swap selector (Chantier A). Changing the
|
||||
engine abandons the conversation history → confirmation. */}
|
||||
|
||||
@ -18,6 +18,29 @@ import type {
|
||||
import type { LiveAgent, OpenTerminalOptions, TerminalHandle } from "@/ports";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
/**
|
||||
* Per-agent session-limit state (ARCHITECTURE §21), populated from the limit
|
||||
* domain events. An agent absent from the map is in its normal (unlimited) state.
|
||||
*/
|
||||
export interface AgentLimitState {
|
||||
/**
|
||||
* Reset instant in epoch-milliseconds when known (from `agentRateLimited` /
|
||||
* `agentRateLimitSuspected`). Absent ⇒ "limité" without a reliable time.
|
||||
*/
|
||||
limitedUntil?: number;
|
||||
/**
|
||||
* Wake-up deadline in epoch-milliseconds of an armed auto-resume (from
|
||||
* `agentResumeScheduled`). Present ⇒ show the countdown + "Annuler la reprise".
|
||||
*/
|
||||
resumeFireAt?: number;
|
||||
/**
|
||||
* Human net (level 3): the limit was suspected without a reliable time
|
||||
* (`agentRateLimitSuspected`). The UI surfaces an "heure inconnue" state and
|
||||
* asks the user — IdeA never resumes blind.
|
||||
*/
|
||||
suspected?: boolean;
|
||||
}
|
||||
|
||||
/** What the agents UI needs from this hook. */
|
||||
export interface AgentsViewModel {
|
||||
/** All agents known to the project. */
|
||||
@ -37,6 +60,13 @@ export interface AgentsViewModel {
|
||||
* without `source`) yields no badge.
|
||||
*/
|
||||
delegationSourceByRequester: Record<string, "mcp" | "file">;
|
||||
/**
|
||||
* Per-agent session-limit state (ARCHITECTURE §21), keyed by agent id. Populated
|
||||
* from the limit domain events (`agentRateLimited`, `agentResumeScheduled`,
|
||||
* `agentResumeCancelled`, `agentResumed`, `agentRateLimitSuspected`). An agent
|
||||
* absent from the map is unlimited.
|
||||
*/
|
||||
limitByAgent: Record<string, AgentLimitState>;
|
||||
/** Last error message, or `null`. */
|
||||
error: string | null;
|
||||
/** Whether a request is in flight. */
|
||||
@ -83,6 +113,21 @@ export interface AgentsViewModel {
|
||||
) => Promise<TerminalHandle>;
|
||||
/** Stops an agent PTY explicitly by closing its live session id. */
|
||||
stopAgent: (agentId?: string) => Promise<void>;
|
||||
/**
|
||||
* Cancels the auto-resume armed for a rate-limited agent (ARCHITECTURE §21):
|
||||
* the "Annuler la reprise" action. Optimistically drops the countdown; the
|
||||
* backend's `agentResumeCancelled` event confirms it. The agent stays "limité"
|
||||
* (no auto-resume will fire). Resolves to the backend verdict (`false` if the
|
||||
* resume had already fired / none was armed).
|
||||
*/
|
||||
cancelResume: (agentId: string) => Promise<boolean>;
|
||||
/**
|
||||
* Human net (level 3, ARCHITECTURE §21.1): arms a resume at a user-chosen
|
||||
* `resetsAtMs` (epoch-ms) for an agent whose limit was suspected without a
|
||||
* reliable time. No optimistic mutation — the backend re-emits
|
||||
* `agentResumeScheduled`, which flips the badge to the nominal countdown.
|
||||
*/
|
||||
setResumeAt: (agentId: string, resetsAtMs: number) => Promise<void>;
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
@ -93,7 +138,7 @@ function describe(e: unknown): string {
|
||||
}
|
||||
|
||||
export function useAgents(projectId: string): AgentsViewModel {
|
||||
const { agent, profile, system, terminal } = useGateways();
|
||||
const { agent, profile, system, terminal, input } = useGateways();
|
||||
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
@ -106,6 +151,9 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
const [delegationSourceByRequester, setDelegationSourceByRequester] = useState<
|
||||
Record<string, "mcp" | "file">
|
||||
>({});
|
||||
const [limitByAgent, setLimitByAgent] = useState<
|
||||
Record<string, AgentLimitState>
|
||||
>({});
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true);
|
||||
@ -173,6 +221,57 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
: { ...prev, [requesterId]: source },
|
||||
);
|
||||
}
|
||||
// Session-limit lifecycle (ARCHITECTURE §21): fold each event into the
|
||||
// per-agent limit state. `agentResumed` clears the entry entirely.
|
||||
switch (event.type) {
|
||||
case "agentRateLimited":
|
||||
setLimitByAgent((prev) => ({
|
||||
...prev,
|
||||
[event.agentId]: {
|
||||
limitedUntil: event.resetsAtMs,
|
||||
resumeFireAt: prev[event.agentId]?.resumeFireAt,
|
||||
suspected: false,
|
||||
},
|
||||
}));
|
||||
break;
|
||||
case "agentResumeScheduled":
|
||||
setLimitByAgent((prev) => ({
|
||||
...prev,
|
||||
[event.agentId]: {
|
||||
...prev[event.agentId],
|
||||
resumeFireAt: event.fireAtMs,
|
||||
},
|
||||
}));
|
||||
break;
|
||||
case "agentResumeCancelled":
|
||||
setLimitByAgent((prev) => {
|
||||
const current = prev[event.agentId];
|
||||
if (!current) return prev;
|
||||
// Keep the agent "limité" but drop the armed resume countdown.
|
||||
const { resumeFireAt: _dropped, ...rest } = current;
|
||||
return { ...prev, [event.agentId]: rest };
|
||||
});
|
||||
break;
|
||||
case "agentResumed":
|
||||
setLimitByAgent((prev) => {
|
||||
if (!(event.agentId in prev)) return prev;
|
||||
const { [event.agentId]: _cleared, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
break;
|
||||
case "agentRateLimitSuspected":
|
||||
setLimitByAgent((prev) => ({
|
||||
...prev,
|
||||
[event.agentId]: {
|
||||
...prev[event.agentId],
|
||||
limitedUntil: event.resetsAtMs,
|
||||
suspected: true,
|
||||
},
|
||||
}));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
})
|
||||
.then((un) => {
|
||||
if (cancelled) un();
|
||||
@ -334,6 +433,40 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
[liveAgents, refreshLiveAgents, runningAgentId, terminal],
|
||||
);
|
||||
|
||||
const cancelResume = useCallback(
|
||||
async (agentId: string): Promise<boolean> => {
|
||||
// Optimistically drop the countdown so the button feels responsive; the
|
||||
// backend's `agentResumeCancelled` event confirms it (and a `false` verdict
|
||||
// — already fired — is reconciled by the eventual `agentResumed`).
|
||||
setLimitByAgent((prev) => {
|
||||
const current = prev[agentId];
|
||||
if (!current?.resumeFireAt) return prev;
|
||||
const { resumeFireAt: _dropped, ...rest } = current;
|
||||
return { ...prev, [agentId]: rest };
|
||||
});
|
||||
try {
|
||||
return (await input?.cancelResume(agentId)) ?? false;
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[input],
|
||||
);
|
||||
|
||||
const setResumeAt = useCallback(
|
||||
async (agentId: string, resetsAtMs: number): Promise<void> => {
|
||||
// No optimistic mutation: the backend re-emits `agentResumeScheduled`,
|
||||
// which the subscription folds into `limitByAgent` (badge → countdown).
|
||||
try {
|
||||
await input?.setResumeAt(agentId, resetsAtMs);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
}
|
||||
},
|
||||
[input],
|
||||
);
|
||||
|
||||
return {
|
||||
agents,
|
||||
selectedAgentId,
|
||||
@ -341,6 +474,7 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
profiles,
|
||||
liveAgents,
|
||||
delegationSourceByRequester,
|
||||
limitByAgent,
|
||||
error,
|
||||
busy,
|
||||
runningAgentId,
|
||||
@ -353,5 +487,7 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
deleteAgent,
|
||||
launchAgent,
|
||||
stopAgent,
|
||||
cancelResume,
|
||||
setResumeAt,
|
||||
};
|
||||
}
|
||||
|
||||
235
frontend/src/features/agents/useAgentsLimits.test.tsx
Normal file
235
frontend/src/features/agents/useAgentsLimits.test.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
/**
|
||||
* LS7-front — session-limit state in {@link useAgents} (ARCHITECTURE §21).
|
||||
*
|
||||
* Drives the hook behind the real {@link DIProvider} with an in-memory
|
||||
* {@link MockSystemGateway} (to emit the limit domain events) and a
|
||||
* {@link MockInputGateway} (to record `cancelResume`). Verifies that each of the
|
||||
* five limit events folds correctly into `limitByAgent`, plus the optimistic
|
||||
* `cancelResume` action and the backend verdict it returns.
|
||||
*
|
||||
* Cases:
|
||||
* - `agentRateLimited` → `{ limitedUntil, suspected: false }`
|
||||
* - `agentResumeScheduled` → arms `resumeFireAt`
|
||||
* - `agentResumeCancelled` → drops `resumeFireAt`, stays limité
|
||||
* - `agentResumed` → clears the entry entirely
|
||||
* - `agentRateLimitSuspected` (with & without `resetsAtMs`) → `suspected: true`
|
||||
* - realistic sequence rateLimited → scheduled → cancelResume (action)
|
||||
* - `cancelResume` action: optimistic drop + both backend verdicts
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
import type { DomainEvent } from "@/domain";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { MockInputGateway, MockSystemGateway } from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { useAgents } from "./useAgents";
|
||||
|
||||
const PROJECT_ID = "proj-limits-001";
|
||||
const AGENT = "agent-A";
|
||||
|
||||
function setup() {
|
||||
const system = new MockSystemGateway();
|
||||
const input = new MockInputGateway();
|
||||
// useAgents calls agent.listAgents / profile.listProfiles on mount; provide
|
||||
// minimal stubs so the mount effect resolves without a backend.
|
||||
const agent = {
|
||||
listAgents: async () => [],
|
||||
listLiveAgents: async () => [],
|
||||
};
|
||||
const profile = { listProfiles: async () => [] };
|
||||
const gateways = { system, input, agent, profile } as unknown as Gateways;
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<DIProvider gateways={gateways}>{children}</DIProvider>
|
||||
);
|
||||
const view = renderHook(() => useAgents(PROJECT_ID), { wrapper });
|
||||
return { system, input, view };
|
||||
}
|
||||
|
||||
/** Emits an event and flushes the async subscription/microtasks. */
|
||||
async function emit(system: MockSystemGateway, event: DomainEvent) {
|
||||
await act(async () => {
|
||||
system.emit(event);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("useAgents — session-limit state (§21)", () => {
|
||||
it("agentRateLimited with a reset time → { limitedUntil, suspected:false }", async () => {
|
||||
const { system, view } = setup();
|
||||
await emit(system, {
|
||||
type: "agentRateLimited",
|
||||
agentId: AGENT,
|
||||
resetsAtMs: 1_800_000_000_000,
|
||||
});
|
||||
expect(view.result.current.limitByAgent[AGENT]).toEqual({
|
||||
limitedUntil: 1_800_000_000_000,
|
||||
resumeFireAt: undefined,
|
||||
suspected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("agentResumeScheduled arms resumeFireAt on top of the limited state", async () => {
|
||||
const { system, view } = setup();
|
||||
await emit(system, {
|
||||
type: "agentRateLimited",
|
||||
agentId: AGENT,
|
||||
resetsAtMs: 1_800_000_000_000,
|
||||
});
|
||||
await emit(system, {
|
||||
type: "agentResumeScheduled",
|
||||
agentId: AGENT,
|
||||
fireAtMs: 1_800_000_300_000,
|
||||
});
|
||||
expect(view.result.current.limitByAgent[AGENT]).toMatchObject({
|
||||
limitedUntil: 1_800_000_000_000,
|
||||
resumeFireAt: 1_800_000_300_000,
|
||||
suspected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("agentResumeCancelled drops resumeFireAt but keeps the agent limité", async () => {
|
||||
const { system, view } = setup();
|
||||
await emit(system, {
|
||||
type: "agentRateLimited",
|
||||
agentId: AGENT,
|
||||
resetsAtMs: 1_800_000_000_000,
|
||||
});
|
||||
await emit(system, {
|
||||
type: "agentResumeScheduled",
|
||||
agentId: AGENT,
|
||||
fireAtMs: 1_800_000_300_000,
|
||||
});
|
||||
await emit(system, { type: "agentResumeCancelled", agentId: AGENT });
|
||||
|
||||
const state = view.result.current.limitByAgent[AGENT];
|
||||
expect(state).toBeDefined();
|
||||
expect(state.resumeFireAt).toBeUndefined();
|
||||
expect(state.limitedUntil).toBe(1_800_000_000_000);
|
||||
});
|
||||
|
||||
it("agentResumeCancelled on an unknown agent is a no-op (no entry created)", async () => {
|
||||
const { system, view } = setup();
|
||||
await emit(system, { type: "agentResumeCancelled", agentId: AGENT });
|
||||
expect(view.result.current.limitByAgent[AGENT]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("agentResumed clears the limit entry entirely", async () => {
|
||||
const { system, view } = setup();
|
||||
await emit(system, {
|
||||
type: "agentRateLimited",
|
||||
agentId: AGENT,
|
||||
resetsAtMs: 1_800_000_000_000,
|
||||
});
|
||||
await emit(system, { type: "agentResumed", agentId: AGENT });
|
||||
expect(view.result.current.limitByAgent[AGENT]).toBeUndefined();
|
||||
expect(AGENT in view.result.current.limitByAgent).toBe(false);
|
||||
});
|
||||
|
||||
it("agentRateLimitSuspected with a time → { limitedUntil, suspected:true }", async () => {
|
||||
const { system, view } = setup();
|
||||
await emit(system, {
|
||||
type: "agentRateLimitSuspected",
|
||||
agentId: AGENT,
|
||||
resetsAtMs: 1_800_000_500_000,
|
||||
});
|
||||
expect(view.result.current.limitByAgent[AGENT]).toMatchObject({
|
||||
limitedUntil: 1_800_000_500_000,
|
||||
suspected: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("agentRateLimitSuspected WITHOUT a time → suspected:true, no limitedUntil", async () => {
|
||||
const { system, view } = setup();
|
||||
await emit(system, { type: "agentRateLimitSuspected", agentId: AGENT });
|
||||
const state = view.result.current.limitByAgent[AGENT];
|
||||
expect(state.suspected).toBe(true);
|
||||
expect(state.limitedUntil).toBeUndefined();
|
||||
});
|
||||
|
||||
it("realistic sequence: rateLimited → scheduled → cancelResume (action)", async () => {
|
||||
const { system, input, view } = setup();
|
||||
await emit(system, {
|
||||
type: "agentRateLimited",
|
||||
agentId: AGENT,
|
||||
resetsAtMs: 1_800_000_000_000,
|
||||
});
|
||||
await emit(system, {
|
||||
type: "agentResumeScheduled",
|
||||
agentId: AGENT,
|
||||
fireAtMs: 1_800_000_300_000,
|
||||
});
|
||||
expect(view.result.current.limitByAgent[AGENT].resumeFireAt).toBe(
|
||||
1_800_000_300_000,
|
||||
);
|
||||
|
||||
// The user clicks "Annuler la reprise": optimistic drop + port call.
|
||||
let verdict: boolean | undefined;
|
||||
await act(async () => {
|
||||
verdict = await view.result.current.cancelResume(AGENT);
|
||||
});
|
||||
|
||||
// Optimistic: the countdown is gone immediately; the agent stays limité.
|
||||
expect(view.result.current.limitByAgent[AGENT].resumeFireAt).toBeUndefined();
|
||||
expect(view.result.current.limitByAgent[AGENT].limitedUntil).toBe(
|
||||
1_800_000_000_000,
|
||||
);
|
||||
// The action routed through the InputGateway and returned the verdict.
|
||||
expect(input.cancelledResumes).toEqual([AGENT]);
|
||||
expect(verdict).toBe(true);
|
||||
});
|
||||
|
||||
it("cancelResume relays a `false` backend verdict (already fired / none armed)", async () => {
|
||||
const { system, input, view } = setup();
|
||||
input.cancelResumeResult = false;
|
||||
await emit(system, {
|
||||
type: "agentResumeScheduled",
|
||||
agentId: AGENT,
|
||||
fireAtMs: 1_800_000_300_000,
|
||||
});
|
||||
|
||||
let verdict: boolean | undefined;
|
||||
await act(async () => {
|
||||
verdict = await view.result.current.cancelResume(AGENT);
|
||||
});
|
||||
expect(verdict).toBe(false);
|
||||
expect(input.cancelledResumes).toEqual([AGENT]);
|
||||
// Still optimistically dropped locally.
|
||||
expect(view.result.current.limitByAgent[AGENT]?.resumeFireAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("cancelResume with no armed resume still calls the port (no-op locally)", async () => {
|
||||
const { input, view } = setup();
|
||||
// No event emitted: the agent has no entry at all.
|
||||
await waitFor(() => expect(view.result.current).toBeTruthy());
|
||||
let verdict: boolean | undefined;
|
||||
await act(async () => {
|
||||
verdict = await view.result.current.cancelResume(AGENT);
|
||||
});
|
||||
expect(verdict).toBe(true);
|
||||
expect(input.cancelledResumes).toEqual([AGENT]);
|
||||
expect(view.result.current.limitByAgent[AGENT]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("folds limits for two agents independently", async () => {
|
||||
const { system, view } = setup();
|
||||
await emit(system, {
|
||||
type: "agentRateLimited",
|
||||
agentId: "agent-A",
|
||||
resetsAtMs: 111,
|
||||
});
|
||||
await emit(system, {
|
||||
type: "agentRateLimitSuspected",
|
||||
agentId: "agent-B",
|
||||
});
|
||||
expect(view.result.current.limitByAgent["agent-A"]).toMatchObject({
|
||||
limitedUntil: 111,
|
||||
suspected: false,
|
||||
});
|
||||
expect(view.result.current.limitByAgent["agent-B"]).toMatchObject({
|
||||
suspected: true,
|
||||
});
|
||||
expect(view.result.current.limitByAgent["agent-B"].limitedUntil).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@ -552,6 +552,24 @@ export interface InputGateway {
|
||||
* mounted cell keeps the write-portal path. Best-effort; never throws into the UI.
|
||||
*/
|
||||
setFrontAttached(agentId: string, attached: boolean): Promise<void>;
|
||||
/**
|
||||
* Cancels the **auto-resume** armed for a rate-limited agent (ARCHITECTURE §21):
|
||||
* the user clicked "Annuler la reprise" during the cancellable window. Disarms
|
||||
* the scheduled wake-up. Resolves to `true` iff a resume was effectively
|
||||
* cancelled (a wake-up was still pending); `false` if none was armed or it had
|
||||
* already fired (the resume then runs its course). The backend also emits
|
||||
* `agentResumeCancelled` on success, which clears the countdown in the UI.
|
||||
*/
|
||||
cancelResume(agentId: string): Promise<boolean>;
|
||||
/**
|
||||
* Human net (level 3, ARCHITECTURE §21.1): arms a resume at a user-chosen
|
||||
* instant for an agent whose limit was *suspected* without a reliable reset
|
||||
* time. `resetsAtMs` is the chosen wake-up in epoch-milliseconds; the backend
|
||||
* clamps a past instant to "now" (⇒ immediate resume). This arms the same
|
||||
* cancellable resume as the automatic path and re-emits `agentResumeScheduled`,
|
||||
* so the UI flips on its own from "heure inconnue" to the nominal countdown.
|
||||
*/
|
||||
setResumeAt(agentId: string, resetsAtMs: number): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user