Compare commits
24 Commits
wip/p8c-ch
...
d7041c53ce
| Author | SHA1 | Date | |
|---|---|---|---|
| d7041c53ce | |||
| 3f3504efa3 | |||
| 5d9dd32c29 | |||
| c480d2820a | |||
| 4fad0423e7 | |||
| 9df592389c | |||
| ea94e756e2 | |||
| 98bfcf4f22 | |||
| 9000b4d09f | |||
| 253310bb3e | |||
| a1755e51bc | |||
| 0bf1eb3b11 | |||
| fa5b826df5 | |||
| 401c18ad3c | |||
| a04fbb7e1c | |||
| 69304b0f6b | |||
| 64ab3835c7 | |||
| 00bda6a988 | |||
| 31b636037d | |||
| 40bce5c8bf | |||
| ab9162f686 | |||
| 6236cd727b | |||
| 7e01ac60cb | |||
| 17ca65ed0f |
Submodule .claude/worktrees/agent-a2650e91d2bd39ca2 deleted from 480e7c7bbe
Submodule .claude/worktrees/agent-aeb1e862ef04b991b deleted from ef101db9dc
@ -37,16 +37,9 @@
|
||||
"synchronized": false
|
||||
},
|
||||
{
|
||||
"agentId": "c932c770-cf36-4fb2-a966-71bb1644e4b4",
|
||||
"name": "TestConversation",
|
||||
"mdPath": "agents/testconversation.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"synchronized": false
|
||||
},
|
||||
{
|
||||
"agentId": "484eff91-60a1-459f-9ebe-c9552cc70447",
|
||||
"name": "NewTest",
|
||||
"mdPath": "agents/newtest.md",
|
||||
"agentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5",
|
||||
"name": "Git",
|
||||
"mdPath": "agents/git.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"synchronized": false
|
||||
}
|
||||
|
||||
0
.ideai/agents/codextest.md
Normal file
0
.ideai/agents/codextest.md
Normal file
116
.ideai/agents/git.md
Normal file
116
.ideai/agents/git.md
Normal file
@ -0,0 +1,116 @@
|
||||
# Git — Agent de gestion du dépôt git local
|
||||
|
||||
> Tu es l'**agent Git** d'IdeA. Ton unique responsabilité est la **gestion du dépôt
|
||||
> git local** : commits de l'application, création et bascule de branches, merges et
|
||||
> rebases. Tu es le **seul** à décider de la topologie des branches et à manipuler
|
||||
> l'historique local. Main te sollicite ; tu décides et tu exécutes.
|
||||
|
||||
---
|
||||
|
||||
## 1. Ton rôle (et ses limites)
|
||||
|
||||
Tu t'occupes **du local du repo git**, rien d'autre :
|
||||
|
||||
- **Commits** : tu transformes le travail réalisé par les agents de dev en commits
|
||||
propres, atomiques, au bon endroit (bonne branche), avec des messages cohérents.
|
||||
- **Branches** : tu **crées, checkout, switch** les branches selon ce qui est en cours.
|
||||
- **Intégration** : tu **merges** et **rebases** les branches entre elles selon le
|
||||
modèle ci-dessous.
|
||||
- Tu **décides** : quand Main t'annonce une nouvelle feature (après cadrage Architect),
|
||||
c'est **toi** qui tranches s'il faut une nouvelle branche, un checkout/switch, ou rien.
|
||||
Après chaque implémentation, Main revient vers toi pour que tu décides si un **merge**
|
||||
doit avoir lieu quelque part, ou non.
|
||||
|
||||
**Hors périmètre :**
|
||||
- Tu **n'écris pas de code de feature** (c'est DevBackend/DevFrontend).
|
||||
- Tu ne fais **aucune action sortante** (`push`, publication, création de PR distante)
|
||||
sans validation explicite de Main / de l'utilisateur. Ton terrain est **local**.
|
||||
- Tu ne prends pas de décision produit/archi : si un choix dépend de l'architecture,
|
||||
tu remontes à Main.
|
||||
|
||||
---
|
||||
|
||||
## 2. Modèle de branches (git-flow simplifié)
|
||||
|
||||
Le dépôt s'articule autour de trois niveaux :
|
||||
|
||||
```
|
||||
main ← branche de RELEASE. Stable, livrable. On n'y commite jamais en direct.
|
||||
│
|
||||
develop ← branche d'INTÉGRATION. On y merge chaque feature une fois TERMINÉE et VERTE.
|
||||
│
|
||||
feature/* ← une branche PAR nouvelle feature. C'est là que le dev se fait.
|
||||
```
|
||||
|
||||
- **`main`** : reçoit uniquement des releases (merge depuis `develop` quand on décide
|
||||
de livrer). Jamais de dev direct.
|
||||
- **`develop`** : base d'intégration. Toute feature terminée (tests verts) y est mergée.
|
||||
C'est le point de départ de chaque nouvelle branche de feature.
|
||||
- **`feature/<nom-court>`** : une branche par feature, créée **depuis `develop`**. Nom
|
||||
dérivé du sujet de la feature (ex. `feature/sandbox-allow-fallback`,
|
||||
`feature/sidebar-tabs-responsive`).
|
||||
|
||||
> Si le dépôt ne possède pas encore `main`/`develop`, c'est à toi de les établir
|
||||
> proprement (création de `develop` depuis `main`) lors de ta première sollicitation.
|
||||
|
||||
---
|
||||
|
||||
## 3. Le cycle, vu de Git
|
||||
|
||||
Tu interviens à **deux moments** du cycle de dev (cf. CLAUDE.md §3), encadré par Main :
|
||||
|
||||
```
|
||||
1. Main : « nouvelle feature X » (architecture cadrée par Architect)
|
||||
→ TOI : décider de la branche.
|
||||
- nouvelle feature indépendante → créer feature/X depuis develop, switch dessus
|
||||
- reprise/extension d'un travail en cours → rester / switch sur la branche existante
|
||||
- simple correctif sur une feature vivante → rester sur sa branche
|
||||
→ tu annonces à Main sur quelle branche le dev va se faire.
|
||||
|
||||
2. Dev (DevBackend/DevFrontend) + Test (QA) implémentent sur cette branche.
|
||||
|
||||
3. Implémentation terminée → Main revient vers TOI :
|
||||
→ committer le travail (commits atomiques, message clair) sur la branche de feature.
|
||||
→ décider d'un éventuel merge :
|
||||
- feature TERMINÉE et VERTE → merge feature/X → develop
|
||||
(rebase préalable sur develop si l'historique a divergé, pour rester linéaire),
|
||||
puis suppression de la branche de feature si plus utile.
|
||||
- feature pas finie / tests KO → on NE merge PAS, on reste sur feature/X.
|
||||
- décision de release → merge develop → main (sur validation explicite).
|
||||
```
|
||||
|
||||
**Règle d'or partagée** : aucune feature n'est mergée dans `develop` tant que ses
|
||||
**tests ne passent pas**. Si on te demande de merger une feature rouge, tu refuses et
|
||||
tu le dis.
|
||||
|
||||
---
|
||||
|
||||
## 4. Conventions
|
||||
|
||||
- **Messages de commit** : en **français**, style Conventional Commits cohérent avec
|
||||
l'historique : `feat(scope): …`, `fix(scope): …`, `chore(scope): …`, `docs(scope): …`,
|
||||
`refactor(scope): …`. Corps multi-ligne expliquant le **pourquoi** quand utile.
|
||||
- **Atomicité** : un commit = une intention cohérente. Tu sépares le code de feature de
|
||||
l'état runtime (`.ideai/` conversations, layouts, manifestes) et des docs.
|
||||
- **Co-author** : termine les messages de commit par
|
||||
`Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` (convention de l'environnement).
|
||||
- **Branches** : `feature/<kebab-case>`, dérivé du sujet. Pas d'espaces, pas de majuscules.
|
||||
- **Historique linéaire** privilégié sur les features : **rebase** avant merge quand la
|
||||
base a avancé ; merge `--no-ff` vers `develop`/`main` pour garder la trace de
|
||||
l'intégration de la feature.
|
||||
- **Pas d'interactif** : pas de `rebase -i` / `add -i` (non supportés dans l'environnement).
|
||||
- **Jamais** d'action destructive hors-projet ni de réécriture d'historique déjà poussé
|
||||
sans validation explicite.
|
||||
|
||||
---
|
||||
|
||||
## 5. Délégation & collaboration
|
||||
|
||||
- Tu réponds à Main via le protocole d'orchestration IdeA (`idea_reply`). Quand Main te
|
||||
délègue une tâche (message `[IdeA · tâche de … · ticket …]`), tu traites puis tu
|
||||
appelles **impérativement** `idea_reply(result=…)`.
|
||||
- Tu rends compte clairement : branche courante, ce que tu as committé (hash + message
|
||||
court), ce que tu as mergé/rebasé, et **ta décision** (pourquoi cette branche, pourquoi
|
||||
ce merge ou ce non-merge).
|
||||
- En cas de conflit de merge/rebase, tu le signales à Main avec le détail ; tu ne forces
|
||||
pas une résolution hasardeuse.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,14 @@
|
||||
---
|
||||
upTo: a6169530-fc73-4057-91c6-900da4a611fb
|
||||
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
|
||||
|
||||
- **Prompt:** [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, et (3) l'heure courante. Aucune autre action requise.
|
||||
- **Prompt:** Test de conversation inter-agent. Calcule 123 × 4 et réponds-moi simplement avec le résultat via idea_reply.
|
||||
- **Response:** 123 × 4 = 492
|
||||
- **Prompt:** Le chantier permissions backend (LP3, projection vers les CLI Claude/Codex + swap) est terminé et `cargo test --workspace` est 100% vert. Mais `npx vitest run` côté frontend a 4 échecs, signalés comme pré-existants liés au chantier UI permissions en cours (gateway permissions ajoutée, PermissionsPanel monté dans ProjectsView). Diagnostique-les et corrige-les pour remettre le frontend au vert. Lance d'abord : `cd frontend && npx vitest run` pour voir l'état réel. Les 4 échecs rapportés : 1. `mock.test.ts > exposes all thirteen gateways` — le mock expose désormais une gateway de plus (permissions ⇒ 14). Mets à jour le test ET vérifie que le mock (`frontend/src/adapters/mock/index.ts`) expose bien une gateway permissions cohérente avec le port réel (`TauriPermissionGateway`). Le titre du test (« thirteen ») et le compte attendu doivent refléter le vrai nombre de gateways. 2. `LayoutGrid.chat.test.tsx` (2 échecs) — routage cellule terminal/chat. 3. `agentAlreadyRunning.test.tsx` (1 échec) — notice R0d. POUR CHAQUE échec : déterminer si c'est (a) un test à mettre à jour parce que le comportement/contrat a légitimement changé avec le feature permissions, ou (b) une vraie régression du code à corriger. Explique ton verdict pour chacun. Ne « fais pas passer » un test en masquant un vrai bug — si 2 et 3 ne sont pas liés aux permissions et révèlent une régression réelle, dis-le clairement plutôt que de les bricoler. Vérifie aussi qu'il n'y a pas d'incohérence entre le port permissions (`frontend/src/ports/index.ts`), l'adapter Tauri (`frontend/src/adapters/permission.ts`), le mock, et les types domaine (`frontend/src/domain/index.ts`) — les 4 doivent être alignés. Quand c'est fini, réponds via idea_reply : verdict par échec (test à jour vs régression corrigée), ce que tu as changé, et la SORTIE RÉELLE de `npx vitest run` (doit être 100% vert). Si un échec est une vraie régression hors de ton périmètre permissions, signale-le précisément.
|
||||
- **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).
|
||||
@ -0,0 +1,8 @@
|
||||
{"id":"f473c5bc-e6b6-4efd-927d-452cb587fac2","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781517530284,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"[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, et (3) l'heure courante. Aucune autre action requise."}
|
||||
{"id":"63f0981d-a083-4b21-97fb-e5d681b99468","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781522526877,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de conversation inter-agent. Calcule 123 × 4 et réponds-moi simplement avec le résultat via idea_reply."}
|
||||
{"id":"223e1427-7aad-4de2-ab3a-ac08f90e1256","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781522534227,"source":{"kind":"agent","agentId":"af7f86da-76bc-48e1-9900-71f45a624800"},"role":"response","text":"123 × 4 = 492"}
|
||||
{"id":"42327a10-fc63-48f7-a943-f89c9647ac42","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781526082185,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Le chantier permissions backend (LP3, projection vers les CLI Claude/Codex + swap) est terminé et `cargo test --workspace` est 100% vert. Mais `npx vitest run` côté frontend a 4 échecs, signalés comme pré-existants liés au chantier UI permissions en cours (gateway permissions ajoutée, PermissionsPanel monté dans ProjectsView). Diagnostique-les et corrige-les pour remettre le frontend au vert.\n\nLance d'abord : `cd frontend && npx vitest run` pour voir l'état réel.\n\nLes 4 échecs rapportés :\n1. `mock.test.ts > exposes all thirteen gateways` — le mock expose désormais une gateway de plus (permissions ⇒ 14). Mets à jour le test ET vérifie que le mock (`frontend/src/adapters/mock/index.ts`) expose bien une gateway permissions cohérente avec le port réel (`TauriPermissionGateway`). Le titre du test (« thirteen ») et le compte attendu doivent refléter le vrai nombre de gateways.\n2. `LayoutGrid.chat.test.tsx` (2 échecs) — routage cellule terminal/chat.\n3. `agentAlreadyRunning.test.tsx` (1 échec) — notice R0d.\n\nPOUR CHAQUE échec : déterminer si c'est (a) un test à mettre à jour parce que le comportement/contrat a légitimement changé avec le feature permissions, ou (b) une vraie régression du code à corriger. Explique ton verdict pour chacun. Ne « fais pas passer » un test en masquant un vrai bug — si 2 et 3 ne sont pas liés aux permissions et révèlent une régression réelle, dis-le clairement plutôt que de les bricoler.\n\nVérifie aussi qu'il n'y a pas d'incohérence entre le port permissions (`frontend/src/ports/index.ts`), l'adapter Tauri (`frontend/src/adapters/permission.ts`), le mock, et les types domaine (`frontend/src/domain/index.ts`) — les 4 doivent être alignés.\n\nQuand c'est fini, réponds via idea_reply : verdict par échec (test à jour vs régression corrigée), ce que tu as changé, et la SORTIE RÉELLE de `npx vitest run` (doit être 100% vert). Si un échec est une vraie régression hors de ton périmètre permissions, signale-le précisément."}
|
||||
{"id":"2a7f8f6d-3163-496a-a5b8-764a4229a40f","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781544382139,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"[Test de communication inter-agent] Ping. Réponds simplement via idea_reply avec « pong DevFrontend OK » pour confirmer que le canal fonctionne."}
|
||||
{"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)."}
|
||||
@ -0,0 +1,13 @@
|
||||
---
|
||||
upTo: 190cd7b8-23ad-42cb-8873-a17ddddff71d
|
||||
objective: Petit test de communication inter-agents. Calcule 17 × 23 et donne-moi uniquement le résultat numérique. Réponds-moi impérativement via l'outil idea_reply (pas en texte libre).
|
||||
---
|
||||
**Objectif :** Petit test de communication inter-agents. Calcule 17 × 23 et donne-moi uniquement le résultat numérique. Réponds-moi impérativement via l'outil idea_reply (pas en texte libre).
|
||||
|
||||
- **Prompt:** Petit test de communication inter-agents. Calcule 17 × 23 et donne-moi uniquement le résultat numérique. Réponds-moi impérativement via l'outil idea_reply (pas en texte libre).
|
||||
- **Response:** 391
|
||||
- **Prompt:** Test de communication inter-agents. Calcule 17 × 23 et réponds-moi UNIQUEMENT le résultat numérique, impérativement via l'outil idea_reply (jamais en texte libre).
|
||||
- **Prompt:** Test de conversation inter-agent. Peux-tu calculer (347 × 28) + 1569 et me retourner uniquement le résultat numérique avec le détail du calcul ? Réponds via idea_reply.
|
||||
- **Response:** 347 × 28 = 9716 ; 9716 + 1569 = 11285
|
||||
- **Prompt:** Test de conversation inter-agent. Calcule 78 × 9 et réponds-moi simplement avec le résultat via idea_reply.
|
||||
- **Response:** 702
|
||||
@ -0,0 +1,7 @@
|
||||
{"id":"3cbaced3-dd78-4f48-b168-f09a26d83444","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781384306910,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Petit test de communication inter-agents. Calcule 17 × 23 et donne-moi uniquement le résultat numérique. Réponds-moi impérativement via l'outil idea_reply (pas en texte libre)."}
|
||||
{"id":"2dd9de9a-d59d-493e-bf07-ffccf6addeb1","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781386172145,"source":{"kind":"agent","agentId":"93dc1f5f-921e-44a2-a7e7-5173d87c1a58"},"role":"response","text":"391"}
|
||||
{"id":"34916afd-1054-4257-805f-9c5b3ed5f7f3","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781386172204,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de communication inter-agents. Calcule 17 × 23 et réponds-moi UNIQUEMENT le résultat numérique, impérativement via l'outil idea_reply (jamais en texte libre)."}
|
||||
{"id":"6e037034-c65a-4eca-b494-06420c5eed06","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781386464926,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de conversation inter-agent. Peux-tu calculer (347 × 28) + 1569 et me retourner uniquement le résultat numérique avec le détail du calcul ? Réponds via idea_reply."}
|
||||
{"id":"f82caa94-daba-4171-aa1f-8f69f71671c4","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781386489560,"source":{"kind":"agent","agentId":"93dc1f5f-921e-44a2-a7e7-5173d87c1a58"},"role":"response","text":"347 × 28 = 9716 ; 9716 + 1569 = 11285"}
|
||||
{"id":"6187c292-4d9a-4c07-9381-732486ab6d03","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781522784017,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de conversation inter-agent. Calcule 78 × 9 et réponds-moi simplement avec le résultat via idea_reply."}
|
||||
{"id":"190cd7b8-23ad-42cb-8873-a17ddddff71d","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781522789866,"source":{"kind":"agent","agentId":"93dc1f5f-921e-44a2-a7e7-5173d87c1a58"},"role":"response","text":"702"}
|
||||
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
@ -0,0 +1,7 @@
|
||||
---
|
||||
upTo: 419c60a5-2aca-4711-ad83-0a185bb3214e
|
||||
objective: Ping de test inter-agent depuis DevBackend — réponds avec un court PONG et l'heure que tu vois.
|
||||
---
|
||||
**Objectif :** Ping de test inter-agent depuis DevBackend — réponds avec un court PONG et l'heure que tu vois.
|
||||
|
||||
- **Prompt:** Ping de test inter-agent depuis DevBackend — réponds avec un court PONG et l'heure que tu vois.
|
||||
@ -0,0 +1 @@
|
||||
{"id":"419c60a5-2aca-4711-ad83-0a185bb3214e","conversation":"dd35eeb0-2329-0dfa-3255-df7dba90a0aa","atMs":1781427081894,"source":{"kind":"agent","agentId":"73c853d1-c0fd-463b-ad17-1d24fefa371f"},"role":"prompt","text":"Ping de test inter-agent depuis DevBackend — réponds avec un court PONG et l'heure que tu vois."}
|
||||
@ -1,24 +1,24 @@
|
||||
{
|
||||
"version": 1,
|
||||
"activeId": "1af250f0-65ef-4b78-8905-b1746673aee0",
|
||||
"activeId": "dd38b8ed-7199-48a6-a63f-1d1462ca6a34",
|
||||
"layouts": [
|
||||
{
|
||||
"id": "1af250f0-65ef-4b78-8905-b1746673aee0",
|
||||
"id": "dd38b8ed-7199-48a6-a63f-1d1462ca6a34",
|
||||
"name": "Default",
|
||||
"kind": "terminal",
|
||||
"tree": {
|
||||
"root": {
|
||||
"type": "split",
|
||||
"node": {
|
||||
"id": "56ffe1e4-636c-458d-9ab2-7e278fd45897",
|
||||
"id": "2ea41e76-262d-4dc3-a674-348ceac9b543",
|
||||
"direction": "row",
|
||||
"children": [
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "c3319a9a-1345-4fa2-b64e-5f3fe00d13d8",
|
||||
"session": "4965c71a-f69f-4c06-90de-ecb81acff710",
|
||||
"id": "14d20dcb-f243-4381-a130-2d4d3d224f42",
|
||||
"session": "4e60d6c2-6be6-499c-a296-577ce1e5ec62",
|
||||
"agent": "a6ced819-b893-4213-b003-9e9dc79b9641",
|
||||
"agentWasRunning": true
|
||||
}
|
||||
@ -27,36 +27,11 @@
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"type": "split",
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "8cf1c06e-2654-45a3-bf17-a9c2507da935",
|
||||
"direction": "column",
|
||||
"children": [
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "69dc2e23-86f5-4770-84c4-b1b4b2c25299",
|
||||
"session": "11fbf6b4-eb62-4420-95e9-feb2ff667c43",
|
||||
"agent": "c932c770-cf36-4fb2-a966-71bb1644e4b4",
|
||||
"agentWasRunning": true
|
||||
}
|
||||
},
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "b9251e74-3bd5-43ee-90e5-e6bb87faab38",
|
||||
"session": "69a3bf52-05ef-45c0-badf-26b0d8224f0e",
|
||||
"agent": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5",
|
||||
"agentWasRunning": true
|
||||
}
|
||||
},
|
||||
"weight": 1.0
|
||||
}
|
||||
]
|
||||
"id": "71f5cc81-9bef-4b2b-b7dc-2e0a5a4eafcb",
|
||||
"session": "bb08a08e-71f3-4ab2-842c-71a8da5c74b4",
|
||||
"agent": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"
|
||||
}
|
||||
},
|
||||
"weight": 1.0
|
||||
|
||||
@ -4,3 +4,5 @@
|
||||
- [idea-product-directives-main-handoff](idea-product-directives-main-handoff.md) — Directives produit consolidees pour guider Main sur la robustesse, la persistance, le handoff cross-profile et la sobriete UX.
|
||||
- [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.
|
||||
|
||||
26
.ideai/memory/permissions-sandbox-system-state.md
Normal file
26
.ideai/memory/permissions-sandbox-system-state.md
Normal file
@ -0,0 +1,26 @@
|
||||
---
|
||||
name: permissions-sandbox-system-state
|
||||
description: Etat du systeme de permissions/sandbox IdeA (domaine pur -> projection advisory -> enforcement OS Landlock sur PTY ET structure) et l'unique risque residuel.
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
# Systeme de permissions / sandbox IdeA
|
||||
|
||||
Chantier "permissions des agents" complet bout-en-bout au 2026-06-16, sur la branche `wip/p8c-checkpoint-before-codex`. Lots committes : LP4-0 (`b05d04a`), LP4-1/LP4-2 (`17ca65e`), LP4-3 (`7e01ac6`), LP4-4 (`6236cd7`).
|
||||
|
||||
## Acquis (tout vert, ~80 suites)
|
||||
|
||||
- **Domaine pur** (`crates/domain/src/permission.rs`, `sandbox.rs`) : modele declaratif (`Capability`, `Effect`, `Posture` Allow<Ask<Deny, deny-wins), `resolve()` (invariant produit : rien pose => `None` => CLI garde son prompting natif), `compile_sandbox_plan(eff, ctx) -> Option<SandboxPlan>`, port `SandboxEnforcer`, `render_permission_summary` (honnetete : fichiers = OS-enforced, commandes = advisory).
|
||||
- **Store** : `.ideai/permissions.json` (separe d'`agents.json` : securite projet, pas identite).
|
||||
- **Projection advisory LP3** : projecteurs Claude (`settings.json`) + Codex (sandbox/`config.toml`).
|
||||
- **Enforcement OS LP4** : `LandlockSandbox` (Linux) / `NoopSandbox` / `default_enforcer()`. Actif sur **les deux chemins** : PTY brut (`pty/mod.rs` `spawn_command_sandboxed`) ET structure Claude/Codex JSON (`session/process.rs` `run_turn_sandboxed`).
|
||||
|
||||
## Technique d'enforcement (load-bearing, ne pas casser)
|
||||
|
||||
`enforce(plan)` tourne sur un **thread jetable AVANT le spawn**, puis le child est spawne depuis ce thread => il herite le domaine Landlock via les credentials de la tache (garanti par le noyau a travers fork/clone/execve, y compris posix_spawn). **Pas de `pre_exec`** : `infrastructure` est `#![forbid(unsafe_code)]` (intact), et le pre_exec n'aurait ete qu'une assurance de determinisme, sans valeur de securite ; allouer dans `landlock` post-fork (pre_exec) serait au contraire dangereux (deadlock malloc en process multithreade). Fail-closed : `Err` d'enforce => aucun child. Le garde-fou anti-regression est le test e2e **"ecriture hors-grant bloquee"** (present pour PTY et structure) : tant qu'il est vert, l'heritage tient.
|
||||
|
||||
## Risque residuel a traiter (signale par l'Architecte, NON couvert)
|
||||
|
||||
Les CLI structurees (claude/codex = binaires Node) ont des besoins FS ambiants lourds : `~/.claude`/`~/.codex` (credentials + cache de session pour le **resume**), `node_modules`, libs systeme, caches temp. `compile_sandbox_plan` ne fence que les classes explicitement posees et garde les lectures ouvertes — mais une policy posant un `Deny`/posture restrictive touchant `$HOME` **peut casser le resume**. Le mecanisme est valide au fake CLI (zero token), mais **avant d'activer un plan restrictif en prod sur le chemin structure**, valider e2e manuellement qu'un vrai tour claude/codex sous plan representatif garde run_dir + home de la CLI atteignables. C'est un sujet de **composition du plan** (run_dir reachability ; `SandboxContext.run_dir` existe mais n'est pas encore consomme par la traduction pure), pas du mecanisme — lot suivant si besoin.
|
||||
|
||||
Voir [[remaining-work-idea-agent-control-ide]] pour le reste des chantiers produit.
|
||||
26
.ideai/memory/session-limit-handling-design.md
Normal file
26
.ideai/memory/session-limit-handling-design.md
Normal file
@ -0,0 +1,26 @@
|
||||
---
|
||||
name: session-limit-handling-design
|
||||
description: Design valide pour la detection des limites de session des agents et la reprise auto a la levee.
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
Feature : detecter quand un agent IA est en limite de session (et jusqu'a quelle heure), puis reprendre ou il en etait une fois la limite levee. Cadree le 2026-06-16.
|
||||
|
||||
Constat dur : l'heure exacte de reset n'existe nulle part de facon universelle (ni OS, ni code de sortie, ni API inter-modeles) — elle est fabriquee par le fournisseur et seulement exposee dans le flux de sa CLI. Donc « 100% fiable + zero dependance modele + heure exacte » sont incompatibles simultanement ; on vise le meilleur compromis via un detecteur hierarchique.
|
||||
|
||||
Solution retenue — detecteur hierarchique calque sur la hierarchie de readiness existante ([[remaining-work-idea-agent-control-ide]]) :
|
||||
- Niveau 1 (solide) : l'adapter structure extrait limite + reset du flux machine. Pour Claude, `rate_limit_event.rate_limit_info` est DEJA parse dans `infrastructure/session/claude.rs` mais jete (reduit a Heartbeat) — il suffit de lire `resetsAt`.
|
||||
- Niveau 2 (configurable) : champ de profil `rate_limit_pattern` (regex + capture heure) pour agents PTY/TUI sans adapter structure, dans la lignee des profils declaratifs §9.
|
||||
- Niveau 3 (filet humain) : si rien ne matche mais agent `Stalled` (deja prevu dans `domain/readiness.rs`), IdeA DEMANDE a l'utilisateur. Garantit le « 100% meme pour un novice » : jamais d'inaction silencieuse.
|
||||
|
||||
Model-agnostic tenu AU DOMAINE : le domaine ne connait que `RateLimited { until: Option<Instant> }`. Le savoir specifique modele est confine aux adapters/profils (philosophie §9).
|
||||
|
||||
Reprise = partie facile et deja model-agnostique : pivot sur `conversation_id` du moteur + `--resume` natif (deja cable dans `session/claude.rs` + `agent/resume.rs`) qui portent tout l'historique. Pas de reconstruction manuelle fragile.
|
||||
|
||||
Decisions produit verrouillees (2026-06-16) :
|
||||
- Reprise : AUTOMATIQUE a l'heure de reset, ANNULABLE (fenetre + notification).
|
||||
- Couverture : les TROIS niveaux d'emblee (incl. repli regex niveau 2).
|
||||
- Etat : EN MEMOIRE uniquement (pas de persistance de SessionLimit). Consequence assumee : le reveil auto ne joue que tant qu'IdeA reste ouvert ; si l'IDE est ferme/rouvert apres le reset, le chemin existant `ListResumableAgents` (agent_was_running/conversation_id) prend le relais.
|
||||
|
||||
Decoupage (cycle dev/test §3) : 1) domaine (variante `ReplyEvent::RateLimited`, `ReadinessSignal::RateLimited`, etat `SessionLimit`) ; 2) adapter Claude (extraire resetsAt) ; 3) profil (`rate_limit_pattern`) ; 4) application (planificateur de reprise sur le port Clock) ; 5) UI (badge « limite jusqu'a HH:MM » + filet humain).
|
||||
38
.ideai/permissions.json
Normal file
38
.ideai/permissions.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"version": 1,
|
||||
"projectDefaults": {
|
||||
"rules": [
|
||||
{
|
||||
"capability": "read",
|
||||
"effect": "allow",
|
||||
"paths": [
|
||||
"**"
|
||||
],
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"capability": "write",
|
||||
"effect": "allow",
|
||||
"paths": [
|
||||
"**"
|
||||
],
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"capability": "delete",
|
||||
"effect": "allow",
|
||||
"paths": [
|
||||
"**"
|
||||
],
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"capability": "executeBash",
|
||||
"effect": "allow",
|
||||
"paths": [],
|
||||
"commands": []
|
||||
}
|
||||
],
|
||||
"fallback": "allow"
|
||||
}
|
||||
}
|
||||
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.*
|
||||
|
||||
28
CLAUDE.md
28
CLAUDE.md
@ -39,6 +39,21 @@ Je suis responsable de :
|
||||
- Produisent un **rapport d'erreurs** clair quand un test échoue.
|
||||
- Re-testent après chaque correction.
|
||||
|
||||
### 2.4 Agent Git (1 pour tout le projet)
|
||||
- Garant du **dépôt git local** : commits de l'application, création/checkout/switch de
|
||||
branches, merges et rebases. Contexte : `.ideai/agents/git.md`.
|
||||
- **C'est lui qui décide** de la topologie des branches, pas moi. Je le sollicite, il tranche.
|
||||
- Modèle de branches : **`main`** (release) ← **`develop`** (intégration des features
|
||||
terminées) ← une branche **`feature/*`** par nouvelle feature.
|
||||
- **Quand je commande une nouvelle feature** : une fois l'architecture cadrée par Architect,
|
||||
je passe la main à **Git** qui décide s'il faut créer une branche, faire un checkout/switch,
|
||||
ou rester en place — **avant** que le dev commence.
|
||||
- **Après chaque implémentation** : je reparle à **Git** pour qu'il décide si un **merge**
|
||||
doit être fait quelque part (typiquement `feature/* → develop` une fois les tests verts),
|
||||
ou non.
|
||||
- Périmètre **local uniquement** : aucune action sortante (`push`, publication) sans ma
|
||||
validation explicite.
|
||||
|
||||
---
|
||||
|
||||
## 3. Le cycle de développement (boucle obligatoire)
|
||||
@ -47,11 +62,14 @@ Pour **chaque** feature implémentée ou modifiée :
|
||||
|
||||
```
|
||||
1. Agent Architecture → valide le découpage et les contrats (ports/interfaces)
|
||||
2. Agent Développement → écrit le code
|
||||
3. Agent Test → écrit les tests unitaires + les exécute
|
||||
4a. Tests OK → feature validée, on passe à la suite
|
||||
4b. Tests KO → rapport d'erreurs → retour à l'agent Développement
|
||||
→ correction → retour à l'étape 3 (boucle jusqu'au vert)
|
||||
2. Agent Git → décide de la branche (créer feature/*, switch, ou rester)
|
||||
3. Agent Développement → écrit le code
|
||||
4. Agent Test → écrit les tests unitaires + les exécute
|
||||
5a. Tests OK → feature validée
|
||||
→ Agent Git décide d'un merge éventuel (feature/* → develop)
|
||||
→ on passe à la suite
|
||||
5b. Tests KO → rapport d'erreurs → retour à l'agent Développement
|
||||
→ correction → retour à l'étape 4 (boucle jusqu'au vert)
|
||||
```
|
||||
|
||||
**Règle d'or :** aucune feature n'est considérée terminée tant que ses tests ne passent pas.
|
||||
|
||||
33
Cargo.lock
generated
33
Cargo.lock
generated
@ -979,6 +979,26 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||
dependencies = [
|
||||
"enumflags2_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2_derive"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@ -1923,8 +1943,10 @@ dependencies = [
|
||||
"domain",
|
||||
"fastembed",
|
||||
"git2",
|
||||
"landlock",
|
||||
"notify",
|
||||
"portable-pty",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@ -2131,6 +2153,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "landlock"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "635839550ae8b90d9fd2571460a6645dc0aec070225956ca7a2831ed31d2795d"
|
||||
dependencies = [
|
||||
"enumflags2",
|
||||
"libc",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
|
||||
@ -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 {
|
||||
@ -393,7 +506,9 @@ impl AppState {
|
||||
let update_project_context = Arc::new(UpdateProjectContext::new(Arc::clone(&fs_port)));
|
||||
|
||||
// --- PTY adapter + terminal use cases (L3) ---
|
||||
let pty = Arc::new(PortablePtyAdapter::new());
|
||||
let pty = Arc::new(
|
||||
PortablePtyAdapter::new().with_sandbox_enforcer(infrastructure::default_enforcer()),
|
||||
);
|
||||
let pty_port = Arc::clone(&pty) as Arc<dyn PtyPort>;
|
||||
let terminal_sessions = Arc::new(TerminalSessions::new());
|
||||
|
||||
@ -402,8 +517,10 @@ impl AppState {
|
||||
// `profile.structured_adapter`. Injectés dans LaunchAgent (routage §17.4) et
|
||||
// ChangeAgentProfile (shutdown polymorphe au hot-swap).
|
||||
let structured_sessions = Arc::new(StructuredSessions::new());
|
||||
let session_factory =
|
||||
Arc::new(StructuredSessionFactory::new()) as Arc<dyn AgentSessionFactory>;
|
||||
let session_factory = Arc::new(
|
||||
StructuredSessionFactory::new()
|
||||
.with_sandbox_enforcer(infrastructure::default_enforcer()),
|
||||
) as Arc<dyn AgentSessionFactory>;
|
||||
|
||||
let open_terminal = Arc::new(OpenTerminal::new(
|
||||
Arc::clone(&pty_port),
|
||||
@ -906,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())
|
||||
@ -1047,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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
@ -28,6 +28,7 @@ use domain::{
|
||||
ProjectedFile, ProjectionContext, ProjectorKey, Project, ProjectPath, ProviderSessionStore,
|
||||
PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
|
||||
};
|
||||
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::layout::{persist_doc, resolve_doc};
|
||||
@ -935,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
|
||||
@ -1330,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1349,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1387,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1510,6 +1526,22 @@ impl LaunchAgent {
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 5d. ── PLAN DE SANDBOX OS (lot LP4-3) ──
|
||||
// Strictement APRÈS la résolution des permissions (3) et la projection
|
||||
// advisory (5c), AVANT le split structuré/PTY : `compile_sandbox_plan`
|
||||
// est **pur** (domaine) — le launch path ne fait qu'orchestrer
|
||||
// `EffectivePermissions` (déjà lue du store en 3) → `SandboxPlan` et
|
||||
// remplir `spec.sandbox`. `eff == None` (rien posé) ⇒ `None` : aucun plan,
|
||||
// comportement natif conservé (invariant produit). L'enforcer concret
|
||||
// (Landlock/Noop) est injecté côté PTY au composition root.
|
||||
spec.sandbox = compile_sandbox_plan(
|
||||
effective_permissions.as_ref(),
|
||||
&SandboxContext {
|
||||
project_root: input.project.root.as_str(),
|
||||
run_dir: run_dir.as_str(),
|
||||
},
|
||||
);
|
||||
|
||||
// 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ──
|
||||
// Le convention file (CLAUDE.md / AGENTS.md) vient d'être écrit dans le
|
||||
// run dir (étape 5) ; la CLI structurée le lira à chaque tour
|
||||
@ -1552,6 +1584,7 @@ impl LaunchAgent {
|
||||
&input.project.root,
|
||||
input.node_id,
|
||||
size,
|
||||
spec.sandbox.as_ref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@ -1587,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()),
|
||||
})
|
||||
}
|
||||
|
||||
@ -1613,9 +1649,12 @@ impl LaunchAgent {
|
||||
root: &ProjectPath,
|
||||
node_id: Option<NodeId>,
|
||||
size: PtySize,
|
||||
sandbox: Option<&SandboxPlan>,
|
||||
) -> Result<LaunchAgentOutput, AppError> {
|
||||
// Relaie le plan de sandbox OS (lot LP4-4) à la fabrique : `spec.sandbox`,
|
||||
// déjà compilé (pur, domaine) en step 5d. `None` ⇒ exécution native inchangée.
|
||||
let session = factory
|
||||
.start(profile, prepared, run_dir, session_plan)
|
||||
.start(profile, prepared, run_dir, session_plan, sandbox)
|
||||
.await
|
||||
.map_err(|e| AppError::Process(e.to_string()))?;
|
||||
|
||||
@ -1669,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
|
||||
|
||||
@ -3022,6 +3022,7 @@ impl AgentSessionFactory for CountingFactory {
|
||||
_ctx: &PreparedContext,
|
||||
_cwd: &ProjectPath,
|
||||
_session: &SessionPlan,
|
||||
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||
self.starts.fetch_add(1, Ordering::SeqCst);
|
||||
let id = {
|
||||
|
||||
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"));
|
||||
}
|
||||
@ -520,6 +520,7 @@ impl AgentSessionFactory for FakeFactory {
|
||||
_ctx: &PreparedContext,
|
||||
_cwd: &ProjectPath,
|
||||
session: &SessionPlan,
|
||||
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||
self.starts
|
||||
.lock()
|
||||
@ -1114,7 +1115,7 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() {
|
||||
let cwd = ProjectPath::new(ROOT).unwrap();
|
||||
let session = f
|
||||
.factory
|
||||
.start(&profile, &ctx, &cwd, &SessionPlan::None)
|
||||
.start(&profile, &ctx, &cwd, &SessionPlan::None, None)
|
||||
.await
|
||||
.expect("seed structured session");
|
||||
f.structured.insert(session, agent.id, host);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -531,6 +579,13 @@ pub trait AgentSessionFactory: Send + Sync {
|
||||
/// §14.1), avec le contexte déjà préparé ([`PreparedContext`]) et l'intention de
|
||||
/// session ([`SessionPlan`] : neuf / assign / resume — réutilise §15).
|
||||
///
|
||||
/// `sandbox` est le plan de sandbox OS **par lancement** (lot LP4-4), déjà
|
||||
/// compilé (pur, domaine) au launch path et porté jusqu'ici comme il l'est pour
|
||||
/// le chemin PTY via [`SpawnSpec::sandbox`]. `None` ⇒ aucun plan ⇒ exécution
|
||||
/// native inchangée (invariant produit : rien posé ⇒ rien projeté). Le plan
|
||||
/// franchit le port en tant que **valeur domaine** ([`crate::sandbox::SandboxPlan`]) ;
|
||||
/// l'enforcer concret reste côté infra (injecté par instance dans la fabrique).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AgentSessionError::Start`] si la CLI/SDK est indisponible ou le mode
|
||||
/// structuré ne peut s'initialiser.
|
||||
@ -540,6 +595,7 @@ pub trait AgentSessionFactory: Send + Sync {
|
||||
ctx: &PreparedContext,
|
||||
cwd: &ProjectPath,
|
||||
session: &SessionPlan,
|
||||
sandbox: Option<&crate::sandbox::SandboxPlan>,
|
||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError>;
|
||||
}
|
||||
|
||||
@ -1238,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());
|
||||
}
|
||||
}
|
||||
@ -340,6 +340,7 @@ impl AgentSessionFactory for FakeFactory {
|
||||
_ctx: &PreparedContext,
|
||||
_cwd: &ProjectPath,
|
||||
_session: &SessionPlan,
|
||||
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||
Ok(Arc::new(FakeSession {
|
||||
id: SessionId::from_uuid(Uuid::from_u128(7)),
|
||||
@ -394,7 +395,7 @@ async fn fake_factory_supports_only_structured_profiles_and_starts() {
|
||||
};
|
||||
let cwd = ProjectPath::new("/srv/run").unwrap();
|
||||
let session = factory
|
||||
.start(&structured, &ctx, &cwd, &SessionPlan::None)
|
||||
.start(&structured, &ctx, &cwd, &SessionPlan::None, None)
|
||||
.await
|
||||
.expect("factory starts a session");
|
||||
assert_eq!(session.id(), SessionId::from_uuid(Uuid::from_u128(7)));
|
||||
|
||||
@ -20,8 +20,17 @@ 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 }
|
||||
|
||||
# OS sandbox (lot LP4-1) — Linux Landlock LSM, best-effort/ABI-compat. Linux-only
|
||||
# target dep so the Windows/macOS builds (and the Noop fallback) compile nothing
|
||||
# extra. `landlock` is a thin, dependency-light wrapper over the kernel ABI.
|
||||
# Filesystem change notifications used to *wake* the orchestrator poll loop early
|
||||
# (the poll loop remains the robust cross-platform correctness guarantee).
|
||||
notify = "6"
|
||||
@ -36,6 +45,9 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
|
||||
# `vector-onnx` feature; the zero-dependency default compiles nothing extra.
|
||||
fastembed = { version = "5", default-features = false, features = ["hf-hub-rustls-tls", "ort-download-binaries-rustls-tls"], optional = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
landlock = "0.4.5"
|
||||
|
||||
[features]
|
||||
# Real HTTP-backed embedders (`localServer` Ollama/llama.cpp, `api` OpenAI/Voyage…).
|
||||
# OFF by default: the founding posture is `none` ⇒ naïve recall, zero dependency.
|
||||
|
||||
@ -27,10 +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;
|
||||
@ -55,6 +59,11 @@ pub use process::LocalProcessSpawner;
|
||||
pub use pty::PortablePtyAdapter;
|
||||
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;
|
||||
|
||||
@ -42,9 +42,10 @@ use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySystem};
|
||||
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySystem, SlavePty};
|
||||
|
||||
use domain::ports::{ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort, SpawnSpec};
|
||||
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
||||
use domain::terminal::PtySize;
|
||||
use domain::SessionId;
|
||||
|
||||
@ -143,16 +144,32 @@ struct LivePty {
|
||||
#[derive(Default)]
|
||||
pub struct PortablePtyAdapter {
|
||||
sessions: Mutex<HashMap<SessionId, LivePty>>,
|
||||
/// Optional OS-sandbox enforcer (lot LP4-1). `None` ⇒ no sandboxing at all
|
||||
/// (the default, zero behaviour change). When set **and** a [`SpawnSpec`]
|
||||
/// carries a [`SandboxPlan`], the plan is enforced on the child at spawn
|
||||
/// (see [`spawn_command_sandboxed`]).
|
||||
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||
}
|
||||
|
||||
impl PortablePtyAdapter {
|
||||
/// Creates an empty adapter.
|
||||
/// Creates an empty adapter (no OS sandbox).
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
sandbox_enforcer: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Additive builder: wires an OS-sandbox [`SandboxEnforcer`] (lot LP4-1). With
|
||||
/// it set, any [`SpawnSpec`] whose `sandbox` is `Some` has that plan enforced on
|
||||
/// the spawned child. Without it (the default), no spawn is ever sandboxed —
|
||||
/// `new()`'s behaviour is unchanged.
|
||||
#[must_use]
|
||||
pub fn with_sandbox_enforcer(mut self, enforcer: Arc<dyn SandboxEnforcer>) -> Self {
|
||||
self.sandbox_enforcer = Some(enforcer);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps the domain [`PtySize`] to the `portable-pty` one.
|
||||
@ -165,6 +182,54 @@ fn to_pty_size(size: PtySize) -> portable_pty::PtySize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the child **under an OS sandbox** (lot LP4-1).
|
||||
///
|
||||
/// ## Why a dedicated thread instead of a `pre_exec` hook
|
||||
///
|
||||
/// `portable-pty` 0.9 exposes **no** user-injectable `pre_exec` closure: its
|
||||
/// `CommandBuilder` has no such method, and `SlavePty::spawn_command` installs its
|
||||
/// *own* internal `pre_exec` (for `setsid` / controlling-tty) before exec, with no
|
||||
/// extension point. So we cannot run `enforcer.enforce(plan)` in the child's
|
||||
/// `pre_exec` directly.
|
||||
///
|
||||
/// Instead we lean on a guaranteed kernel property: **a Landlock domain is
|
||||
/// inherited across `fork` and preserved across `execve`**. We therefore restrict a
|
||||
/// **dedicated throwaway thread** with `enforce`, then call `spawn_command` *from
|
||||
/// that thread*. `std::process::Command` (which `spawn_command` drives) forks from
|
||||
/// the calling thread — and because `portable-pty` always sets a `pre_exec`, std is
|
||||
/// forced down the `fork`+`exec` path (never `posix_spawn`) — so the child inherits
|
||||
/// the restricted thread's domain. The throwaway thread then dies, taking its
|
||||
/// (irreversible) restriction with it, leaving IdeA's other threads untouched.
|
||||
///
|
||||
/// On `enforce` returning `Err` (fail-closed, e.g. a `Deny` posture on a kernel
|
||||
/// without Landlock) the spawn is failed and **no child runs**. `Ok(Unsupported /
|
||||
/// Degraded)` proceeds (best-effort).
|
||||
///
|
||||
/// Everything is **moved** into the thread (no borrow), so no `Sync` bound is
|
||||
/// required of the slave; the resulting `Child` is `Send` and returned to the
|
||||
/// caller.
|
||||
fn spawn_command_sandboxed(
|
||||
slave: Box<dyn SlavePty + Send>,
|
||||
cmd: CommandBuilder,
|
||||
enforcer: Arc<dyn SandboxEnforcer>,
|
||||
plan: SandboxPlan,
|
||||
) -> Result<Box<dyn Child + Send + Sync>, PtyError> {
|
||||
let join = std::thread::spawn(move || -> Result<Box<dyn Child + Send + Sync>, PtyError> {
|
||||
// Restrict THIS thread; the forked child inherits the domain.
|
||||
if let Err(e) = enforcer.enforce(&plan) {
|
||||
return Err(PtyError::Spawn(format!("sandbox enforcement failed: {e}")));
|
||||
}
|
||||
let child = slave
|
||||
.spawn_command(cmd)
|
||||
.map_err(|e| PtyError::Spawn(e.to_string()))?;
|
||||
// The slave is held by the child; drop our copy so EOF propagates on exit.
|
||||
drop(slave);
|
||||
Ok(child)
|
||||
});
|
||||
join.join()
|
||||
.map_err(|_| PtyError::Spawn("sandbox spawn thread panicked".to_owned()))?
|
||||
}
|
||||
|
||||
/// Builds the `portable-pty` command from a domain [`SpawnSpec`].
|
||||
fn to_command(spec: &SpawnSpec) -> CommandBuilder {
|
||||
let mut cmd = CommandBuilder::new(&spec.command);
|
||||
@ -185,12 +250,22 @@ impl PtyPort for PortablePtyAdapter {
|
||||
.map_err(|e| PtyError::Spawn(e.to_string()))?;
|
||||
|
||||
let cmd = to_command(&spec);
|
||||
let child = pair
|
||||
.slave
|
||||
.spawn_command(cmd)
|
||||
.map_err(|e| PtyError::Spawn(e.to_string()))?;
|
||||
// The slave is held by the child; drop our copy so EOF propagates on exit.
|
||||
drop(pair.slave);
|
||||
// Move the slave out of the pair (the master stays usable) so it can be
|
||||
// either spawned directly or handed to the sandboxed-spawn thread.
|
||||
let slave = pair.slave;
|
||||
let child = match (spec.sandbox.clone(), self.sandbox_enforcer.clone()) {
|
||||
// Sandboxed spawn: a plan AND an enforcer are present.
|
||||
(Some(plan), Some(enforcer)) => spawn_command_sandboxed(slave, cmd, enforcer, plan)?,
|
||||
// No plan or no enforcer ⇒ plain spawn (the default path, unchanged).
|
||||
_ => {
|
||||
let child = slave
|
||||
.spawn_command(cmd)
|
||||
.map_err(|e| PtyError::Spawn(e.to_string()))?;
|
||||
// The slave is held by the child; drop our copy so EOF propagates.
|
||||
drop(slave);
|
||||
child
|
||||
}
|
||||
};
|
||||
|
||||
let writer = pair
|
||||
.master
|
||||
@ -371,3 +446,178 @@ mod tests {
|
||||
assert_eq!(hub.snapshot(), b"abc".to_vec());
|
||||
}
|
||||
}
|
||||
|
||||
/// **End-to-end PTY × Landlock enforcement (lot LP4-3).**
|
||||
///
|
||||
/// These tests prove the OS sandbox is *really live on the raw PTY path*: a real
|
||||
/// [`PortablePtyAdapter`] wired with the Landlock enforcer, a [`SpawnSpec`] whose
|
||||
/// `sandbox = Some(plan)`, and a harmless `sh -c` that tries to write **inside** the
|
||||
/// granted root (must succeed) and **outside** it (must be blocked by the kernel).
|
||||
/// No AI CLI is launched — zero tokens. The whole chain enforcer → spec.sandbox →
|
||||
/// `spawn` → `spawn_command_sandboxed` → restricted thread → fork/exec is exercised.
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod sandbox_e2e_tests {
|
||||
use super::*;
|
||||
use crate::sandbox::LandlockSandbox;
|
||||
use domain::sandbox::{PathAccess, PathGrant, SandboxPlan, SandboxStatus};
|
||||
use domain::terminal::PtySize;
|
||||
use domain::Posture;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// A unique temp dir for one test (no external tempfile dep), mirroring the
|
||||
/// helper used by the Landlock adapter tests.
|
||||
fn fresh_dir(tag: &str) -> PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let p = std::env::temp_dir().join(format!("idea-pty-sandbox-{tag}-{nanos}"));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
/// Probes whether the running kernel actually enforces Landlock, exactly the way
|
||||
/// the launch path would observe it: enforce a tiny RW plan on a throwaway thread
|
||||
/// (the restriction is irreversible, so it must not run on the test thread) and
|
||||
/// report `false` when the adapter returns [`SandboxStatus::Unsupported`]. Lets us
|
||||
/// skip cleanly on CI / old kernels without a Landlock LSM, like the adapter tests.
|
||||
fn landlock_is_enforced() -> bool {
|
||||
let probe = fresh_dir("probe");
|
||||
let plan = SandboxPlan {
|
||||
allowed: vec![PathGrant {
|
||||
abs_root: probe.to_string_lossy().into_owned(),
|
||||
access: PathAccess::RW,
|
||||
}],
|
||||
default_posture: Posture::Ask,
|
||||
};
|
||||
let status = std::thread::spawn(move || LandlockSandbox::new().enforce(&plan))
|
||||
.join()
|
||||
.unwrap()
|
||||
.expect("enforce must not error under an Ask posture");
|
||||
let _ = std::fs::remove_dir_all(&probe);
|
||||
!matches!(status, SandboxStatus::Unsupported)
|
||||
}
|
||||
|
||||
/// Waits (bounded) for `path` to appear, returning whether it showed up in time.
|
||||
fn wait_for(path: &Path, timeout: Duration) -> bool {
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
if path.exists() {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
path.exists()
|
||||
}
|
||||
|
||||
/// THE high-fidelity end-to-end proof. With the enforcer wired and a plan that
|
||||
/// grants RW on `allowed` only, a child `sh` writing to **both** an out-of-grant
|
||||
/// path and the granted path must have its out-of-grant write blocked while the
|
||||
/// in-grant write lands on disk. The script writes the denied target *first*, so
|
||||
/// once the allowed marker exists we know the denied write was already attempted
|
||||
/// (and fenced) — making the assertion deterministic without racing the child.
|
||||
#[tokio::test]
|
||||
async fn pty_spawn_enforces_sandbox_plan_end_to_end() {
|
||||
if !landlock_is_enforced() {
|
||||
eprintln!("skipping pty_spawn_enforces_sandbox_plan_end_to_end: \
|
||||
Landlock not available/enforced on this kernel");
|
||||
return;
|
||||
}
|
||||
|
||||
let allowed = fresh_dir("allowed");
|
||||
let denied = fresh_dir("denied");
|
||||
let allowed_marker = allowed.join("in.txt");
|
||||
let denied_marker = denied.join("out.txt");
|
||||
|
||||
// RW on `allowed` only ⇒ the write class is handled and fenced to that root;
|
||||
// reads stay globally unrestricted so `sh`/libc still load normally.
|
||||
let plan = SandboxPlan {
|
||||
allowed: vec![PathGrant {
|
||||
abs_root: allowed.to_string_lossy().into_owned(),
|
||||
access: PathAccess::RW,
|
||||
}],
|
||||
default_posture: Posture::Ask,
|
||||
};
|
||||
|
||||
// Denied write FIRST, then the allowed write: the allowed marker appearing is
|
||||
// a happens-after signal that the denied attempt already ran.
|
||||
let script = format!(
|
||||
"echo outside > '{}'; echo inside > '{}'",
|
||||
denied_marker.display(),
|
||||
allowed_marker.display()
|
||||
);
|
||||
let spec = SpawnSpec {
|
||||
command: "sh".to_owned(),
|
||||
args: vec!["-c".to_owned(), script],
|
||||
cwd: domain::project::ProjectPath::new(allowed.to_string_lossy().into_owned())
|
||||
.expect("temp dir is absolute"),
|
||||
env: vec![],
|
||||
context_plan: None,
|
||||
sandbox: Some(plan),
|
||||
};
|
||||
|
||||
let adapter =
|
||||
PortablePtyAdapter::new().with_sandbox_enforcer(crate::sandbox::default_enforcer());
|
||||
let handle = adapter
|
||||
.spawn(spec, PtySize { rows: 24, cols: 80 })
|
||||
.await
|
||||
.expect("sandboxed spawn must succeed (Ask posture never fails closed)");
|
||||
|
||||
// Wait for the in-grant write to land (the child's last action).
|
||||
assert!(
|
||||
wait_for(&allowed_marker, Duration::from_secs(5)),
|
||||
"in-grant write must succeed: {allowed_marker:?} never appeared — \
|
||||
the sandbox may have wrongly fenced the granted root"
|
||||
);
|
||||
|
||||
// THE GUARANTEE: the out-of-grant write was attempted before the marker and
|
||||
// must have been blocked by Landlock, so the file must not exist.
|
||||
assert!(
|
||||
!denied_marker.exists(),
|
||||
"SANDBOX BREACH: out-of-grant write succeeded ({denied_marker:?} exists) — \
|
||||
the Landlock plan was NOT enforced on the PTY child"
|
||||
);
|
||||
|
||||
let _ = adapter.kill(&handle).await;
|
||||
let _ = std::fs::remove_dir_all(&allowed);
|
||||
let _ = std::fs::remove_dir_all(&denied);
|
||||
}
|
||||
|
||||
/// Companion sanity check: with the **same** adapter+enforcer but a [`SpawnSpec`]
|
||||
/// carrying `sandbox = None`, the out-of-grant write must succeed — proving the
|
||||
/// previous test's block comes from the *enforced plan*, not from some ambient PTY
|
||||
/// restriction. (Guards against a false-positive where writes fail for unrelated
|
||||
/// reasons.) `denied` here is just an ordinary writable temp dir.
|
||||
#[tokio::test]
|
||||
async fn pty_spawn_without_plan_does_not_sandbox() {
|
||||
let denied = fresh_dir("noplan");
|
||||
let marker = denied.join("out.txt");
|
||||
let script = format!("echo outside > '{}'", marker.display());
|
||||
|
||||
let spec = SpawnSpec {
|
||||
command: "sh".to_owned(),
|
||||
args: vec!["-c".to_owned(), script],
|
||||
cwd: domain::project::ProjectPath::new(denied.to_string_lossy().into_owned())
|
||||
.expect("temp dir is absolute"),
|
||||
env: vec![],
|
||||
context_plan: None,
|
||||
sandbox: None,
|
||||
};
|
||||
|
||||
let adapter =
|
||||
PortablePtyAdapter::new().with_sandbox_enforcer(crate::sandbox::default_enforcer());
|
||||
let handle = adapter
|
||||
.spawn(spec, PtySize { rows: 24, cols: 80 })
|
||||
.await
|
||||
.expect("plain spawn must succeed");
|
||||
|
||||
assert!(
|
||||
wait_for(&marker, Duration::from_secs(5)),
|
||||
"without a sandbox plan the write must succeed: {marker:?} never appeared"
|
||||
);
|
||||
|
||||
let _ = adapter.kill(&handle).await;
|
||||
let _ = std::fs::remove_dir_all(&denied);
|
||||
}
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
@ -38,7 +38,9 @@ impl LocalHost {
|
||||
Self {
|
||||
fs: Arc::new(LocalFileSystem::new()),
|
||||
spawner: Arc::new(LocalProcessSpawner::new()),
|
||||
pty: Arc::new(PortablePtyAdapter::new()),
|
||||
pty: Arc::new(
|
||||
PortablePtyAdapter::new().with_sandbox_enforcer(crate::sandbox::default_enforcer()),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
479
crates/infrastructure/src/sandbox/landlock.rs
Normal file
479
crates/infrastructure/src/sandbox/landlock.rs
Normal file
@ -0,0 +1,479 @@
|
||||
//! [`LandlockSandbox`] — the Linux [`SandboxEnforcer`] backed by the kernel
|
||||
//! Landlock LSM (lot LP4-1), via the `landlock` crate, **best-effort / ABI-compat**.
|
||||
//!
|
||||
//! # What it does
|
||||
//!
|
||||
//! [`SandboxEnforcer::enforce`] translates a domain [`SandboxPlan`] into a Landlock
|
||||
//! ruleset and restricts the **current thread** (the contract says `enforce` runs
|
||||
//! post-fork / pre-exec in the child, so the restriction is inherited by the exec'd
|
||||
//! program — Landlock domains survive `execve`).
|
||||
//!
|
||||
//! # Per-access-class handling (mirrors the LP4-0 compile invariant)
|
||||
//!
|
||||
//! Landlock works by *handling* a set of access rights — everything **not** granted
|
||||
//! is then denied **for those handled rights only**. We therefore handle a right
|
||||
//! class **only if the plan actually posed a grant of that class**:
|
||||
//!
|
||||
//! - some `RO` grant present ⇒ handle the **read** class (`from_read`): reads become
|
||||
//! restricted to the granted roots;
|
||||
//! - some `RW` grant present ⇒ handle the **write** class (`from_write`): writes /
|
||||
//! create / delete become restricted to the granted roots.
|
||||
//!
|
||||
//! A write-only policy thus leaves **reads globally unrestricted** (so the agent can
|
||||
//! still load `libc`, read `/etc`, …) and only fences writes — exactly the autonomy
|
||||
//! the per-access-class compile guarantees. The flip side: a policy that restricts
|
||||
//! reads governs *all* reads, so the compiled plan must include the system read
|
||||
//! paths the program needs; that enrichment is the launch-path's concern (LP4-2),
|
||||
//! not this adapter's — here we translate the plan faithfully.
|
||||
//!
|
||||
//! # Fallback policy
|
||||
//!
|
||||
//! On a kernel/ABI without (sufficient) Landlock, `restrict_self` reports
|
||||
//! [`RulesetStatus::NotEnforced`]. We then apply the product fallback: **fail-open**
|
||||
//! ([`SandboxStatus::Unsupported`]) for any posture **except** [`Posture::Deny`],
|
||||
//! where we **fail-closed** with [`SandboxError::KernelTooOld`] (a `Deny` posture
|
||||
//! cannot be silently dropped). Partial enforcement (older ABI missing some rights)
|
||||
//! maps to [`SandboxStatus::Degraded`].
|
||||
|
||||
use domain::sandbox::{
|
||||
PathAccess, SandboxEnforcer, SandboxError, SandboxKind, SandboxPlan, SandboxStatus,
|
||||
};
|
||||
use domain::Posture;
|
||||
|
||||
// Leading `::` selects the extern `landlock` crate (this module is also named
|
||||
// `landlock`, so the disambiguation matters).
|
||||
use ::landlock::{
|
||||
path_beneath_rules, AccessFs, BitFlags, CompatLevel, Compatible, Ruleset, RulesetAttr,
|
||||
RulesetCreatedAttr, RulesetStatus, ABI,
|
||||
};
|
||||
|
||||
/// The Landlock ABI we author rules against. `ABI::V1` (Linux ≥ 5.13) is the broad
|
||||
/// floor; `CompatLevel::BestEffort` lets newer kernels run it unchanged and older /
|
||||
/// absent support degrade rather than hard-error.
|
||||
const TARGET_ABI: ABI = ABI::V1;
|
||||
|
||||
/// The Linux Landlock [`SandboxEnforcer`].
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct LandlockSandbox;
|
||||
|
||||
impl LandlockSandbox {
|
||||
/// Builds the Landlock enforcer.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
/// The Landlock access rights a single grant opens, intersected later with the
|
||||
/// handled set. An `RW` grant also opens the read rights (writing a tree usually
|
||||
/// implies reading it) — harmless when reads are ungoverned, correct when they are.
|
||||
fn grant_access(access: PathAccess, abi: ABI) -> BitFlags<AccessFs> {
|
||||
let mut acc = BitFlags::<AccessFs>::empty();
|
||||
if access.contains(PathAccess::RO) {
|
||||
acc |= AccessFs::from_read(abi);
|
||||
}
|
||||
if access.contains(PathAccess::RW) {
|
||||
acc |= AccessFs::from_read(abi) | AccessFs::from_write(abi);
|
||||
}
|
||||
if access.contains(PathAccess::EXEC) {
|
||||
// The domain never emits EXEC today; honoured for completeness.
|
||||
acc |= AccessFs::Execute;
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
impl SandboxEnforcer for LandlockSandbox {
|
||||
fn kind(&self) -> SandboxKind {
|
||||
SandboxKind::Landlock
|
||||
}
|
||||
|
||||
fn enforce(&self, plan: &SandboxPlan) -> Result<SandboxStatus, SandboxError> {
|
||||
let abi = TARGET_ABI;
|
||||
|
||||
// Landlock is an **additive allowlist** — it can only *remove* access, i.e.
|
||||
// it is deny-by-default. A `Posture::Allow` fallback means the opposite,
|
||||
// **allow-by-default**: paths matched by no grant must stay reachable. An
|
||||
// allowlist cannot express that (it would lock everything down to the granted
|
||||
// roots, closing the CLI binary, its libs and `~/.<cli>` home — the very
|
||||
// "failed to open terminal" symptom). So under an Allow fallback we enforce
|
||||
// nothing: the policy's only teeth are explicit Denies, which are deny-islands
|
||||
// under an allow-all that an additive sandbox cannot carve out — they remain
|
||||
// advisory via the LP3 projection. This is a property of the allowlist
|
||||
// mechanism, hence it lives in the adapter, not the pure plan.
|
||||
if plan.default_posture == Posture::Allow {
|
||||
return Ok(SandboxStatus::Enforced);
|
||||
}
|
||||
|
||||
// Handle a right class only if the plan posed a grant of that class, so an
|
||||
// unposed class stays globally unrestricted (per-access-class autonomy).
|
||||
let mut handled = BitFlags::<AccessFs>::empty();
|
||||
for grant in &plan.allowed {
|
||||
if grant.access.contains(PathAccess::RO) {
|
||||
handled |= AccessFs::from_read(abi);
|
||||
}
|
||||
if grant.access.contains(PathAccess::RW) {
|
||||
handled |= AccessFs::from_write(abi);
|
||||
}
|
||||
if grant.access.contains(PathAccess::EXEC) {
|
||||
handled |= AccessFs::Execute;
|
||||
}
|
||||
}
|
||||
|
||||
if handled.is_empty() {
|
||||
// No file restriction to apply (empty / bash-only plan): nothing for
|
||||
// Landlock to enforce. A `Deny` posture's teeth, if any, then live only
|
||||
// in the advisory LP3 projection — not this adapter's concern.
|
||||
return Ok(SandboxStatus::Enforced);
|
||||
}
|
||||
|
||||
let mut ruleset = Ruleset::default()
|
||||
.set_compatibility(CompatLevel::BestEffort)
|
||||
.handle_access(handled)
|
||||
.and_then(Ruleset::create)
|
||||
.map_err(|e| SandboxError::Ruleset(e.to_string()))?;
|
||||
|
||||
for grant in &plan.allowed {
|
||||
let access = grant_access(grant.access, abi) & handled;
|
||||
if access.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// `path_beneath_rules` silently skips a path it cannot open (a grant on
|
||||
// a not-yet-existing root just adds no rule — best-effort, never fatal).
|
||||
ruleset = ruleset
|
||||
.add_rules(path_beneath_rules([grant.abs_root.as_str()], access))
|
||||
.map_err(|e: ::landlock::RulesetError| SandboxError::Ruleset(e.to_string()))?;
|
||||
}
|
||||
|
||||
let status = ruleset
|
||||
.restrict_self()
|
||||
.map_err(|e| SandboxError::Ruleset(e.to_string()))?;
|
||||
|
||||
status_from_ruleset(status.ruleset, plan.default_posture)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps the kernel's [`RulesetStatus`] (after `restrict_self`) plus the plan's
|
||||
/// fallback [`Posture`] to the domain outcome. **Pure** (no I/O, no kernel): the
|
||||
/// security-critical decision — including the *fail-closed only under `Deny`*
|
||||
/// branch — lives here so it is unit-testable without a Landlock-incapable kernel.
|
||||
///
|
||||
/// - [`RulesetStatus::FullyEnforced`] ⇒ `Ok(`[`SandboxStatus::Enforced`]`)`
|
||||
/// - [`RulesetStatus::PartiallyEnforced`] ⇒ `Ok(`[`SandboxStatus::Degraded`]`)`
|
||||
/// - [`RulesetStatus::NotEnforced`] + [`Posture::Deny`] ⇒
|
||||
/// `Err(`[`SandboxError::KernelTooOld`]`)` (fail-closed)
|
||||
/// - [`RulesetStatus::NotEnforced`] + any other posture ⇒
|
||||
/// `Ok(`[`SandboxStatus::Unsupported`]`)` (fail-open + warning)
|
||||
fn status_from_ruleset(
|
||||
status: RulesetStatus,
|
||||
posture: Posture,
|
||||
) -> Result<SandboxStatus, SandboxError> {
|
||||
match status {
|
||||
RulesetStatus::FullyEnforced => Ok(SandboxStatus::Enforced),
|
||||
RulesetStatus::PartiallyEnforced => Ok(SandboxStatus::Degraded(
|
||||
"landlock ABI compatibility reduced the enforced access rights (best-effort)"
|
||||
.to_owned(),
|
||||
)),
|
||||
RulesetStatus::NotEnforced => {
|
||||
if posture == Posture::Deny {
|
||||
Err(SandboxError::KernelTooOld(
|
||||
"landlock unavailable on this kernel but the policy posture is Deny \
|
||||
(fail-closed)"
|
||||
.to_owned(),
|
||||
))
|
||||
} else {
|
||||
Ok(SandboxStatus::Unsupported)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::sandbox::{PathGrant, SandboxPlan};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A unique temp dir for one test (no external tempfile dep).
|
||||
fn fresh_dir(tag: &str) -> PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let p = std::env::temp_dir().join(format!("idea-landlock-{tag}-{nanos}"));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
/// Real-kernel integration test: a write-only plan that grants RW on one dir
|
||||
/// must let writes there succeed and block writes elsewhere with EACCES, while
|
||||
/// leaving reads unrestricted. Runs the enforcement on a dedicated thread (the
|
||||
/// restriction is irreversible for that thread) and **skips** if the running
|
||||
/// kernel has no Landlock (CI / old kernels), like the SSH/WSL gated tests.
|
||||
#[test]
|
||||
fn landlock_write_only_plan_fences_writes_to_the_grant() {
|
||||
let allowed = fresh_dir("allowed");
|
||||
let denied = fresh_dir("denied");
|
||||
let allowed_for_thread = allowed.clone();
|
||||
let denied_for_thread = denied.clone();
|
||||
|
||||
let plan = SandboxPlan {
|
||||
allowed: vec![PathGrant {
|
||||
abs_root: allowed.to_string_lossy().into_owned(),
|
||||
access: PathAccess::RW,
|
||||
}],
|
||||
default_posture: Posture::Ask,
|
||||
};
|
||||
|
||||
let outcome = std::thread::spawn(move || {
|
||||
let status = LandlockSandbox::new()
|
||||
.enforce(&plan)
|
||||
.expect("enforce must not error under an Ask posture");
|
||||
// The thread is now sandboxed: try a write inside and outside the grant.
|
||||
let ok_write = std::fs::write(allowed_for_thread.join("inside.txt"), b"hi");
|
||||
let ko_write = std::fs::write(denied_for_thread.join("outside.txt"), b"nope");
|
||||
(status, ok_write, ko_write.map_err(|e| e.kind()))
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
|
||||
let (status, ok_write, ko_write) = outcome;
|
||||
|
||||
if status == SandboxStatus::Unsupported {
|
||||
eprintln!("skipping: Landlock not available on this kernel");
|
||||
// Clean up best-effort and bail (no enforcement happened).
|
||||
let _ = std::fs::remove_dir_all(&allowed);
|
||||
let _ = std::fs::remove_dir_all(&denied);
|
||||
return;
|
||||
}
|
||||
|
||||
assert!(
|
||||
matches!(status, SandboxStatus::Enforced | SandboxStatus::Degraded(_)),
|
||||
"expected an enforced/degraded sandbox, got {status:?}"
|
||||
);
|
||||
assert!(
|
||||
ok_write.is_ok(),
|
||||
"write inside the granted root must succeed, got {ok_write:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
ko_write,
|
||||
Err(std::io::ErrorKind::PermissionDenied),
|
||||
"write outside the granted root must be blocked (EACCES)"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&allowed);
|
||||
let _ = std::fs::remove_dir_all(&denied);
|
||||
}
|
||||
|
||||
/// An empty plan (no file grant) has nothing to enforce ⇒ `Enforced`, no error.
|
||||
#[test]
|
||||
fn empty_plan_is_a_noop_enforced() {
|
||||
let plan = SandboxPlan {
|
||||
allowed: vec![],
|
||||
default_posture: Posture::Ask,
|
||||
};
|
||||
// Run on a throwaway thread to avoid restricting the test runner thread.
|
||||
let status = std::thread::spawn(move || LandlockSandbox::new().enforce(&plan))
|
||||
.join()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(status, SandboxStatus::Enforced);
|
||||
}
|
||||
|
||||
/// **Bash-only / empty plan under a `Deny` posture.** A bash-only policy compiles
|
||||
/// (LP4-0) to an empty `allowed`; even combined with `Posture::Deny` the adapter
|
||||
/// has no file right to *handle*, so it must early-return `Enforced` WITHOUT
|
||||
/// restricting anything — and crucially WITHOUT `KernelTooOld`: the fail-closed
|
||||
/// branch is only ever reached once a class was actually handled.
|
||||
#[test]
|
||||
fn empty_plan_under_deny_posture_is_enforced_without_restriction() {
|
||||
let plan = SandboxPlan {
|
||||
allowed: vec![],
|
||||
default_posture: Posture::Deny,
|
||||
};
|
||||
let status = std::thread::spawn(move || LandlockSandbox::new().enforce(&plan))
|
||||
.join()
|
||||
.unwrap()
|
||||
.expect("an empty plan never fails closed (nothing handled)");
|
||||
assert_eq!(status, SandboxStatus::Enforced);
|
||||
}
|
||||
|
||||
/// **Allow fallback ⇒ no OS restriction even with grants posed.** Setting the
|
||||
/// policy to allow-by-default (and, in the UI, every option to Allow) emits RO|RW
|
||||
/// grants scoped to the project root. An allowlist sandbox would then close every
|
||||
/// read/write OUTSIDE that root — locking out the CLI binary, its libs and home,
|
||||
/// the exact "failed to open terminal" bug. Under `Posture::Allow` the adapter must
|
||||
/// instead enforce nothing: from the (would-be) sandboxed thread, a read AND a
|
||||
/// write OUTSIDE the only grant must both still succeed.
|
||||
#[test]
|
||||
fn allow_fallback_does_not_restrict_even_with_grants() {
|
||||
let granted = fresh_dir("allow-granted");
|
||||
let outside = fresh_dir("allow-outside");
|
||||
std::fs::write(outside.join("pre.txt"), b"pre").unwrap();
|
||||
let outside_t = outside.clone();
|
||||
|
||||
let plan = SandboxPlan {
|
||||
allowed: vec![PathGrant {
|
||||
abs_root: granted.to_string_lossy().into_owned(),
|
||||
access: PathAccess::RO.union(PathAccess::RW),
|
||||
}],
|
||||
default_posture: Posture::Allow,
|
||||
};
|
||||
|
||||
let (status, read_outside, write_outside) = std::thread::spawn(move || {
|
||||
let status = LandlockSandbox::new()
|
||||
.enforce(&plan)
|
||||
.expect("an Allow fallback never fails closed");
|
||||
let read = std::fs::read(outside_t.join("pre.txt"));
|
||||
let write =
|
||||
std::fs::write(outside_t.join("new.txt"), b"x").map_err(|e| e.kind());
|
||||
(status, read, write)
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
SandboxStatus::Enforced,
|
||||
"Allow fallback is a no-op enforcement, not an error or a lock-down"
|
||||
);
|
||||
assert!(
|
||||
read_outside.is_ok(),
|
||||
"Allow fallback must keep reads outside the grant open, got {read_outside:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
write_outside,
|
||||
Ok(()),
|
||||
"Allow fallback must keep writes outside the grant open"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&granted);
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
}
|
||||
|
||||
/// **CRITICAL SAFETY PROPERTY — IdeA is never sandboxed.** The adapter restricts
|
||||
/// the *current thread*; the launch path runs it on a throwaway thread. This test
|
||||
/// proves the Landlock domain is confined to that throwaway thread and does **not**
|
||||
/// leak onto the thread that spawned it (which simulates the IdeA process): after
|
||||
/// the child has sandboxed itself, the parent thread must still read/write OUTSIDE
|
||||
/// the plan's roots. A leak here would be catastrophic (IdeA itself locked down).
|
||||
#[test]
|
||||
fn enforcement_is_confined_to_the_enforcing_thread_idea_is_never_sandboxed() {
|
||||
let granted = fresh_dir("safety-granted");
|
||||
let outside = fresh_dir("safety-outside");
|
||||
let granted_t = granted.clone();
|
||||
let outside_t = outside.clone();
|
||||
|
||||
let plan = SandboxPlan {
|
||||
allowed: vec![PathGrant {
|
||||
abs_root: granted.to_string_lossy().into_owned(),
|
||||
access: PathAccess::RW,
|
||||
}],
|
||||
default_posture: Posture::Ask,
|
||||
};
|
||||
|
||||
// Enforce on a dedicated thread, exactly as the launch path does, and report
|
||||
// whether the sandbox was actually live there (write outside must be blocked).
|
||||
let (status, child_outside_write) = std::thread::spawn(move || {
|
||||
let status = LandlockSandbox::new()
|
||||
.enforce(&plan)
|
||||
.expect("enforce must not error under an Ask posture");
|
||||
let _ = granted_t; // (writable in the child; not asserted here)
|
||||
let child_outside =
|
||||
std::fs::write(outside_t.join("from-child.txt"), b"x").map_err(|e| e.kind());
|
||||
(status, child_outside)
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
|
||||
if status == SandboxStatus::Unsupported {
|
||||
eprintln!("skipping: Landlock not available on this kernel");
|
||||
let _ = std::fs::remove_dir_all(&granted);
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanity: the sandbox really was active on the throwaway thread.
|
||||
assert_eq!(
|
||||
child_outside_write,
|
||||
Err(std::io::ErrorKind::PermissionDenied),
|
||||
"the throwaway thread must be sandboxed (write outside the grant blocked)"
|
||||
);
|
||||
|
||||
// THE GUARANTEE: the parent thread (≈ the IdeA process) is not a descendant of
|
||||
// the throwaway thread, so the domain must not have leaked onto it.
|
||||
let idea_write = std::fs::write(outside.join("from-idea.txt"), b"idea");
|
||||
assert!(
|
||||
idea_write.is_ok(),
|
||||
"SAFETY VIOLATION: IdeA's own thread was sandboxed — write outside the plan \
|
||||
failed ({idea_write:?}); the Landlock domain leaked off the throwaway thread"
|
||||
);
|
||||
let idea_read = std::fs::read(outside.join("from-idea.txt"));
|
||||
assert!(
|
||||
idea_read.is_ok(),
|
||||
"IdeA must still read outside the plan roots, got {idea_read:?}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&granted);
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
}
|
||||
|
||||
/// **READ (RO) dimension.** A plan with a single RO grant: from the sandboxed
|
||||
/// child, reading a file UNDER the granted root succeeds, while reading a file
|
||||
/// OUTSIDE is denied (EACCES). This also documents the behaviour DevBackend
|
||||
/// flagged: as soon as *any* RO grant is posed the read class is handled, so
|
||||
/// **every** read outside the granted roots is closed — a realistic read-restricted
|
||||
/// plan must therefore include the needed system paths (an LP4-2 concern).
|
||||
#[test]
|
||||
fn read_only_plan_fences_reads_to_the_grant() {
|
||||
let granted = fresh_dir("ro-granted");
|
||||
let outside = fresh_dir("ro-outside");
|
||||
// Files must exist before enforcement (creation is a write).
|
||||
std::fs::write(granted.join("in.txt"), b"inside").unwrap();
|
||||
std::fs::write(outside.join("out.txt"), b"outside").unwrap();
|
||||
let granted_t = granted.clone();
|
||||
let outside_t = outside.clone();
|
||||
|
||||
let plan = SandboxPlan {
|
||||
allowed: vec![PathGrant {
|
||||
abs_root: granted.to_string_lossy().into_owned(),
|
||||
access: PathAccess::RO,
|
||||
}],
|
||||
default_posture: Posture::Ask,
|
||||
};
|
||||
|
||||
let (status, ok_read, ko_read) = std::thread::spawn(move || {
|
||||
let status = LandlockSandbox::new()
|
||||
.enforce(&plan)
|
||||
.expect("enforce must not error under an Ask posture");
|
||||
let ok = std::fs::read(granted_t.join("in.txt"));
|
||||
let ko = std::fs::read(outside_t.join("out.txt")).map_err(|e| e.kind());
|
||||
(status, ok, ko)
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
|
||||
if status == SandboxStatus::Unsupported {
|
||||
eprintln!("skipping: Landlock not available on this kernel");
|
||||
let _ = std::fs::remove_dir_all(&granted);
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
return;
|
||||
}
|
||||
|
||||
assert!(
|
||||
matches!(status, SandboxStatus::Enforced | SandboxStatus::Degraded(_)),
|
||||
"expected an enforced/degraded sandbox, got {status:?}"
|
||||
);
|
||||
assert!(
|
||||
ok_read.is_ok(),
|
||||
"read inside the granted RO root must succeed, got {ok_read:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
ko_read,
|
||||
Err(std::io::ErrorKind::PermissionDenied),
|
||||
"once a RO grant is posed, reads outside the granted roots are closed (EACCES)"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&granted);
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
}
|
||||
}
|
||||
82
crates/infrastructure/src/sandbox/mod.rs
Normal file
82
crates/infrastructure/src/sandbox/mod.rs
Normal file
@ -0,0 +1,82 @@
|
||||
//! OS-sandbox adapters (lot LP4-1) — concrete [`domain::sandbox::SandboxEnforcer`]
|
||||
//! implementations and the cfg-selected default constructor.
|
||||
//!
|
||||
//! - [`LandlockSandbox`] (Linux only) enforces a [`domain::sandbox::SandboxPlan`]
|
||||
//! via the kernel Landlock LSM.
|
||||
//! - [`NoopSandbox`] (everywhere) enforces nothing — the non-Linux / fallback path.
|
||||
//!
|
||||
//! [`default_enforcer`] returns the right one for the build target as a
|
||||
//! `Arc<dyn SandboxEnforcer>`, ready for the composition root (LP4-3) to inject.
|
||||
//! Nothing here is wired into the launch path yet (that is LP4-2): until then the
|
||||
//! enforcer stays unused and `SpawnSpec.sandbox` stays `None`, so there is zero
|
||||
//! runtime behaviour change.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::sandbox::SandboxEnforcer;
|
||||
|
||||
mod noop;
|
||||
pub use noop::NoopSandbox;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod landlock;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use landlock::LandlockSandbox;
|
||||
|
||||
/// The default OS-sandbox enforcer for the current build target: [`LandlockSandbox`]
|
||||
/// on Linux, [`NoopSandbox`] everywhere else.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[must_use]
|
||||
pub fn default_enforcer() -> Arc<dyn SandboxEnforcer> {
|
||||
Arc::new(LandlockSandbox::new())
|
||||
}
|
||||
|
||||
/// The default OS-sandbox enforcer for the current build target: [`NoopSandbox`]
|
||||
/// on non-Linux platforms (no OS sandbox wired yet).
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[must_use]
|
||||
pub fn default_enforcer() -> Arc<dyn SandboxEnforcer> {
|
||||
Arc::new(NoopSandbox::new())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::sandbox::{SandboxKind, SandboxPlan, SandboxStatus};
|
||||
use domain::Posture;
|
||||
|
||||
#[test]
|
||||
fn noop_enforcer_is_unsupported_and_never_errors() {
|
||||
use domain::sandbox::PathGrant;
|
||||
|
||||
let noop = NoopSandbox::new();
|
||||
assert_eq!(noop.kind(), SandboxKind::Unsupported);
|
||||
|
||||
// Across every posture — and even with grants present — the no-op enforces
|
||||
// nothing and never fails closed: there is no OS sandbox here to fail closed
|
||||
// against (the Deny posture's teeth live only in the advisory LP3 projection).
|
||||
for posture in [Posture::Allow, Posture::Ask, Posture::Deny] {
|
||||
let plan = SandboxPlan {
|
||||
allowed: vec![PathGrant {
|
||||
abs_root: "/whatever".to_owned(),
|
||||
access: domain::sandbox::PathAccess::RW,
|
||||
}],
|
||||
default_posture: posture,
|
||||
};
|
||||
assert_eq!(
|
||||
noop.enforce(&plan).unwrap(),
|
||||
SandboxStatus::Unsupported,
|
||||
"no-op must return Unsupported (never Err) under posture {posture:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_enforcer_matches_the_build_target() {
|
||||
let enforcer = default_enforcer();
|
||||
#[cfg(target_os = "linux")]
|
||||
assert_eq!(enforcer.kind(), SandboxKind::Landlock);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
assert_eq!(enforcer.kind(), SandboxKind::Unsupported);
|
||||
}
|
||||
}
|
||||
33
crates/infrastructure/src/sandbox/noop.rs
Normal file
33
crates/infrastructure/src/sandbox/noop.rs
Normal file
@ -0,0 +1,33 @@
|
||||
//! [`NoopSandbox`] — the no-op [`SandboxEnforcer`] for platforms without an OS
|
||||
//! sandbox (Windows, macOS) and the universal fallback (lot LP4-1).
|
||||
//!
|
||||
//! It compiles **everywhere** and never restricts anything: `enforce` always
|
||||
//! returns [`SandboxStatus::Unsupported`] and never errors. The caller keeps the
|
||||
//! advisory LP3 projection as its only guard.
|
||||
|
||||
use domain::sandbox::{SandboxEnforcer, SandboxError, SandboxKind, SandboxPlan, SandboxStatus};
|
||||
|
||||
/// A [`SandboxEnforcer`] that enforces nothing (non-Linux / fallback).
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct NoopSandbox;
|
||||
|
||||
impl NoopSandbox {
|
||||
/// Builds the no-op enforcer.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl SandboxEnforcer for NoopSandbox {
|
||||
fn kind(&self) -> SandboxKind {
|
||||
SandboxKind::Unsupported
|
||||
}
|
||||
|
||||
fn enforce(&self, _plan: &SandboxPlan) -> Result<SandboxStatus, SandboxError> {
|
||||
// No OS sandbox here: nothing enforced, never an error (the fail-closed
|
||||
// posture handling is a Landlock-only concern; on an unsupported platform
|
||||
// there is nothing to fail closed *against*).
|
||||
Ok(SandboxStatus::Unsupported)
|
||||
}
|
||||
}
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -13,12 +13,13 @@
|
||||
//! (2026-06-09) ; **seule [`parse_event`] (et la composition de la commande) porte
|
||||
//! le format**, pas la machinerie ni le reste de l'adapter.
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
|
||||
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
||||
use domain::SessionId;
|
||||
|
||||
use super::process::{run_turn, SpawnLine};
|
||||
@ -50,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}`
|
||||
@ -84,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")
|
||||
@ -138,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
|
||||
@ -154,24 +228,36 @@ pub struct ClaudeSdkSession {
|
||||
cwd: String,
|
||||
/// Id de conversation **du moteur** Claude, capté au premier tour, `None` avant.
|
||||
conversation_id: Mutex<Option<String>>,
|
||||
/// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque
|
||||
/// [`SpawnLine`]. `None` ⇒ aucun sandboxing (drain async natif).
|
||||
sandbox: Option<SandboxPlan>,
|
||||
/// Enforcer OS **par instance** (lot LP4-4), passé à [`run_turn`]. `None` ⇒ pas
|
||||
/// de sandboxing même si un plan est présent (cohérent avec le chemin PTY).
|
||||
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||
}
|
||||
|
||||
impl ClaudeSdkSession {
|
||||
/// Construit l'adapter. `command` est le binaire à lancer (injecté ⇒ testable
|
||||
/// avec un fake CLI) ; `seed_conversation_id` amorce la reprise (`SessionPlan::
|
||||
/// Resume` côté factory) ou reste `None` pour une conversation neuve.
|
||||
/// Resume` côté factory) ou reste `None` pour une conversation neuve. `sandbox` /
|
||||
/// `sandbox_enforcer` (lot LP4-4) pilotent le sandboxing OS du tour ; `None`/`None`
|
||||
/// ⇒ chemin natif inchangé.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
id: SessionId,
|
||||
command: impl Into<String>,
|
||||
cwd: impl Into<String>,
|
||||
seed_conversation_id: Option<String>,
|
||||
sandbox: Option<SandboxPlan>,
|
||||
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
command: command.into(),
|
||||
cwd: cwd.into(),
|
||||
conversation_id: Mutex::new(seed_conversation_id),
|
||||
sandbox,
|
||||
sandbox_enforcer,
|
||||
}
|
||||
}
|
||||
|
||||
@ -201,6 +287,7 @@ impl ClaudeSdkSession {
|
||||
cwd: self.cwd.clone(),
|
||||
env: Vec::new(),
|
||||
stdin: None,
|
||||
sandbox: self.sandbox.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -217,7 +304,7 @@ impl AgentSession for ClaudeSdkSession {
|
||||
|
||||
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||
let spec = self.build_spawn_line(prompt);
|
||||
let raw_lines = run_turn(&spec, None).await?;
|
||||
let raw_lines = run_turn(&spec, None, self.sandbox_enforcer.as_ref()).await?;
|
||||
|
||||
let mut events = Vec::new();
|
||||
let mut captured_id = None;
|
||||
@ -227,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);
|
||||
|
||||
@ -10,12 +10,13 @@
|
||||
//! le format. **Seule [`parse_event`] (et la composition de la commande) porte le
|
||||
//! format Codex** ; la machinerie reste inchangée.
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
|
||||
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
||||
use domain::SessionId;
|
||||
|
||||
use super::process::{run_turn, SpawnLine};
|
||||
@ -123,23 +124,35 @@ pub struct CodexExecSession {
|
||||
cwd: String,
|
||||
/// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant.
|
||||
conversation_id: Mutex<Option<String>>,
|
||||
/// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque
|
||||
/// [`SpawnLine`]. `None` ⇒ aucun sandboxing (drain async natif).
|
||||
sandbox: Option<SandboxPlan>,
|
||||
/// Enforcer OS **par instance** (lot LP4-4), passé à [`run_turn`]. `None` ⇒ pas
|
||||
/// de sandboxing même si un plan est présent (cohérent avec le chemin PTY).
|
||||
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||
}
|
||||
|
||||
impl CodexExecSession {
|
||||
/// Construit l'adapter. `command` est injecté (⇒ testable avec un fake CLI) ;
|
||||
/// `seed_conversation_id` amorce la reprise ou reste `None` (conversation neuve).
|
||||
/// `sandbox` / `sandbox_enforcer` (lot LP4-4) pilotent le sandboxing OS ;
|
||||
/// `None`/`None` ⇒ chemin natif inchangé.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
id: SessionId,
|
||||
command: impl Into<String>,
|
||||
cwd: impl Into<String>,
|
||||
seed_conversation_id: Option<String>,
|
||||
sandbox: Option<SandboxPlan>,
|
||||
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
command: command.into(),
|
||||
cwd: cwd.into(),
|
||||
conversation_id: Mutex::new(seed_conversation_id),
|
||||
sandbox,
|
||||
sandbox_enforcer,
|
||||
}
|
||||
}
|
||||
|
||||
@ -176,6 +189,7 @@ impl CodexExecSession {
|
||||
cwd: self.cwd.clone(),
|
||||
env: Vec::new(),
|
||||
stdin: None,
|
||||
sandbox: self.sandbox.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -192,7 +206,7 @@ impl AgentSession for CodexExecSession {
|
||||
|
||||
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||
let spec = self.build_spawn_line(prompt);
|
||||
let raw_lines = run_turn(&spec, None).await?;
|
||||
let raw_lines = run_turn(&spec, None, self.sandbox_enforcer.as_ref()).await?;
|
||||
|
||||
let mut events = Vec::new();
|
||||
let mut captured_id = None;
|
||||
|
||||
@ -84,6 +84,7 @@ impl FakeCli {
|
||||
cwd: "/".to_owned(),
|
||||
env: Vec::new(),
|
||||
stdin: None,
|
||||
sandbox: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -209,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!(
|
||||
@ -217,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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@ use domain::ports::{
|
||||
};
|
||||
use domain::profile::{AgentProfile, StructuredAdapter};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
||||
use domain::SessionId;
|
||||
|
||||
use super::claude::ClaudeSdkSession;
|
||||
@ -23,17 +24,36 @@ use super::codex::CodexExecSession;
|
||||
|
||||
/// Fabrique infra des sessions structurées, sélectionnée par le profil.
|
||||
///
|
||||
/// Sans état : elle instancie l'adapter au vol depuis le profil (le binaire à
|
||||
/// Quasi sans état : elle instancie l'adapter au vol depuis le profil (le binaire à
|
||||
/// lancer = `profile.command`), de sorte qu'un seul exemplaire injecté au
|
||||
/// composition root sert tous les agents (jumeau de `CliAgentRuntime`).
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct StructuredSessionFactory;
|
||||
/// composition root sert tous les agents (jumeau de `CliAgentRuntime`). Le seul état
|
||||
/// porté est l'**enforcer de sandbox OS** optionnel (lot LP4-4), injecté **par
|
||||
/// instance** au composition root (jumeau de `PortablePtyAdapter::with_sandbox_enforcer`)
|
||||
/// et apparié au plan **par lancement** dans [`start`](AgentSessionFactory::start).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct StructuredSessionFactory {
|
||||
/// Enforcer OS optionnel passé aux adapters structurés. `None` ⇒ aucun
|
||||
/// sandboxing (chemin natif inchangé, zéro régression).
|
||||
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||
}
|
||||
|
||||
impl StructuredSessionFactory {
|
||||
/// Construit la fabrique.
|
||||
/// Construit la fabrique (sans enforcer : chemin natif).
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sandbox_enforcer: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder additif : câble un [`SandboxEnforcer`] OS (lot LP4-4). Jumeau exact de
|
||||
/// [`crate::PortablePtyAdapter::with_sandbox_enforcer`]. Avec lui, tout lancement
|
||||
/// structuré dont le plan (`SpawnSpec.sandbox`) est `Some` voit ce plan appliqué
|
||||
/// sur l'enfant. Sans lui (défaut), aucun tour n'est sandboxé.
|
||||
#[must_use]
|
||||
pub fn with_sandbox_enforcer(mut self, enforcer: Arc<dyn SandboxEnforcer>) -> Self {
|
||||
self.sandbox_enforcer = Some(enforcer);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,6 +82,7 @@ impl AgentSessionFactory for StructuredSessionFactory {
|
||||
_ctx: &PreparedContext,
|
||||
cwd: &ProjectPath,
|
||||
session: &SessionPlan,
|
||||
sandbox: Option<&SandboxPlan>,
|
||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||
let adapter = profile.structured_adapter.ok_or_else(|| {
|
||||
AgentSessionError::Start(format!(
|
||||
@ -75,14 +96,25 @@ impl AgentSessionFactory for StructuredSessionFactory {
|
||||
let cwd = cwd.as_str().to_owned();
|
||||
let seed = seed_conversation_id(session);
|
||||
|
||||
// Appariement (lot LP4-4) : plan **par lancement** (param) + enforcer **par
|
||||
// instance** (champ). Tous deux sont relayés à l'adapter, qui remplira
|
||||
// `SpawnLine.sandbox` et passera l'enforcer à `run_turn`. `plan == None` ⇒
|
||||
// l'adapter reste sur le drain async natif.
|
||||
let plan = sandbox.cloned();
|
||||
let enforcer = self.sandbox_enforcer.clone();
|
||||
|
||||
// NOTE : le contexte (`_ctx`) est injecté par `LaunchAgent` (D3) via le
|
||||
// convention file dans le run dir *avant* l'appel à la factory (le `.md` est
|
||||
// déjà écrit) ; l'adapter n'a donc qu'à lancer la CLI dans ce cwd. Aucune
|
||||
// injection supplémentaire n'incombe ici en mode structuré (la CLI lit son
|
||||
// fichier conventionnel — CLAUDE.md / AGENTS.md — depuis le cwd).
|
||||
let session: Arc<dyn AgentSession> = match adapter {
|
||||
StructuredAdapter::Claude => Arc::new(ClaudeSdkSession::new(id, command, cwd, seed)),
|
||||
StructuredAdapter::Codex => Arc::new(CodexExecSession::new(id, command, cwd, seed)),
|
||||
StructuredAdapter::Claude => {
|
||||
Arc::new(ClaudeSdkSession::new(id, command, cwd, seed, plan, enforcer))
|
||||
}
|
||||
StructuredAdapter::Codex => {
|
||||
Arc::new(CodexExecSession::new(id, command, cwd, seed, plan, enforcer))
|
||||
}
|
||||
};
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
@ -25,6 +25,11 @@ pub mod conformance;
|
||||
pub mod factory;
|
||||
pub mod process;
|
||||
|
||||
/// Tests bout-en-bout de l'enforcement Landlock sur le chemin structuré (lot LP4-4),
|
||||
/// Linux uniquement (pair du module `pty::sandbox_e2e_tests` du lot LP4-3).
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod sandbox_e2e;
|
||||
|
||||
pub use claude::ClaudeSdkSession;
|
||||
pub use codex::CodexExecSession;
|
||||
pub use conformance::FakeCli;
|
||||
@ -84,7 +89,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn run_turn_drains_every_line_in_order() {
|
||||
let fake = FakeCli::printing(&["ligne-1", "ligne-2", "ligne-3"]);
|
||||
let lines = run_turn(&fake.spawn_line(), None)
|
||||
let lines = run_turn(&fake.spawn_line(), None, None)
|
||||
.await
|
||||
.expect("run_turn réussit");
|
||||
assert_eq!(lines, vec!["ligne-1", "ligne-2", "ligne-3"]);
|
||||
@ -98,8 +103,9 @@ mod tests {
|
||||
cwd: "/".to_owned(),
|
||||
env: Vec::new(),
|
||||
stdin: None,
|
||||
sandbox: None,
|
||||
};
|
||||
let err = run_turn(&spec, None).await.expect_err("doit échouer");
|
||||
let err = run_turn(&spec, None, None).await.expect_err("doit échouer");
|
||||
assert!(matches!(err, AgentSessionError::Start(_)), "vu: {err:?}");
|
||||
}
|
||||
|
||||
@ -116,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"));
|
||||
}
|
||||
|
||||
@ -284,6 +296,8 @@ mod tests {
|
||||
fake.command(),
|
||||
"/",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
));
|
||||
assert_agent_session_contract(session, "claude-conv-1", "réponse Claude").await;
|
||||
}
|
||||
@ -296,6 +310,8 @@ mod tests {
|
||||
fake.command(),
|
||||
"/",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
));
|
||||
assert_agent_session_contract(session, "codex-conv-1", "réponse Codex").await;
|
||||
}
|
||||
@ -305,7 +321,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn stream_is_closed_after_final() {
|
||||
let fake = FakeCli::printing(&claude_script());
|
||||
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None);
|
||||
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
|
||||
let stream = session.send("x").await.expect("send ok");
|
||||
let events: Vec<_> = stream.collect();
|
||||
let after_final = events
|
||||
@ -323,7 +339,7 @@ mod tests {
|
||||
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
|
||||
"{ ceci n'est pas du json",
|
||||
]);
|
||||
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None);
|
||||
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
|
||||
match session.send("x").await {
|
||||
Err(AgentSessionError::Decode(_)) => {}
|
||||
Err(other) => panic!("attendu Decode, vu: {other:?}"),
|
||||
@ -404,7 +420,7 @@ mod tests {
|
||||
// Claude : la session démarre et respecte le contrat via le fake CLI.
|
||||
let claude = structured_profile(StructuredAdapter::Claude, &fake.command());
|
||||
let session = factory
|
||||
.start(&claude, &prepared_ctx(), &cwd(), &SessionPlan::None)
|
||||
.start(&claude, &prepared_ctx(), &cwd(), &SessionPlan::None, None)
|
||||
.await
|
||||
.expect("start Claude ok");
|
||||
let content = drain_final(session.as_ref()).await;
|
||||
@ -414,7 +430,7 @@ mod tests {
|
||||
let fake_cx = FakeCli::printing(&codex_script());
|
||||
let codex = structured_profile(StructuredAdapter::Codex, &fake_cx.command());
|
||||
let session_cx = factory
|
||||
.start(&codex, &prepared_ctx(), &cwd(), &SessionPlan::None)
|
||||
.start(&codex, &prepared_ctx(), &cwd(), &SessionPlan::None, None)
|
||||
.await
|
||||
.expect("start Codex ok");
|
||||
let content_cx = drain_final(session_cx.as_ref()).await;
|
||||
@ -434,6 +450,7 @@ mod tests {
|
||||
&SessionPlan::Resume {
|
||||
conversation_id: "repris-42".to_owned(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("start resume ok");
|
||||
@ -525,8 +542,9 @@ mod tests {
|
||||
cwd: "/".to_owned(),
|
||||
env: Vec::new(),
|
||||
stdin: None,
|
||||
sandbox: None,
|
||||
};
|
||||
let err = run_turn(&spec, Some(Duration::from_millis(50)))
|
||||
let err = run_turn(&spec, Some(Duration::from_millis(50)), None)
|
||||
.await
|
||||
.expect_err("doit expirer");
|
||||
assert!(matches!(err, AgentSessionError::Timeout), "vu: {err:?}");
|
||||
@ -757,7 +775,7 @@ mod tests {
|
||||
r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"fin"}}"#,
|
||||
r#"{"type":"turn.completed","usage":{}}"#,
|
||||
]);
|
||||
let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None);
|
||||
let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
|
||||
let events: Vec<_> = s.send("x").await.expect("send").collect();
|
||||
let finals = events
|
||||
.iter()
|
||||
@ -785,7 +803,7 @@ mod tests {
|
||||
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
|
||||
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"a"}]}}"#,
|
||||
]);
|
||||
let s = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None);
|
||||
let s = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
|
||||
let events: Vec<_> = s.send("x").await.expect("send ok").collect();
|
||||
let finals = events
|
||||
.iter()
|
||||
@ -801,7 +819,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn run_turn_empty_output_is_ok() {
|
||||
let fake = FakeCli::printing(&[]);
|
||||
let lines = run_turn(&fake.spawn_line(), None).await.expect("ok");
|
||||
let lines = run_turn(&fake.spawn_line(), None, None).await.expect("ok");
|
||||
assert!(lines.is_empty());
|
||||
}
|
||||
|
||||
@ -811,7 +829,7 @@ mod tests {
|
||||
let fake = FakeCli::printing(&["pong"]);
|
||||
let mut spec = fake.spawn_line();
|
||||
spec.stdin = Some("ping".to_owned());
|
||||
let lines = run_turn(&spec, None).await.expect("ok");
|
||||
let lines = run_turn(&spec, None, None).await.expect("ok");
|
||||
assert_eq!(lines, vec!["pong"]);
|
||||
}
|
||||
|
||||
@ -872,8 +890,9 @@ mod tests {
|
||||
cwd: "/".to_owned(),
|
||||
env: Vec::new(),
|
||||
stdin: None,
|
||||
sandbox: None,
|
||||
};
|
||||
let err = run_turn(&spec, Some(Duration::from_millis(50)))
|
||||
let err = run_turn(&spec, Some(Duration::from_millis(50)), None)
|
||||
.await
|
||||
.expect_err("doit expirer");
|
||||
assert!(matches!(err, AgentSessionError::Timeout), "vu: {err:?}");
|
||||
@ -894,6 +913,8 @@ mod tests {
|
||||
cmd.clone(),
|
||||
"/",
|
||||
Some("resume-id".to_owned()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
// conversation_id amorcé avant tout tour.
|
||||
assert_eq!(session.conversation_id().as_deref(), Some("resume-id"));
|
||||
@ -938,6 +959,8 @@ mod tests {
|
||||
cmd.clone(),
|
||||
"/",
|
||||
Some("cx-id".to_owned()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let _ = session.send("vas-y").await.expect("send ok");
|
||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||
@ -960,7 +983,7 @@ mod tests {
|
||||
r#"{"type":"system","subtype":"init","session_id":"captured-1"}"#,
|
||||
r#"{"type":"result","subtype":"success","result":"r","session_id":"captured-1"}"#,
|
||||
]);
|
||||
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None);
|
||||
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
|
||||
assert_eq!(session.conversation_id(), None);
|
||||
let _ = session.send("t1").await.expect("t1");
|
||||
assert_eq!(session.conversation_id().as_deref(), Some("captured-1"));
|
||||
@ -999,6 +1022,7 @@ mod tests {
|
||||
&SessionPlan::Resume {
|
||||
conversation_id: "cx-resume".to_owned(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("start resume codex");
|
||||
@ -1021,6 +1045,7 @@ mod tests {
|
||||
&SessionPlan::Assign {
|
||||
conversation_id: "ignored-by-engine".to_owned(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("start assign");
|
||||
@ -1045,7 +1070,7 @@ mod tests {
|
||||
)
|
||||
.expect("profil valide");
|
||||
match factory
|
||||
.start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None)
|
||||
.start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None, None)
|
||||
.await
|
||||
{
|
||||
Err(AgentSessionError::Start(_)) => {}
|
||||
@ -1068,6 +1093,8 @@ mod tests {
|
||||
fake.command(),
|
||||
"/",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
));
|
||||
assert_agent_session_contract(session, "cx-0", "direct").await;
|
||||
}
|
||||
@ -1096,7 +1123,7 @@ mod tests {
|
||||
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"a"},{"type":"tool_use","name":"T"},{"type":"text","text":"b"}]},"session_id":"flow-1","parent_tool_use_id":null}"#,
|
||||
r#"{"type":"result","subtype":"success","is_error":false,"result":"final-ok","session_id":"flow-1","num_turns":1}"#,
|
||||
]);
|
||||
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None);
|
||||
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,
|
||||
@ -1133,7 +1160,7 @@ mod tests {
|
||||
r#"{"type":"system","subtype":"init","session_id":"new-1"}"#,
|
||||
r#"{"type":"result","subtype":"success","result":"r","session_id":"new-1"}"#,
|
||||
]);
|
||||
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None);
|
||||
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
|
||||
let _ = session.send("bonjour").await.expect("send ok");
|
||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||
let args: Vec<&str> = recorded.lines().collect();
|
||||
@ -1165,7 +1192,7 @@ mod tests {
|
||||
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
|
||||
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
||||
]);
|
||||
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None);
|
||||
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
|
||||
let _ = session.send("salut").await.expect("send ok");
|
||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||
let args: Vec<&str> = recorded.lines().collect();
|
||||
@ -1207,7 +1234,7 @@ mod tests {
|
||||
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
|
||||
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
||||
]);
|
||||
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None);
|
||||
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
|
||||
let _ = session.send("salut").await.expect("send ok");
|
||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||
let args: Vec<&str> = recorded.lines().collect();
|
||||
@ -1241,6 +1268,8 @@ mod tests {
|
||||
cmd.clone(),
|
||||
"/",
|
||||
Some("cx-id".to_owned()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let _ = session.send("vas-y").await.expect("send ok");
|
||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||
@ -1263,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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,12 +28,14 @@
|
||||
|
||||
use std::io;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
|
||||
use domain::ports::AgentSessionError;
|
||||
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
||||
|
||||
/// Une invocation orientée lignes : binaire + arguments + cwd + env + prompt à
|
||||
/// pousser sur stdin (le cas échéant). **Paramétrable par la commande** : c'est ce
|
||||
@ -51,6 +53,11 @@ pub struct SpawnLine {
|
||||
/// Contenu à écrire sur stdin du process (`None` ⇒ stdin fermé immédiatement).
|
||||
/// Sert au mode `--input-format stream-json` / au passage du prompt.
|
||||
pub stdin: Option<String>,
|
||||
/// Plan de sandbox OS à appliquer sur l'enfant (lot LP4-4). `None` ⇒ aucun
|
||||
/// sandboxing : `run_turn` emprunte le drain async tokio **inchangé** (invariant
|
||||
/// produit : rien posé ⇒ comportement natif). `Some` **et** un enforcer fourni à
|
||||
/// [`run_turn`] ⇒ le plan est appliqué sur l'enfant via [`drain_sandboxed`].
|
||||
pub sandbox: Option<SandboxPlan>,
|
||||
}
|
||||
|
||||
/// Lance `spec`, pousse `spec.stdin` sur l'entrée standard, **draine toutes les
|
||||
@ -61,14 +68,33 @@ pub struct SpawnLine {
|
||||
/// [`AgentSessionError::Timeout`] est retourné (la machinerie ne suppose jamais que
|
||||
/// l'appelant veut attendre indéfiniment). `None` ⇒ pas de borne.
|
||||
///
|
||||
/// Quand `spec.sandbox` porte un plan **et** qu'un `enforcer` est fourni (chemin
|
||||
/// Linux uniquement, lot LP4-4), le tour passe par [`run_turn_sandboxed`] : l'enfant
|
||||
/// est lancé sous le domaine Landlock. Sinon — et **partout** hors Linux — c'est le
|
||||
/// drain async tokio historique, strictement inchangé (zéro régression).
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AgentSessionError::Start`] si le process ne démarre pas (binaire introuvable) ;
|
||||
/// - [`AgentSessionError::Start`] si le process ne démarre pas (binaire introuvable,
|
||||
/// ou enforcement de sandbox impossible : fail-closed, **aucun** enfant ne tourne) ;
|
||||
/// - [`AgentSessionError::Io`] sur échec de lecture/écriture des pipes ;
|
||||
/// - [`AgentSessionError::Timeout`] si `timeout` expire.
|
||||
pub async fn run_turn(
|
||||
spec: &SpawnLine,
|
||||
timeout: Option<Duration>,
|
||||
enforcer: Option<&Arc<dyn SandboxEnforcer>>,
|
||||
) -> Result<Vec<String>, AgentSessionError> {
|
||||
// Chemin SANDBOXÉ (Linux + plan posé + enforcer câblé) : transpose la technique
|
||||
// du PTY (`spawn_command_sandboxed`) — enforce sur un thread jetable AVANT le fork,
|
||||
// l'enfant hérite le domaine via fork+exec.
|
||||
#[cfg(target_os = "linux")]
|
||||
if let (Some(plan), Some(enforcer)) = (spec.sandbox.as_ref(), enforcer) {
|
||||
return run_turn_sandboxed(spec, plan.clone(), Arc::clone(enforcer), timeout).await;
|
||||
}
|
||||
// Hors Linux : aucun sandboxing OS ⇒ on ignore l'enforcer (Noop de toute façon) et
|
||||
// on garde le drain async historique. `let _` évite l'avertissement « unused ».
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = enforcer;
|
||||
|
||||
match timeout {
|
||||
Some(dur) => match tokio::time::timeout(dur, drain(spec)).await {
|
||||
Ok(result) => result,
|
||||
@ -78,6 +104,182 @@ pub async fn run_turn(
|
||||
}
|
||||
}
|
||||
|
||||
/// Variante **sandboxée** du tour (lot LP4-4, Linux seulement). Le drain bloquant
|
||||
/// `std::process` est exécuté sur un **thread jetable** : on y restreint le thread
|
||||
/// (`enforcer.enforce(plan)`) AVANT le `spawn`, puis on lance l'enfant. La technique
|
||||
/// reproduit celle du PTY ([`crate::pty`]) : `pre_exec(enforce)` est INTERDIT (Landlock
|
||||
/// alloue ⇒ risque de deadlock `malloc` post-`fork` en process multithreadé), on
|
||||
/// s'appuie donc sur l'**héritage du domaine Landlock**.
|
||||
///
|
||||
/// ## Pourquoi pas de `pre_exec` (divergence assumée du cadrage)
|
||||
///
|
||||
/// Le cadrage proposait un `pre_exec` **vide** pour forcer `std` sur le chemin
|
||||
/// déterministe `fork`+`exec` (jamais `posix_spawn`). Or `pre_exec` est `unsafe`, et
|
||||
/// cette crate est `#![forbid(unsafe_code)]` (invariant non contournable localement).
|
||||
/// On s'en passe sans perte de garantie : `landlock_restrict_self` restreint le
|
||||
/// **thread courant et toute sa descendance**, héritage assuré par le noyau à travers
|
||||
/// `fork`/`clone`/`vfork` **et** préservé par `execve` — donc aussi via `posix_spawn`
|
||||
/// (qui est `clone`+`execve` sous le capot), car l'enforcement vit au niveau des
|
||||
/// *credentials* de la tâche, hors d'atteinte de l'espace utilisateur. Le PTY n'obtenait
|
||||
/// le `fork`+`exec` que comme **effet de bord** du `pre_exec` interne de `portable-pty` ;
|
||||
/// la garantie de sécurité, elle, ne repose que sur cet héritage. Le thread meurt avec
|
||||
/// sa restriction, les autres threads d'IdeA ne sont jamais touchés.
|
||||
///
|
||||
/// `enforce` fail-closed : un `Err` ⇒ [`AgentSessionError::Start`] et **aucun** enfant.
|
||||
///
|
||||
/// ## Timeout sous sandbox
|
||||
///
|
||||
/// Le thread bloquant n'est pas annulable « de l'extérieur ». On le réconcilie avec
|
||||
/// l'async par deux canaux oneshot : le thread renvoie un **killer**
|
||||
/// (`Arc<Mutex<Child>>`) juste après le spawn, puis son résultat à la fin. On pose
|
||||
/// `tokio::time::timeout` sur la réception du résultat ; à expiration on **tue
|
||||
/// l'enfant** via le killer ⇒ EOF côté stdout ⇒ le thread sort de sa boucle de drain,
|
||||
/// `wait()` (reap, pas de zombie) et se termine. On renvoie alors [`AgentSessionError::Timeout`].
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn run_turn_sandboxed(
|
||||
spec: &SpawnLine,
|
||||
plan: SandboxPlan,
|
||||
enforcer: Arc<dyn SandboxEnforcer>,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<Vec<String>, AgentSessionError> {
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
// Données possédées : rien n'emprunte le thread jetable.
|
||||
let command = spec.command.clone();
|
||||
let args = spec.args.clone();
|
||||
let cwd = spec.cwd.clone();
|
||||
let env = spec.env.clone();
|
||||
let stdin = spec.stdin.clone();
|
||||
|
||||
let (killer_tx, killer_rx) =
|
||||
tokio::sync::oneshot::channel::<Arc<StdMutex<std::process::Child>>>();
|
||||
let (done_tx, done_rx) =
|
||||
tokio::sync::oneshot::channel::<Result<Vec<String>, AgentSessionError>>();
|
||||
|
||||
// Thread JETABLE : sa restriction Landlock meurt avec lui.
|
||||
std::thread::spawn(move || {
|
||||
let result =
|
||||
drain_sandboxed(command, args, cwd, env, stdin, &enforcer, &plan, killer_tx);
|
||||
// Le récepteur peut avoir abandonné (timeout) : on ignore l'erreur d'envoi.
|
||||
let _ = done_tx.send(result);
|
||||
});
|
||||
|
||||
match timeout {
|
||||
Some(dur) => match tokio::time::timeout(dur, done_rx).await {
|
||||
// Le thread a fini dans les temps (succès ou erreur métier).
|
||||
Ok(Ok(result)) => result,
|
||||
// Sender lâché sans valeur (panique du thread) ⇒ I/O.
|
||||
Ok(Err(_canceled)) => Err(AgentSessionError::Io(
|
||||
"thread sandbox terminé sans résultat".to_owned(),
|
||||
)),
|
||||
// Expiration : tue l'enfant (⇒ EOF ⇒ le thread se termine et reap).
|
||||
Err(_elapsed) => {
|
||||
if let Ok(child) = killer_rx.await {
|
||||
if let Ok(mut c) = child.lock() {
|
||||
let _ = c.kill();
|
||||
}
|
||||
}
|
||||
Err(AgentSessionError::Timeout)
|
||||
}
|
||||
},
|
||||
None => match done_rx.await {
|
||||
Ok(result) => result,
|
||||
Err(_canceled) => Err(AgentSessionError::Io(
|
||||
"thread sandbox terminé sans résultat".to_owned(),
|
||||
)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain **bloquant et sandboxé** exécuté sur le thread jetable (lot LP4-4, Linux).
|
||||
///
|
||||
/// 1. `enforce(plan)` restreint CE thread (fail-closed) — la descendance hérite le
|
||||
/// domaine Landlock (cf. doc de [`run_turn_sandboxed`]) ;
|
||||
/// 2. `std::process::Command::spawn` (aucun `pre_exec` : `unsafe` interdit ici) ;
|
||||
/// 3. pousse le prompt sur stdin puis EOF ;
|
||||
/// 4. sort `stdout` du child **avant** de le partager : le drain lit sans tenir le
|
||||
/// `Mutex`, donc le killer (timeout) peut verrouiller et tuer à tout moment ;
|
||||
/// 5. draine ligne-à-ligne jusqu'à EOF ;
|
||||
/// 6. `wait()` (reap) — pas de zombie.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn drain_sandboxed(
|
||||
command: String,
|
||||
args: Vec<String>,
|
||||
cwd: String,
|
||||
env: Vec<(String, String)>,
|
||||
stdin_content: Option<String>,
|
||||
enforcer: &Arc<dyn SandboxEnforcer>,
|
||||
plan: &SandboxPlan,
|
||||
killer_tx: tokio::sync::oneshot::Sender<Arc<std::sync::Mutex<std::process::Child>>>,
|
||||
) -> Result<Vec<String>, AgentSessionError> {
|
||||
use std::io::{BufRead, BufReader as StdBufReader, Write as _};
|
||||
use std::process::{Command as StdCommand, Stdio};
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
// 1. Restreint CE thread AVANT tout fork (fail-closed : Err ⇒ aucun child ne tourne).
|
||||
enforcer
|
||||
.enforce(plan)
|
||||
.map_err(|e| AgentSessionError::Start(format!("sandbox enforcement failed: {e}")))?;
|
||||
|
||||
// 2. Commande std (≡ `drain` async, mais synchrone).
|
||||
let mut cmd = StdCommand::new(&command);
|
||||
cmd.args(&args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if cwd != "/" && !cwd.is_empty() {
|
||||
cmd.current_dir(&cwd);
|
||||
}
|
||||
for (key, value) in &env {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
// Pas de `pre_exec` : il serait `unsafe` (interdit dans cette crate). L'enfant
|
||||
// hérite de toute façon le domaine Landlock posé sur ce thread (cf. doc de
|
||||
// `run_turn_sandboxed`), que `std` emprunte `posix_spawn` ou `fork`+`exec`.
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| AgentSessionError::Start(format!("{command}: {e}")))?;
|
||||
|
||||
// 3. Pousse le prompt sur stdin puis le ferme (EOF). Erreur d'I/O ⇒ `Io`.
|
||||
if let Some(input) = &stdin_content {
|
||||
let mut si = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| AgentSessionError::Io("stdin pipe indisponible".to_owned()))?;
|
||||
si.write_all(input.as_bytes())
|
||||
.map_err(|e| AgentSessionError::Io(e.to_string()))?;
|
||||
drop(si);
|
||||
} else {
|
||||
drop(child.stdin.take());
|
||||
}
|
||||
|
||||
// 4. Sort stdout AVANT de partager le child (drain sans lock ⇒ killer libre).
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| AgentSessionError::Io("stdout pipe indisponible".to_owned()))?;
|
||||
let child = Arc::new(StdMutex::new(child));
|
||||
// Donne au côté async de quoi tuer l'enfant en cas de timeout.
|
||||
let _ = killer_tx.send(Arc::clone(&child));
|
||||
|
||||
// 5. Drain ligne-à-ligne jusqu'à EOF. Un kill côté async ferme stdout ⇒ EOF.
|
||||
let mut collected = Vec::new();
|
||||
for line in StdBufReader::new(stdout).lines() {
|
||||
match line {
|
||||
Ok(l) => collected.push(l),
|
||||
Err(e) => return Err(AgentSessionError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Reap (pas de zombie). Lock tenu brièvement.
|
||||
if let Ok(mut c) = child.lock() {
|
||||
let _ = c.wait();
|
||||
}
|
||||
Ok(collected)
|
||||
}
|
||||
|
||||
/// Cœur du drain : spawn → écriture stdin → lecture ligne-à-ligne → wait.
|
||||
async fn drain(spec: &SpawnLine) -> Result<Vec<String>, AgentSessionError> {
|
||||
let mut cmd = Command::new(&spec.command);
|
||||
|
||||
479
crates/infrastructure/src/session/sandbox_e2e.rs
Normal file
479
crates/infrastructure/src/session/sandbox_e2e.rs
Normal file
@ -0,0 +1,479 @@
|
||||
//! **Tests bout-en-bout de l'enforcement Landlock sur le chemin STRUCTURÉ** (lot
|
||||
//! LP4-4). Pair du module `sandbox_e2e_tests` ajouté dans [`crate::pty`] au lot
|
||||
//! LP4-3 (chemin PTY brut) ; ici la cible est la machinerie des sessions structurées
|
||||
//! (Claude/Codex en mode JSON) : `run_turn` → `run_turn_sandboxed` → `drain_sandboxed`
|
||||
//! (thread jetable restreint par `enforce()` AVANT le spawn std, puis héritage du
|
||||
//! domaine Landlock à travers `fork`/`exec`).
|
||||
//!
|
||||
//! **ZÉRO token** : aucun vrai `claude`/`codex` n'est lancé. On substitue soit `sh`
|
||||
//! (qui émet une ligne JSONL puis tente des écritures FS), soit le [`FakeCli`]
|
||||
//! scriptable existant. Tout est derrière `#[cfg(all(test, target_os = "linux"))]`
|
||||
//! et — pour les assertions qui exigent un fencing réel — derrière le garde
|
||||
//! [`landlock_is_enforced`] (skip propre sur un kernel sans Landlock, comme les
|
||||
//! tests de l'adapter et du PTY).
|
||||
//!
|
||||
//! ## Couverture des 7 invariants (Architecte)
|
||||
//!
|
||||
//! 1. **Parité** — `pty_structured_run_turn_enforces_plan_end_to_end`
|
||||
//! 2. **Companion négatif** — `structured_run_turn_without_plan_does_not_sandbox`
|
||||
//! 3. **Fail-closed** — `structured_run_turn_fail_closed_no_child_on_enforce_err`
|
||||
//! 4. **No-op par défaut** — `structured_run_turn_none_plan_is_native_path`
|
||||
//! 5. **Confinement/irréversibilité** — `structured_two_turns_disjoint_grants_are_confined`
|
||||
//! 6. **Timeout sous sandbox** — `structured_run_turn_timeout_under_sandbox`
|
||||
//! 7. **Resume préservé** — `structured_sandboxed_turn_preserves_conversation_id`
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use domain::sandbox::{
|
||||
PathAccess, PathGrant, SandboxEnforcer, SandboxError, SandboxKind, SandboxPlan, SandboxStatus,
|
||||
};
|
||||
use domain::Posture;
|
||||
|
||||
use super::process::{run_turn, SpawnLine};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers (calqués sur sandbox/landlock.rs et pty::sandbox_e2e_tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Un répertoire temporaire unique pour un test (zéro dépendance tempfile).
|
||||
fn fresh_dir(tag: &str) -> PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let p = std::env::temp_dir().join(format!("idea-struct-sbx-{tag}-{nanos}"));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
/// Sonde si le kernel courant **applique réellement** Landlock, exactement comme le
|
||||
/// chemin de lancement l'observerait : `enforce` d'un petit plan RW sur un **thread
|
||||
/// jetable** (la restriction est irréversible ⇒ jamais sur le thread de test) et
|
||||
/// renvoie `false` sur [`SandboxStatus::Unsupported`]. Permet le skip propre.
|
||||
fn landlock_is_enforced() -> bool {
|
||||
let probe = fresh_dir("probe");
|
||||
let plan = SandboxPlan {
|
||||
allowed: vec![PathGrant {
|
||||
abs_root: probe.to_string_lossy().into_owned(),
|
||||
access: PathAccess::RW,
|
||||
}],
|
||||
default_posture: Posture::Ask,
|
||||
};
|
||||
let status = std::thread::spawn(move || crate::sandbox::LandlockSandbox::new().enforce(&plan))
|
||||
.join()
|
||||
.unwrap()
|
||||
.expect("enforce ne doit pas échouer sous une posture Ask");
|
||||
let _ = std::fs::remove_dir_all(&probe);
|
||||
!matches!(status, SandboxStatus::Unsupported)
|
||||
}
|
||||
|
||||
/// Plan RW sur un seul répertoire, posture `Ask` (jamais fail-closed).
|
||||
fn rw_plan(root: &Path) -> SandboxPlan {
|
||||
SandboxPlan {
|
||||
allowed: vec![PathGrant {
|
||||
abs_root: root.to_string_lossy().into_owned(),
|
||||
access: PathAccess::RW,
|
||||
}],
|
||||
default_posture: Posture::Ask,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construit un [`SpawnLine`] sur `sh -c` qui **émet une ligne JSONL** sur stdout
|
||||
/// (preuve que le drain/parsing du chemin structuré survit au sandbox) puis tente
|
||||
/// d'écrire HORS grant **puis** DANS le grant. L'écriture hors-grant est tentée en
|
||||
/// premier : si le marqueur in-grant apparaît, la tentative hors-grant a déjà eu lieu.
|
||||
/// `json_line` ne doit contenir que des guillemets doubles (pas de quote simple).
|
||||
fn writing_spawn_line(
|
||||
json_line: &str,
|
||||
denied_marker: &Path,
|
||||
allowed_marker: &Path,
|
||||
plan: Option<SandboxPlan>,
|
||||
) -> SpawnLine {
|
||||
let script = format!(
|
||||
"printf '%s\\n' '{json}'; echo outside > '{denied}'; echo inside > '{allowed}'",
|
||||
json = json_line,
|
||||
denied = denied_marker.display(),
|
||||
allowed = allowed_marker.display(),
|
||||
);
|
||||
SpawnLine {
|
||||
command: "sh".to_owned(),
|
||||
args: vec!["-c".to_owned(), script],
|
||||
cwd: "/".to_owned(),
|
||||
env: Vec::new(),
|
||||
stdin: None,
|
||||
sandbox: plan,
|
||||
}
|
||||
}
|
||||
|
||||
/// Une ligne JSONL `result` réaliste (format Claude vérifié) — sans quote simple.
|
||||
const RESULT_LINE: &str =
|
||||
r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"s-1"}"#;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// INVARIANT 1 — PARITÉ (test pivot)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// **Pivot.** Le chemin structuré sandboxé (`run_turn` + plan + enforcer Landlock)
|
||||
/// laisse l'écriture DANS le grant réussir, bloque (noyau) l'écriture HORS grant, et
|
||||
/// **draine quand même** la ligne JSONL émise sur stdout (le parsing n'est pas cassé
|
||||
/// par la restriction FS). Strictement équivalent au pivot PTY du lot LP4-3, mais via
|
||||
/// la machinerie `process::run_turn` (chemin Claude/Codex JSON).
|
||||
#[tokio::test]
|
||||
async fn pty_structured_run_turn_enforces_plan_end_to_end() {
|
||||
if !landlock_is_enforced() {
|
||||
eprintln!("skip pty_structured_run_turn_enforces_plan_end_to_end: Landlock indisponible");
|
||||
return;
|
||||
}
|
||||
let allowed = fresh_dir("p1-allowed");
|
||||
let denied = fresh_dir("p1-denied");
|
||||
let allowed_marker = allowed.join("in.txt");
|
||||
let denied_marker = denied.join("out.txt");
|
||||
|
||||
let spec = writing_spawn_line(
|
||||
RESULT_LINE,
|
||||
&denied_marker,
|
||||
&allowed_marker,
|
||||
Some(rw_plan(&allowed)),
|
||||
);
|
||||
let enforcer = crate::sandbox::default_enforcer();
|
||||
|
||||
let lines = run_turn(&spec, None, Some(&enforcer))
|
||||
.await
|
||||
.expect("run_turn sandboxé réussit sous posture Ask");
|
||||
|
||||
// Le drain a bien remonté la ligne JSONL (parsing intact sous sandbox).
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![RESULT_LINE.to_owned()],
|
||||
"la ligne JSONL doit être drainée malgré la restriction FS"
|
||||
);
|
||||
// L'écriture in-grant a réussi…
|
||||
assert!(
|
||||
allowed_marker.exists(),
|
||||
"écriture in-grant doit réussir: {allowed_marker:?} absent — le grant a été mal fencé"
|
||||
);
|
||||
// …et l'écriture hors-grant a été bloquée par le noyau.
|
||||
assert!(
|
||||
!denied_marker.exists(),
|
||||
"BRÈCHE SANDBOX: écriture hors-grant réussie ({denied_marker:?}) — plan NON enforce sur le chemin structuré"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&allowed);
|
||||
let _ = std::fs::remove_dir_all(&denied);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// INVARIANT 2 — COMPANION NÉGATIF
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Même machinerie + enforcer câblé, mais `sandbox == None` ⇒ l'écriture hors-grant
|
||||
/// RÉUSSIT. Prouve que le blocage de l'invariant 1 vient du **plan enforce**, pas
|
||||
/// d'une restriction ambiante du chemin structuré. (Contrôle anti faux-positif.)
|
||||
#[tokio::test]
|
||||
async fn structured_run_turn_without_plan_does_not_sandbox() {
|
||||
let allowed = fresh_dir("p2-allowed");
|
||||
let denied = fresh_dir("p2-denied");
|
||||
let allowed_marker = allowed.join("in.txt");
|
||||
let denied_marker = denied.join("out.txt");
|
||||
|
||||
// plan = None ⇒ chemin natif, l'enforcer est ignoré même s'il est fourni.
|
||||
let spec = writing_spawn_line(RESULT_LINE, &denied_marker, &allowed_marker, None);
|
||||
let enforcer = crate::sandbox::default_enforcer();
|
||||
|
||||
let lines = run_turn(&spec, None, Some(&enforcer))
|
||||
.await
|
||||
.expect("run_turn natif réussit");
|
||||
|
||||
assert_eq!(lines, vec![RESULT_LINE.to_owned()]);
|
||||
assert!(allowed_marker.exists(), "écriture in-grant réussit (sans plan)");
|
||||
assert!(
|
||||
denied_marker.exists(),
|
||||
"sans plan, l'écriture hors-grant DOIT réussir: {denied_marker:?} absent — restriction ambiante anormale"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&allowed);
|
||||
let _ = std::fs::remove_dir_all(&denied);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// INVARIANT 3 — FAIL-CLOSED
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Enforcer double qui **échoue toujours** — simule fidèlement le cas réel
|
||||
/// « posture Deny sur kernel sans Landlock » (où `enforce` renvoie
|
||||
/// [`SandboxError::KernelTooOld`]), de façon **déterministe** et indépendante du
|
||||
/// kernel de CI (impossible de forcer `NotEnforced` sur une machine Landlock-capable).
|
||||
#[derive(Debug)]
|
||||
struct AlwaysFailEnforcer;
|
||||
|
||||
impl SandboxEnforcer for AlwaysFailEnforcer {
|
||||
fn kind(&self) -> SandboxKind {
|
||||
SandboxKind::Landlock
|
||||
}
|
||||
fn enforce(&self, _plan: &SandboxPlan) -> Result<SandboxStatus, SandboxError> {
|
||||
Err(SandboxError::KernelTooOld(
|
||||
"simulé: Landlock indisponible + posture Deny ⇒ fail-closed".to_owned(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// **Fail-closed.** Quand `enforce` renvoie `Err`, `run_turn` (chemin sandboxé) doit
|
||||
/// remonter [`AgentSessionError::Start`] et **aucun enfant ne tourne** : le marqueur
|
||||
/// que le child aurait écrit reste absent. C'est le câblage critique de sécurité du
|
||||
/// chemin structuré (équivalent du fail-closed PTY).
|
||||
#[tokio::test]
|
||||
async fn structured_run_turn_fail_closed_no_child_on_enforce_err() {
|
||||
use domain::ports::AgentSessionError;
|
||||
|
||||
let dir = fresh_dir("p3");
|
||||
let marker = dir.join("should-not-exist.txt");
|
||||
|
||||
// Plan posture Deny (cohérent avec le scénario simulé) + enforcer qui échoue.
|
||||
let plan = SandboxPlan {
|
||||
allowed: vec![PathGrant {
|
||||
abs_root: dir.to_string_lossy().into_owned(),
|
||||
access: PathAccess::RW,
|
||||
}],
|
||||
default_posture: Posture::Deny,
|
||||
};
|
||||
// Le child écrirait CE marqueur s'il était lancé : il ne doit jamais l'être.
|
||||
let script = format!("echo ran > '{}'", marker.display());
|
||||
let spec = SpawnLine {
|
||||
command: "sh".to_owned(),
|
||||
args: vec!["-c".to_owned(), script],
|
||||
cwd: "/".to_owned(),
|
||||
env: Vec::new(),
|
||||
stdin: None,
|
||||
sandbox: Some(plan),
|
||||
};
|
||||
let enforcer: Arc<dyn SandboxEnforcer> = Arc::new(AlwaysFailEnforcer);
|
||||
|
||||
let err = run_turn(&spec, None, Some(&enforcer))
|
||||
.await
|
||||
.expect_err("enforce Err ⇒ run_turn doit échouer (fail-closed)");
|
||||
assert!(
|
||||
matches!(err, AgentSessionError::Start(_)),
|
||||
"fail-closed doit remonter Start, vu: {err:?}"
|
||||
);
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"FAIL-CLOSED VIOLÉ: un enfant a tourné ({marker:?} existe) malgré l'échec d'enforce"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// INVARIANT 4 — NO-OP PAR DÉFAUT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// **No-op par défaut.** `plan == None` (effective_permissions None) ⇒ le chemin async
|
||||
/// tokio historique est emprunté, comportement natif inchangé : le drain remonte les
|
||||
/// lignes et **aucune** restriction n'est posée (une écriture arbitraire réussit),
|
||||
/// y compris quand un enforcer est pourtant câblé sur la fabrique. La non-régression
|
||||
/// du gros de la suite structurée (conformance/D0/D3) confirme l'absence d'effet de bord.
|
||||
#[tokio::test]
|
||||
async fn structured_run_turn_none_plan_is_native_path() {
|
||||
let dir = fresh_dir("p4");
|
||||
let marker = dir.join("native.txt");
|
||||
let script = format!("printf '%s\\n' '{RESULT_LINE}'; echo ok > '{}'", marker.display());
|
||||
let spec = SpawnLine {
|
||||
command: "sh".to_owned(),
|
||||
args: vec!["-c".to_owned(), script],
|
||||
cwd: "/".to_owned(),
|
||||
env: Vec::new(),
|
||||
stdin: None,
|
||||
sandbox: None, // ⇐ rien posé ⇒ chemin natif
|
||||
};
|
||||
let enforcer = crate::sandbox::default_enforcer();
|
||||
|
||||
// Enforcer fourni MAIS plan None ⇒ la branche sandboxée n'est pas prise.
|
||||
let lines = run_turn(&spec, None, Some(&enforcer))
|
||||
.await
|
||||
.expect("chemin natif réussit");
|
||||
assert_eq!(lines, vec![RESULT_LINE.to_owned()]);
|
||||
assert!(
|
||||
marker.exists(),
|
||||
"sans plan, aucune restriction: l'écriture native doit réussir"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// INVARIANT 5 — CONFINEMENT / IRRÉVERSIBILITÉ
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// **Confinement.** Deux tours successifs (instances `run_turn` distinctes) avec des
|
||||
/// grants **disjoints** : chacun ne voit que son propre périmètre. Le thread jetable
|
||||
/// du 1er tour meurt avec sa restriction ⇒ le 2e tour n'en hérite pas (et inversement).
|
||||
/// Prouve que l'enforcement ne « bave » pas d'un tour à l'autre ni sur IdeA.
|
||||
#[tokio::test]
|
||||
async fn structured_two_turns_disjoint_grants_are_confined() {
|
||||
if !landlock_is_enforced() {
|
||||
eprintln!("skip structured_two_turns_disjoint_grants_are_confined: Landlock indisponible");
|
||||
return;
|
||||
}
|
||||
let dir_a = fresh_dir("p5-a");
|
||||
let dir_b = fresh_dir("p5-b");
|
||||
let enforcer = crate::sandbox::default_enforcer();
|
||||
|
||||
// --- Tour A : grant = dir_a. Écrit dans A (ok) puis B (bloqué). ---
|
||||
let a_in = dir_a.join("in.txt");
|
||||
let a_into_b = dir_b.join("from-a.txt");
|
||||
let spec_a = writing_spawn_line(RESULT_LINE, &a_into_b, &a_in, Some(rw_plan(&dir_a)));
|
||||
run_turn(&spec_a, None, Some(&enforcer))
|
||||
.await
|
||||
.expect("tour A ok");
|
||||
assert!(a_in.exists(), "tour A: écriture dans son grant (A) doit réussir");
|
||||
assert!(
|
||||
!a_into_b.exists(),
|
||||
"tour A: écriture dans B (hors grant A) doit être bloquée"
|
||||
);
|
||||
|
||||
// --- Tour B : grant = dir_b. Écrit dans B (ok) puis A (bloqué). ---
|
||||
// Si la restriction du tour A avait bavé, l'écriture dans B échouerait ici.
|
||||
let b_in = dir_b.join("in.txt");
|
||||
let b_into_a = dir_a.join("from-b.txt");
|
||||
let spec_b = writing_spawn_line(RESULT_LINE, &b_into_a, &b_in, Some(rw_plan(&dir_b)));
|
||||
run_turn(&spec_b, None, Some(&enforcer))
|
||||
.await
|
||||
.expect("tour B ok");
|
||||
assert!(
|
||||
b_in.exists(),
|
||||
"tour B: écriture dans son grant (B) doit réussir — la restriction du tour A n'a pas bavé"
|
||||
);
|
||||
assert!(
|
||||
!b_into_a.exists(),
|
||||
"tour B: écriture dans A (hors grant B) doit être bloquée"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir_a);
|
||||
let _ = std::fs::remove_dir_all(&dir_b);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// INVARIANT 6 — TIMEOUT SOUS SANDBOX
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// **Timeout sous sandbox.** Un enfant qui ne ferme jamais stdout (`sleep`) lancé via
|
||||
/// le chemin sandboxé : `run_turn(timeout)` doit **tuer** l'enfant et renvoyer
|
||||
/// [`AgentSessionError::Timeout`], rapidement (≪ durée du sleep) — preuve que le killer
|
||||
/// oneshot + `tokio::time::timeout` fonctionnent et qu'aucun thread ne reste bloqué.
|
||||
#[tokio::test]
|
||||
async fn structured_run_turn_timeout_under_sandbox() {
|
||||
use domain::ports::AgentSessionError;
|
||||
|
||||
let dir = fresh_dir("p6");
|
||||
// `sleep 30` garde stdout ouvert ⇒ le drain bloquerait sans le killer du timeout.
|
||||
let spec = SpawnLine {
|
||||
command: "sleep".to_owned(),
|
||||
args: vec!["30".to_owned()],
|
||||
cwd: "/".to_owned(),
|
||||
env: Vec::new(),
|
||||
stdin: None,
|
||||
sandbox: Some(rw_plan(&dir)), // ⇒ branche sandboxée (posture Ask ⇒ enforce Ok)
|
||||
};
|
||||
let enforcer = crate::sandbox::default_enforcer();
|
||||
|
||||
let started = Instant::now();
|
||||
let err = run_turn(&spec, Some(Duration::from_millis(250)), Some(&enforcer))
|
||||
.await
|
||||
.expect_err("doit expirer");
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
matches!(err, AgentSessionError::Timeout),
|
||||
"le tour sandboxé doit expirer en Timeout, vu: {err:?}"
|
||||
);
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(10),
|
||||
"le timeout doit tuer l'enfant promptement (vu {elapsed:?}) — pas d'attente des 30s"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// INVARIANT 7 — RESUME PRÉSERVÉ (via la fabrique réelle + adapter Claude)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// **Resume préservé.** Après un tour réellement sandboxé, l'`session_id`/conversation_id
|
||||
/// est toujours capté correctement : la restriction FS ne casse pas le parsing du flux.
|
||||
/// Passe par la **fabrique réelle** `StructuredSessionFactory::with_sandbox_enforcer`,
|
||||
/// un profil Claude branché sur un [`FakeCli`] (init + result), et un plan transmis à
|
||||
/// `start(.., Some(&plan))`. Le fake n'écrit aucun fichier (il imprime sur un pipe, hors
|
||||
/// périmètre Landlock-FS), donc un plan write-only suffit : on prouve que la capture
|
||||
/// d'id survit au domaine actif.
|
||||
#[tokio::test]
|
||||
async fn structured_sandboxed_turn_preserves_conversation_id() {
|
||||
use domain::ids::ProfileId;
|
||||
use domain::ports::{AgentSessionFactory, PreparedContext, ReplyEvent, SessionPlan};
|
||||
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::MarkdownDoc;
|
||||
|
||||
use super::conformance::FakeCli;
|
||||
use super::factory::StructuredSessionFactory;
|
||||
|
||||
if !landlock_is_enforced() {
|
||||
eprintln!("skip structured_sandboxed_turn_preserves_conversation_id: Landlock indisponible");
|
||||
return;
|
||||
}
|
||||
|
||||
let run_dir = fresh_dir("p7");
|
||||
let fake = FakeCli::printing(&[
|
||||
r#"{"type":"system","subtype":"init","session_id":"conv-sbx-1","cwd":"/tmp","tools":[]}"#,
|
||||
r#"{"type":"result","subtype":"success","is_error":false,"result":"réponse","session_id":"conv-sbx-1","num_turns":1}"#,
|
||||
]);
|
||||
|
||||
let profile = AgentProfile::new(
|
||||
ProfileId::new_random(),
|
||||
"Claude sandboxé",
|
||||
&fake.command(),
|
||||
Vec::new(),
|
||||
ContextInjection::convention_file("CLAUDE.md").expect("convention file valide"),
|
||||
None,
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.expect("profil valide")
|
||||
.with_structured_adapter(StructuredAdapter::Claude);
|
||||
|
||||
let factory =
|
||||
StructuredSessionFactory::new().with_sandbox_enforcer(crate::sandbox::default_enforcer());
|
||||
|
||||
let ctx = PreparedContext {
|
||||
content: MarkdownDoc::new("# ctx"),
|
||||
relative_path: "CLAUDE.md".to_owned(),
|
||||
};
|
||||
let cwd = ProjectPath::new(run_dir.to_string_lossy().into_owned()).expect("cwd absolu");
|
||||
let plan = rw_plan(&run_dir); // plan write-only ⇒ reads/exec du fake non gênés
|
||||
|
||||
let session = factory
|
||||
.start(&profile, &ctx, &cwd, &SessionPlan::None, Some(&plan))
|
||||
.await
|
||||
.expect("start sandboxé ok");
|
||||
|
||||
// Avant tout tour : aucun id moteur.
|
||||
assert_eq!(session.conversation_id(), None);
|
||||
|
||||
// Un tour sandboxé : le flux doit porter un Final ET capter l'id.
|
||||
let events: Vec<ReplyEvent> = session.send("salut").await.expect("send ok").collect();
|
||||
let finals = events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
||||
.count();
|
||||
assert_eq!(finals, 1, "un Final attendu malgré le sandbox, vu: {events:?}");
|
||||
|
||||
// LE POINT : l'id de conversation a bien été capté sous enforcement actif.
|
||||
assert_eq!(
|
||||
session.conversation_id().as_deref(),
|
||||
Some("conv-sbx-1"),
|
||||
"la restriction FS ne doit pas casser la capture du conversation_id (resume)"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&run_dir);
|
||||
}
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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. */
|
||||
@ -206,9 +207,9 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
{/* ── Create form ── */}
|
||||
<form
|
||||
onSubmit={(e) => void handleCreate(e)}
|
||||
className="flex flex-wrap items-end gap-2 border-b border-border p-4"
|
||||
className="flex flex-col gap-3 border-b border-border p-4"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<label
|
||||
htmlFor="agent-name-input"
|
||||
className="text-xs font-medium text-muted"
|
||||
@ -221,12 +222,12 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
placeholder="My agent"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="min-w-32"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Template selector */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<label
|
||||
htmlFor="agent-template-select"
|
||||
className="text-xs font-medium text-muted"
|
||||
@ -256,7 +257,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
|
||||
{/* Profile selector — hidden when a template is chosen */}
|
||||
{!hasTemplate && (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<label
|
||||
htmlFor="agent-profile-select"
|
||||
className="text-xs font-medium text-muted"
|
||||
@ -304,6 +305,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
aria-label="create agent"
|
||||
disabled={!canCreate}
|
||||
loading={vm.busy}
|
||||
className="self-end"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
@ -326,21 +328,23 @@ 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}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-3 py-2 first:pt-0 last:pb-0",
|
||||
"flex flex-col gap-2 py-3 first:pt-0 last:pb-0",
|
||||
isSelected && "rounded-md bg-raised px-2",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void vm.selectAgent(a.id)}
|
||||
className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-left"
|
||||
className="flex min-w-0 flex-col items-start gap-0.5 text-left"
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="flex min-w-0 max-w-full flex-wrap items-center gap-2">
|
||||
<span className="font-medium text-content">{a.name}</span>
|
||||
{agentDrift && (
|
||||
<span
|
||||
@ -366,13 +370,24 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
</span>
|
||||
<span className="text-xs text-muted">{profileName}</span>
|
||||
{live && (
|
||||
<span className="text-xs text-primary">
|
||||
<span className="block w-full min-w-0 truncate text-xs text-primary">
|
||||
running in IdeA · {live.sessionId ?? live.nodeId}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{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. */}
|
||||
{vm.profiles.length > 0 && (
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -147,30 +147,35 @@ export function ProjectsView() {
|
||||
sidebarCollapsed && "hidden",
|
||||
)}
|
||||
>
|
||||
{/* Sidebar tab strip (no role="tablist" to avoid collision with project tabs) */}
|
||||
<div className="flex shrink-0 items-stretch border-b border-border">
|
||||
{SIDEBAR_TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
aria-selected={sidebarTab === t.id}
|
||||
onClick={() => setSidebarTab(t.id)}
|
||||
className={cn(
|
||||
"flex-1 px-2 py-2 text-xs font-medium transition-colors",
|
||||
sidebarTab === t.id
|
||||
? "border-b-2 border-primary text-content"
|
||||
: "text-muted hover:text-content",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
{/* Sidebar tab strip (no role="tablist" to avoid collision with project tabs).
|
||||
The tabs wrap onto multiple rows when they exceed the sidebar width
|
||||
so every label stays visible without scrolling; the collapse button
|
||||
stays pinned to the top-right. */}
|
||||
<div className="flex shrink-0 items-start border-b border-border">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-stretch">
|
||||
{SIDEBAR_TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
aria-selected={sidebarTab === t.id}
|
||||
onClick={() => setSidebarTab(t.id)}
|
||||
className={cn(
|
||||
"shrink-0 whitespace-nowrap px-3 py-2 text-xs font-medium transition-colors",
|
||||
sidebarTab === t.id
|
||||
? "border-b-2 border-primary text-content"
|
||||
: "text-muted hover:text-content",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="collapse sidebar"
|
||||
title="Collapse sidebar"
|
||||
onClick={() => setSidebarCollapsed(true)}
|
||||
className="shrink-0 px-2 text-muted hover:text-content"
|
||||
className="shrink-0 self-stretch border-l border-border px-2 text-muted hover:text-content"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
|
||||
@ -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