Compare commits
33 Commits
e583b2b49f
...
feature/ag
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f3504efa3 | |||
| 5d9dd32c29 | |||
| c480d2820a | |||
| 4fad0423e7 | |||
| 9df592389c | |||
| ea94e756e2 | |||
| 98bfcf4f22 | |||
| 9000b4d09f | |||
| 253310bb3e | |||
| a1755e51bc | |||
| 0bf1eb3b11 | |||
| fa5b826df5 | |||
| 401c18ad3c | |||
| a04fbb7e1c | |||
| 69304b0f6b | |||
| 64ab3835c7 | |||
| 00bda6a988 | |||
| 31b636037d | |||
| 40bce5c8bf | |||
| ab9162f686 | |||
| 6236cd727b | |||
| 7e01ac60cb | |||
| 17ca65ed0f | |||
| b05d04ab7a | |||
| 27597eb64e | |||
| 46492506e1 | |||
| aa2f67ae89 | |||
| 0f8ba38d51 | |||
| fdcf16c387 | |||
| 4509f0db9d | |||
| d87b8f6ed2 | |||
| 9b053216e3 | |||
| 09cc8f0902 |
Submodule .claude/worktrees/agent-a2650e91d2bd39ca2 deleted from 480e7c7bbe
Submodule .claude/worktrees/agent-aeb1e862ef04b991b deleted from ef101db9dc
@ -35,6 +35,13 @@
|
||||
"mdPath": "agents/qa.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"synchronized": false
|
||||
},
|
||||
{
|
||||
"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.
|
||||
0
.ideai/agents/newtest.md
Normal file
0
.ideai/agents/newtest.md
Normal file
0
.ideai/agents/testconversation.md
Normal file
0
.ideai/agents/testconversation.md
Normal file
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"}
|
||||
@ -0,0 +1,9 @@
|
||||
---
|
||||
upTo: 88f405da-f9c9-44bb-96c9-7e1b0e5af557
|
||||
objective: Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ?
|
||||
---
|
||||
**Objectif :** Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ?
|
||||
|
||||
- **Prompt:** Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ?
|
||||
- **Prompt:** Test de communication inter-agent. Merci de confirmer que tu reçois bien ce message en répondant via idea_reply avec : (1) un court message de confirmation, (2) la date/heure si tu y as accès, (3) le nom de ton AI Profile/contexte tel que tu le perçois. Ceci sert uniquement à valider le canal de communication entre agents IdeA.
|
||||
- **Response:** (1) Confirmation : message bien reçu, le canal de communication inter-agent IdeA fonctionne correctement. (2) Date : 2026-06-13 (je n'ai pas d'heure horodatée plus précise dans mon contexte). (3) AI Profile/contexte perçu : Claude Code (modèle Opus 4.8, claude-opus-4-8), opérant sur le project root /home/anthony/Documents/Projects/IdeA via un run dir isolé. Canal validé. ✅
|
||||
@ -0,0 +1,3 @@
|
||||
{"id":"20dd8bdc-f858-46a6-9c8a-6afde591a6bd","conversation":"6ffc1f69-77a5-0da1-1965-ef26d1df72f5","atMs":1781367769591,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ?"}
|
||||
{"id":"0b8fcc51-c692-4928-9d90-3823857dad5c","conversation":"6ffc1f69-77a5-0da1-1965-ef26d1df72f5","atMs":1781368607174,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de communication inter-agent. Merci de confirmer que tu reçois bien ce message en répondant via idea_reply avec : (1) un court message de confirmation, (2) la date/heure si tu y as accès, (3) le nom de ton AI Profile/contexte tel que tu le perçois. Ceci sert uniquement à valider le canal de communication entre agents IdeA."}
|
||||
{"id":"88f405da-f9c9-44bb-96c9-7e1b0e5af557","conversation":"6ffc1f69-77a5-0da1-1965-ef26d1df72f5","atMs":1781368615582,"source":{"kind":"agent","agentId":"c932c770-cf36-4fb2-a966-71bb1644e4b4"},"role":"response","text":"(1) Confirmation : message bien reçu, le canal de communication inter-agent IdeA fonctionne correctement. (2) Date : 2026-06-13 (je n'ai pas d'heure horodatée plus précise dans mon contexte). (3) AI Profile/contexte perçu : Claude Code (modèle Opus 4.8, claude-opus-4-8), opérant sur le project root /home/anthony/Documents/Projects/IdeA via un run dir isolé. Canal validé. ✅"}
|
||||
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": "2fc8a7df-0bf6-4116-acd6-895ae04aa3e5",
|
||||
"activeId": "dd38b8ed-7199-48a6-a63f-1d1462ca6a34",
|
||||
"layouts": [
|
||||
{
|
||||
"id": "2fc8a7df-0bf6-4116-acd6-895ae04aa3e5",
|
||||
"id": "dd38b8ed-7199-48a6-a63f-1d1462ca6a34",
|
||||
"name": "Default",
|
||||
"kind": "terminal",
|
||||
"tree": {
|
||||
"root": {
|
||||
"type": "split",
|
||||
"node": {
|
||||
"id": "1eb96c70-e954-42d0-907b-f8528d0442bf",
|
||||
"id": "2ea41e76-262d-4dc3-a674-348ceac9b543",
|
||||
"direction": "row",
|
||||
"children": [
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "db40e3de-4980-4bed-b6fa-1c136f49d30e",
|
||||
"session": "d7d4c099-eda3-4b33-82e6-e6f8d57b8b97",
|
||||
"id": "14d20dcb-f243-4381-a130-2d4d3d224f42",
|
||||
"session": "4e60d6c2-6be6-499c-a296-577ce1e5ec62",
|
||||
"agent": "a6ced819-b893-4213-b003-9e9dc79b9641",
|
||||
"agentWasRunning": true
|
||||
}
|
||||
@ -29,10 +29,9 @@
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "92826533-1a7c-4d9e-b6b4-f1e904ddc81a",
|
||||
"session": "5d20f53a-ac76-4677-9a6f-064d3319cc73",
|
||||
"agent": "edce8090-4c57-47c5-a319-c08fd172438b",
|
||||
"agentWasRunning": true
|
||||
"id": "71f5cc81-9bef-4b2b-b7dc-2e0a5a4eafcb",
|
||||
"session": "bb08a08e-71f3-4ab2-842c-71a8da5c74b4",
|
||||
"agent": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"
|
||||
}
|
||||
},
|
||||
"weight": 1.0
|
||||
|
||||
@ -3,3 +3,6 @@
|
||||
- [agent-context-memory-and-profile-handoff](agent-context-memory-and-profile-handoff.md) — Decisions sur l'injection de contexte, la memoire durable, l'etat live et le handoff de profil entre agents IA.
|
||||
- [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.
|
||||
|
||||
51
.ideai/memory/mcp-bridge-and-delegation-runtime-notes.md
Normal file
51
.ideai/memory/mcp-bridge-and-delegation-runtime-notes.md
Normal file
@ -0,0 +1,51 @@
|
||||
---
|
||||
name: mcp-bridge-and-delegation-runtime-notes
|
||||
description: Pieges runtime du pont MCP et de la delegation inter-agents IdeA, et la regle de rebuild de l'AppImage.
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
# Pont MCP & delegation : pieges runtime
|
||||
|
||||
Deux bugs trouves le 2026-06-13 en testant la conversation inter-agents (`idea_ask_agent` vers TestConversation), tous deux corriges. Notes utiles pour ne pas reperdre du temps :
|
||||
|
||||
## Le binaire qui tourne = AppImage installee, pas les sources
|
||||
L'IdeA en cours d'utilisation est `/home/anthony/Documents/IdeA_0.1.0_amd64.AppImage`. **Ce meme binaire sert a la fois de serveur orchestrateur (il tient `OrchestratorService`/`InputMediator` et compose les evenements) ET de binaire-pont** (`<exe> mcp-server …` declare dans chaque `.ideai/run/<id>/.mcp.json`). Donc **tout correctif cote serveur ou cote pont n'est actif dans l'app que apres rebuild + reinstall de l'AppImage et relance d'IdeA**. Un binaire `target/debug` fraichement compile ne valide que le pont (qui parle au serveur via le socket) ; il ne valide pas la composition cote serveur.
|
||||
**Comment appliquer :** apres une correction backend, rebuild AppImage (`npm --prefix frontend run build` puis, depuis `crates/app-tauri/`, `../../frontend/node_modules/.bin/tauri build --bundles appimage`), remplacer l'AppImage, relancer IdeA, puis retester. **Exclure NSIS** (`--bundles appimage`) sur Linux. **Piege FUSE (2026-06-14)** : l'etape finale `linuxdeploy` echoue avec `failed to run linuxdeploy` (linuxdeploy est une AppImage qui se monte via FUSE). Workaround obligatoire : prefixer `APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1`. Le compile Rust reussit avant ce point ; seul le bundling casse, et l'`AppDir` est genere mais pas le `.AppImage`. L'artefact final = `target/release/bundle/appimage/IdeA_0.1.0_amd64.AppImage`.
|
||||
|
||||
## Env AppImage pollue le shell
|
||||
La session shell herite des variables de l'AppImage montee (`APPDIR`, `LD_LIBRARY_PATH`, `PYTHONHOME` -> `/tmp/.mount_IdeA_*`). Consequences : `python3` casse (`No module named 'encodings'`) et lancer un binaire app-tauri fraichement compile tente de booter WebKit et crash. **Workaround :** lancer avec un env propre (`env -i PATH=/usr/bin:/bin HOME=$HOME XDG_RUNTIME_DIR=/run/user/1000 ...`), utiliser `jq` plutot que python.
|
||||
|
||||
## Bug 1 — pont MCP en lockstep (corrige)
|
||||
`mcp_bridge.rs::relay` lisait 1 ligne client -> attendait 1 reponse loopback, en boucle. MCP n'est pas 1:1 : `notifications/initialized` n'a pas de reponse => deadlock juste apres `initialize`, et `tools/list` n'etait jamais relaye => les outils `idea_*` ne se chargeaient jamais (`claude mcp list` affichait quand meme "Connected" car `initialize` repond). Corrige en relay **full-duplex** (deux pompes concurrentes) + drain borne (`DRAIN_GRACE`) a la fermeture stdin. Symptome cote utilisateur : 3 jours de "outils MCP pas charges".
|
||||
|
||||
## Bug 2 — prefixe de delegation perdu (corrige)
|
||||
Le signal `[IdeA · tâche de <demandeur> · ticket <id>]` qui dit a l'agent cible "reponds via `idea_reply`, jamais en texte" n'etait plus compose nulle part : supprime du backend (`service.rs` C3 §5.1) lors du passage de l'ecriture PTY au frontend, mais le frontend (`useWritePortal.ts`) ecrit `head.text` verbatim et ne l'ajoutait pas. La cible recevait la tache brute, repondait en texte => `idea_ask_agent` timeout. Corrige en composant le prefixe dans `infrastructure/src/input/mod.rs` (`delegation_preamble`) a l'emission de `DomainEvent::DelegationReady` ; la tache brute reste dans le `Ticket`/historique. Rappel : la correlation `idea_reply` marche par ticket OU par tete de FIFO (fallback), donc le ticket echo est recommande mais pas strictement requis.
|
||||
|
||||
## Bug 3 — cold-launch race : 1er tour perdu (corrige 2026-06-14)
|
||||
Deleguer a un agent **froid** (pas encore lance) via `idea_ask_agent` echouait silencieusement : terminal cible vide, ask bloque jusqu'au timeout 300s ; un agent **chaud** (deja a son prompt, lance manuellement) marchait. Cause : avec `with_structured` non cable sur l'orchestrateur (regression assumee `aa2f67a`), tous les `ask` passent par le chemin PTY `ensure_live_pty` (`application/src/orchestrator/service.rs`). Un agent jamais vu etait initialise `Idle` (`BusyTracker::start_turn` -> `or_insert(Idle)`, `infrastructure/src/input/mod.rs`), donc le 1er `enqueue` publiait `DelegationReady` **immediatement** et ecrivait la tache dans le PTY avant que le CLI ait affiche son prompt -> tache perdue. Le prompt-ready watcher (lot C5) ne gardait que les tours suivants. Fix : `ensure_live_pty` renvoie un flag `cold_launch` ; si cold ET `prompt_ready_pattern` non vide, l'orchestrateur appelle `mark_starting(agent)` -> la `DelegationReady` du 1er tour est **differee** puis publiee par le watcher a l'apparition du prompt. Sans pattern ou agent chaud -> livraison immediate (zero regression). Touche `domain/src/input.rs` (port `mark_starting`), `infrastructure/src/input/mod.rs`, `service.rs`.
|
||||
|
||||
## Bug 4 — le fix cold-launch ne s'armait jamais en prod (corrige 2026-06-15)
|
||||
Le « fix Bug 3 corrige 2026-06-14 » etait trop optimiste : il ne s'arme que si `gate_cold_start = cold_launch && prompt_ready_pattern non vide` (`service.rs`). Or le profil **Claude Code** (`664cc20c`, partage par TOUS les agents) dans `~/.local/share/app.idea.ide/profiles.json` n'a **aucun** `prompt_ready_pattern`. Donc `mark_starting` n'etait jamais appele -> `enqueue` publiait `DelegationReady` immediatement -> tache ecrite dans le PTY avant le prompt de `claude` -> 1er tour perdu -> `idea_ask_agent` vers un agent **froid** bloque jusqu'au timeout (un agent **chaud** marche). Le Bug 3 n'etait valide que par des tests unitaires qui injectent un pattern a la main : le gap d'integration (profil sans pattern) n'avait pas ete vu.
|
||||
**Fix (option B, signal MCP, sans sniff de prompt TUI) :** on gate le cold-launch des qu'un MCP est configure sur le profil (`gate_cold_start = cold_launch && (pattern non vide || profil.mcp.is_some())`), et on **libere** le tour differe quand le pont MCP de l'agent froid se connecte (= son CLI est up + outils charges) : nouveau port `InputMediator::release_cold_start(agent)` (drain du `deferred`, **sans** `mark_idle` car c'est un signal de DEMARRAGE, idempotent et OR-safe avec `prompt_ready`), `McpServer` fire un `ready_sink: Fn(&str)` sur `initialize` avec le `requester` du handshake, et la composition root (`state.rs::ensure_mcp_server`) parse le requester en `AgentId` -> `OrchestratorService::release_agent_cold_start`. Touche `domain/src/input.rs`, `infrastructure/src/input/mod.rs`, `infrastructure/src/orchestrator/mcp/server.rs`, `application/src/orchestrator/service.rs`, `app-tauri/src/state.rs`. Tests unitaires verts ; **validation live = rebuild AppImage + relance IdeA** (cf. section binaire qui tourne). Filet OR : si un jour un profil porte un `prompt_ready_pattern`, les deux signaux coexistent.
|
||||
**Methodo :** ne PAS deleguer la reparation du systeme inter-agents via le systeme inter-agents (casse) — utiliser les subagents natifs (outil Agent).
|
||||
|
||||
## Bug 5 — la boucle `serve` du serveur MCP est en lockstep, un `ask` sans reponse wedge TOUTE la connexion (corrige 2026-06-15)
|
||||
Symptome : 1er `idea_ask_agent` vers un agent qui repond -> OK ; puis `ask` vers un agent qui ne rappelle JAMAIS `idea_reply` (ex. QA lance mais qui ne repond pas) -> ensuite **tout** appel suivant du MEME demandeur (meme `idea_list_agents`, sans rendezvous) se bloque. Cote utilisateur : "DevBackend ne marche plus" alors qu'il repondait 11s avant — en realite c'est la connexion du DEMANDEUR (Main) qui est morte, pas la cible. Annuler l'appel cote client ne deparke rien.
|
||||
Cause : `infrastructure/src/orchestrator/mcp/server.rs::serve` lisait 1 requete puis **awaitait `handle_raw` en entier** avant de relire. Pour `idea_ask_agent`, `handle_raw -> dispatch -> service.dispatch` attend le `idea_reply` de la cible : pendant cette attente la boucle ne relit plus rien -> pipeline de la connexion fige. C'est l'analogue COTE SERVEUR du Bug 1 (lockstep) qui n'avait ete corrige que cote pont (`mcp_bridge.rs`). NB : la couche application bornait deja le rendezvous (`service.rs::ask_agent` via `tokio::time::timeout`), donc l'attente n'etait pas infinie — le vrai coupable etait bien la serialisation de `serve`, pas l'absence de timeout.
|
||||
Fix : `serve` reecrite full-duplex non bloquante — `tokio::select!` entre `transport.recv()` et un canal `mpsc::unbounded::<Option<Vec<u8>>>` ; chaque message entrant traite dans une tache `tokio::spawn` qui possede un clone cheap `self.for_requester(self.requester.clone())` ('static) + un clone du `tx` ; reponses (`Some`) ou notifications (`None`) renvoyees par le canal et ecrites par la meme boucle ; arret gracieux via compteur `in_flight` (on draine les taches en vol apres EOF). Les reponses MCP portent l'`id` -> ordre indifferent. Le trait `Transport` (`&mut self` recv/send) et les signatures `serve`/`serve_as` restent intacts. + filet de securite : timeout serveur **isole au seul `idea_ask_agent`** (`ASK_RENDEZVOUS_TIMEOUT` 24h, finie ; setter `with_ask_rendezvous_timeout` `#[doc(hidden)]` pour les tests). Tests : `infrastructure/tests/mcp_server.rs` -> `pending_ask_does_not_wedge_the_connection_concurrent_call_still_answered` (anti-wedge, echoue sur l'ancien code) + `ask_agent_rendezvous_times_out_with_a_jsonrpc_error`. Verts : `cargo test -p infrastructure` (20 mcp_server), `cargo test -p app-tauri --lib mcp_e2e_loopback_tests` (6). **Validation live = rebuild AppImage + relance IdeA** (la connexion wedgee ne se deparke pas de l'interieur : il FAUT relancer IdeA). AppImage du 2026-06-15 10:58 contient le fix ; backup `~/Documents/IdeA_0.1.0_amd64.AppImage.old-prewedgefix`.
|
||||
Reste a investiguer (separe, non bloquant) : pourquoi QA lance ne repond pas du tout a une delegation (son `claude` n'appelle pas `idea_reply`) — impossible a creuser tant que la connexion du demandeur est wedgee ; le fix Bug 5 permet desormais de le diagnostiquer sans tout figer.
|
||||
|
||||
## Bug 6 — la tache n'est JAMAIS ecrite dans le PTY d'un agent delegue en arriere-plan (corrige 2026-06-15)
|
||||
C'EST la cause racine du « reste a investiguer » du Bug 5. Symptome : un agent lance a la main (cellule visible, ex. DevBackend) repond ; un agent **froid auto-lance par `idea_ask_agent`** (ex. QA) ne repond JAMAIS → `ask` bloque jusqu'au timeout (24h, `ASK_AGENT_TIMEOUT`). Reproduction sure et non bloquante : `idea_stop_agent` puis `idea_launch_agent(task=…, visibility=background)`, et observer le dossier de session `claude` de la cible (`~/.claude/projects/<run-dir>/*.jsonl`) : **aucune nouvelle session** en 28 s = la tache n'a jamais ete soumise (le pont MCP de la cible, lui, se connecte bien).
|
||||
Cause : depuis ARCHITECTURE §20, l'**ecriture physique du PTV est faite par le write-portal FRONTEND** (`useWritePortal`), qui n'est monte **que pour une cellule de layout (leaf) visible**. Or `ensure_live_pty` cold-lance la cible en **background avec `node_id: None`** (`service.rs`) → aucune cellule montee → `useWritePortal` n'existe pas → l'event `DelegationReady` n'a **aucun consommateur** → tache perdue. Les fix Bug 3/4 (differer/liberer la `DelegationReady`) ne pouvaient donc jamais marcher pour un agent background : ils publient un event que personne n'ecoute cote UI.
|
||||
Fix (option « writer PTY backend ») : le mediateur ecrit lui-meme la tache dans le PTY **quand aucune cellule frontend n'est attachee**. Registre `front_owned` dans `MediatedInbox`, alimente par le front via `bindHandle`/`unbindHandle` → commande Tauri `set_front_attached` → `OrchestratorService::set_agent_front_attached` → `InputMediator::set_front_attached`. Point de livraison unique = `BusyTracker::publish_deferred` (chemin chaud immediat ET drains froids `prompt_ready`/`release_cold_start`), qui passe par un **HeadlessSink** optionnel cable par `MediatedInbox::with_pty`/`with_events` : agent dans `front_owned` ⇒ `Some(d)` (publie l'event, le front ecrit, inchange) ; sinon ⇒ ecrit texte puis (apres `submit_delay_ms`, defaut 60ms, anti-paste-detection) la `submit_sequence` dans le handle PTY bound, sur un `std::thread` detache (le watcher prompt-ready tourne sur un std::thread, PAS tokio — ne pas utiliser `tokio::spawn`). Touche `domain/src/input.rs` (port `set_front_attached`), `infrastructure/src/input/mod.rs` (HeadlessSink + front_owned + handles en `Arc<Mutex>`), `application/src/orchestrator/service.rs`, `app-tauri/src/{dto,commands,lib}.rs`, `frontend/src/{ports,adapters/input,adapters/mock,features/terminals/useWritePortal}`. Tests verts : `cargo test -p infrastructure` (input 34, dont `headless_agent_without_front_cell_is_written_by_the_backend` + `front_attached_agent_is_delivered_via_event_not_backend_write`), app-tauri lib 42, useWritePortal 9, tsc. **Validation live = rebuild AppImage + relance IdeA** (build 2026-06-15 11:42 ; backup `~/Documents/IdeA_0.1.0_amd64.AppImage.old-prefrontwriterfix`).
|
||||
NB diagnostic : `~/.claude/projects/<encoded-run-dir>/*.jsonl` = transcript de session `claude` de l'agent ; pas de nouveau fichier apres une delegation = tache jamais soumise. `ss -xp | grep idea-mcp` = ponts MCP connectes cote serveur.
|
||||
|
||||
## Bug 7 — une delegation interrompue/annulee laisse la cible `Busy` a vie (corrige 2026-06-15, sources ; pas encore en AppImage)
|
||||
Symptome : un agent qui repondait (ex. DevFrontend a « 123x4=492 », QA pendant LP3) ne repond plus du tout aux delegations suivantes ; `idea_ask_agent` bloque jusqu'au timeout. DevBackend, lui, continue de marcher. Diagnostic ecarte 2 fausses pistes : (a) PAS le socket MCP — les 6 ponts sont ESTAB (`ss -xp | grep idea-mcp`), pont vivant ; (b) PAS « lance a la main vs par IdeA » — DevBackend est aussi auto-lance et marche. Le vrai discriminant : **une delegation vers cet agent a-t-elle ete interrompue/annulee cote demandeur ?** DevFrontend coince par l'interruption d'une tache LP3 ; QA coince par un ping diag rejete ; DevBackend jamais annule => OK. Confirmation cote transcript : la tache figure dans le `log.jsonl` de la conversation (donc enqueue cote serveur a eu lieu) mais PAS dans le transcript `claude` de la cible (`~/.claude/projects/<run-dir>/*.jsonl`) => jamais ecrite dans son PTY.
|
||||
Cause (lue dans `application/src/orchestrator/service.rs`, chemins `ask` PTY ~l.847-911 ET `ask_structured` ~l.927-1011) : l'agent passe `Idle→Busy` des `input.enqueue` (~l.978). Il ne redevient `Idle` que sur la branche **succes** (`input.mark_idle`, ~l.1001). Les branches **erreur/annulation/timeout** (~l.901-910 et ~l.1004-1009) appellent `mailbox.cancel_head` mais **jamais `mark_idle`**. Pire : quand le demandeur interrompt l'appel, le futur `ask_agent` est **dropped** => AUCUNE branche du `select!` ne s'execute => l'agent reste `Busy` pour toujours. Une cible `Busy` met les delegations suivantes en file derriere un tour fantome, jamais livrees au PTY. L'etat `Busy` vit en memoire dans le process serveur et **ne se deparke pas de l'interieur** : deblocage immediat = **relancer IdeA** (comme le wedge du Bug 5).
|
||||
Fix (corrige cote sources, `service.rs`) : garde RAII `BusyTurnGuard` (Arc clones de `InputMediator` + mailbox, `agent_id`, `ticket_id`, flag `armed`) cree juste apres l'enqueue dans les DEUX chemins (`ask` PTY et `ask_structured`) ; son `Drop` (si arme) appelle `cancel_head(agent,ticket)` puis `mark_idle(agent)` ; `disarm()` sur la branche succes (le `mark_idle` propre existant reste, pas de double cancel). Les `cancel_head` redondants des branches erreur/`_cancelled`/`_elapsed` ont ete retires au profit du garde (`cancel_head` est positionnel/idempotent). Indispensable que ce soit un garde et pas un `mark_idle` dans les branches : le cas reel est un futur DROPPED, aucune branche du `select!` ne s'execute. Tests verts : `cargo test --workspace` 80 suites 0 echec, dont `dropped_ask_future_frees_busy_target`, `second_delegation_delivered_after_dropped_ask`, `cancelled_ask_marks_target_idle` (tests/orchestrator_service.rs) + 2 tests du garde dans le `mod tests` de service.rs.
|
||||
VERDICT `sweep_stalled` (infra/input/mod.rs) : **purement advisory** — bascule `Alive→Stalled` et emet `AgentLivenessChanged` mais **n'appelle JAMAIS `mark_idle`** (par conception : « la FIFO et le tour continuent »). Ce n'est donc PAS un filet pour ce bug ; le garde RAII est la seule correction. Non recable (changement de semantique hors perimetre).
|
||||
**Pas encore actif live : rebuild AppImage requis** (`npm --prefix frontend run build` puis depuis `crates/app-tauri/` `APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ../../frontend/node_modules/.bin/tauri build --bundles appimage`, remplacer l'AppImage, relancer IdeA). Le relancement d'IdeA debloque aussi DevFrontend/QA actuellement coinces `Busy` (etat en memoire). Methodo (rappel Bug 4) : NE PAS reparer le systeme inter-agent via `idea_ask_agent` (casse) — utiliser les subagents natifs (outil Agent).
|
||||
|
||||
See also [[remaining-work-idea-agent-control-ide]], [[agent-context-memory-and-profile-handoff]].
|
||||
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"
|
||||
}
|
||||
}
|
||||
318
ARCHITECTURE.md
318
ARCHITECTURE.md
@ -2027,4 +2027,322 @@ Avant correction, `launch_structured` persistait l'**id moteur (2)** sur `LeafCe
|
||||
|
||||
---
|
||||
|
||||
## 20. Terminal natif + portail d'écriture unique (cadrage — remplace la barre `MediatedInput`)
|
||||
|
||||
> **Cadrage architecture** d'une feature **déjà décidée** (cf. décision produit « terminal natif »). Produit la frontière, les contrats (ports/events/DTO/profil), le découpage en lots testables et les risques. **Aucun code applicatif ici.**
|
||||
|
||||
### 20.1 Problème & décision produit (rappel, ne pas rediscuter)
|
||||
- **Bug.** En mode agent, l'humain tape dans une barre IdeA séparée (`MediatedInput.tsx`) ; la livraison d'une délégation écrit dans le PTY un texte terminé par `\n` (`MediatedInbox::enqueue`, `infrastructure/src/input/mod.rs:307-311`). Résultat : le texte se dépose dans le prompt de la TUI mais **n'est jamais soumis** (`\n` ≠ Entrée en raw-mode ; la détection de paste absorbe le `\n`). De plus barre IdeA + prompt natif = « double chat ».
|
||||
- **Décision.** La cellule agent héberge la CLI comme **vrai terminal** : **toutes** les frappes (Entrée comprise) vont à la CLI ; on garde le chrome natif. On **supprime** `MediatedInput`. **Pas d'interception d'Entrée** (ambiguë en TUI). IdeA n'**observe** qu'un compteur « ligne humaine en cours » (+1 sur imprimable, reset sur Entrée/Ctrl-C). **Sérialisation par un portail d'écriture unique** vers le PTY : deux écrivains (frappes humaines natives + médiateur IdeA pour les délégations). Une délégation n'est livrée qu'à une **frontière propre = prompt-ready ET ligne humaine vide** ; sinon elle **patiente** (jamais de refus). Invariant inchangé : **1 agent = 1 employé = 1 session CLI** (la « conversation par paire » reste un cloisonnement **logique**, pas des sessions séparées).
|
||||
|
||||
### 20.2 Décision d'architecture — où vit le portail, qui écrit
|
||||
|
||||
**Le portail d'écriture unique vit côté FRONTEND** (le détenteur du terminal). Le backend **décide quand** une délégation est prête et **publie l'intention** ; le frontend, seul détenteur de xterm + des frappes + du compteur de ligne + de l'overlay, **exécute le handshake** (b→e) et **écrit le texte + `\r`** via la **même** `TerminalHandle.write` que les frappes humaines. Ainsi il n'existe **qu'un seul écrivain effectif du PTY** (le front) : pas de course entre deux écrivains physiques, le portail est un mutex **logique** dans la cellule. Le backend conserve son rôle d'**autorité de file/busy** (mailbox FIFO, prompt-ready watcher, `AgentBusyChanged`) mais **n'écrit plus le tour dans le PTY** — c'est le point dur tranché.
|
||||
|
||||
Justification hexagonale : la frontière reste nette. L'**application** (`OrchestratorService`) reste l'autorité métier de l'orchestration (file, corrélation par ticket, cycle, timeout) ; elle parle **ports** (`InputMediator`, `EventBus`) sans connaître le terminal. La **livraison physique** (octets vers le PTY) est un **détail d'I/O** qui appartient à l'adapter sortant — ici l'adapter **frontend** (la cellule), exactement comme les frappes humaines y vivent déjà. On **retire** au `MediatedInbox` la responsabilité d'écrire le PTY (violation SRP : il était à la fois moteur de file ET écrivain d'I/O brut) ; il redevient pur moteur de file/busy/corrélation. Le « double signal OR » prompt-ready/`idea_reply` et le `wait_for`/timeout restent **inchangés**.
|
||||
|
||||
```
|
||||
ask_agent / idea_ask_agent (MCP) idea_reply (MCP)
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────────── OrchestratorService (application) ─────────────┐
|
||||
│ enqueue(ticket) → mailbox FIFO (corrélation) resolve_ticket → réveille ask │
|
||||
│ busy: Idle→Busy → AgentBusyChanged mark_idle (OR signal) │
|
||||
│ PLUS: publie DelegationReady{agent, ticket, text} ◄── NOUVEAU (ne PTY-écrit │
|
||||
└───────────────────────────────┬─────────────────────────────── plus le tour)┘
|
||||
│ EventBus → relais Tauri (event)
|
||||
▼
|
||||
┌──────────────────────── Cellule agent (frontend, détient le terminal) ───────┐
|
||||
│ TerminalView (agent natif): term.onData → handle.write [frappes humaines] │
|
||||
│ writePortal (mutex logique de la cellule): │
|
||||
│ • frappes humaines: write direct + maj compteur ligne (imprimable / reset) │
|
||||
│ • DelegationReady reçue → si prompt-ready & ligne vide → HANDSHAKE b→e: │
|
||||
│ (b) couper le relais frappes + overlay grisé « un agent parle » │
|
||||
│ (c) revérif compteur K ; si K>0 → \x7f ×K (backspaces) │
|
||||
│ (d) write(texte) puis write(submitSequence) après submitDelayMs │
|
||||
│ (e) réactiver + retirer overlay, PLANCHER 2 s depuis (b) │
|
||||
│ • sinon (busy / ligne non vide) → la délégation PATIENTE (file native CLI │
|
||||
│ empile ; la prochaine frontière propre la libère) │
|
||||
│ ack: input.deliveredDelegation(ticket) ── confirme la livraison au backend │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Pourquoi pas le backend ?** Le backend ne connaît ni l'état « ligne humaine en cours » (détenu par xterm côté front) ni l'overlay. Lui faire écrire le PTY impose un round-trip fragile (front→back « ligne vide ? », back→front « j'écris ») et **réintroduit deux écrivains physiques** du même PTY (le back via `PtyPort.write` + le front via `handle.write`) → exactement la classe de course que `terminal-input-accents-ordering` a déjà coûtée. Centraliser l'écriture côté front supprime la race par construction.
|
||||
|
||||
### 20.3 Contrats
|
||||
|
||||
**Profil (`crates/domain/src/profile.rs`) — fix Bug 1, déclaratif & model-agnostic.** Deux champs additifs sur `AgentProfile`, à côté de `prompt_ready_pattern`, sérialisés camelCase, `skip_serializing_if` ⇒ zéro régression :
|
||||
```rust
|
||||
/// Séquence de soumission écrite APRÈS le texte d'une délégation pour la
|
||||
/// faire valider par la CLI (esquive la détection de paste : texte sans `\n`,
|
||||
/// puis cette séquence seule après un court délai). Défaut `"\r"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub submit_sequence: Option<String>, // None ⇒ défaut "\r" appliqué au point d'usage
|
||||
/// Délai (ms) entre l'écriture du texte et celle de `submit_sequence`.
|
||||
/// Défaut ~50–80 ms. Évite que la TUI absorbe la soumission comme un paste.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub submit_delay_ms: Option<u32>, // None ⇒ défaut (p.ex. 60) au point d'usage
|
||||
```
|
||||
Withers additifs `with_submit_sequence`/`with_submit_delay_ms` (comme `with_prompt_ready_pattern`). Le **défaut** (`"\r"`, ~60 ms) est appliqué côté front (DTO `Option` → valeur effective), jamais codé en dur dans le domaine.
|
||||
|
||||
**Port domaine `InputMediator` (`crates/domain/src/input.rs`) — recentrage.** On **retire la sémantique d'écriture PTY** de `enqueue`/`bind_handle*` (la doc de `enqueue` ne promet plus la livraison physique) ; le médiateur reste autorité **file + busy + corrélation + prompt-watcher**. Pas de nouvelle méthode obligatoire : la livraison passe désormais par un **event** (ci-dessous). `bind_handle_with_prompt` **reste** (le prompt-ready watcher observe toujours le flux de sortie côté infra). `delivers_turn` devient inutile (toujours « le front délivre ») → marqué déprécié/`false`.
|
||||
|
||||
**Adapter infra `MediatedInbox` (`crates/infrastructure/src/input/mod.rs`).** `enqueue` **ne fait plus** le `pty.write(line)` (suppression du bloc 305-313, donc du `\n` band-aid). À la place, sur la transition qui **démarre le tour** (Idle→Busy) il publie un **nouvel event** `DelegationReady` portant le **texte de la tâche** + ticket + agent (le `BusyTracker`/`enqueue` a déjà l'`EventBus`). Le `preempt` (ESC) **reste** (interruption = octet de contrôle, légitime côté back ; il ne concourt pas avec une écriture de ligne). Le prompt-ready watcher **reste** identique.
|
||||
|
||||
**Nouvel event domaine `DomainEvent` (`crates/domain/src/events.rs`).**
|
||||
```rust
|
||||
/// Une délégation est prête à être injectée dans le terminal natif de l'agent
|
||||
/// (le front exécute le handshake b→e et écrit texte + submit_sequence). Le
|
||||
/// backend reste l'autorité de file/busy ; il NE PTY-écrit PLUS le tour.
|
||||
DelegationReady {
|
||||
agent_id: AgentId,
|
||||
ticket: TicketId,
|
||||
text: String,
|
||||
/// Profil de la cible : la cellule applique submit_sequence/submit_delay_ms.
|
||||
submit_sequence: Option<String>,
|
||||
submit_delay_ms: Option<u32>,
|
||||
},
|
||||
```
|
||||
Relayé au front comme les autres `DomainEvent` (mapping DTO camelCase déjà en place). Discret, basse fréquence (1/délégation).
|
||||
|
||||
**Commande Tauri (ack de livraison) — `crates/app-tauri/src/commands.rs` + `dto.rs`.** Le front confirme qu'il a **effectivement écrit** la délégation (clôt la boucle « le tour est parti »), pour distinguer « en file car ligne occupée » d'« écrit » côté observabilité/persistance :
|
||||
```rust
|
||||
// DTO
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeliveredDelegationRequestDto { pub project_id: String, pub agent_id: String, pub ticket: String }
|
||||
// commande
|
||||
#[tauri::command] pub async fn delegation_delivered(request: DeliveredDelegationRequestDto, state: …) -> Result<(), ErrorDto>
|
||||
```
|
||||
Mappé vers une méthode applicative best-effort (`OrchestratorService::note_delegation_delivered`) — **ne change pas** la corrélation (le réveil de l'`ask` reste `idea_reply`→`resolve_ticket`) ; sert l'observabilité/log. Les commandes existantes `submit_agent_input`/`reattach_agent_chat` deviennent **mortes** pour les agents (retirées en L5) ; `interrupt_agent` **reste** (Échap → `preempt`), mais l'UI l'invoque désormais depuis le terminal (raccourci), plus depuis la barre.
|
||||
|
||||
**Ports/adapter frontend (`frontend/src/ports/index.ts`, `adapters/input.ts`).** `InputGateway` perd `submit` (plus de barre) et **gagne** :
|
||||
```ts
|
||||
export interface InputGateway {
|
||||
/** Interrompre = preempt (Échap/stop). Reste. */
|
||||
interrupt(projectId: string, agentId: string): Promise<void>;
|
||||
/** Ack : la cellule a écrit la délégation `ticket` dans le PTY natif. */
|
||||
delegationDelivered(projectId: string, agentId: string, ticket: string): Promise<void>;
|
||||
}
|
||||
```
|
||||
Un nouveau port d'**abonnement** aux `DelegationReady` n'est **pas** nécessaire : `SystemGateway.onDomainEvent` les relaie déjà (filtrer `event.type === "delegationReady"`), à l'image de `useAgentBusy`. Côté domaine front, ajouter la variante `DelegationReady` au type `DomainEvent`.
|
||||
|
||||
**Frontend — le portail.** Un hook `useWritePortal(handle, agentId)` détient : (1) le **compteur de ligne** (incrément/`reset` branchés sur `term.onData` dans `TerminalView`), (2) la **file locale** des `DelegationReady` reçues, (3) l'exécution du **handshake** (b→e) avec **plancher 2 s** et **revérif K + backspaces**, (4) l'**overlay** (état booléen rendu par la cellule). `TerminalView` (agent) écrit les frappes **et** notifie le portail (imprimable/Entrée/Ctrl-C) ; quand le portail injecte, il **coupe** le relais des frappes (flag `suspended`) et écrit via `handle.write`.
|
||||
|
||||
### 20.4 Comment le backend connaît « ligne humaine vide » — il ne la connaît PAS
|
||||
La décision frontière l'évite : **la frontière propre est jugée côté front** (seul détenteur du compteur). Le backend publie `DelegationReady` **dès** que le tour démarre ; c'est le **front** qui retient l'injection jusqu'à `prompt-ready ET ligne vide`. Le « prompt-ready » est connu des **deux** : le watcher backend le détecte sur le flux de sortie pour le **busy** ; le front peut soit ré-utiliser un signal (un futur `AgentPromptReady` event, optionnel) soit, plus simplement, considérer la **ligne vide** comme condition front suffisante et s'appuyer sur le fait que la CLI **empile** nativement les soumissions (robustesse : même injectée « tôt », la CLI met en file). **Choix retenu (sobre)** : le front conditionne sur **ligne humaine vide** uniquement ; la nativité de la CLI gère le reste ; aucun round-trip. Si l'expérience montre des injections trop précoces, on ajoute l'event `AgentPromptReady` (additif, sans changer le portail). Aucune des deux variantes ne crée deux écrivains.
|
||||
|
||||
### 20.5 Découpage en lots (dev/test séquencés)
|
||||
|
||||
| Lot | Couche | Contenu | Fichiers | Testable (vert) |
|
||||
|---|---|---|---|---|
|
||||
| **L1** | domaine+infra | Profil `submit_sequence`/`submit_delay_ms` (+ withers) ; `MediatedInbox::enqueue` **cesse** d'écrire le PTY (suppr. bloc `\n`) et **publie** `DelegationReady` ; `DomainEvent::DelegationReady` | `domain/src/profile.rs`, `domain/src/events.rs`, `infrastructure/src/input/mod.rs` | round-trip serde (clés omises si `None`, legacy→`None`) ; `enqueue` ne fait **aucun** `pty.write` ; publie 1 `DelegationReady{text,ticket}` sur Idle→Busy, **0** sur 2ᵉ enqueue busy ; `preempt` inchangé ; prompt-watcher tests intacts |
|
||||
| **L2** | app+app-tauri | `OrchestratorService` : `ask_agent`/`submit_human_input` n'attendent plus du médiateur l'écriture (déjà le cas) ; ajout `note_delegation_delivered` ; commande `delegation_delivered` + DTO ; relais `DelegationReady` au front | `application/src/orchestrator/service.rs`, `app-tauri/src/commands.rs`, `dto.rs`, `lib.rs` (register) | `ask_agent` toujours réveillé par `idea_reply` (timeout/cycle inchangés) ; `delegation_delivered` best-effort (no-op si non câblé) ne casse pas la corrélation ; event mappé camelCase `delegationReady` |
|
||||
| **L3** | frontend | `TerminalView` agent **natif** : frappes → PTY **inconditionnellement** (retrait du drop `agentMode`) ; compteur de ligne (imprimable +1 / Entrée|Ctrl-C reset) exposé au portail ; `InputGateway` (retrait `submit`, ajout `delegationDelivered`) + adapter ; type front `DomainEvent.DelegationReady` | `frontend/src/features/terminals/TerminalView.tsx`, `ports/index.ts`, `adapters/input.ts`, `domain/*`, `app/di.tsx`, mocks | Vitest : en agent, une frappe écrit le PTY ; compteur +1 sur `a`, reset sur `\r` et `\x03` ; multiligne natif n'altère pas le compteur de façon erronée ; adapter appelle `delegation_delivered` avec `{projectId,agentId,ticket}` |
|
||||
| **L4** | frontend | `useWritePortal` + overlay : réception `DelegationReady` → file ; injection **ssi ligne vide** ; handshake (b) couper relais+overlay, (c) revérif K→`\x7f`×K, (d) `write(text)` puis `write(submitSequence??"\r")` après `submitDelayMs??60`, (e) réactiver+overlay off **plancher 2 s** ; ack `delegationDelivered` | `frontend/src/features/terminals/useWritePortal.ts` (nouveau), `LayoutGrid.tsx` (overlay + montage), `features/terminals/index.ts` | Vitest (faux timers + faux handle) : injecte **rien** tant que ligne non vide ; à ligne vide → écrit `text` **sans** `\n` puis `\r` après le délai ; course « K=2 lettres dans le micro-intervalle » → 2 `\x7f` avant le texte ; **plancher 2 s** : overlay maintenu si (e) < 2 s après (b) ; relais frappes coupé pendant l'overlay ; `delegationDelivered` appelé une fois après (d) |
|
||||
| **L5** | frontend+app-tauri | **Retrait** de `MediatedInput` (suppr. composant + montage `LayoutGrid`), de `useAgentBusy` si plus utilisé (ou conservé pour un badge), des commandes mortes `submit_agent_input`/`reattach_agent_chat`/DTO afférents ; `interrupt` rebranché sur un raccourci terminal | `LayoutGrid.tsx`, `MediatedInput.tsx` (suppr.), `useAgentBusy.ts`, `app-tauri/src/commands.rs`/`dto.rs`/`lib.rs`, tests | suite front verte sans `MediatedInput` ; aucune cellule **plain** modifiée (régression nulle) ; `cargo build` sans les commandes retirées ; `interrupt_agent` toujours appelable |
|
||||
|
||||
**Ordre** : L1→L2 (back prêt) ∥ L3 (front natif) → L4 (portail, dépend L1/L3) → L5 (nettoyage). L1 est le pivot (supprime le `\n`, source du bug, et bascule la livraison sur event).
|
||||
|
||||
### 20.6 Plan de tests — cas limites explicites
|
||||
- **Course 1–2 lettres** (étape c) : entre (a) « ligne vide constatée » et (b) « relais coupé », l'humain tape K∈{1,2} ⇒ le portail écrit **exactement** K `\x7f` puis le texte ; **jamais** Ctrl-U. *(Vitest, faux handle enregistrant les writes.)*
|
||||
- **Plancher 2 s** (étape e) : (d) se termine à t<2 s ⇒ overlay + relais coupés **maintenus** jusqu'à t=2 s ; (e) à t≥2 s ⇒ pas d'attente résiduelle. *(faux timers.)*
|
||||
- **Multiligne natif** : l'humain compose une commande multiligne (la CLI gère ses propres `\n` internes) ⇒ le compteur **n'injecte pas** au milieu (ligne « non vide » tant que la frappe est en cours) ; on ne réécrit jamais le contenu humain.
|
||||
- **Ligne non vide à la frontière** : `DelegationReady` reçue alors que compteur>0 ⇒ **mise en file**, **aucune** écriture ; libérée à la prochaine ligne vide. *(pas de refus, pas de perte.)*
|
||||
- **Préemption pendant overlay** : Échap (`interrupt`) pendant l'overlay ⇒ `preempt` (ESC) côté back inchangé ; le portail lève l'overlay au plancher ; la délégation en cours d'écriture n'est pas dupliquée (ack idempotent).
|
||||
- **Back (Rust)** : `enqueue` ne PTY-écrit plus ; 1 `DelegationReady` sur démarrage de tour, 0 en re-enqueue busy ; `preempt`/prompt-watcher/`mark_idle` inchangés ; `ask_agent` réveillé par `idea_reply`, timeout/cycle intacts ; profil serde zéro régression.
|
||||
|
||||
### 20.7 Risques résiduels & vigilance
|
||||
- **Frontière « tôt »** : conditionner sur « ligne vide » seule peut injecter avant le tout premier prompt-ready. Mitigation : la CLI empile nativement ; si insuffisant, event additif `AgentPromptReady` (déjà détecté côté back) sans toucher le portail.
|
||||
- **`submit_sequence` par CLI** : `"\r"` + délai esquive la paste-detection de Claude Code ; d'autres TUI pourraient exiger une autre séquence/délai → c'est précisément pourquoi c'est **déclaratif** (profil), pas codé en dur.
|
||||
- **Compteur de ligne vs séquences ANSI** : ne compter que les **imprimables** issus de `term.onData` (frappes), pas la sortie ; ignorer les séquences de contrôle (flèches/échap) pour éviter un faux « ligne non vide » qui bloquerait toute injection.
|
||||
- **Reattach** : à la réouverture d'une cellule, le portail repart d'un **compteur=0** et d'une file vide ; une `DelegationReady` perdue pendant la navigation est rejouée par le `ask` en attente (le back retient le ticket jusqu'au reply/timeout) → pas de perte de corrélation.
|
||||
- **Plain shell strictement inchangé** : tout le mécanisme est gardé par `agentMode`/présence d'agent ; aucune cellule plain ne voit overlay, portail, ni compteur.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
@ -175,11 +175,20 @@ impl ChatBridge {
|
||||
/// Maps a domain [`ReplyEvent`] to its wire [`ReplyChunk`]. Pure translation, no
|
||||
/// I/O — the single point where the typed turn event becomes a serialisable chunk
|
||||
/// (kept here so the pump and any test share one mapping).
|
||||
///
|
||||
/// 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. 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) -> ReplyChunk {
|
||||
pub fn chunk_from_event(event: ReplyEvent) -> Option<ReplyChunk> {
|
||||
match event {
|
||||
ReplyEvent::TextDelta { text } => ReplyChunk::TextDelta { text },
|
||||
ReplyEvent::ToolActivity { label } => ReplyChunk::ToolActivity { label },
|
||||
ReplyEvent::Final { content } => ReplyChunk::Final { content },
|
||||
ReplyEvent::TextDelta { text } => Some(ReplyChunk::TextDelta { text }),
|
||||
ReplyEvent::ToolActivity { label } => Some(ReplyChunk::ToolActivity { label }),
|
||||
ReplyEvent::Final { content } => Some(ReplyChunk::Final { content }),
|
||||
ReplyEvent::Heartbeat => None,
|
||||
ReplyEvent::RateLimited { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,48 +9,49 @@ use tauri::State;
|
||||
|
||||
use crate::dto::DismissEmbedderSuggestionRequestDto;
|
||||
use application::{
|
||||
AppError, AssignSkillToAgentInput, ChangeAgentProfileInput, CloseProjectInput, CreateAgentInput,
|
||||
CreateLayoutInput, CreateMemoryInput, CreateSkillInput, DeleteAgentInput,
|
||||
DeleteEmbedderProfileInput,
|
||||
DeleteLayoutInput, DeleteMemoryInput, DeleteSkillInput, DeleteTemplateInput,
|
||||
DetectAgentDriftInput, GetMemoryInput, GitBranchesInput, GitCheckoutInput, GitCommitInput,
|
||||
GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput,
|
||||
InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput, LiveSessions,
|
||||
McpRuntime,
|
||||
ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LoadLayoutInput, MutateLayoutInput,
|
||||
OpenProjectInput,
|
||||
ReadAgentContextInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
|
||||
ReconcileLayoutsInput, RenameLayoutInput, ResolveMemoryLinksInput, SetActiveLayoutInput,
|
||||
SnapshotRunningAgentsInput,
|
||||
SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContextInput,
|
||||
UpdateMemoryInput, UpdateProjectContextInput, UpdateSkillInput,
|
||||
AppError, AssignSkillToAgentInput, ChangeAgentProfileInput, CloseProjectInput,
|
||||
CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput, DeleteAgentInput,
|
||||
DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput, DeleteSkillInput,
|
||||
DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, GitBranchesInput, GitCheckoutInput,
|
||||
GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput,
|
||||
InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput,
|
||||
ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput,
|
||||
McpRuntime, MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadMemoryIndexInput,
|
||||
ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, RenameLayoutInput,
|
||||
ResolveAgentPermissionsInput, ResolveMemoryLinksInput, SetActiveLayoutInput,
|
||||
SnapshotRunningAgentsInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
|
||||
UpdateAgentContextInput, UpdateAgentPermissionsInput, UpdateMemoryInput,
|
||||
UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
|
||||
};
|
||||
use domain::ports::PtyHandle;
|
||||
|
||||
use crate::dto::{
|
||||
parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_memory_slug,
|
||||
parse_node_id, parse_profile_id, parse_project_id, parse_session_id, parse_skill_id,
|
||||
parse_template_id, AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto,
|
||||
AttachLiveAgentRequestDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
|
||||
ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||
parse_template_id, parse_ticket_id, AgentDriftListDto, AgentDto, AgentListDto,
|
||||
AssignSkillRequestDto, AttachLiveAgentRequestDto, ChangeAgentProfileDto,
|
||||
ChangeAgentProfileRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
|
||||
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
||||
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
||||
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
|
||||
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
|
||||
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
||||
EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
|
||||
ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto,
|
||||
GitCommitDto,
|
||||
GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
|
||||
LiveAgentListDto, SubmitAgentInputRequestDto,
|
||||
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto,
|
||||
ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto, ReattachChatDto,
|
||||
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
||||
ResizeTerminalRequestDto, ResumableAgentListDto,
|
||||
SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto,
|
||||
SkillListDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
||||
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
||||
OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto,
|
||||
ProjectPermissionsDto, ReadAgentContextResponseDto, ReattachChatDto, ReattachResultDto,
|
||||
RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
||||
ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
|
||||
SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto, SkillListDto,
|
||||
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
||||
TerminalClosedDto, TerminalSessionDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
||||
UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateSkillRequestDto,
|
||||
UpdateTemplateRequestDto, WriteTerminalRequestDto,
|
||||
UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||
UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
||||
WriteTerminalRequestDto,
|
||||
};
|
||||
use crate::pty::{PtyBridge, PtyChunk};
|
||||
use crate::state::AppState;
|
||||
@ -122,6 +123,7 @@ pub async fn open_project(
|
||||
.await;
|
||||
// (Re)start the orchestrator watcher for this project (idempotent, §14.3).
|
||||
state.ensure_orchestrator_watch(&output.project);
|
||||
state.reconcile_claude_run_dirs(&output.project).await;
|
||||
Ok(ProjectDto::from(output))
|
||||
}
|
||||
|
||||
@ -210,6 +212,87 @@ pub async fn update_project_context(
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `get_project_permissions` — read `.ideai/permissions.json`.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] on invalid project id or store failure.
|
||||
#[tauri::command]
|
||||
pub async fn get_project_permissions(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ProjectPermissionsDto, ErrorDto> {
|
||||
let project = resolve_project(&project_id, &state).await?;
|
||||
state
|
||||
.get_project_permissions
|
||||
.execute(application::GetProjectPermissionsInput { project })
|
||||
.await
|
||||
.map(|out| ProjectPermissionsDto(out.permissions))
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `update_project_permissions` — replace project default permissions.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] on invalid project id or store failure.
|
||||
#[tauri::command]
|
||||
pub async fn update_project_permissions(
|
||||
request: UpdateProjectPermissionsRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ProjectPermissionsDto, ErrorDto> {
|
||||
let project = resolve_project(&request.project_id, &state).await?;
|
||||
state
|
||||
.update_project_permissions
|
||||
.execute(UpdateProjectPermissionsInput {
|
||||
project,
|
||||
permissions: request.permissions,
|
||||
})
|
||||
.await
|
||||
.map(|out| ProjectPermissionsDto(out.permissions))
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `update_agent_permissions` — replace or remove one agent override.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] on invalid ids or store failure.
|
||||
#[tauri::command]
|
||||
pub async fn update_agent_permissions(
|
||||
request: UpdateAgentPermissionsRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ProjectPermissionsDto, ErrorDto> {
|
||||
let project = resolve_project(&request.project_id, &state).await?;
|
||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||
state
|
||||
.update_agent_permissions
|
||||
.execute(UpdateAgentPermissionsInput {
|
||||
project,
|
||||
agent_id,
|
||||
permissions: request.permissions,
|
||||
})
|
||||
.await
|
||||
.map(|out| ProjectPermissionsDto(out.permissions))
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `resolve_agent_permissions` — resolve project defaults plus agent override.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] on invalid ids or store failure.
|
||||
#[tauri::command]
|
||||
pub async fn resolve_agent_permissions(
|
||||
request: ResolveAgentPermissionsRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<EffectivePermissionsDto>, ErrorDto> {
|
||||
let project = resolve_project(&request.project_id, &state).await?;
|
||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||
state
|
||||
.resolve_agent_permissions
|
||||
.execute(ResolveAgentPermissionsInput { project, agent_id })
|
||||
.await
|
||||
.map(|out| out.effective.map(EffectivePermissionsDto))
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terminals (L3)
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -1049,6 +1132,16 @@ pub async fn launch_agent(
|
||||
requester: agent_id.to_string(),
|
||||
});
|
||||
|
||||
// Functional migration seam: before any launch/reattach/idempotent early-return,
|
||||
// repair the target Claude run dir so stale `.mcp.json` / `.claude/settings.local.json`
|
||||
// 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 {
|
||||
@ -1065,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
|
||||
@ -1078,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.
|
||||
@ -1085,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;
|
||||
}
|
||||
@ -1191,9 +1332,28 @@ 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 {
|
||||
let chunk = crate::chat::chunk_from_event(event);
|
||||
// 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 {
|
||||
continue;
|
||||
};
|
||||
// `send_output` always records into the conversation scrollback; the
|
||||
// boolean only reflects live delivery. If the view navigated away
|
||||
// (no channel at this generation) we keep draining so the turn still
|
||||
@ -1206,29 +1366,77 @@ pub async fn agent_send(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `submit_agent_input` — the human **Envoyer** path (cadrage C4 §4.2).
|
||||
/// `cancel_resume` — annule la **reprise automatique** armée pour un agent limité
|
||||
/// (ARCHITECTURE §21.1-4, fenêtre annulable).
|
||||
///
|
||||
/// Routes the operator's text to [`OrchestratorService::submit_human_input`], which
|
||||
/// enqueues a `from_human` ticket in the **same FIFO** the inter-agent delegations
|
||||
/// use (serialised per agent). Fire-and-forget: the human watches the terminal for
|
||||
/// the answer. The mediator emits `AgentBusyChanged` at the source.
|
||||
/// 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 id or unwired mediator,
|
||||
/// `NOT_FOUND` if the project or agent is unknown, `PROCESS` on a launch/PTY failure).
|
||||
/// Returns an [`ErrorDto`] (`INVALID` for a malformed agent id).
|
||||
#[tauri::command]
|
||||
pub async fn submit_agent_input(
|
||||
request: SubmitAgentInputRequestDto,
|
||||
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 project = resolve_project(&request.project_id, &state).await?;
|
||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||
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
|
||||
.orchestrator_service
|
||||
.submit_human_input(&project, agent_id, request.text)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(ErrorDto::from)
|
||||
.session_limit_service
|
||||
.confirm_human_resume(id, node_id, conversation_id, resets_at_ms);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `interrupt_agent` — the **Interrompre** path (cadrage C4 §4.2).
|
||||
@ -1255,6 +1463,55 @@ pub async fn interrupt_agent(
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `delegation_delivered` — the frontend write-portal's **ack** (ARCHITECTURE §20.3).
|
||||
///
|
||||
/// Called once the cell has physically written a delegation `ticket` into the agent's
|
||||
/// native PTY (text + submit sequence). Routes to the best-effort
|
||||
/// [`OrchestratorService::note_delegation_delivered`] (observability/log only): it does
|
||||
/// **not** change correlation — the requester's `ask` is still woken by `idea_reply`,
|
||||
/// and timeouts/cycle guards are untouched. Infallible on the application side; only a
|
||||
/// malformed id (project/agent/ticket) yields an `INVALID` error here.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the project is
|
||||
/// unknown).
|
||||
#[tauri::command]
|
||||
pub async fn delegation_delivered(
|
||||
request: DeliveredDelegationRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
let project = resolve_project(&request.project_id, &state).await?;
|
||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||
let ticket = parse_ticket_id(&request.ticket)?;
|
||||
state
|
||||
.orchestrator_service
|
||||
.note_delegation_delivered(&project, agent_id, ticket);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `set_front_attached` — the write-portal reports whether a **frontend terminal cell**
|
||||
/// is mounted for an agent (mount ⇒ `true`, unmount ⇒ `false`).
|
||||
///
|
||||
/// Routes to [`OrchestratorService::set_agent_front_attached`]. This is what lets the
|
||||
/// mediator deliver a turn to a **headless** (background-delegated, cell-less) agent by
|
||||
/// writing its PTY itself: with no mounted cell nobody consumes `DelegationReady`, so
|
||||
/// the task would otherwise be lost (a delegated agent that never receives — and never
|
||||
/// answers — its task). An agent **with** a cell keeps the frontend write-portal path.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] (`INVALID`) for a malformed agent id.
|
||||
#[tauri::command]
|
||||
pub async fn set_front_attached(
|
||||
request: FrontAttachedRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||
state
|
||||
.orchestrator_service
|
||||
.set_agent_front_attached(agent_id, request.attached);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `reattach_agent_chat` — re-bind a view to a **still-living** structured session
|
||||
/// without re-sending or re-spawning it (ARCHITECTURE §17.6/§17.7).
|
||||
///
|
||||
|
||||
@ -1066,7 +1066,7 @@ use application::{
|
||||
ChangeAgentProfileOutput, CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput,
|
||||
ListAgentsOutput, ReadAgentContextOutput,
|
||||
};
|
||||
use domain::{Agent, TerminalSession};
|
||||
use domain::{Agent, EffectivePermissions, PermissionSet, ProjectPermissions, TerminalSession};
|
||||
|
||||
/// An agent crossing the wire. [`Agent`] already serialises camelCase
|
||||
/// (`id`, `name`, `contextPath`, `profileId`, `origin` tagged, `synchronized`),
|
||||
@ -1135,6 +1135,52 @@ pub struct UpdateAgentContextRequestDto {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Permissions (LP1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Full project permission document crossing the wire.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ProjectPermissionsDto(pub ProjectPermissions);
|
||||
|
||||
/// Effective permissions crossing the wire.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct EffectivePermissionsDto(pub EffectivePermissions);
|
||||
|
||||
/// Request DTO for updating project default permissions.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateProjectPermissionsRequestDto {
|
||||
/// Id of the owning project.
|
||||
pub project_id: String,
|
||||
/// New project defaults. `null` removes defaults.
|
||||
pub permissions: Option<PermissionSet>,
|
||||
}
|
||||
|
||||
/// Request DTO for updating one agent permission override.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateAgentPermissionsRequestDto {
|
||||
/// Id of the owning project.
|
||||
pub project_id: String,
|
||||
/// Target agent id.
|
||||
pub agent_id: String,
|
||||
/// New override. `null` removes the override.
|
||||
pub permissions: Option<PermissionSet>,
|
||||
}
|
||||
|
||||
/// Request DTO for resolving one agent's effective permissions.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResolveAgentPermissionsRequestDto {
|
||||
/// Id of the owning project.
|
||||
pub project_id: String,
|
||||
/// Target agent id.
|
||||
pub agent_id: String,
|
||||
}
|
||||
|
||||
/// Request DTO for `update_project_context`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -1197,20 +1243,6 @@ impl From<LaunchAgentOutput> for TerminalSessionDto {
|
||||
}
|
||||
}
|
||||
|
||||
/// Request DTO for `submit_agent_input` (cadrage C4 §4.2): the human Envoyer path.
|
||||
/// The frontend's [`InputGateway`](../../../frontend/src/ports) sends
|
||||
/// `{ request: { projectId, agentId, text } }`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubmitAgentInputRequestDto {
|
||||
/// Id of the owning project.
|
||||
pub project_id: String,
|
||||
/// Id of the agent to send the human input to.
|
||||
pub agent_id: String,
|
||||
/// The text the operator typed (enqueued as a `from_human` ticket).
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Request DTO for `interrupt_agent` (cadrage C4 §4.2): the Interrompre path. The
|
||||
/// frontend sends `{ request: { projectId, agentId } }`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@ -1222,6 +1254,37 @@ pub struct InterruptAgentRequestDto {
|
||||
pub agent_id: String,
|
||||
}
|
||||
|
||||
/// Request DTO for `delegation_delivered` (ARCHITECTURE §20.3): the frontend write-
|
||||
/// portal acks that it **physically wrote** a delegation `ticket` into the agent's
|
||||
/// native PTY. Best-effort observability — it never changes correlation (the `ask` is
|
||||
/// still woken by `idea_reply`). The frontend sends `{ request: { projectId, agentId,
|
||||
/// ticket } }`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeliveredDelegationRequestDto {
|
||||
/// Id of the owning project.
|
||||
pub project_id: String,
|
||||
/// Id of the agent whose terminal received the delegation.
|
||||
pub agent_id: String,
|
||||
/// Id of the delivered mailbox ticket.
|
||||
pub ticket: String,
|
||||
}
|
||||
|
||||
/// Request DTO for `set_front_attached`: the write-portal of an agent cell reports
|
||||
/// whether a **frontend terminal cell is mounted** for `agentId` (`true` on mount,
|
||||
/// `false` on unmount). The mediator uses it to choose, at delivery time, between
|
||||
/// publishing `DelegationReady` (a cell will write it) and writing the turn into the
|
||||
/// PTY itself (headless/background-delegated agent with no cell). The frontend sends
|
||||
/// `{ request: { agentId, attached } }`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FrontAttachedRequestDto {
|
||||
/// Id of the agent whose terminal cell mounted/unmounted.
|
||||
pub agent_id: String,
|
||||
/// `true` when the cell's write-portal is now active, `false` on teardown.
|
||||
pub attached: bool,
|
||||
}
|
||||
|
||||
/// Request DTO for `change_agent_profile` (§15.1): hot-swap an agent's runtime
|
||||
/// profile, optionally relaunching its live session in place.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@ -1435,6 +1498,19 @@ pub fn parse_agent_id(raw: &str) -> Result<AgentId, ErrorDto> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses a ticket-id string (UUID) coming from the frontend (`delegation_delivered`).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID.
|
||||
pub fn parse_ticket_id(raw: &str) -> Result<domain::TicketId, ErrorDto> {
|
||||
uuid::Uuid::parse_str(raw)
|
||||
.map(domain::TicketId::from_uuid)
|
||||
.map_err(|_| ErrorDto {
|
||||
code: "INVALID".to_owned(),
|
||||
message: format!("invalid ticket id: {raw}"),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resumable agents (§15.2 — Chantier B2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -13,6 +13,7 @@ use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use domain::events::{DomainEvent, OrchestrationSource};
|
||||
use domain::input::AgentLiveness;
|
||||
use infrastructure::TokioBroadcastEventBus;
|
||||
|
||||
/// Name of the Tauri event carrying relayed [`DomainEvent`]s.
|
||||
@ -56,6 +57,26 @@ pub enum DomainEventDto {
|
||||
/// `true` when a turn is in flight, `false` when idle.
|
||||
busy: bool,
|
||||
},
|
||||
/// A delegation is ready to be injected into the agent's **native terminal**
|
||||
/// (ARCHITECTURE §20). The frontend write-portal writes `text` + `submitSequence`
|
||||
/// (default `"\r"`) after `submitDelayMs` (default ~60) once the human line is empty,
|
||||
/// then acks via the `delegation_delivered` command. The backend no longer PTY-writes
|
||||
/// the turn.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
DelegationReady {
|
||||
/// Target agent id (UUID string).
|
||||
agent_id: String,
|
||||
/// Mailbox ticket id (UUID string) to ack back via `delegation_delivered`.
|
||||
ticket: String,
|
||||
/// Task text to inject (written without a trailing newline by the portal).
|
||||
text: String,
|
||||
/// Profile's submit sequence; `null` ⇒ the front applies its default (`"\r"`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
submit_sequence: Option<String>,
|
||||
/// Profile's submit delay in ms; `null` ⇒ the front default (~60 ms).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
submit_delay_ms: Option<u32>,
|
||||
},
|
||||
/// A target agent produced a synchronous reply to an inter-agent `ask` (§17.4).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentReplied {
|
||||
@ -64,6 +85,17 @@ pub enum DomainEventDto {
|
||||
/// Reply length in bytes (metric, not the payload).
|
||||
reply_len: usize,
|
||||
},
|
||||
/// An agent's liveness (alive/stalled) changed (lot 2, readiness/heartbeat). The
|
||||
/// frontend can badge a frozen agent; `"stalled"` while no proof of liveness arrived
|
||||
/// for longer than the profile's `stallAfterMs`, back to `"alive"` on a late
|
||||
/// battement or when the turn ends.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentLivenessChanged {
|
||||
/// Agent id (UUID string).
|
||||
agent_id: String,
|
||||
/// New liveness, as a lowercase string (`"alive"` / `"stalled"`).
|
||||
liveness: AgentLivenessDto,
|
||||
},
|
||||
/// An agent's runtime profile was changed (hot-swap of the AI engine).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentProfileChanged {
|
||||
@ -173,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 {
|
||||
@ -195,6 +273,26 @@ pub enum OrchestrationSourceDto {
|
||||
Mcp,
|
||||
}
|
||||
|
||||
/// Wire mirror of [`AgentLiveness`]: the alive/stalled liveness of an agent (lot 2),
|
||||
/// serialised as a lowercase string (`"alive"` / `"stalled"`) the frontend badges.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AgentLivenessDto {
|
||||
/// The agent shows proof of liveness (or is idle).
|
||||
Alive,
|
||||
/// No proof of liveness past the profile's `stallAfterMs` threshold.
|
||||
Stalled,
|
||||
}
|
||||
|
||||
impl From<AgentLiveness> for AgentLivenessDto {
|
||||
fn from(liveness: AgentLiveness) -> Self {
|
||||
match liveness {
|
||||
AgentLiveness::Alive => Self::Alive,
|
||||
AgentLiveness::Stalled => Self::Stalled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OrchestrationSource> for OrchestrationSourceDto {
|
||||
fn from(source: OrchestrationSource) -> Self {
|
||||
match source {
|
||||
@ -225,6 +323,19 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
agent_id: agent_id.to_string(),
|
||||
busy: *busy,
|
||||
},
|
||||
DomainEvent::DelegationReady {
|
||||
agent_id,
|
||||
ticket,
|
||||
text,
|
||||
submit_sequence,
|
||||
submit_delay_ms,
|
||||
} => Self::DelegationReady {
|
||||
agent_id: agent_id.to_string(),
|
||||
ticket: ticket.to_string(),
|
||||
text: text.clone(),
|
||||
submit_sequence: submit_sequence.clone(),
|
||||
submit_delay_ms: *submit_delay_ms,
|
||||
},
|
||||
DomainEvent::AgentReplied {
|
||||
agent_id,
|
||||
reply_len,
|
||||
@ -232,6 +343,12 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
agent_id: agent_id.to_string(),
|
||||
reply_len: *reply_len,
|
||||
},
|
||||
DomainEvent::AgentLivenessChanged { agent_id, liveness } => {
|
||||
Self::AgentLivenessChanged {
|
||||
agent_id: agent_id.to_string(),
|
||||
liveness: (*liveness).into(),
|
||||
}
|
||||
}
|
||||
DomainEvent::AgentProfileChanged {
|
||||
agent_id,
|
||||
profile_id,
|
||||
@ -306,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(),
|
||||
@ -343,3 +487,55 @@ pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::ids::AgentId;
|
||||
|
||||
fn agent(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
/// Lot 2 : un `AgentLivenessChanged{Stalled}` du domaine se relaie en DTO
|
||||
/// `Stalled` portant le même agent, et se sérialise en `"stalled"` (le mot que
|
||||
/// le front badge). Garantit le câblage présentation de la détection de stall.
|
||||
#[test]
|
||||
fn liveness_changed_stalled_relays_to_dto_and_wire() {
|
||||
let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged {
|
||||
agent_id: agent(7),
|
||||
liveness: AgentLiveness::Stalled,
|
||||
});
|
||||
let json = serde_json::to_value(&dto).expect("serialisable");
|
||||
assert_eq!(json["type"], "agentLivenessChanged");
|
||||
assert_eq!(json["agentId"], agent(7).to_string());
|
||||
assert_eq!(json["liveness"], "stalled");
|
||||
}
|
||||
|
||||
/// La reprise `Stalled→Alive` se relaie en DTO `Alive` ⇒ wire `"alive"`.
|
||||
#[test]
|
||||
fn liveness_changed_alive_relays_to_dto_and_wire() {
|
||||
let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged {
|
||||
agent_id: agent(3),
|
||||
liveness: AgentLiveness::Alive,
|
||||
});
|
||||
let json = serde_json::to_value(&dto).expect("serialisable");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,15 +44,10 @@ pub const MCP_SERVER_SUBCOMMAND: &str = "mcp-server";
|
||||
#[must_use]
|
||||
pub fn dispatch() -> ExitCode {
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
if args
|
||||
.next()
|
||||
.is_some_and(|a| a == *MCP_SERVER_SUBCOMMAND)
|
||||
{
|
||||
if args.next().is_some_and(|a| a == *MCP_SERVER_SUBCOMMAND) {
|
||||
// Headless bridge: bypass Tauri entirely. Forward the remaining args
|
||||
// (`--endpoint`, `--project`, `--requester`) to the bridge parser.
|
||||
let rest: Vec<String> = args
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let rest: Vec<String> = args.map(|a| a.to_string_lossy().into_owned()).collect();
|
||||
return mcp_bridge::run_mcp_bridge(rest);
|
||||
}
|
||||
|
||||
@ -132,6 +127,10 @@ pub fn run() {
|
||||
commands::list_projects,
|
||||
commands::read_project_context,
|
||||
commands::update_project_context,
|
||||
commands::get_project_permissions,
|
||||
commands::update_project_permissions,
|
||||
commands::update_agent_permissions,
|
||||
commands::resolve_agent_permissions,
|
||||
commands::open_terminal,
|
||||
commands::write_terminal,
|
||||
commands::resize_terminal,
|
||||
@ -166,8 +165,11 @@ pub fn run() {
|
||||
commands::launch_agent,
|
||||
commands::change_agent_profile,
|
||||
commands::agent_send,
|
||||
commands::submit_agent_input,
|
||||
commands::cancel_resume,
|
||||
commands::set_resume_at,
|
||||
commands::interrupt_agent,
|
||||
commands::delegation_delivered,
|
||||
commands::set_front_attached,
|
||||
commands::reattach_agent_chat,
|
||||
commands::close_agent_session,
|
||||
commands::list_resumable_agents,
|
||||
|
||||
@ -49,14 +49,18 @@ use std::time::Duration;
|
||||
use interprocess::local_socket::tokio::Stream as LocalSocketStream;
|
||||
use interprocess::local_socket::traits::tokio::Stream as _;
|
||||
use interprocess::local_socket::{GenericFilePath, ToFsName as _};
|
||||
use tokio::io::{
|
||||
AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader,
|
||||
};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
|
||||
|
||||
/// How long the bridge waits to connect to the project loopback before giving up.
|
||||
/// Bounded so an absent/unreachable endpoint fails fast instead of hanging.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// After the CLI closes stdin, how long to keep draining the loopback so an
|
||||
/// in-flight response still reaches the CLI. Bounded so the bridge never blocks
|
||||
/// waiting on the server: when stdin closes the CLI is gone, and the OS closes the
|
||||
/// loopback fd at process exit anyway.
|
||||
const DRAIN_GRACE: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Parsed `mcp-server` invocation arguments.
|
||||
///
|
||||
/// `--endpoint` is **required** (without it the bridge has nowhere to relay).
|
||||
@ -222,18 +226,26 @@ async fn connect_loopback(endpoint: &str) -> Result<LocalSocketStream, String> {
|
||||
///
|
||||
/// Sequence:
|
||||
/// 1. Write the **handshake line** (`project`+`requester`) to the loopback.
|
||||
/// 2. Loop: read one **line** from the CLI (`cli_in`) → write it to the loopback;
|
||||
/// read one **line** from the loopback → write it to the CLI (`cli_out`).
|
||||
/// 3. **CLI stdin EOF** ⇒ stop, return `Ok(())` (clean code 0). Dropping the
|
||||
/// loopback writer closes that direction of the connection.
|
||||
/// 2. Run **two independent directional pumps concurrently** (full duplex):
|
||||
/// - `CLI → loopback`: every line from `cli_in` is forwarded to the loopback;
|
||||
/// - `loopback → CLI`: every line from the loopback is forwarded to `cli_out`.
|
||||
/// 3. **CLI stdin EOF** ⇒ half-close the loopback write side (so the server sees
|
||||
/// EOF), **drain** any responses still in flight, then return `Ok(())`.
|
||||
///
|
||||
/// The loop is request/response paced (one CLI line in, one loopback line back),
|
||||
/// matching the JSON Lines `StdioTransport` the server speaks. A loopback that
|
||||
/// closes mid-exchange surfaces as an error (non-zero exit).
|
||||
/// ## Why full duplex (and not lockstep)
|
||||
///
|
||||
/// JSON-RPC over MCP is **not** one-response-per-request. **Notifications** (e.g.
|
||||
/// `notifications/initialized` sent right after `initialize`) carry no `id` and get
|
||||
/// **no response**, and the server may push messages unsolicited. A lockstep pump
|
||||
/// that reads one CLI line then *blocks* for exactly one loopback line **deadlocks**
|
||||
/// on the first notification: it waits forever for a reply that never comes, and
|
||||
/// never reads the client's next request (e.g. `tools/list`) — so the CLI never
|
||||
/// receives its tool list. Two decoupled pumps let notifications and asynchronous
|
||||
/// server messages flow freely in both directions.
|
||||
async fn relay<CIn, COut, LIn, LOut>(
|
||||
args: &BridgeArgs,
|
||||
cli_in: CIn,
|
||||
mut cli_out: COut,
|
||||
cli_out: COut,
|
||||
lp_read: LIn,
|
||||
mut lp_write: LOut,
|
||||
) -> Result<(), String>
|
||||
@ -253,48 +265,66 @@ where
|
||||
.await
|
||||
.map_err(|e| format!("handshake flush failed: {e}"))?;
|
||||
|
||||
let mut cli_reader = BufReader::new(cli_in);
|
||||
let mut lp_reader = BufReader::new(lp_read);
|
||||
let mut cli_line = String::new();
|
||||
let mut lp_line = String::new();
|
||||
// 2. Two decoupled pumps. Each owns its streams so neither blocks the other.
|
||||
let cli_to_lp = pump_lines(BufReader::new(cli_in), lp_write, "stdin", "loopback");
|
||||
let lp_to_cli = pump_lines(BufReader::new(lp_read), cli_out, "loopback", "stdout");
|
||||
tokio::pin!(cli_to_lp);
|
||||
tokio::pin!(lp_to_cli);
|
||||
|
||||
tokio::select! {
|
||||
// CLI closed stdin (or errored): half-close the loopback writer to nudge
|
||||
// the server, then drain briefly so an in-flight response still reaches the
|
||||
// CLI — but never block on the server (bounded by DRAIN_GRACE).
|
||||
r = &mut cli_to_lp => {
|
||||
let mut lp_write = r?;
|
||||
let _ = lp_write.shutdown().await;
|
||||
let _ = tokio::time::timeout(DRAIN_GRACE, &mut lp_to_cli).await;
|
||||
Ok(())
|
||||
}
|
||||
// Loopback closed first (server hangup): nothing left to relay. The CLI's
|
||||
// stdin EOF is no longer needed to exit.
|
||||
r = &mut lp_to_cli => r.map(|_| ()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Forwards every newline-delimited line from `reader` to `writer` until `reader`
|
||||
/// hits EOF, flushing after each line so a peer blocked on a read sees it promptly.
|
||||
///
|
||||
/// On clean EOF it returns the (now-drained) `writer` so the caller can half-close
|
||||
/// it. `src`/`dst` name the two ends for error messages only.
|
||||
async fn pump_lines<R, W>(
|
||||
mut reader: BufReader<R>,
|
||||
mut writer: W,
|
||||
src: &str,
|
||||
dst: &str,
|
||||
) -> Result<W, String>
|
||||
where
|
||||
R: AsyncRead + Unpin,
|
||||
W: AsyncWrite + Unpin,
|
||||
{
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
// 2a. CLI → loopback (one line). EOF ⇒ clean shutdown.
|
||||
cli_line.clear();
|
||||
let n = cli_reader
|
||||
.read_line(&mut cli_line)
|
||||
line.clear();
|
||||
let n = reader
|
||||
.read_line(&mut line)
|
||||
.await
|
||||
.map_err(|e| format!("stdin read failed: {e}"))?;
|
||||
.map_err(|e| format!("{src} read failed: {e}"))?;
|
||||
if n == 0 {
|
||||
// CLI closed stdin: nothing more to forward. Clean exit.
|
||||
return Ok(());
|
||||
}
|
||||
lp_write
|
||||
.write_all(cli_line.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("loopback write failed: {e}"))?;
|
||||
lp_write
|
||||
// Source closed: drain and hand the writer back for half-close.
|
||||
writer
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| format!("loopback flush failed: {e}"))?;
|
||||
|
||||
// 2b. loopback → CLI (one line). EOF here is a server hangup mid-exchange.
|
||||
lp_line.clear();
|
||||
let m = lp_reader
|
||||
.read_line(&mut lp_line)
|
||||
.await
|
||||
.map_err(|e| format!("loopback read failed: {e}"))?;
|
||||
if m == 0 {
|
||||
return Err("loopback closed before answering".to_string());
|
||||
.map_err(|e| format!("{dst} flush failed: {e}"))?;
|
||||
return Ok(writer);
|
||||
}
|
||||
cli_out
|
||||
.write_all(lp_line.as_bytes())
|
||||
writer
|
||||
.write_all(line.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("stdout write failed: {e}"))?;
|
||||
cli_out
|
||||
.map_err(|e| format!("{dst} write failed: {e}"))?;
|
||||
writer
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| format!("stdout flush failed: {e}"))?;
|
||||
.map_err(|e| format!("{dst} flush failed: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
@ -313,7 +343,12 @@ mod tests {
|
||||
#[test]
|
||||
fn parses_all_three_flags() {
|
||||
let a = BridgeArgs::parse([
|
||||
"--endpoint", "/tmp/x.sock", "--project", "p1", "--requester", "agent-7",
|
||||
"--endpoint",
|
||||
"/tmp/x.sock",
|
||||
"--project",
|
||||
"p1",
|
||||
"--requester",
|
||||
"agent-7",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(a.endpoint, "/tmp/x.sock");
|
||||
@ -355,8 +390,7 @@ mod tests {
|
||||
};
|
||||
let line = a.handshake_line();
|
||||
assert_eq!(*line.last().unwrap(), b'\n');
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_slice(&line[..line.len() - 1]).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&line[..line.len() - 1]).unwrap();
|
||||
assert_eq!(v["project"], "proj");
|
||||
assert_eq!(v["requester"], "rq");
|
||||
}
|
||||
@ -393,24 +427,19 @@ mod tests {
|
||||
reader.read_line(&mut request).await.unwrap();
|
||||
|
||||
let mut w = srv_write;
|
||||
w.write_all(b"{\"result\":\"ok\",\"id\":1}\n").await.unwrap();
|
||||
w.write_all(b"{\"result\":\"ok\",\"id\":1}\n")
|
||||
.await
|
||||
.unwrap();
|
||||
w.flush().await.unwrap();
|
||||
(handshake, request)
|
||||
});
|
||||
|
||||
relay(
|
||||
&args,
|
||||
&cli_in[..],
|
||||
&mut cli_out,
|
||||
lp_read,
|
||||
lp_write,
|
||||
)
|
||||
relay(&args, &cli_in[..], &mut cli_out, lp_read, lp_write)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (handshake, request) = server.await.unwrap();
|
||||
let hs: serde_json::Value =
|
||||
serde_json::from_str(handshake.trim_end()).unwrap();
|
||||
let hs: serde_json::Value = serde_json::from_str(handshake.trim_end()).unwrap();
|
||||
assert_eq!(hs["project"], "proj-1");
|
||||
assert_eq!(hs["requester"], "agent-9");
|
||||
assert_eq!(request.trim_end(), "{\"method\":\"tools/call\",\"id\":1}");
|
||||
@ -420,6 +449,69 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression (the bug that hid the tool list for days): a **notification**
|
||||
/// (no `id`, no response) sent between two requests must NOT stall the relay.
|
||||
/// A lockstep pump deadlocks here — after forwarding the notification it blocks
|
||||
/// waiting for a reply that never comes, and never reads `tools/list`. The
|
||||
/// full-duplex relay forwards all three lines and delivers the one response.
|
||||
#[tokio::test]
|
||||
async fn relay_does_not_block_on_notification_without_response() {
|
||||
// initialize result, then an unanswered notification, then tools/list.
|
||||
let cli_in = b"{\"method\":\"initialize\",\"id\":1}\n\
|
||||
{\"method\":\"notifications/initialized\"}\n\
|
||||
{\"method\":\"tools/list\",\"id\":2}\n"
|
||||
.to_vec();
|
||||
let mut cli_out: Vec<u8> = Vec::new();
|
||||
|
||||
let (lp_read, srv_write) = tokio::io::duplex(4096); // server → bridge
|
||||
let (srv_read, lp_write) = tokio::io::duplex(4096); // bridge → server
|
||||
|
||||
let args = BridgeArgs {
|
||||
endpoint: "unused".into(),
|
||||
project: "p".into(),
|
||||
requester: "agent".into(),
|
||||
};
|
||||
|
||||
// Server: handshake, then read all three forwarded lines, answering only
|
||||
// the two that carry an `id`. The notification gets no reply — exactly the
|
||||
// case that used to wedge the relay.
|
||||
let server = tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(srv_read);
|
||||
let mut w = srv_write;
|
||||
for _ in 0..4 {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).await.unwrap() == 0 {
|
||||
break;
|
||||
}
|
||||
let v: serde_json::Value = match serde_json::from_str(line.trim_end()) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue, // handshake line
|
||||
};
|
||||
if let Some(id) = v.get("id") {
|
||||
w.write_all(format!("{{\"result\":\"ok\",\"id\":{id}}}\n").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
w.flush().await.unwrap();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
relay(&args, &cli_in[..], &mut cli_out, lp_read, lp_write),
|
||||
)
|
||||
.await
|
||||
.expect("relay must not deadlock on a notification")
|
||||
.expect("relay ok");
|
||||
|
||||
server.await.unwrap();
|
||||
|
||||
let out = String::from_utf8(cli_out).unwrap();
|
||||
// Both request responses arrived; the notification produced none.
|
||||
assert!(out.contains("\"id\":1"), "missing initialize response: {out}");
|
||||
assert!(out.contains("\"id\":2"), "missing tools/list response: {out}");
|
||||
}
|
||||
|
||||
/// stdin EOF with no request ⇒ relay returns `Ok` (code 0), having still sent
|
||||
/// the handshake.
|
||||
#[tokio::test]
|
||||
@ -485,10 +577,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn connect_to_absent_endpoint_errors_fast() {
|
||||
let endpoint = temp_endpoint("absent");
|
||||
let res = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
connect_loopback(&endpoint),
|
||||
)
|
||||
let res = tokio::time::timeout(Duration::from_secs(10), connect_loopback(&endpoint))
|
||||
.await
|
||||
.expect("connect_loopback must not hang");
|
||||
assert!(res.is_err(), "absent endpoint must yield an error");
|
||||
@ -545,10 +634,7 @@ mod tests {
|
||||
|
||||
// Drive the whole bridge_over_loopback path (real connect + split + relay),
|
||||
// but feed our own stdio streams via `relay` to avoid touching process std.
|
||||
let conn = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
connect_loopback(&endpoint),
|
||||
)
|
||||
let conn = tokio::time::timeout(Duration::from_secs(10), connect_loopback(&endpoint))
|
||||
.await
|
||||
.expect("no hang")
|
||||
.expect("connect to live endpoint");
|
||||
@ -565,8 +651,7 @@ mod tests {
|
||||
server.await.unwrap();
|
||||
|
||||
let (handshake, request) = captured.lock().await.clone();
|
||||
let hs: serde_json::Value =
|
||||
serde_json::from_str(handshake.trim_end()).unwrap();
|
||||
let hs: serde_json::Value = serde_json::from_str(handshake.trim_end()).unwrap();
|
||||
assert_eq!(hs["project"], "proj-e2e");
|
||||
assert_eq!(hs["requester"], "agent-e2e");
|
||||
assert_eq!(request.trim_end(), "{\"method\":\"ping\",\"id\":42}");
|
||||
|
||||
@ -136,9 +136,11 @@ pub fn mcp_endpoint(project_id: &ProjectId) -> McpEndpoint {
|
||||
/// Hors AppImage `$APPIMAGE` est absent ⇒ on retombe sur `current_exe()`. `None` si
|
||||
/// aucun des deux n'est résolvable (ne devrait pas arriver) ⇒ déclaration minimale.
|
||||
pub(crate) fn idea_exe_path() -> Option<String> {
|
||||
std::env::var("APPIMAGE")
|
||||
std::env::var("APPIMAGE").ok().or_else(|| {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.or_else(|| std::env::current_exe().ok().map(|p| p.to_string_lossy().into_owned()))
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
})
|
||||
}
|
||||
|
||||
/// Implémentation app-tauri du port [`McpRuntimeProvider`] : fournit à
|
||||
@ -209,7 +211,9 @@ mod tests {
|
||||
fn unix_endpoint_is_a_sock_path_under_the_runtime_dir() {
|
||||
let p = pid("11111111-1111-1111-1111-111111111111");
|
||||
let ep = mcp_endpoint(&p);
|
||||
let path = ep.socket_path().expect("unix endpoint exposes a socket path");
|
||||
let path = ep
|
||||
.socket_path()
|
||||
.expect("unix endpoint exposes a socket path");
|
||||
assert!(path.extension().is_some_and(|e| e == "sock"));
|
||||
assert_eq!(path.parent().unwrap(), unix_runtime_dir());
|
||||
}
|
||||
@ -252,9 +256,9 @@ mod tests {
|
||||
/// `project_id` en forme `simple` 32-hex, `requester == agent_id.to_string()`.
|
||||
#[test]
|
||||
fn app_provider_runtime_for_matches_sources_of_truth() {
|
||||
use domain::{AgentId, ProjectId, Project};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::{AgentId, Project, ProjectId};
|
||||
|
||||
let project = Project::new(
|
||||
ProjectId::from_uuid(Uuid::parse_str("abcdef01-2345-6789-abcd-ef0123456789").unwrap()),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -57,7 +57,7 @@ fn final_chunk(s: &str) -> ReplyChunk {
|
||||
fn chunk_from_event_maps_text_delta() {
|
||||
assert_eq!(
|
||||
chunk_from_event(ReplyEvent::TextDelta { text: "hi".into() }),
|
||||
ReplyChunk::TextDelta { text: "hi".into() }
|
||||
Some(ReplyChunk::TextDelta { text: "hi".into() })
|
||||
);
|
||||
}
|
||||
|
||||
@ -67,9 +67,9 @@ fn chunk_from_event_maps_tool_activity() {
|
||||
chunk_from_event(ReplyEvent::ToolActivity {
|
||||
label: "reads file".into()
|
||||
}),
|
||||
ReplyChunk::ToolActivity {
|
||||
Some(ReplyChunk::ToolActivity {
|
||||
label: "reads file".into()
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@ -79,12 +79,19 @@ fn chunk_from_event_maps_final() {
|
||||
chunk_from_event(ReplyEvent::Final {
|
||||
content: "done".into()
|
||||
}),
|
||||
ReplyChunk::Final {
|
||||
Some(ReplyChunk::Final {
|
||||
content: "done".into()
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_from_event_drops_heartbeat() {
|
||||
// A heartbeat is a non-terminal liveness proof with no chat content (lot 1) ⇒ no
|
||||
// wire chunk; the pump skips it while still draining to the Final.
|
||||
assert_eq!(chunk_from_event(ReplyEvent::Heartbeat), None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// agent_send pump behaviour: deltas* then exactly one Final (zone 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -94,7 +101,10 @@ fn chunk_from_event_maps_final() {
|
||||
/// end. This is the body of the spawned pump thread, run inline.
|
||||
fn pump_turn(bridge: &ChatBridge, session: &SessionId, gen: u64, events: Vec<ReplyEvent>) {
|
||||
for event in events {
|
||||
let _ = bridge.send_output(session, chunk_from_event(event));
|
||||
// Mirror the real pump: heartbeats map to no chunk and are skipped.
|
||||
if let Some(chunk) = chunk_from_event(event) {
|
||||
let _ = bridge.send_output(session, chunk);
|
||||
}
|
||||
}
|
||||
bridge.detach_if(session, gen);
|
||||
}
|
||||
@ -126,7 +136,10 @@ fn pump_delivers_deltas_then_exactly_one_final_in_order() {
|
||||
"deltas in order, then the single Final"
|
||||
);
|
||||
// Exactly one Final, and it is last (deterministic terminal chunk).
|
||||
let finals = got.iter().filter(|c| matches!(c, ReplyChunk::Final { .. })).count();
|
||||
let finals = got
|
||||
.iter()
|
||||
.filter(|c| matches!(c, ReplyChunk::Final { .. }))
|
||||
.count();
|
||||
assert_eq!(finals, 1, "exactly one Final per turn");
|
||||
assert!(matches!(got.last(), Some(ReplyChunk::Final { .. })));
|
||||
}
|
||||
@ -227,8 +240,12 @@ fn scrollback_accumulates_every_routed_chunk_in_order() {
|
||||
gen,
|
||||
vec![
|
||||
ReplyEvent::TextDelta { text: "x".into() },
|
||||
ReplyEvent::ToolActivity { label: "runs".into() },
|
||||
ReplyEvent::Final { content: "x".into() },
|
||||
ReplyEvent::ToolActivity {
|
||||
label: "runs".into(),
|
||||
},
|
||||
ReplyEvent::Final {
|
||||
content: "x".into(),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
@ -236,7 +253,9 @@ fn scrollback_accumulates_every_routed_chunk_in_order() {
|
||||
bridge.scrollback(&session),
|
||||
vec![
|
||||
delta("x"),
|
||||
ReplyChunk::ToolActivity { label: "runs".into() },
|
||||
ReplyChunk::ToolActivity {
|
||||
label: "runs".into()
|
||||
},
|
||||
final_chunk("x"),
|
||||
],
|
||||
"scrollback retains the full conversation, in order"
|
||||
|
||||
@ -148,7 +148,8 @@ fn orchestration_source_dto_serialises_lowercase() {
|
||||
use domain::events::OrchestrationSource;
|
||||
|
||||
// File → "file"; Mcp → "mcp" (camelCase rename on a unit enum = lowercase).
|
||||
let file = serde_json::to_value(OrchestrationSourceDto::from(OrchestrationSource::File)).unwrap();
|
||||
let file =
|
||||
serde_json::to_value(OrchestrationSourceDto::from(OrchestrationSource::File)).unwrap();
|
||||
assert_eq!(file, json!("file"));
|
||||
|
||||
let mcp = serde_json::to_value(OrchestrationSourceDto::from(OrchestrationSource::Mcp)).unwrap();
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -8,9 +8,9 @@
|
||||
|
||||
use app_tauri_lib::dto::{CellKind, ReattachChatDto, ReplyChunk, TerminalSessionDto};
|
||||
use application::{LaunchAgentOutput, StructuredSessionDescriptor};
|
||||
use domain::{AgentId, NodeId, PtySize, SessionKind, SessionStatus, TerminalSession};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::SessionId;
|
||||
use domain::{AgentId, NodeId, PtySize, SessionKind, SessionStatus, TerminalSession};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -49,8 +49,12 @@ fn reply_chunk_final_serialises_exact_camel_case() {
|
||||
fn reply_chunk_round_trips_through_json_for_every_variant() {
|
||||
for chunk in [
|
||||
ReplyChunk::TextDelta { text: "x".into() },
|
||||
ReplyChunk::ToolActivity { label: "runs".into() },
|
||||
ReplyChunk::Final { content: "y".into() },
|
||||
ReplyChunk::ToolActivity {
|
||||
label: "runs".into(),
|
||||
},
|
||||
ReplyChunk::Final {
|
||||
content: "y".into(),
|
||||
},
|
||||
] {
|
||||
let v = serde_json::to_value(&chunk).unwrap();
|
||||
let back: ReplyChunk = serde_json::from_value(v).unwrap();
|
||||
@ -84,7 +88,9 @@ fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() {
|
||||
session_id: "sess-1".into(),
|
||||
scrollback: vec![
|
||||
ReplyChunk::TextDelta { text: "Hi".into() },
|
||||
ReplyChunk::Final { content: "Hi".into() },
|
||||
ReplyChunk::Final {
|
||||
content: "Hi".into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
@ -151,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);
|
||||
@ -167,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");
|
||||
@ -201,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());
|
||||
|
||||
@ -16,9 +16,7 @@ use async_trait::async_trait;
|
||||
use app_tauri_lib::dto::LiveAgentListDto;
|
||||
use application::{LiveSessions, StructuredSessions, TerminalSessions};
|
||||
use domain::ports::{AgentSession, AgentSessionError, PtyHandle, ReplyStream};
|
||||
use domain::{
|
||||
AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession,
|
||||
};
|
||||
use domain::{AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession};
|
||||
use uuid::Uuid;
|
||||
|
||||
// --- petits constructeurs déterministes ------------------------------------
|
||||
@ -160,7 +158,11 @@ fn same_agent_in_both_registries_is_deduplicated() {
|
||||
structured.insert(fake(sid(2)), a, nid(200));
|
||||
|
||||
let dto = dto_for(&pty, &structured);
|
||||
assert_eq!(dto.0.len(), 1, "un même agent ne doit apparaître qu'une fois");
|
||||
assert_eq!(
|
||||
dto.0.len(),
|
||||
1,
|
||||
"un même agent ne doit apparaître qu'une fois"
|
||||
);
|
||||
assert_eq!(dto.0[0].agent_id, a.to_string());
|
||||
// On garde la première occurrence (PTY, listé en premier par l'agrégateur).
|
||||
assert_eq!(dto.0[0].node_id, nid(100).to_string());
|
||||
|
||||
@ -164,10 +164,7 @@ async fn stop_watch_unregisters_the_mcp_server() {
|
||||
assert!(has_mcp(&state, &project.id));
|
||||
|
||||
state.stop_orchestrator_watch(&project.id);
|
||||
assert!(
|
||||
!has_mcp(&state, &project.id),
|
||||
"MCP server removed on close"
|
||||
);
|
||||
assert!(!has_mcp(&state, &project.id), "MCP server removed on close");
|
||||
assert_eq!(mcp_count(&state), 0);
|
||||
|
||||
// Stopping an unknown project is a no-op (does not panic) for the MCP twin too.
|
||||
@ -274,7 +271,10 @@ async fn double_open_keeps_a_single_endpoint_no_address_in_use() {
|
||||
// socket) — it returns early. One endpoint, still bound, no panic.
|
||||
state.ensure_orchestrator_watch(&project);
|
||||
assert_eq!(mcp_count(&state), 1, "one endpoint per project");
|
||||
assert!(socket_exists(&project), "endpoint still bound after re-open");
|
||||
assert!(
|
||||
socket_exists(&project),
|
||||
"endpoint still bound after re-open"
|
||||
);
|
||||
|
||||
state.stop_orchestrator_watch(&project.id);
|
||||
}
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
@ -18,8 +18,10 @@
|
||||
//! making the reference profiles addressable across runs without a registry.
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::permission::ProjectorKey;
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
|
||||
StructuredAdapter,
|
||||
};
|
||||
|
||||
/// A fixed UUID namespace used to derive stable ids for reference profiles.
|
||||
@ -60,6 +62,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
)
|
||||
.expect("claude reference profile is valid")
|
||||
.with_structured_adapter(StructuredAdapter::Claude)
|
||||
.with_projector(ProjectorKey::Claude)
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::config_file(".mcp.json")
|
||||
.expect(".mcp.json is a valid relative MCP config target"),
|
||||
@ -78,9 +81,13 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
)
|
||||
.expect("codex reference profile is valid")
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_projector(ProjectorKey::Codex)
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::config_file(".mcp.json")
|
||||
.expect(".mcp.json is a valid relative MCP config target"),
|
||||
// Codex lit ses serveurs MCP dans `$CODEX_HOME/config.toml`, pas `.mcp.json` :
|
||||
// IdeA écrit ce TOML DANS le run dir et pointe `CODEX_HOME` dessus pour
|
||||
// isoler l'agent du `~/.codex` global (miroir du `.mcp.json` de Claude).
|
||||
McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME")
|
||||
.expect(".codex/config.toml + CODEX_HOME is a valid MCP config target"),
|
||||
McpTransport::Stdio,
|
||||
)),
|
||||
AgentProfile::new(
|
||||
@ -156,16 +163,36 @@ mod mcp_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_and_codex_mcp_use_config_file_mcp_json() {
|
||||
for slug in ["claude", "codex"] {
|
||||
let mcp = profile(slug).mcp.expect("mcp present");
|
||||
fn claude_mcp_uses_config_file_mcp_json() {
|
||||
let mcp = profile("claude").mcp.expect("mcp present");
|
||||
assert_eq!(
|
||||
mcp.config,
|
||||
McpConfigStrategy::ConfigFile { target: ".mcp.json".to_owned() },
|
||||
"profile `{slug}` should declare `.mcp.json`"
|
||||
McpConfigStrategy::ConfigFile {
|
||||
target: ".mcp.json".to_owned()
|
||||
},
|
||||
"Claude should declare `.mcp.json`"
|
||||
);
|
||||
assert_eq!(mcp.transport, McpTransport::Stdio);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_mcp_uses_toml_config_home_codex() {
|
||||
// Codex lit `$CODEX_HOME/config.toml`, pas `.mcp.json` : le seed doit déclarer
|
||||
// la stratégie TOML isolée par `CODEX_HOME` (pendant Codex de Claude).
|
||||
let mcp = profile("codex").mcp.expect("mcp present");
|
||||
assert_eq!(
|
||||
mcp.config,
|
||||
McpConfigStrategy::TomlConfigHome {
|
||||
target: ".codex/config.toml".to_owned(),
|
||||
home_env: "CODEX_HOME".to_owned(),
|
||||
},
|
||||
"Codex should declare `.codex/config.toml` + CODEX_HOME"
|
||||
);
|
||||
assert_eq!(mcp.transport, McpTransport::Stdio);
|
||||
assert!(
|
||||
profile("codex").materializes_idea_bridge(),
|
||||
"the Codex seed must materialise the idea bridge"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -177,4 +204,30 @@ mod mcp_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Lot LP3 : projector (clé du projecteur de permissions par-CLI) ----------
|
||||
|
||||
#[test]
|
||||
fn claude_and_codex_seed_their_projector_key() {
|
||||
assert_eq!(
|
||||
profile("claude").projector,
|
||||
Some(ProjectorKey::Claude),
|
||||
"the Claude seed must pose the Claude projector"
|
||||
);
|
||||
assert_eq!(
|
||||
profile("codex").projector,
|
||||
Some(ProjectorKey::Codex),
|
||||
"the Codex seed must pose the Codex projector"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_and_aider_have_no_projector() {
|
||||
for slug in ["gemini", "aider"] {
|
||||
assert!(
|
||||
profile(slug).projector.is_none(),
|
||||
"non-structured profile `{slug}` must NOT carry a projector (native prompting)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -10,27 +10,30 @@ 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::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};
|
||||
pub use resume::{
|
||||
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
|
||||
};
|
||||
pub use lifecycle::{
|
||||
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
|
||||
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider,
|
||||
LaunchAgent,
|
||||
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, McpRuntime,
|
||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
||||
ListAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider,
|
||||
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor,
|
||||
UpdateAgentContext,
|
||||
UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
|
||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
pub use resume::{
|
||||
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
|
||||
};
|
||||
pub use usecases::{
|
||||
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile,
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -14,7 +14,35 @@
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use domain::input::InputMediator;
|
||||
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é.
|
||||
@ -33,20 +61,134 @@ use domain::ports::{AgentSession, AgentSessionError, ReplyEvent};
|
||||
/// - [`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> {
|
||||
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
|
||||
/// tour, [`ReadinessPolicy::classify`] est consulté et, dès qu'il renvoie
|
||||
/// [`ReadinessSignal::TurnEnded`] (le `Final`), le médiateur d'entrée est notifié
|
||||
/// (`mark_idle(agent)`) pour que la FIFO de l'agent avance — **sans** dépendre d'un
|
||||
/// `idea_reply` explicite ni du sniff de prompt PTY (chantier readiness/heartbeat,
|
||||
/// lot 1, fix de la cause racine du blocage `Busy`).
|
||||
///
|
||||
/// DRY : **un seul** chemin de lecture du flux (la boucle de [`drain_bounded`]) ;
|
||||
/// cette fonction n'est que `send_blocking` muni d'un *sink* de readiness. Le `Final`
|
||||
/// réveille donc à la fois le `pending` (via la valeur de retour) **et** la FIFO (via
|
||||
/// `mark_idle`). `idea_reply` reste un signal alternatif (premier arrivé gagne) côté
|
||||
/// orchestrateur.
|
||||
///
|
||||
/// # Errors
|
||||
/// Identiques à [`send_blocking`] (échec `send`/décodage, flux clos sans `Final`,
|
||||
/// 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,
|
||||
timeout: Option<Duration>,
|
||||
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
|
||||
// (delta / activité / heartbeat) ⇒ on passe un sink d'événement bruts `on_event`.
|
||||
drain_bounded_events(
|
||||
session,
|
||||
prompt,
|
||||
timeout,
|
||||
|event| {
|
||||
// Tout événement **non terminal** prouve la vivacité ⇒ un battement.
|
||||
if !matches!(event, ReplyEvent::Final { .. }) {
|
||||
mediator.mark_alive(agent);
|
||||
}
|
||||
},
|
||||
|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);
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Ouvre le flux du tour (`send`) et le **draine jusqu'au `Final`**, en appliquant
|
||||
/// la borne temporelle `timeout`, en notifiant `on_event` à **chaque** événement brut
|
||||
/// (pour le battement de vivacité, lot 2) et `on_signal` à chaque [`ReadinessSignal`]
|
||||
/// dérivé par [`ReadinessPolicy`] (le `Final` ⇒ `TurnEnded`).
|
||||
///
|
||||
/// **Chemin de lecture unique** (DRY) : `send_blocking` et `drain_with_readiness`
|
||||
/// passent tous deux par ici, en différant seulement par leurs *sinks*. La session
|
||||
/// **reste vivante** sur timeout (on ne `shutdown` rien ici, §17.1).
|
||||
async fn drain_bounded_events(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
timeout: Option<Duration>,
|
||||
on_event: impl FnMut(&ReplyEvent),
|
||||
on_signal: impl FnMut(ReadinessSignal),
|
||||
) -> Result<TurnOutcome, AgentSessionError> {
|
||||
match timeout {
|
||||
Some(dur) => match tokio::time::timeout(dur, drain_to_final(session, prompt)).await {
|
||||
Some(dur) => match tokio::time::timeout(
|
||||
dur,
|
||||
drain_to_final(session, prompt, on_event, on_signal),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
// La session **reste vivante** : on ne `shutdown` rien ici (§17.1).
|
||||
Err(_elapsed) => Err(AgentSessionError::Timeout),
|
||||
},
|
||||
None => drain_to_final(session, prompt).await,
|
||||
None => drain_to_final(session, prompt, on_event, on_signal).await,
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,20 +196,144 @@ pub async fn send_blocking(
|
||||
///
|
||||
/// 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`] ; 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,
|
||||
) -> Result<String, AgentSessionError> {
|
||||
mut on_event: impl FnMut(&ReplyEvent),
|
||||
mut on_signal: impl FnMut(ReadinessSignal),
|
||||
) -> 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 {
|
||||
if let ReplyEvent::Final { content } = event {
|
||||
return Ok(content);
|
||||
// Battement de vivacité (lot 2) : notifié pour CHAQUE événement brut, avant le
|
||||
// classement readiness. Le sink décide (les non-terminaux prouvent la vivacité).
|
||||
on_event(&event);
|
||||
if let Some(signal) = ReadinessPolicy::classify(&event) {
|
||||
on_signal(signal);
|
||||
}
|
||||
// TextDelta / ToolActivity : ignorés par le rendez-vous synchrone.
|
||||
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 => {}
|
||||
}
|
||||
Err(AgentSessionError::Io(
|
||||
}
|
||||
// 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use domain::ids::SessionId;
|
||||
use domain::input::{AgentBusyState, SubmitConfig};
|
||||
use domain::mailbox::{PendingReply, Ticket};
|
||||
use domain::ports::PtyHandle;
|
||||
|
||||
fn agent(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
/// Session factice : `send` rejoue une liste fixe d'événements (terminée par un
|
||||
/// `Final`).
|
||||
struct FakeSession {
|
||||
events: Vec<ReplyEvent>,
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl AgentSession for FakeSession {
|
||||
fn id(&self) -> SessionId {
|
||||
SessionId::from_uuid(uuid::Uuid::from_u128(1))
|
||||
}
|
||||
fn conversation_id(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
async fn send(
|
||||
&self,
|
||||
_prompt: &str,
|
||||
) -> Result<domain::ports::ReplyStream, AgentSessionError> {
|
||||
Ok(Box::new(self.events.clone().into_iter()))
|
||||
}
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Médiateur factice qui enregistre l'ordre des `mark_alive` / `mark_idle`.
|
||||
#[derive(Default)]
|
||||
struct RecordingMediator {
|
||||
calls: Mutex<Vec<&'static str>>,
|
||||
}
|
||||
impl InputMediator for RecordingMediator {
|
||||
fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply {
|
||||
unreachable!("non utilisé par drain_with_readiness")
|
||||
}
|
||||
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
|
||||
}
|
||||
fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {}
|
||||
fn bind_handle_with_prompt(
|
||||
&self,
|
||||
_agent: AgentId,
|
||||
_handle: PtyHandle,
|
||||
_pattern: Option<String>,
|
||||
_submit: SubmitConfig,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drain_marks_alive_on_each_non_terminal_then_idle_on_final() {
|
||||
let session = FakeSession {
|
||||
events: vec![
|
||||
ReplyEvent::TextDelta { text: "a".into() },
|
||||
ReplyEvent::ToolActivity { label: "lit".into() },
|
||||
ReplyEvent::Heartbeat,
|
||||
ReplyEvent::Final {
|
||||
content: "fini".into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
let mediator = RecordingMediator::default();
|
||||
let out = drain_with_readiness(&session, "go", None, &mediator, agent(1))
|
||||
.await
|
||||
.expect("drain ok");
|
||||
assert_eq!(out, "fini");
|
||||
// Trois battements (delta, activité, heartbeat) PUIS l'idle sur le Final.
|
||||
assert_eq!(
|
||||
*mediator.calls.lock().unwrap(),
|
||||
vec!["alive", "alive", "alive", "idle"],
|
||||
"un battement par événement non terminal, idle au Final (pas de battement sur le Final)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,8 +35,8 @@ pub use reconcile::{ReconcileLayouts, ReconcileLayoutsInput, ReconcileLayoutsOut
|
||||
pub use snapshot::{
|
||||
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput,
|
||||
};
|
||||
pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
|
||||
pub(crate) use store::{persist_doc, resolve_doc};
|
||||
pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
|
||||
pub use usecases::{
|
||||
LayoutOperation, LoadLayout, LoadLayoutInput, LoadLayoutOutput, MutateLayout,
|
||||
MutateLayoutInput, MutateLayoutOutput,
|
||||
|
||||
@ -109,12 +109,7 @@ impl LayoutOperation {
|
||||
direction,
|
||||
new_leaf,
|
||||
container,
|
||||
} => tree.split(
|
||||
*target,
|
||||
*direction,
|
||||
LeafCell::new(*new_leaf),
|
||||
*container,
|
||||
),
|
||||
} => tree.split(*target, *direction, LeafCell::new(*new_leaf), *container),
|
||||
Self::Merge {
|
||||
container,
|
||||
keep_index,
|
||||
|
||||
@ -20,6 +20,7 @@ pub mod health;
|
||||
pub mod layout;
|
||||
pub mod memory;
|
||||
pub mod orchestrator;
|
||||
pub mod permission;
|
||||
pub mod project;
|
||||
pub mod remote;
|
||||
pub mod skill;
|
||||
@ -28,19 +29,21 @@ pub mod terminal;
|
||||
pub mod window;
|
||||
|
||||
pub use agent::{
|
||||
reference_profile_id, reference_profiles, selectable_reference_profiles, ChangeAgentProfile,
|
||||
ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput,
|
||||
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
||||
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
|
||||
HandoffProvider,
|
||||
InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent,
|
||||
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
||||
ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, McpRuntime,
|
||||
ListResumableAgentsOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput,
|
||||
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile,
|
||||
SaveProfileInput, SaveProfileOutput, StructuredSessionDescriptor, UpdateAgentContext,
|
||||
UpdateAgentContextInput, send_blocking, AGENT_MEMORY_RECALL_BUDGET,
|
||||
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,
|
||||
FirstRunStateOutput, HandoffProvider, InspectConversation, InspectConversationInput,
|
||||
InspectConversationOutput, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents,
|
||||
ListAgentsInput, ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
|
||||
ListResumableAgentsInput, ListResumableAgentsOutput, McpRuntime, PermissionProjectorRegistry,
|
||||
ProfileAvailability,
|
||||
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
|
||||
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput,
|
||||
SaveProfileOutput, SessionLimitService, StructuredSessionDescriptor, TurnOutcome,
|
||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, RESUME_PROMPT,
|
||||
};
|
||||
pub use conversation::RecordTurn;
|
||||
pub use embedder::{
|
||||
@ -77,6 +80,12 @@ pub use memory::{
|
||||
pub use orchestrator::{
|
||||
McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, RecordTurnProvider,
|
||||
};
|
||||
pub use permission::{
|
||||
GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput,
|
||||
ResolveAgentPermissions, ResolveAgentPermissionsInput, ResolveAgentPermissionsOutput,
|
||||
UpdateAgentPermissions, UpdateAgentPermissionsInput, UpdateProjectPermissions,
|
||||
UpdateProjectPermissionsInput,
|
||||
};
|
||||
pub use project::{
|
||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
||||
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,
|
||||
|
||||
@ -29,9 +29,7 @@ use domain::conversation::ConversationParty;
|
||||
use domain::fileguard::{FileGuard, GuardError, GuardedResource};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
|
||||
use domain::ports::{
|
||||
AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath,
|
||||
};
|
||||
use domain::ports::{AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath};
|
||||
use domain::{AgentId, Project};
|
||||
|
||||
use crate::error::AppError;
|
||||
@ -90,7 +88,11 @@ impl ReadContext {
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
) -> Self {
|
||||
Self { guard, contexts, fs }
|
||||
Self {
|
||||
guard,
|
||||
contexts,
|
||||
fs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the requested context, returning its Markdown body.
|
||||
@ -98,7 +100,11 @@ impl ReadContext {
|
||||
/// # Errors
|
||||
/// [`AppError`] when the agent/context does not exist or the store/fs fails.
|
||||
pub async fn execute(&self, input: ReadContextInput) -> Result<MarkdownDoc, AppError> {
|
||||
let ReadContextInput { project, target, requester } = input;
|
||||
let ReadContextInput {
|
||||
project,
|
||||
target,
|
||||
requester,
|
||||
} = input;
|
||||
match target {
|
||||
None => {
|
||||
// Global project context: shared read-lease, then read the root file.
|
||||
@ -109,8 +115,8 @@ impl ReadContext {
|
||||
.map_err(map_guard_err)?;
|
||||
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
||||
let bytes = self.fs.read(&path).await?;
|
||||
let text = String::from_utf8(bytes)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let text =
|
||||
String::from_utf8(bytes).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
Ok(MarkdownDoc::new(text))
|
||||
}
|
||||
Some(name) => {
|
||||
@ -173,7 +179,12 @@ impl ProposeContext {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
clock: Arc<dyn Clock>,
|
||||
) -> Self {
|
||||
Self { guard, contexts, fs, clock }
|
||||
Self {
|
||||
guard,
|
||||
contexts,
|
||||
fs,
|
||||
clock,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the proposal: direct write under a write-lease, or a materialised
|
||||
@ -182,7 +193,12 @@ impl ProposeContext {
|
||||
/// # Errors
|
||||
/// [`AppError`] when the agent does not exist or the store/fs fails.
|
||||
pub async fn execute(&self, input: ProposeContextInput) -> Result<ProposeOutcome, AppError> {
|
||||
let ProposeContextInput { project, target, content, requester } = input;
|
||||
let ProposeContextInput {
|
||||
project,
|
||||
target,
|
||||
content,
|
||||
requester,
|
||||
} = input;
|
||||
match target {
|
||||
Some(name) => {
|
||||
// Per-agent context: direct write under an exclusive write-lease.
|
||||
@ -273,7 +289,11 @@ impl ReadMemory {
|
||||
/// [`AppError`] when the note does not exist or the store fails. An invalid slug
|
||||
/// is [`AppError::Invalid`].
|
||||
pub async fn execute(&self, input: ReadMemoryInput) -> Result<String, AppError> {
|
||||
let ReadMemoryInput { project, slug, requester } = input;
|
||||
let ReadMemoryInput {
|
||||
project,
|
||||
slug,
|
||||
requester,
|
||||
} = input;
|
||||
match slug {
|
||||
Some(raw) => {
|
||||
let slug = MemorySlug::new(raw).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
@ -329,7 +349,12 @@ impl WriteMemory {
|
||||
/// [`AppError::Invalid`] for a bad slug or empty body; [`AppError`] on a store
|
||||
/// failure.
|
||||
pub async fn execute(&self, input: WriteMemoryInput) -> Result<(), AppError> {
|
||||
let WriteMemoryInput { project, slug, content, requester } = input;
|
||||
let WriteMemoryInput {
|
||||
project,
|
||||
slug,
|
||||
content,
|
||||
requester,
|
||||
} = input;
|
||||
let slug = MemorySlug::new(slug).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let _lease = self
|
||||
.guard
|
||||
@ -527,11 +552,7 @@ mod tests {
|
||||
async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
slug: &MemorySlug,
|
||||
) -> Result<Memory, MemoryError> {
|
||||
async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
|
||||
let body = self
|
||||
.notes
|
||||
.lock()
|
||||
@ -556,11 +577,7 @@ mod tests {
|
||||
.insert(memory.slug().to_string(), memory.body.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_slug: &MemorySlug,
|
||||
) -> Result<(), MemoryError> {
|
||||
async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn read_index(
|
||||
@ -805,7 +822,10 @@ mod tests {
|
||||
guard.acquire_write(agent_party(2), slug.clone()),
|
||||
)
|
||||
.await;
|
||||
assert!(blocked.is_err(), "a second writer must block while w1 holds");
|
||||
assert!(
|
||||
blocked.is_err(),
|
||||
"a second writer must block while w1 holds"
|
||||
);
|
||||
drop(w1);
|
||||
let w2 = tokio::time::timeout(
|
||||
Duration::from_millis(200),
|
||||
|
||||
@ -19,21 +19,22 @@ use std::time::Duration;
|
||||
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
use domain::conversation::{
|
||||
ConversationParty, ConversationRegistry, SessionRef, WaitForGraph,
|
||||
};
|
||||
use domain::conversation::{ConversationParty, ConversationRegistry, SessionRef, WaitForGraph};
|
||||
use domain::conversation_log::{ConversationTurn, TurnId, TurnRole};
|
||||
use domain::input::{InputMediator, InputSource};
|
||||
use domain::input::{InputMediator, InputSource, SubmitConfig};
|
||||
use domain::mailbox::{Ticket, TicketId};
|
||||
use domain::ports::{Clock, EventBus, ProfileStore, PtyHandle};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::{AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project};
|
||||
use domain::{
|
||||
AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project,
|
||||
};
|
||||
|
||||
use crate::conversation::RecordTurn;
|
||||
|
||||
use crate::agent::{
|
||||
CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, ListAgents,
|
||||
ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext, UpdateAgentContextInput,
|
||||
drain_with_readiness, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput,
|
||||
ListAgents, ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext,
|
||||
UpdateAgentContextInput,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::orchestrator::{
|
||||
@ -41,7 +42,7 @@ use crate::orchestrator::{
|
||||
ReadMemoryInput, WriteMemory, WriteMemoryInput,
|
||||
};
|
||||
use crate::skill::{CreateSkill, CreateSkillInput};
|
||||
use crate::terminal::{CloseTerminal, CloseTerminalInput, TerminalSessions};
|
||||
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions};
|
||||
|
||||
/// Default terminal geometry for an orchestrator-launched agent cell. The UI
|
||||
/// resizes the PTY to the real cell size on attach; these are sane starting rows
|
||||
@ -57,7 +58,13 @@ const DEFAULT_COLS: u16 = 80;
|
||||
/// **without killing the session**, so the requester can retry. Internal and
|
||||
/// intentionally not yet config-exposed (it may become a per-project setting
|
||||
/// without changing the contract).
|
||||
const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
///
|
||||
/// TEMPORAIRE (demande utilisateur 2026-06-13) : la borne de 300 s coupait des tours
|
||||
/// délégués légitimement très longs (gros refactors + tests). On la relève donc à 24 h
|
||||
/// (≈ « pas de limite ») le temps de concevoir un vrai signal de vivacité (savoir si
|
||||
/// l'agent travaille encore) qui remplacera ce timeout brut. NE PAS considérer comme
|
||||
/// définitif : à reconvertir en borne courte + heartbeat once that liveness signal exists.
|
||||
const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
/// Borne d'attente **en file** pour acquérir le verrou de tour d'un agent (A0,
|
||||
/// cadrage v5 §4).
|
||||
@ -72,7 +79,82 @@ const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
/// [`domain::ports::AgentSessionError::Timeout`]) ; le tour en cours n'est pas
|
||||
/// affecté. Largeur = un tour complet + sa propre file ⇒ on autorise deux tours
|
||||
/// pleins d'attente.
|
||||
const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(600);
|
||||
///
|
||||
/// TEMPORAIRE (cf. [`ASK_AGENT_TIMEOUT`]) : relevé à 48 h (≈ deux tours « sans limite »)
|
||||
/// en cohérence avec la levée temporaire de la borne de tour, le temps d'un vrai
|
||||
/// signal de vivacité.
|
||||
const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(48 * 60 * 60);
|
||||
|
||||
/// Borne de tour effective (lot 2, timeouts pilotés par profil) : le
|
||||
/// `turn_timeout_ms` du profil de la cible **quand il existe**, sinon le défaut
|
||||
/// historique [`ASK_AGENT_TIMEOUT`] (zéro régression pour un profil sans `liveness` /
|
||||
/// sans `turn_timeout_ms`). Pure et testable sans wiring de service.
|
||||
#[must_use]
|
||||
fn resolve_turn_timeout(turn_timeout_ms: Option<u32>) -> Duration {
|
||||
turn_timeout_ms.map_or(ASK_AGENT_TIMEOUT, |ms| Duration::from_millis(u64::from(ms)))
|
||||
}
|
||||
|
||||
/// Garde RAII de **fin de tour** : ramène la cible `Idle` quoi qu'il arrive (succès,
|
||||
/// erreur, timeout, **et surtout future abandonné/dropped**) tant qu'elle n'a pas été
|
||||
/// désarmée.
|
||||
///
|
||||
/// Cause racine du blocage `Busy` à vie : l'agent passe `Idle→Busy` dès l'`enqueue`
|
||||
/// (qui démarre le tour) mais ne revenait `Idle` que sur la **branche succès** du
|
||||
/// `select!`/`match`. Si le futur `ask_agent` est **dropped** (le demandeur a interrompu
|
||||
/// son appel), AUCUNE branche ne s'exécute ⇒ l'agent reste `Busy`, et toutes les
|
||||
/// délégations suivantes s'empilent derrière ce tour fantôme sans jamais être livrées.
|
||||
///
|
||||
/// Le garde tient les `Arc` nécessaires pour, à son `Drop`, faire à la fois
|
||||
/// `cancel_head(agent, ticket)` (retire le ticket fantôme de la FIFO — idempotent et
|
||||
/// positionnel : no-op si le head a déjà changé) **et** `mark_idle(agent)` (libère la
|
||||
/// FIFO — idempotent). Sur **succès**, on appelle [`BusyTurnGuard::disarm`] AVANT de
|
||||
/// retourner : le `mark_idle` « propre » déjà présent reste en place et on évite tout
|
||||
/// double `cancel_head` d'un ticket déjà résolu.
|
||||
struct BusyTurnGuard {
|
||||
input: Arc<dyn InputMediator>,
|
||||
mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
|
||||
agent: AgentId,
|
||||
ticket: TicketId,
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
impl BusyTurnGuard {
|
||||
/// Arme le garde juste après l'`enqueue` (qui a démarré le tour ⇒ cible `Busy`).
|
||||
fn new(
|
||||
input: Arc<dyn InputMediator>,
|
||||
mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
|
||||
agent: AgentId,
|
||||
ticket: TicketId,
|
||||
) -> Self {
|
||||
Self {
|
||||
input,
|
||||
mailbox,
|
||||
agent,
|
||||
ticket,
|
||||
armed: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Désarme le garde (branche succès) : le `Drop` devient un no-op. À appeler AVANT
|
||||
/// de retourner le contenu, pour préserver le `mark_idle` propre déjà fait et ne pas
|
||||
/// re-`cancel_head` un ticket déjà résolu.
|
||||
fn disarm(mut self) {
|
||||
self.armed = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BusyTurnGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.armed {
|
||||
// Ordre : retirer le ticket fantôme de la FIFO PUIS libérer le busy state.
|
||||
// `cancel_head` est positionnel/idempotent (no-op si le head n'est plus ce
|
||||
// ticket) et `mark_idle` est idempotent (no-op si déjà Idle) ⇒ sûr quel que
|
||||
// soit le chemin (erreur, timeout, drop).
|
||||
self.mailbox.cancel_head(self.agent, self.ticket);
|
||||
self.input.mark_idle(self.agent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fournit les faits OS/runtime (exe + endpoint projet) pour écrire la déclaration MCP
|
||||
/// réelle quand l'orchestrateur (re)lance une cible sur le chemin `ask`. Implémenté dans
|
||||
@ -182,6 +264,15 @@ pub struct OrchestratorService {
|
||||
/// injectée avec [`Self::record_turn`]. La couche `application` reste **pure** : pas
|
||||
/// de `SystemTime::now()` brut ici, le temps vient du port injecté au composition root.
|
||||
clock: Option<Arc<dyn Clock>>,
|
||||
/// Registre des **sessions IA structurées** vivantes (§17.5), pour router un `ask`
|
||||
/// vers une cible à `structured_adapter` directement sur sa session
|
||||
/// ([`drain_with_readiness`](crate::agent::drain_with_readiness)) — le `Final`
|
||||
/// déterministe réveille le `pending` **et** marque l'agent `Idle` (chantier
|
||||
/// readiness/heartbeat, lot 1, fix du blocage `Busy`). Injecté via
|
||||
/// [`Self::with_structured`] ; `None` ⇒ on conserve **uniquement** le chemin
|
||||
/// PTY/mailbox legacy (zéro régression pour les call sites/tests qui ne le branchent
|
||||
/// pas).
|
||||
structured: Option<Arc<StructuredSessions>>,
|
||||
}
|
||||
|
||||
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
|
||||
@ -244,9 +335,19 @@ impl OrchestratorService {
|
||||
context_guard: None,
|
||||
record_turn: None,
|
||||
clock: None,
|
||||
structured: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Branche le registre des [`StructuredSessions`] (§17.5) pour router un `ask`
|
||||
/// vers une cible **structurée** sur sa propre session. Builder additif (signature
|
||||
/// de [`Self::new`] inchangée) ; `None` ⇒ chemin PTY/mailbox legacy uniquement.
|
||||
#[must_use]
|
||||
pub fn with_structured(mut self, structured: Arc<StructuredSessions>) -> Self {
|
||||
self.structured = Some(structured);
|
||||
self
|
||||
}
|
||||
|
||||
/// Branche les use cases C7 (`context.*`/`memory.*`) sous
|
||||
/// [`domain::fileguard::FileGuard`]. Builder additif (signature de [`Self::new`]
|
||||
/// inchangée).
|
||||
@ -416,7 +517,10 @@ impl OrchestratorService {
|
||||
target,
|
||||
content,
|
||||
requester,
|
||||
} => self.propose_context(project, target, content, requester).await,
|
||||
} => {
|
||||
self.propose_context(project, target, content, requester)
|
||||
.await
|
||||
}
|
||||
OrchestratorCommand::ReadMemory { slug, requester } => {
|
||||
self.read_memory(project, slug, requester).await
|
||||
}
|
||||
@ -453,10 +557,7 @@ impl OrchestratorService {
|
||||
})
|
||||
.await?;
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!(
|
||||
"read {} context",
|
||||
target.as_deref().unwrap_or("project")
|
||||
),
|
||||
detail: format!("read {} context", target.as_deref().unwrap_or("project")),
|
||||
reply: Some(md.into_string()),
|
||||
})
|
||||
}
|
||||
@ -481,15 +582,17 @@ impl OrchestratorService {
|
||||
})
|
||||
.await?;
|
||||
let detail = match outcome {
|
||||
ProposeOutcome::Written => format!(
|
||||
"wrote {} context",
|
||||
target.as_deref().unwrap_or("project")
|
||||
),
|
||||
ProposeOutcome::Written => {
|
||||
format!("wrote {} context", target.as_deref().unwrap_or("project"))
|
||||
}
|
||||
ProposeOutcome::Proposed { path } => {
|
||||
format!("filed proposal for project context at {path}")
|
||||
}
|
||||
};
|
||||
Ok(OrchestratorOutcome { detail, reply: None })
|
||||
Ok(OrchestratorOutcome {
|
||||
detail,
|
||||
reply: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `memory.read` → reads a note (or the index) under a shared read-lease; the
|
||||
@ -748,15 +851,62 @@ impl OrchestratorService {
|
||||
// Poser l'arête d'attente A→B (retirée en fin de tour par le RAII `_edge`).
|
||||
let _edge = requester.map(|from| WaitEdgeGuard::new(self, from, agent_id));
|
||||
|
||||
// ── Chemin **structuré** (readiness/heartbeat lot 1) ──────────────────────
|
||||
// Une cible à `structured_adapter` n'a **pas** de PTY : `ensure_live_pty`
|
||||
// échouerait, et le tour ne pourrait se débloquer que par un `idea_reply`
|
||||
// explicite (cause racine du blocage `Busy`). Quand le registre structuré est
|
||||
// câblé et que la cible a une session vivante, on draine son tour via
|
||||
// `drain_with_readiness` : le `Final` déterministe réveille le `pending` (valeur
|
||||
// de retour) **et** marque l'agent `Idle` (`mark_idle`). On enregistre tout de
|
||||
// même un ticket dans la FIFO pour la comptabilité busy et pour préserver
|
||||
// `idea_reply` comme signal **alternatif** (premier arrivé gagne).
|
||||
if let Some(structured) = self.structured.as_ref() {
|
||||
// Lot 1b — auto-lancement d'une cible **froide** structurée : si le registre
|
||||
// est câblé, qu'aucune session ne vit encore pour la cible, mais que son
|
||||
// profil porte un `structured_adapter`, on **démarre** sa session via le
|
||||
// launcher (qui route §17.4 vers `launch_structured` et l'insère dans CE
|
||||
// même registre, avec la conf MCP matérialisée) plutôt que de tomber dans
|
||||
// `ensure_live_pty` — chemin PTY qui échouerait pour une cible sans PTY.
|
||||
// Une cible SANS `structured_adapter` (agent PTY/TUI legacy) conserve le
|
||||
// chemin `ensure_live_pty` ci-dessous (zéro régression).
|
||||
if let Some(session) = self
|
||||
.ensure_structured_session(project, agent_id, &agent.profile_id, structured)
|
||||
.await?
|
||||
{
|
||||
return self
|
||||
.ask_structured(
|
||||
project,
|
||||
agent_id,
|
||||
&target,
|
||||
conversation_id,
|
||||
requester,
|
||||
task,
|
||||
session.as_ref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Garantir la cible vivante en PTY pour CE fil ; lier sa session à la
|
||||
// conversation, et brancher son handle d'entrée sur le médiateur (livraison).
|
||||
let handle = self
|
||||
let (handle, cold_launch) = self
|
||||
.ensure_live_pty(project, agent_id, conversation_id, &target)
|
||||
.await?;
|
||||
// Arm prompt-ready detection (C5) with the target profile's literal marker, so a
|
||||
// return-to-prompt frees the turn (the other OR signal being `idea_reply`).
|
||||
let prompt_pattern = self.prompt_pattern_for_agent(project, agent_id).await;
|
||||
input.bind_handle_with_prompt(agent_id, handle.clone(), prompt_pattern);
|
||||
let (prompt_pattern, submit, has_mcp) =
|
||||
self.prompt_and_submit_for_agent(project, agent_id).await;
|
||||
// Gate cold-launch : un agent froid n'est pas encore à son prompt. On diffère le
|
||||
// 1er tour s'il existe un signal pour le libérer — soit le prompt-ready watcher
|
||||
// (pattern non vide), soit la connexion du pont MCP de l'agent
|
||||
// (`InputMediator::release_cold_start`, déclenchée par l'McpServer). Sans aucun
|
||||
// des deux ⇒ pas de gate (livraison immédiate, sinon blocage indéfini).
|
||||
let gate_cold_start =
|
||||
cold_launch && (prompt_pattern.as_ref().is_some_and(|p| !p.is_empty()) || has_mcp);
|
||||
if gate_cold_start {
|
||||
input.mark_starting(agent_id);
|
||||
}
|
||||
input.bind_handle_with_prompt(agent_id, handle.clone(), prompt_pattern, submit);
|
||||
|
||||
// 2. Enregistrer le ticket (slot de réponse) + livrer le tour via le médiateur
|
||||
// (écriture sérialisée dans le PTY — plus d'écriture ad hoc ici). Le ticket
|
||||
@ -784,14 +934,28 @@ impl OrchestratorService {
|
||||
}
|
||||
None => Ticket::from_human(ticket_id, conversation_id, requester_label, task),
|
||||
};
|
||||
// Timeout de tour piloté par profil (lot 2) : `turn_timeout_ms` de la cible si
|
||||
// défini, sinon le défaut [`ASK_AGENT_TIMEOUT`]. Arme aussi le seuil de stall sur
|
||||
// le médiateur AVANT l'enqueue (consommé au start_turn).
|
||||
let turn_timeout = self.turn_timeout_for(project, agent_id).await;
|
||||
let pending = input.enqueue(agent_id, ticket);
|
||||
// Garde RAII de fin de tour, armé JUSTE après l'enqueue (la cible est maintenant
|
||||
// `Busy`). Quel que soit le chemin de sortie — erreur, timeout, ou **futur
|
||||
// abandonné (drop)** — son `Drop` ramène la cible `Idle` et retire le ticket
|
||||
// fantôme de la FIFO. C'est le fix de la cause racine (cf. [`BusyTurnGuard`]).
|
||||
let busy_guard = BusyTurnGuard::new(
|
||||
Arc::clone(input),
|
||||
Arc::clone(mailbox),
|
||||
agent_id,
|
||||
ticket_id,
|
||||
);
|
||||
// Delivery is the mediator's responsibility (`InputMediator::enqueue` writes the
|
||||
// turn into the bound handle). The service no longer writes the PTY directly —
|
||||
// no ad-hoc `[IdeA · tâche …]` line here, no `\r` band-aid (cadrage C3 §5.1).
|
||||
|
||||
// 3. Attendre la réponse, bornée. Timeout/canal fermé ⇒ retirer le ticket
|
||||
// (cible laissée vivante) et renvoyer une erreur typée.
|
||||
match tokio::time::timeout(ASK_AGENT_TIMEOUT, pending).await {
|
||||
// 3. Attendre la réponse, bornée. Timeout/canal fermé ⇒ le garde retire le ticket
|
||||
// (cible laissée vivante) et la ramène `Idle` au Drop ; renvoie une erreur typée.
|
||||
match tokio::time::timeout(turn_timeout, pending).await {
|
||||
Ok(Ok(result)) => {
|
||||
// Checkpoint Response (P6b, best-effort) : persister la réponse AVANT de
|
||||
// déplacer `result` dans `reply_outcome`. Source = la **cible** (c'est
|
||||
@ -804,19 +968,143 @@ impl OrchestratorService {
|
||||
result.clone(),
|
||||
)
|
||||
.await;
|
||||
// Succès : désarmer le garde AVANT de retourner. Le `mark_idle` propre
|
||||
// sur cette branche est porté par le médiateur (prompt-ready / idea_reply
|
||||
// qui a résolu le `pending`) ; on ne veut ni re-`cancel_head` un ticket
|
||||
// déjà résolu, ni libérer un busy state qui ne nous appartient plus.
|
||||
busy_guard.disarm();
|
||||
Ok(self.reply_outcome(agent_id, &target, result))
|
||||
}
|
||||
Ok(Err(_cancelled)) => {
|
||||
mailbox.cancel_head(agent_id, ticket_id);
|
||||
Err(AppError::Process(format!(
|
||||
// Erreur / timeout : on laisse le garde faire `cancel_head` + `mark_idle` au
|
||||
// Drop (retrait des `cancel_head` redondants — `cancel_head` reste idempotent).
|
||||
Ok(Err(_cancelled)) => Err(AppError::Process(format!(
|
||||
"agent {target} : canal de réponse fermé avant un résultat"
|
||||
)))
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
mailbox.cancel_head(agent_id, ticket_id);
|
||||
Err(AppError::from(domain::ports::AgentSessionError::Timeout))
|
||||
))),
|
||||
Err(_elapsed) => Err(AppError::from(domain::ports::AgentSessionError::Timeout)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Chemin `ask` **structuré** (readiness/heartbeat lot 1) : la cible a un
|
||||
/// `structured_adapter` et une session vivante ([`StructuredSessions`]). On pilote
|
||||
/// son tour **directement** sur le port [`domain::ports::AgentSession`] et on le
|
||||
/// draine via [`drain_with_readiness`] : le [`domain::ports::ReplyEvent::Final`]
|
||||
/// déterministe rend le contenu (réveille le `pending`) **et** marque l'agent `Idle`
|
||||
/// (`mark_idle`), sans dépendre d'un `idea_reply` explicite ni d'un PTY (que la cible
|
||||
/// n'a pas). C'est le fix de la cause racine du blocage `Busy`.
|
||||
///
|
||||
/// On enregistre tout de même un ticket dans la FIFO (`enqueue`) pour la comptabilité
|
||||
/// busy et pour **préserver `idea_reply` comme signal alternatif** : on attend la
|
||||
/// **première** des deux issues (réponse de la session OU résolution du `pending`).
|
||||
/// Le checkpoint Prompt/Response best-effort est conservé à l'identique du chemin PTY.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn ask_structured(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
target: &str,
|
||||
conversation_id: domain::conversation::ConversationId,
|
||||
requester: Option<AgentId>,
|
||||
task: String,
|
||||
session: &dyn domain::ports::AgentSession,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
let (input, mailbox) = match (&self.input, &self.mailbox) {
|
||||
(Some(i), Some(m)) => (i, m),
|
||||
_ => {
|
||||
return Err(AppError::Invalid(
|
||||
"la messagerie inter-agents (idea_ask_agent) n'est pas disponible : \
|
||||
médiateur d'entrée non câblé"
|
||||
.to_owned(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Checkpoint Prompt (best-effort), AVANT de déplacer `task` dans le ticket.
|
||||
let prompt_source = match requester {
|
||||
Some(from) => InputSource::agent(from),
|
||||
None => InputSource::Human,
|
||||
};
|
||||
self.record_turn_best_effort(
|
||||
&project.root,
|
||||
conversation_id,
|
||||
prompt_source,
|
||||
TurnRole::Prompt,
|
||||
task.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Ticket dans la FIFO : comptabilité busy + `idea_reply` comme signal alternatif.
|
||||
let requester_label = self.requester_label(project, requester).await;
|
||||
let ticket_id = TicketId::new_random();
|
||||
let ticket = match requester {
|
||||
Some(from) => Ticket::from_agent(
|
||||
ticket_id,
|
||||
from,
|
||||
conversation_id,
|
||||
requester_label,
|
||||
task.clone(),
|
||||
),
|
||||
None => Ticket::from_human(ticket_id, conversation_id, requester_label, task.clone()),
|
||||
};
|
||||
// Timeout de tour piloté par profil (lot 2) + armement du seuil de stall, AVANT
|
||||
// l'enqueue qui démarre le tour (le médiateur arme alors sa fenêtre de vivacité).
|
||||
let turn_timeout = self.turn_timeout_for(project, agent_id).await;
|
||||
let pending = input.enqueue(agent_id, ticket);
|
||||
// Garde RAII de fin de tour, armé JUSTE après l'enqueue (cible `Busy`). Couvre
|
||||
// toutes les sorties — `return Err` des bras du select, OU **futur abandonné
|
||||
// (drop)** — en ramenant la cible `Idle` au Drop (cf. [`BusyTurnGuard`]). C'est
|
||||
// le fix de la cause racine du blocage `Busy` à vie.
|
||||
let busy_guard = BusyTurnGuard::new(
|
||||
Arc::clone(input),
|
||||
Arc::clone(mailbox),
|
||||
agent_id,
|
||||
ticket_id,
|
||||
);
|
||||
|
||||
// Drainer le tour structuré (le `Final` ⇒ contenu + `mark_idle`), borné par le
|
||||
// **même** garde-fou que le chemin PTY (profil ou défaut). On attend la
|
||||
// **première** issue : le tour structuré OU un `idea_reply` explicite.
|
||||
let drain =
|
||||
drain_with_readiness(session, &task, Some(turn_timeout), input.as_ref(), agent_id);
|
||||
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
// Issue déterministe : la session a rendu son `Final`.
|
||||
drained = drain => match drained {
|
||||
Ok(content) => content,
|
||||
// Erreur de drain : le garde fait `cancel_head` + `mark_idle` au Drop.
|
||||
Err(err) => return Err(AppError::from(err)),
|
||||
},
|
||||
// Issue alternative : un `idea_reply` explicite a résolu le ticket d'abord.
|
||||
replied = pending => match replied {
|
||||
Ok(content) => {
|
||||
// La session draine encore en arrière-plan ; on s'assure que la FIFO
|
||||
// avance même si le `Final` n'est pas encore observé.
|
||||
input.mark_idle(agent_id);
|
||||
content
|
||||
}
|
||||
// Canal fermé : le garde fait `cancel_head` + `mark_idle` au Drop.
|
||||
Err(_cancelled) => {
|
||||
return Err(AppError::Process(format!(
|
||||
"agent {target} : canal de réponse fermé avant un résultat"
|
||||
)));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Succès : désarmer le garde (le `mark_idle` propre est déjà porté par le `Final`
|
||||
// de la session ou par le bras `replied`) — pas de double `cancel_head`.
|
||||
busy_guard.disarm();
|
||||
|
||||
// Checkpoint Response (best-effort), AVANT de déplacer `result`.
|
||||
self.record_turn_best_effort(
|
||||
&project.root,
|
||||
conversation_id,
|
||||
InputSource::agent(agent_id),
|
||||
TurnRole::Response,
|
||||
result.clone(),
|
||||
)
|
||||
.await;
|
||||
Ok(self.reply_outcome(agent_id, target, result))
|
||||
}
|
||||
|
||||
/// `SubmitHumanInput` (cadrage C4 §5.3) — the **human** Envoyer path.
|
||||
@ -863,11 +1151,22 @@ impl OrchestratorService {
|
||||
|
||||
// Ensure the target is live for this thread and bind its input handle on the
|
||||
// mediator (delivery path). Same call the ask path uses.
|
||||
let handle = self
|
||||
let (handle, cold_launch) = self
|
||||
.ensure_live_pty(project, agent_id, conversation_id, target)
|
||||
.await?;
|
||||
let prompt_pattern = self.prompt_pattern_for_agent(project, agent_id).await;
|
||||
input.bind_handle_with_prompt(agent_id, handle, prompt_pattern);
|
||||
let (prompt_pattern, submit, has_mcp) =
|
||||
self.prompt_and_submit_for_agent(project, agent_id).await;
|
||||
// Gate cold-launch : un agent froid n'est pas encore à son prompt. On diffère le
|
||||
// 1er tour s'il existe un signal pour le libérer — soit le prompt-ready watcher
|
||||
// (pattern non vide), soit la connexion du pont MCP de l'agent
|
||||
// (`InputMediator::release_cold_start`, déclenchée par l'McpServer). Sans aucun
|
||||
// des deux ⇒ pas de gate (livraison immédiate, sinon blocage indéfini).
|
||||
let gate_cold_start =
|
||||
cold_launch && (prompt_pattern.as_ref().is_some_and(|p| !p.is_empty()) || has_mcp);
|
||||
if gate_cold_start {
|
||||
input.mark_starting(agent_id);
|
||||
}
|
||||
input.bind_handle_with_prompt(agent_id, handle, prompt_pattern, submit);
|
||||
|
||||
// Enqueue a human-sourced ticket in the SAME FIFO as delegations. Fire-and-
|
||||
// forget: we drop the PendingReply (the human reads the terminal). The
|
||||
@ -922,6 +1221,54 @@ impl OrchestratorService {
|
||||
})
|
||||
}
|
||||
|
||||
/// **Ack de livraison** (ARCHITECTURE §20.3) — best-effort, observabilité only.
|
||||
///
|
||||
/// The frontend write-portal calls this (via the `delegation_delivered` Tauri
|
||||
/// command) once it has **physically written** a delegation `ticket` into the agent's
|
||||
/// native PTY, to distinguish "queued because the human line was busy" from "written"
|
||||
/// in logs/observability. It **does not** change correlation: the requester's `ask`
|
||||
/// is still woken by `idea_reply`→`resolve_ticket`, and the per-turn timeout/cycle
|
||||
/// guards are untouched. It is a pure log no-op when nothing is wired, so a missing
|
||||
/// mediator/registry never breaks the rendezvous.
|
||||
pub fn note_delegation_delivered(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
ticket: TicketId,
|
||||
) {
|
||||
// Observability beacon only: never mutates the mailbox/busy state nor resolves
|
||||
// the ticket (that stays `idea_reply`-driven). Kept best-effort by construction —
|
||||
// a pure, infallible hook so a missing mediator/registry never breaks the
|
||||
// rendezvous. The application crate carries no logging framework (zero new dep);
|
||||
// the ack is materialised as an `eprintln!` trace, the same lightweight channel
|
||||
// the orchestrator already uses for best-effort diagnostics.
|
||||
let _ = project;
|
||||
eprintln!(
|
||||
"[orchestrator] delegation delivered into agent {agent_id}'s native terminal \
|
||||
(front ack, ticket {ticket})"
|
||||
);
|
||||
}
|
||||
|
||||
/// Libère le premier tour différé d'un agent **lancé à froid** quand son pont MCP se
|
||||
/// connecte (readiness de démarrage). Pont entre l'McpServer (adapter entrant) et le
|
||||
/// médiateur d'entrée. No-op si aucun médiateur n'est câblé ou si rien n'est différé.
|
||||
pub fn release_agent_cold_start(&self, agent: domain::AgentId) {
|
||||
if let Some(input) = &self.input {
|
||||
input.release_cold_start(agent);
|
||||
}
|
||||
}
|
||||
|
||||
/// Déclare si une **cellule terminal frontend** est montée pour `agent` (write-portal
|
||||
/// actif). Pont entre le write-portal (adapter UI) et le médiateur : un agent avec
|
||||
/// cellule reçoit ses tours via l'événement `DelegationReady` (le front écrit) ; un
|
||||
/// agent **headless** (délégué en arrière-plan, sans cellule) voit le médiateur écrire
|
||||
/// lui-même la tâche dans son PTY — sinon le tour est perdu. No-op sans médiateur.
|
||||
pub fn set_agent_front_attached(&self, agent: domain::AgentId, attached: bool) {
|
||||
if let Some(input) = &self.input {
|
||||
input.set_front_attached(agent, attached);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the conversation thread id for an ask: `A↔B` when an agent requests,
|
||||
/// else `User↔B` (cadrage C3 §5.2). Without a wired registry, falls back to a
|
||||
/// stable per-agent id derived from the target (legacy routing — never panics).
|
||||
@ -944,7 +1291,6 @@ impl OrchestratorService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// `agent.reply` / `idea_reply`: the target agent renders the result of the task
|
||||
/// it is currently processing (Option 1, lot B-4).
|
||||
///
|
||||
@ -997,13 +1343,19 @@ impl OrchestratorService {
|
||||
/// a launch the handle is resolved from the registry; a missing handle is a
|
||||
/// [`AppError::Process`] (the launch did not register a PTY session, e.g. a profile
|
||||
/// IdeA cannot drive as a terminal).
|
||||
///
|
||||
/// Le booléen renvoyé indique un **lancement à froid** (`true` quand l'agent n'avait
|
||||
/// pas de session vivante et vient d'être démarré) : l'appelant l'utilise pour
|
||||
/// *gater* le premier tour sur le prompt-ready (cf. [`InputMediator::mark_starting`]),
|
||||
/// car un agent froid n'est pas encore à son prompt. `false` ⇒ session réutilisée
|
||||
/// (agent déjà chaud) ⇒ livraison immédiate, comportement inchangé.
|
||||
async fn ensure_live_pty(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
conversation_id: domain::conversation::ConversationId,
|
||||
target: &str,
|
||||
) -> Result<PtyHandle, AppError> {
|
||||
) -> Result<(PtyHandle, bool), AppError> {
|
||||
// «1 session vivante / conversation» (cadrage C3 §5.2) : on cherche d'abord la
|
||||
// session du **fil**, puis on retombe sur la session de l'agent (compat : un
|
||||
// agent mono-fil dont la session n'a pas encore été liée à sa conversation).
|
||||
@ -1013,9 +1365,10 @@ impl OrchestratorService {
|
||||
.or_else(|| self.sessions.session_for_agent(&agent_id));
|
||||
if let Some(session_id) = existing {
|
||||
if let Some(handle) = self.sessions.handle(&session_id) {
|
||||
// (Re)lier le fil à cette session vivante (idempotent).
|
||||
// (Re)lier le fil à cette session vivante (idempotent). Réutilisation
|
||||
// d'une session déjà chaude ⇒ pas de gate de démarrage à froid.
|
||||
self.bind_conversation_session(conversation_id, session_id);
|
||||
return Ok(handle);
|
||||
return Ok((handle, false));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1039,10 +1392,7 @@ impl OrchestratorService {
|
||||
})
|
||||
.await?;
|
||||
|
||||
let session_id = self
|
||||
.sessions
|
||||
.session_for_agent(&agent_id)
|
||||
.ok_or_else(|| {
|
||||
let session_id = self.sessions.session_for_agent(&agent_id).ok_or_else(|| {
|
||||
AppError::Process(format!(
|
||||
"agent {target} n'a pas de session terminal vivante après lancement"
|
||||
))
|
||||
@ -1050,8 +1400,83 @@ impl OrchestratorService {
|
||||
// Lier la session fraîchement lancée à CE fil (registre terminal + registre de
|
||||
// conversations) ⇒ un prochain ask sur le même fil la réutilise.
|
||||
self.bind_conversation_session(conversation_id, session_id);
|
||||
self.sessions.handle(&session_id).ok_or_else(|| {
|
||||
AppError::Process(format!("handle PTY de l'agent {target} introuvable après lancement"))
|
||||
let handle = self.sessions.handle(&session_id).ok_or_else(|| {
|
||||
AppError::Process(format!(
|
||||
"handle PTY de l'agent {target} introuvable après lancement"
|
||||
))
|
||||
})?;
|
||||
// Lancement à froid : `true` ⇒ l'appelant gatera le premier tour sur le prompt.
|
||||
Ok((handle, true))
|
||||
}
|
||||
|
||||
/// Lot 1b — garantit une **session structurée vivante** pour la cible d'un `ask`.
|
||||
///
|
||||
/// Retourne :
|
||||
/// - `Ok(Some(session))` si la cible a déjà une session vivante, **ou** si son
|
||||
/// profil porte un `structured_adapter` et qu'on vient de la (re)lancer ;
|
||||
/// - `Ok(None)` si la cible n'est **pas** structurée (profil sans `structured_adapter`,
|
||||
/// ou profil introuvable) ⇒ l'appelant retombe sur le chemin PTY `ensure_live_pty`.
|
||||
///
|
||||
/// L'auto-lancement réutilise le **même** [`LaunchAgent`] que le chemin PTV
|
||||
/// ([`Self::ensure_live_pty`]) avec le **même** `mcp_runtime` matérialisé : pour un
|
||||
/// profil structuré, le launcher route §17.4 vers `launch_structured`, démarre la
|
||||
/// session via la fabrique et l'insère dans le registre [`StructuredSessions`]
|
||||
/// **partagé** (le même `Arc` que `self.structured`, câblé au composition root).
|
||||
/// La conf MCP est donc matérialisée comme pour une cellule chat lancée à la main,
|
||||
/// si bien que la cible voit les outils `idea_*` pour répondre.
|
||||
async fn ensure_structured_session(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
profile_id: &ProfileId,
|
||||
structured: &Arc<StructuredSessions>,
|
||||
) -> Result<Option<Arc<dyn domain::ports::AgentSession>>, AppError> {
|
||||
// Cible déjà chaude : route directe (aucun lancement).
|
||||
if let Some(session) = structured.session_for_agent(&agent_id) {
|
||||
return Ok(Some(session));
|
||||
}
|
||||
|
||||
// Cible froide : ne (re)lancer que si le profil sait être piloté en mode
|
||||
// structuré. Sinon (agent PTY/TUI legacy, ou profil introuvable) ⇒ chemin PTY.
|
||||
let is_structured = self
|
||||
.profiles
|
||||
.list()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|p| &p.id == profile_id)
|
||||
.is_some_and(|p| p.is_selectable());
|
||||
if !is_structured {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Démarrer la session via le launcher (route §17.4 → `launch_structured`,
|
||||
// insère dans CE registre). Mêmes faits MCP que le chemin PTY pour que le pont
|
||||
// `idea_*` de la cible se branche. `conversation_id: None` ⇒ le launcher dérive
|
||||
// l'id de paire (User↔agent) ou réutilise celui de la cellule (P8a), comme pour
|
||||
// un lancement direct utilisateur.
|
||||
self.launch_agent
|
||||
.execute(LaunchAgentInput {
|
||||
project: project.clone(),
|
||||
agent_id,
|
||||
rows: DEFAULT_ROWS,
|
||||
cols: DEFAULT_COLS,
|
||||
node_id: None,
|
||||
conversation_id: None,
|
||||
mcp_runtime: self
|
||||
.mcp_runtime_provider
|
||||
.as_ref()
|
||||
.and_then(|p| p.runtime_for(project, agent_id)),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Le launcher a inséré la session dans le registre partagé : la relire.
|
||||
structured
|
||||
.session_for_agent(&agent_id)
|
||||
.map(Some)
|
||||
.ok_or_else(|| {
|
||||
AppError::Process(format!(
|
||||
"agent {agent_id} : aucune session structurée vivante après lancement"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
@ -1287,8 +1712,6 @@ impl OrchestratorService {
|
||||
profile_id: &ProfileId,
|
||||
target: &str,
|
||||
) -> Result<(), AppError> {
|
||||
use domain::profile::{McpConfigStrategy, StructuredAdapter};
|
||||
|
||||
let Some(profile) = self
|
||||
.profiles
|
||||
.list()
|
||||
@ -1299,49 +1722,110 @@ impl OrchestratorService {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let honours_mcp_json = matches!(
|
||||
profile.mcp.as_ref().map(|c| &c.config),
|
||||
Some(McpConfigStrategy::ConfigFile { target }) if target == ".mcp.json"
|
||||
);
|
||||
let is_claude = profile.structured_adapter == Some(StructuredAdapter::Claude);
|
||||
|
||||
if honours_mcp_json && is_claude {
|
||||
// Source de vérité UNIQUE (domaine) : un profil supporte le pont `idea_*` ssi
|
||||
// IdeA matérialise réellement sa config MCP pour la CLI qu'il pilote — Claude
|
||||
// via `.mcp.json`, Codex via `config.toml`/`CODEX_HOME`. Tout autre couple
|
||||
// (y compris MCP absent) ⇒ repli fichier, pont non branché.
|
||||
if profile.materializes_idea_bridge() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(AppError::Invalid(format!(
|
||||
"la cible '{target}' (profil '{}', adaptateur {:?}) ne supporte pas encore le \
|
||||
pont idea_* : la délégation inter-agents passe par un serveur MCP déclaré en \
|
||||
.mcp.json, que seul un profil Claude consomme aujourd'hui. Cible un agent au \
|
||||
profil Claude.",
|
||||
pont idea_* : la délégation inter-agents passe par un serveur MCP qu'IdeA \
|
||||
matérialise pour la CLI cible, et ce profil ne déclare pas un couple \
|
||||
(adaptateur × stratégie MCP) pris en charge. Cible un agent dont le profil \
|
||||
expose le pont MCP (Claude ou Codex).",
|
||||
profile.name, profile.structured_adapter
|
||||
)))
|
||||
}
|
||||
|
||||
/// Resolves the **prompt-ready pattern** (cadrage §6, lot C5) of the agent's
|
||||
/// profile, used to arm the [`InputMediator`]'s prompt detection at `bind_handle`
|
||||
/// time. Returns `None` when the agent, its profile, or the pattern is absent —
|
||||
/// the safe fallback: no pattern ⇒ Idle only via explicit signal/timeout.
|
||||
async fn prompt_pattern_for_agent(
|
||||
/// Resolves the target agent profile's **prompt-ready pattern** (§6, lot C5) **and**
|
||||
/// its **submit config** (`submit_sequence`/`submit_delay_ms`, ARCHITECTURE §20.3) in
|
||||
/// a single profile lookup. Both are carried into `bind_handle_with_prompt`: the
|
||||
/// pattern arms prompt detection, the submit config is echoed on the next
|
||||
/// `DelegationReady` so the frontend write-portal knows how to submit. Returns
|
||||
/// `(None, default)` when the agent, its profile, or the field is absent — the safe
|
||||
/// fallback (no pattern ⇒ Idle only via explicit signal/timeout; no submit ⇒ the
|
||||
/// front applies its own `"\r"`/~60 ms default).
|
||||
async fn prompt_and_submit_for_agent(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
) -> Option<String> {
|
||||
let agent = self
|
||||
) -> (Option<String>, SubmitConfig, bool) {
|
||||
let Some(agent) = self
|
||||
.list_agents
|
||||
.execute(ListAgentsInput {
|
||||
project: project.clone(),
|
||||
})
|
||||
.await
|
||||
.ok()?
|
||||
.agents
|
||||
.into_iter()
|
||||
.find(|a| a.id == agent_id)?;
|
||||
let profiles = self.profiles.list().await.ok()?;
|
||||
profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id == agent.profile_id)
|
||||
.and_then(|p| p.prompt_ready_pattern)
|
||||
.ok()
|
||||
.and_then(|out| out.agents.into_iter().find(|a| a.id == agent_id))
|
||||
else {
|
||||
return (None, SubmitConfig::default(), false);
|
||||
};
|
||||
let Some(profile) = self
|
||||
.profiles
|
||||
.list()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|ps| ps.into_iter().find(|p| p.id == agent.profile_id))
|
||||
else {
|
||||
return (None, SubmitConfig::default(), false);
|
||||
};
|
||||
let submit = SubmitConfig::new(profile.submit_sequence, profile.submit_delay_ms);
|
||||
// 3e élément : le profil cible déclare-t-il un pont MCP ? Si oui, sa connexion
|
||||
// (initialize) servira de signal de readiness de démarrage pour libérer un 1er
|
||||
// tour différé — d'où le gate cold-launch même sans `prompt_ready_pattern`.
|
||||
(profile.prompt_ready_pattern, submit, profile.mcp.is_some())
|
||||
}
|
||||
|
||||
/// Résout la [`domain::profile::LivenessStrategy`] du profil de la cible (lot 2) :
|
||||
/// le seuil de stagnation (`stall_after_ms`) et le timeout de tour
|
||||
/// (`turn_timeout_ms`). `None`/absent ⇒ `(None, None)` : pas de détection de stall et
|
||||
/// repli sur les bornes par défaut codées en dur (zéro régression pour un profil sans
|
||||
/// bloc `liveness`).
|
||||
async fn liveness_for_agent(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
) -> (Option<u32>, Option<u32>) {
|
||||
let Some(agent) = self
|
||||
.list_agents
|
||||
.execute(ListAgentsInput {
|
||||
project: project.clone(),
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|out| out.agents.into_iter().find(|a| a.id == agent_id))
|
||||
else {
|
||||
return (None, None);
|
||||
};
|
||||
let Some(profile) = self
|
||||
.profiles
|
||||
.list()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|ps| ps.into_iter().find(|p| p.id == agent.profile_id))
|
||||
else {
|
||||
return (None, None);
|
||||
};
|
||||
match profile.liveness {
|
||||
Some(l) => (l.stall_after_ms, l.turn_timeout_ms),
|
||||
None => (None, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Borne de tour effective pour un `ask` : le `turn_timeout_ms` du profil de la cible
|
||||
/// **quand il existe**, sinon le défaut historique [`ASK_AGENT_TIMEOUT`] (lot 2,
|
||||
/// timeouts pilotés par profil). Arme aussi le seuil de stagnation sur le médiateur
|
||||
/// avant l'enqueue (battement/`sweep_stalled`).
|
||||
async fn turn_timeout_for(&self, project: &Project, agent_id: AgentId) -> Duration {
|
||||
let (stall_after_ms, turn_timeout_ms) = self.liveness_for_agent(project, agent_id).await;
|
||||
if let Some(input) = &self.input {
|
||||
input.set_stall_threshold(agent_id, stall_after_ms);
|
||||
}
|
||||
resolve_turn_timeout(turn_timeout_ms)
|
||||
}
|
||||
|
||||
/// Resolves a human-friendly profile reference (slug like `claude-code`,
|
||||
@ -1420,6 +1904,18 @@ mod tests {
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::ProfileId;
|
||||
|
||||
// (c) — timeouts pilotés par profil (lot 2) : `turn_timeout_ms` prime sur le défaut.
|
||||
#[test]
|
||||
fn profile_turn_timeout_overrides_default() {
|
||||
// Seuil de profil défini ⇒ il prime sur ASK_AGENT_TIMEOUT.
|
||||
assert_eq!(
|
||||
resolve_turn_timeout(Some(120_000)),
|
||||
Duration::from_millis(120_000)
|
||||
);
|
||||
// Absent (profil sans liveness / sans turn_timeout_ms) ⇒ repli sur le défaut.
|
||||
assert_eq!(resolve_turn_timeout(None), ASK_AGENT_TIMEOUT);
|
||||
}
|
||||
|
||||
fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
ProfileId::from_uuid(uuid::Uuid::from_u128(id)),
|
||||
@ -1452,4 +1948,109 @@ mod tests {
|
||||
assert!(by_name);
|
||||
assert_eq!(p.id.to_string(), p.id.to_string());
|
||||
}
|
||||
|
||||
// --- BusyTurnGuard (RAII de fin de tour) -------------------------------
|
||||
//
|
||||
// Fakes minimaux pour observer ce que le garde appelle à son Drop : un médiateur
|
||||
// qui enregistre les `mark_idle` et un mailbox qui enregistre les `cancel_head`.
|
||||
|
||||
use std::sync::Mutex as TestMutex;
|
||||
|
||||
#[derive(Default)]
|
||||
struct SpyMediator {
|
||||
idled: TestMutex<Vec<AgentId>>,
|
||||
}
|
||||
impl domain::input::InputMediator for SpyMediator {
|
||||
fn enqueue(
|
||||
&self,
|
||||
_agent: AgentId,
|
||||
_ticket: domain::mailbox::Ticket,
|
||||
) -> domain::mailbox::PendingReply {
|
||||
domain::mailbox::PendingReply::new(Box::pin(async {
|
||||
Err(domain::mailbox::MailboxError::Cancelled)
|
||||
}))
|
||||
}
|
||||
fn preempt(&self, _agent: AgentId) {}
|
||||
fn mark_idle(&self, agent: AgentId) {
|
||||
self.idled.lock().unwrap().push(agent);
|
||||
}
|
||||
fn busy_state(&self, _agent: AgentId) -> domain::input::AgentBusyState {
|
||||
domain::input::AgentBusyState::Idle
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SpyMailbox {
|
||||
cancelled: TestMutex<Vec<(AgentId, TicketId)>>,
|
||||
}
|
||||
impl domain::mailbox::AgentMailbox for SpyMailbox {
|
||||
fn enqueue(
|
||||
&self,
|
||||
_agent: AgentId,
|
||||
_ticket: domain::mailbox::Ticket,
|
||||
) -> domain::mailbox::PendingReply {
|
||||
domain::mailbox::PendingReply::new(Box::pin(async {
|
||||
Err(domain::mailbox::MailboxError::Cancelled)
|
||||
}))
|
||||
}
|
||||
fn resolve(
|
||||
&self,
|
||||
_agent: AgentId,
|
||||
_result: String,
|
||||
) -> Result<(), domain::mailbox::MailboxError> {
|
||||
Ok(())
|
||||
}
|
||||
fn cancel_head(&self, agent: AgentId, ticket_id: TicketId) {
|
||||
self.cancelled.lock().unwrap().push((agent, ticket_id));
|
||||
}
|
||||
}
|
||||
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
fn tid(n: u128) -> TicketId {
|
||||
TicketId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
/// Drop d'un garde **armé** ⇒ `cancel_head` + `mark_idle` sur la cible (c'est le
|
||||
/// comportement qui débloque un agent resté `Busy` sur un futur abandonné).
|
||||
#[test]
|
||||
fn armed_guard_drop_cancels_head_and_marks_idle() {
|
||||
let med = Arc::new(SpyMediator::default());
|
||||
let mb = Arc::new(SpyMailbox::default());
|
||||
{
|
||||
let _g = BusyTurnGuard::new(
|
||||
Arc::clone(&med) as Arc<dyn domain::input::InputMediator>,
|
||||
Arc::clone(&mb) as Arc<dyn domain::mailbox::AgentMailbox>,
|
||||
aid(1),
|
||||
tid(7),
|
||||
);
|
||||
} // Drop ici.
|
||||
assert_eq!(*med.idled.lock().unwrap(), vec![aid(1)], "mark_idle au Drop");
|
||||
assert_eq!(
|
||||
*mb.cancelled.lock().unwrap(),
|
||||
vec![(aid(1), tid(7))],
|
||||
"cancel_head au Drop"
|
||||
);
|
||||
}
|
||||
|
||||
/// Garde **désarmé** (branche succès) ⇒ son Drop est un no-op : pas de `mark_idle`
|
||||
/// parasite, surtout pas de `cancel_head` d'un ticket déjà résolu.
|
||||
#[test]
|
||||
fn disarmed_guard_drop_is_a_noop() {
|
||||
let med = Arc::new(SpyMediator::default());
|
||||
let mb = Arc::new(SpyMailbox::default());
|
||||
let g = BusyTurnGuard::new(
|
||||
Arc::clone(&med) as Arc<dyn domain::input::InputMediator>,
|
||||
Arc::clone(&mb) as Arc<dyn domain::mailbox::AgentMailbox>,
|
||||
aid(1),
|
||||
tid(7),
|
||||
);
|
||||
g.disarm();
|
||||
assert!(med.idled.lock().unwrap().is_empty(), "pas de mark_idle après disarm");
|
||||
assert!(
|
||||
mb.cancelled.lock().unwrap().is_empty(),
|
||||
"pas de cancel_head après disarm"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
150
crates/application/src/permission.rs
Normal file
150
crates/application/src/permission.rs
Normal file
@ -0,0 +1,150 @@
|
||||
//! Permission use cases.
|
||||
//!
|
||||
//! This module stays at the application boundary: it loads the project
|
||||
//! permission document through [`PermissionStore`], applies simple mutations, and
|
||||
//! delegates all merge semantics to the pure domain model.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::PermissionStore;
|
||||
use domain::{AgentId, EffectivePermissions, PermissionSet, Project, ProjectPermissions};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Reads the full project permission document.
|
||||
pub struct GetProjectPermissions {
|
||||
store: Arc<dyn PermissionStore>,
|
||||
}
|
||||
|
||||
impl GetProjectPermissions {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Executes the read.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: GetProjectPermissionsInput,
|
||||
) -> Result<GetProjectPermissionsOutput, AppError> {
|
||||
let permissions = self.store.load_permissions(&input.project).await?;
|
||||
Ok(GetProjectPermissionsOutput { permissions })
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`GetProjectPermissions`].
|
||||
pub struct GetProjectPermissionsInput {
|
||||
/// Target project.
|
||||
pub project: Project,
|
||||
}
|
||||
|
||||
/// Output for [`GetProjectPermissions`].
|
||||
pub struct GetProjectPermissionsOutput {
|
||||
/// Persisted permission document.
|
||||
pub permissions: ProjectPermissions,
|
||||
}
|
||||
|
||||
/// Replaces the project default policy.
|
||||
pub struct UpdateProjectPermissions {
|
||||
store: Arc<dyn PermissionStore>,
|
||||
}
|
||||
|
||||
impl UpdateProjectPermissions {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Executes the mutation.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: UpdateProjectPermissionsInput,
|
||||
) -> Result<GetProjectPermissionsOutput, AppError> {
|
||||
let mut doc = self.store.load_permissions(&input.project).await?;
|
||||
doc.set_project_defaults(input.permissions);
|
||||
self.store.save_permissions(&input.project, &doc).await?;
|
||||
Ok(GetProjectPermissionsOutput { permissions: doc })
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`UpdateProjectPermissions`].
|
||||
pub struct UpdateProjectPermissionsInput {
|
||||
/// Target project.
|
||||
pub project: Project,
|
||||
/// New project default policy. `None` removes project defaults.
|
||||
pub permissions: Option<PermissionSet>,
|
||||
}
|
||||
|
||||
/// Replaces one agent override.
|
||||
pub struct UpdateAgentPermissions {
|
||||
store: Arc<dyn PermissionStore>,
|
||||
}
|
||||
|
||||
impl UpdateAgentPermissions {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Executes the mutation.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: UpdateAgentPermissionsInput,
|
||||
) -> Result<GetProjectPermissionsOutput, AppError> {
|
||||
let mut doc = self.store.load_permissions(&input.project).await?;
|
||||
doc.set_agent_permissions(input.agent_id, input.permissions);
|
||||
self.store.save_permissions(&input.project, &doc).await?;
|
||||
Ok(GetProjectPermissionsOutput { permissions: doc })
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`UpdateAgentPermissions`].
|
||||
pub struct UpdateAgentPermissionsInput {
|
||||
/// Target project.
|
||||
pub project: Project,
|
||||
/// Target agent.
|
||||
pub agent_id: AgentId,
|
||||
/// New agent policy. `None` removes the override.
|
||||
pub permissions: Option<PermissionSet>,
|
||||
}
|
||||
|
||||
/// Resolves effective permissions for one agent.
|
||||
pub struct ResolveAgentPermissions {
|
||||
store: Arc<dyn PermissionStore>,
|
||||
}
|
||||
|
||||
impl ResolveAgentPermissions {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Executes the resolution.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ResolveAgentPermissionsInput,
|
||||
) -> Result<ResolveAgentPermissionsOutput, AppError> {
|
||||
let doc = self.store.load_permissions(&input.project).await?;
|
||||
Ok(ResolveAgentPermissionsOutput {
|
||||
effective: doc.resolve_for(input.agent_id),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`ResolveAgentPermissions`].
|
||||
pub struct ResolveAgentPermissionsInput {
|
||||
/// Target project.
|
||||
pub project: Project,
|
||||
/// Target agent.
|
||||
pub agent_id: AgentId,
|
||||
}
|
||||
|
||||
/// Output for [`ResolveAgentPermissions`].
|
||||
pub struct ResolveAgentPermissionsOutput {
|
||||
/// Resolved policy, or `None` when neither project nor agent policy exists.
|
||||
pub effective: Option<EffectivePermissions>,
|
||||
}
|
||||
@ -90,7 +90,12 @@ impl TerminalSessions {
|
||||
/// per-agent lookup for the orchestrator's ask path.
|
||||
#[must_use]
|
||||
pub fn session_for(&self, conversation: ConversationId) -> Option<SessionId> {
|
||||
let sid = self.conversations.lock().ok()?.get(&conversation).copied()?;
|
||||
let sid = self
|
||||
.conversations
|
||||
.lock()
|
||||
.ok()?
|
||||
.get(&conversation)
|
||||
.copied()?;
|
||||
// Only return it if the session is still live in `entries`.
|
||||
self.entries
|
||||
.lock()
|
||||
@ -375,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
|
||||
|
||||
@ -79,6 +79,7 @@ impl OpenTerminal {
|
||||
cwd: cwd.clone(),
|
||||
env: Vec::new(),
|
||||
context_plan: None,
|
||||
sandbox: None,
|
||||
};
|
||||
|
||||
// The PTY layer owns the session identity; we adopt the returned handle's
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -61,7 +61,12 @@ impl InMemoryConversationLog {
|
||||
}
|
||||
|
||||
fn thread(&self, conv: ConversationId) -> Vec<ConversationTurn> {
|
||||
self.threads.lock().unwrap().get(&conv).cloned().unwrap_or_default()
|
||||
self.threads
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&conv)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,11 +161,7 @@ impl HandoffStore for InMemoryHandoffStore {
|
||||
Ok(self.store.lock().unwrap().get(&conversation).cloned())
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
handoff: Handoff,
|
||||
) -> Result<(), StoreError> {
|
||||
async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError> {
|
||||
if let Some(err) = self.fail_save.lock().unwrap().clone() {
|
||||
return Err(err);
|
||||
}
|
||||
@ -219,7 +220,9 @@ async fn single_record_appends_turn_and_advances_handoff() {
|
||||
let conv = conv_id(1);
|
||||
let t1 = turn(conv, turn_id(10), "hello world");
|
||||
|
||||
uc.record(conv, t1.clone()).await.expect("record should succeed");
|
||||
uc.record(conv, t1.clone())
|
||||
.await
|
||||
.expect("record should succeed");
|
||||
|
||||
// Log contains the turn.
|
||||
assert_eq!(log.thread(conv), vec![t1.clone()]);
|
||||
@ -227,7 +230,11 @@ async fn single_record_appends_turn_and_advances_handoff() {
|
||||
// Handoff advanced: up_to == turn id, summary contains the turn text.
|
||||
let h = handoffs.loaded(conv).expect("handoff should be saved");
|
||||
assert_eq!(h.up_to, t1.id);
|
||||
assert!(h.summary_md.contains("hello world"), "summary={:?}", h.summary_md);
|
||||
assert!(
|
||||
h.summary_md.contains("hello world"),
|
||||
"summary={:?}",
|
||||
h.summary_md
|
||||
);
|
||||
|
||||
// First fold: prev = None, exactly one new turn.
|
||||
assert_eq!(summarizer.calls(), vec![(false, 1)]);
|
||||
@ -254,8 +261,16 @@ async fn two_records_fold_incrementally_without_full_reread() {
|
||||
// Handoff up_to == t2.id; summary contains both texts.
|
||||
let h = handoffs.loaded(conv).expect("handoff saved");
|
||||
assert_eq!(h.up_to, t2.id);
|
||||
assert!(h.summary_md.contains("first-text"), "summary={:?}", h.summary_md);
|
||||
assert!(h.summary_md.contains("second-text"), "summary={:?}", h.summary_md);
|
||||
assert!(
|
||||
h.summary_md.contains("first-text"),
|
||||
"summary={:?}",
|
||||
h.summary_md
|
||||
);
|
||||
assert!(
|
||||
h.summary_md.contains("second-text"),
|
||||
"summary={:?}",
|
||||
h.summary_md
|
||||
);
|
||||
|
||||
// Proof of incrementality: prev None then Some, and len == 1 on BOTH calls
|
||||
// (never 2 → the whole log is never re-read into the fold).
|
||||
@ -311,9 +326,9 @@ async fn load_error_propagates_as_store() {
|
||||
#[tokio::test]
|
||||
async fn save_error_propagates_as_store() {
|
||||
let log = Arc::new(InMemoryConversationLog::default());
|
||||
let handoffs = Arc::new(InMemoryHandoffStore::failing_save(StoreError::Serialization(
|
||||
"save boom".to_owned(),
|
||||
)));
|
||||
let handoffs = Arc::new(InMemoryHandoffStore::failing_save(
|
||||
StoreError::Serialization("save boom".to_owned()),
|
||||
));
|
||||
let summarizer = Arc::new(RecordingSummarizer::default());
|
||||
let uc = RecordTurn::new(log.clone(), handoffs.clone(), summarizer.clone());
|
||||
|
||||
|
||||
338
crates/application/tests/drain_with_readiness_lot1.rs
Normal file
338
crates/application/tests/drain_with_readiness_lot1.rs
Normal file
@ -0,0 +1,338 @@
|
||||
//! Lot 1 (readiness/heartbeat model-agnostique) — tests unitaires QA **indépendants**
|
||||
//! du helper applicatif `drain_with_readiness`, **100 % fakes**.
|
||||
//!
|
||||
//! Couvre les points 5 et 6 du périmètre QA :
|
||||
//!
|
||||
//! - **Point 5** : un flux se terminant par `Final` appelle `mark_idle(agent)`
|
||||
//! **exactement une fois** ; les `Heartbeat`/deltas/activités intermédiaires
|
||||
//! n'appellent **jamais** `mark_idle`.
|
||||
//! - **Point 6 (le bug d'origine)** : un agent cible qui **ne fait que renvoyer un
|
||||
//! `Final`** (sans jamais appeler `idea_reply`) débloque bien la file via
|
||||
//! `mark_idle` — c'est exactement la cause racine du blocage `Busy`.
|
||||
//!
|
||||
//! On teste au niveau `drain_with_readiness` (le rendez-vous synchrone qu'`ask_agent`
|
||||
//! utilise sur la session structurée d'une cible) avec :
|
||||
//! - un fake `AgentSession` scriptable (calqué sur `send_blocking_d1.rs`) ;
|
||||
//! - un fake `InputMediator` qui **compte** les `mark_idle` par agent et journalise
|
||||
//! l'ordre des appels, sans seconde file ni I/O.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use application::drain_with_readiness;
|
||||
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 sid(n: u128) -> SessionId {
|
||||
SessionId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake AgentSession scriptable (mono-usage), repris de send_blocking_d1.rs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum Script {
|
||||
Stream(Vec<ReplyEvent>),
|
||||
Err(AgentSessionError),
|
||||
}
|
||||
|
||||
struct ScriptedSession {
|
||||
id: SessionId,
|
||||
script: Mutex<Option<Script>>,
|
||||
shutdowns: AtomicUsize,
|
||||
}
|
||||
|
||||
impl ScriptedSession {
|
||||
fn new(script: Script) -> Self {
|
||||
Self {
|
||||
id: sid(1),
|
||||
script: Mutex::new(Some(script)),
|
||||
shutdowns: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
fn shutdown_count(&self) -> usize {
|
||||
self.shutdowns.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentSession for ScriptedSession {
|
||||
fn id(&self) -> SessionId {
|
||||
self.id
|
||||
}
|
||||
fn conversation_id(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||
let script = self
|
||||
.script
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take()
|
||||
.expect("send scripté une seule fois");
|
||||
match script {
|
||||
Script::Stream(events) => Ok(Box::new(events.into_iter())),
|
||||
Script::Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
self.shutdowns.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake InputMediator : compte les mark_idle par agent + journalise les appels.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct RecordingMediator {
|
||||
/// (agent, "mark_idle") dans l'ordre des appels.
|
||||
calls: Mutex<Vec<(AgentId, &'static str)>>,
|
||||
}
|
||||
|
||||
impl RecordingMediator {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
calls: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
fn mark_idle_count(&self, agent: AgentId) -> usize {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(a, kind)| *a == agent && *kind == "mark_idle")
|
||||
.count()
|
||||
}
|
||||
fn total_calls(&self) -> usize {
|
||||
self.calls.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
impl InputMediator for RecordingMediator {
|
||||
fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply {
|
||||
// Jamais utilisé par drain_with_readiness ; un future qui ne résout pas.
|
||||
PendingReply::new(Box::pin(std::future::pending()))
|
||||
}
|
||||
fn preempt(&self, agent: AgentId) {
|
||||
self.calls.lock().unwrap().push((agent, "preempt"));
|
||||
}
|
||||
fn mark_idle(&self, agent: AgentId) {
|
||||
self.calls.lock().unwrap().push((agent, "mark_idle"));
|
||||
}
|
||||
fn busy_state(&self, _agent: AgentId) -> AgentBusyState {
|
||||
AgentBusyState::Idle
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers d'événements
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn delta(t: &str) -> ReplyEvent {
|
||||
ReplyEvent::TextDelta { text: t.to_owned() }
|
||||
}
|
||||
fn tool(l: &str) -> ReplyEvent {
|
||||
ReplyEvent::ToolActivity {
|
||||
label: l.to_owned(),
|
||||
}
|
||||
}
|
||||
fn heartbeat() -> ReplyEvent {
|
||||
ReplyEvent::Heartbeat
|
||||
}
|
||||
fn final_(c: &str) -> ReplyEvent {
|
||||
ReplyEvent::Final {
|
||||
content: c.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Point 6 (LE point qui compte) : un Final SEUL débloque la file via mark_idle,
|
||||
// sans aucun idea_reply.
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn final_only_stream_unblocks_queue_via_mark_idle() {
|
||||
// L'agent cible ne fait QUE renvoyer un Final (jamais d'idea_reply).
|
||||
let agent = aid(42);
|
||||
let session = ScriptedSession::new(Script::Stream(vec![final_("done")]));
|
||||
let mediator = RecordingMediator::new();
|
||||
|
||||
let out = drain_with_readiness(&session, "tâche", None, &mediator, agent).await;
|
||||
|
||||
assert_eq!(out, Ok("done".to_owned()), "le Final rend bien son contenu");
|
||||
assert_eq!(
|
||||
mediator.mark_idle_count(agent),
|
||||
1,
|
||||
"le Final déterministe DOIT marquer l'agent Idle (fix de la cause racine du blocage Busy)"
|
||||
);
|
||||
// Aucun autre appel parasite, et la session reste vivante (pas de shutdown).
|
||||
assert_eq!(mediator.total_calls(), 1);
|
||||
assert_eq!(session.shutdown_count(), 0);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Point 5 : exactement UN mark_idle ; les events intermédiaires n'en émettent pas.
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn intermediate_events_do_not_mark_idle_only_final_does() {
|
||||
let agent = aid(7);
|
||||
// Heartbeats + deltas + activité PUIS le Final.
|
||||
let session = ScriptedSession::new(Script::Stream(vec![
|
||||
heartbeat(),
|
||||
delta("hel"),
|
||||
tool("lit un fichier"),
|
||||
heartbeat(),
|
||||
delta("lo"),
|
||||
final_("hello"),
|
||||
]));
|
||||
let mediator = RecordingMediator::new();
|
||||
|
||||
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
|
||||
|
||||
assert_eq!(out, Ok("hello".to_owned()));
|
||||
assert_eq!(
|
||||
mediator.mark_idle_count(agent),
|
||||
1,
|
||||
"mark_idle exactement UNE fois (au Final), jamais sur heartbeat/delta/activité"
|
||||
);
|
||||
assert_eq!(
|
||||
mediator.total_calls(),
|
||||
1,
|
||||
"aucun appel mediator hormis l'unique mark_idle"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn heartbeats_alone_never_mark_idle_before_final() {
|
||||
// Que des heartbeats avant le Final : aucun ne doit marquer Idle.
|
||||
let agent = aid(9);
|
||||
let session = ScriptedSession::new(Script::Stream(vec![
|
||||
heartbeat(),
|
||||
heartbeat(),
|
||||
heartbeat(),
|
||||
final_("fini"),
|
||||
]));
|
||||
let mediator = RecordingMediator::new();
|
||||
|
||||
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
|
||||
assert_eq!(out, Ok("fini".to_owned()));
|
||||
assert_eq!(mediator.mark_idle_count(agent), 1);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Bords : pas de Final ⇒ pas de mark_idle ; erreur de send propagée ; mauvais agent.
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_without_final_does_not_mark_idle_and_is_io_error() {
|
||||
let agent = aid(3);
|
||||
let session = ScriptedSession::new(Script::Stream(vec![heartbeat(), delta("a"), tool("b")]));
|
||||
let mediator = RecordingMediator::new();
|
||||
|
||||
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
|
||||
assert!(
|
||||
matches!(out, Err(AgentSessionError::Io(_))),
|
||||
"flux épuisé sans Final ⇒ Io, obtenu {out:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
mediator.mark_idle_count(agent),
|
||||
0,
|
||||
"sans Final, on ne marque JAMAIS Idle (jamais de faux Idle)"
|
||||
);
|
||||
assert_eq!(mediator.total_calls(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_error_is_propagated_and_no_mark_idle() {
|
||||
let agent = aid(5);
|
||||
let session =
|
||||
ScriptedSession::new(Script::Err(AgentSessionError::Decode("bad json".to_owned())));
|
||||
let mediator = RecordingMediator::new();
|
||||
|
||||
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
|
||||
assert_eq!(out, Err(AgentSessionError::Decode("bad json".to_owned())));
|
||||
assert_eq!(mediator.mark_idle_count(agent), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mark_idle_targets_the_drained_agent_only() {
|
||||
// Le mark_idle doit porter sur l'agent passé à drain_with_readiness, pas un autre.
|
||||
let drained = aid(100);
|
||||
let other = aid(200);
|
||||
let session = ScriptedSession::new(Script::Stream(vec![final_("ok")]));
|
||||
let mediator = RecordingMediator::new();
|
||||
|
||||
let _ = drain_with_readiness(&session, "x", None, &mediator, drained).await;
|
||||
assert_eq!(mediator.mark_idle_count(drained), 1);
|
||||
assert_eq!(
|
||||
mediator.mark_idle_count(other),
|
||||
0,
|
||||
"mark_idle ne doit cibler que l'agent drainé"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Timeout (garde-fou du tour) : Timeout renvoyé, pas de mark_idle, session vivante.
|
||||
// (Le helper borne via tokio::time::timeout ; on force l'expiration avec un Final
|
||||
// reporté — réutilise un fake retardé minimal local.)
|
||||
// ===========================================================================
|
||||
|
||||
struct DelayedSession {
|
||||
delay: Duration,
|
||||
shutdowns: AtomicUsize,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentSession for DelayedSession {
|
||||
fn id(&self) -> SessionId {
|
||||
sid(2)
|
||||
}
|
||||
fn conversation_id(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||
tokio::time::sleep(self.delay).await;
|
||||
Ok(Box::new(vec![final_("too late")].into_iter()))
|
||||
}
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
self.shutdowns.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_returns_timeout_no_mark_idle_session_alive() {
|
||||
let agent = aid(11);
|
||||
let session = DelayedSession {
|
||||
delay: Duration::from_secs(1),
|
||||
shutdowns: AtomicUsize::new(0),
|
||||
};
|
||||
let mediator = RecordingMediator::new();
|
||||
|
||||
let out =
|
||||
drain_with_readiness(&session, "x", Some(Duration::from_millis(20)), &mediator, agent).await;
|
||||
assert_eq!(out, Err(AgentSessionError::Timeout));
|
||||
assert_eq!(
|
||||
mediator.mark_idle_count(agent),
|
||||
0,
|
||||
"un timeout ne marque pas Idle (le tour n'a pas rendu son Final)"
|
||||
);
|
||||
assert_eq!(
|
||||
session.shutdowns.load(Ordering::SeqCst),
|
||||
0,
|
||||
"timeout ne tue PAS la session (§17.1)"
|
||||
);
|
||||
}
|
||||
@ -19,6 +19,7 @@ use std::sync::{Arc, Mutex};
|
||||
use async_trait::async_trait;
|
||||
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
||||
use domain::layout::Workspace;
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{
|
||||
AgentContextStore, DirEntry, FileSystem, FsError, ProfileStore, ProjectStore, RemotePath,
|
||||
StoreError,
|
||||
@ -26,7 +27,6 @@ use domain::ports::{
|
||||
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::{
|
||||
AgentId, Direction, GridCell, GridContainer, LayoutId, LayoutNode, LayoutTree, LeafCell,
|
||||
NodeId, ProfileId, ProjectId, SplitContainer, WeightedChild,
|
||||
@ -521,12 +521,7 @@ async fn resume_supported_follows_profile() {
|
||||
weight: 1.0,
|
||||
},
|
||||
WeightedChild {
|
||||
node: LayoutNode::Leaf(agent_leaf(
|
||||
plain_leaf,
|
||||
Some(plain_agent),
|
||||
Some("c2"),
|
||||
true,
|
||||
)),
|
||||
node: LayoutNode::Leaf(agent_leaf(plain_leaf, Some(plain_agent), Some("c2"), true)),
|
||||
weight: 1.0,
|
||||
},
|
||||
],
|
||||
@ -537,17 +532,16 @@ async fn resume_supported_follows_profile() {
|
||||
entry(supported_agent, "Resumable", pid(1)),
|
||||
entry(plain_agent, "Plain", pid(2)),
|
||||
]);
|
||||
let profiles =
|
||||
FakeProfiles::new(vec![profile_with_session(pid(1)), profile_no_session(pid(2))]);
|
||||
let profiles = FakeProfiles::new(vec![
|
||||
profile_with_session(pid(1)),
|
||||
profile_no_session(pid(2)),
|
||||
]);
|
||||
let uc = make_use_case(&store, &fs, &contexts, &profiles);
|
||||
|
||||
let out = run(&uc).await;
|
||||
assert_eq!(out.len(), 2, "both still listed: {out:?}");
|
||||
|
||||
let supported = out
|
||||
.iter()
|
||||
.find(|r| r.agent_id == supported_agent)
|
||||
.unwrap();
|
||||
let supported = out.iter().find(|r| r.agent_id == supported_agent).unwrap();
|
||||
assert!(supported.resume_supported, "profile pid(1) has a session");
|
||||
|
||||
let plain = out.iter().find(|r| r.agent_id == plain_agent).unwrap();
|
||||
@ -620,7 +614,12 @@ async fn agent_absent_from_manifest_is_ignored() {
|
||||
direction: Direction::Row,
|
||||
children: vec![
|
||||
WeightedChild {
|
||||
node: LayoutNode::Leaf(agent_leaf(orphan_leaf, Some(orphan_agent), Some("c1"), true)),
|
||||
node: LayoutNode::Leaf(agent_leaf(
|
||||
orphan_leaf,
|
||||
Some(orphan_agent),
|
||||
Some("c1"),
|
||||
true,
|
||||
)),
|
||||
weight: 1.0,
|
||||
},
|
||||
WeightedChild {
|
||||
@ -667,7 +666,11 @@ async fn failing_profile_store_lists_agents_without_resume_support() {
|
||||
let uc = make_use_case(&store, &fs, &contexts, &profiles);
|
||||
|
||||
let out = run(&uc).await;
|
||||
assert_eq!(out.len(), 1, "agent still listed despite profile failure: {out:?}");
|
||||
assert_eq!(
|
||||
out.len(),
|
||||
1,
|
||||
"agent still listed despite profile failure: {out:?}"
|
||||
);
|
||||
assert_eq!(out[0].agent_id, agent);
|
||||
assert!(
|
||||
!out[0].resume_supported,
|
||||
|
||||
@ -270,6 +270,7 @@ impl AgentRuntime for FakeRuntime {
|
||||
cwd: cwd.clone(),
|
||||
env: Vec::new(),
|
||||
context_plan: Some(ContextInjectionPlan::Stdin),
|
||||
sandbox: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -343,10 +344,10 @@ impl PtyPort for FakePty {
|
||||
})
|
||||
}
|
||||
fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError> {
|
||||
self.writes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((handle.session_id, String::from_utf8_lossy(data).into_owned()));
|
||||
self.writes.lock().unwrap().push((
|
||||
handle.session_id,
|
||||
String::from_utf8_lossy(data).into_owned(),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> {
|
||||
@ -380,7 +381,6 @@ impl EventBus for SpyBus {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct SeqIds(Mutex<u128>);
|
||||
impl SeqIds {
|
||||
fn new() -> Self {
|
||||
@ -655,13 +655,19 @@ async fn agent_run_visible_other_cell_refuses_when_live_elsewhere() {
|
||||
assert_eq!(err.code(), "AGENT_ALREADY_RUNNING", "got {err:?}");
|
||||
match err {
|
||||
application::AppError::AgentAlreadyRunning { node_id, .. } => {
|
||||
assert_eq!(node_id, first_cell, "reports the live host cell, not target");
|
||||
assert_eq!(
|
||||
node_id, first_cell,
|
||||
"reports the live host cell, not target"
|
||||
);
|
||||
}
|
||||
other => panic!("expected AgentAlreadyRunning, got {other:?}"),
|
||||
}
|
||||
|
||||
let session = fx.sessions.session(&sid(777)).expect("live session");
|
||||
assert_eq!(session.node_id, first_cell, "session stays on its host cell");
|
||||
assert_eq!(
|
||||
session.node_id, first_cell,
|
||||
"session stays on its host cell"
|
||||
);
|
||||
assert_eq!(fx.pty.spawns().len(), 1, "refused launch must not respawn");
|
||||
}
|
||||
|
||||
@ -784,7 +790,6 @@ async fn create_skill_honours_global_scope() {
|
||||
assert_eq!(saved[0].scope, SkillScope::Global);
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Option 1 « Terminal + MCP » — idea_ask_agent / idea_reply (B-3 / B-4)
|
||||
//
|
||||
@ -826,7 +831,11 @@ impl TestMailbox {
|
||||
}
|
||||
}
|
||||
fn pending(&self, agent: &AgentId) -> usize {
|
||||
self.queues.lock().unwrap().get(agent).map_or(0, VecDeque::len)
|
||||
self.queues
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(agent)
|
||||
.map_or(0, VecDeque::len)
|
||||
}
|
||||
/// Ids of the tickets queued for `agent`, in FIFO order (test inspection).
|
||||
fn ticket_ids(&self, agent: &AgentId) -> Vec<TicketId> {
|
||||
@ -953,7 +962,10 @@ impl InputMediator for TestMediator {
|
||||
self.preempts.lock().unwrap().push(agent);
|
||||
}
|
||||
fn mark_idle(&self, agent: AgentId) {
|
||||
self.busy.lock().unwrap().insert(agent, AgentBusyState::Idle);
|
||||
self.busy
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(agent, AgentBusyState::Idle);
|
||||
}
|
||||
fn busy_state(&self, agent: AgentId) -> AgentBusyState {
|
||||
self.busy
|
||||
@ -1004,7 +1016,9 @@ impl ConversationRegistry for TestConversations {
|
||||
}
|
||||
fn bind_session(&self, id: ConversationId, session: SessionRef) {
|
||||
if let Some(c) = self.by_id.lock().unwrap().get_mut(&id) {
|
||||
c.session = ConversationSession::Live { handle_ref: session };
|
||||
c.session = ConversationSession::Live {
|
||||
handle_ref: session,
|
||||
};
|
||||
}
|
||||
}
|
||||
fn suspend(&self, id: ConversationId, resumable_id: Option<String>) {
|
||||
@ -1131,8 +1145,7 @@ async fn await_until<F: Fn() -> bool>(cond: F) {
|
||||
.expect("condition never reached within guard (possible hang/deadlock)");
|
||||
}
|
||||
|
||||
const ASK_JSON: &str =
|
||||
r#"{ "type":"agent.message", "requestedBy":"Main", "targetAgent":"architect", "task":"Analyse §17" }"#;
|
||||
const ASK_JSON: &str = r#"{ "type":"agent.message", "requestedBy":"Main", "targetAgent":"architect", "task":"Analyse §17" }"#;
|
||||
|
||||
/// Builds an `agent.reply` command for `from` with `result` (the handshake-injected
|
||||
/// path, exactly what the MCP server produces from a peer's `idea_reply`).
|
||||
@ -1171,12 +1184,25 @@ async fn ask_live_pty_target_writes_task_and_returns_reply() {
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
// The task was written into the target's terminal, prefixed for idea_reply.
|
||||
let writes = fx.pty.writes_for(sid(800));
|
||||
assert_eq!(writes.len(), 1, "exactly one task write to the live terminal");
|
||||
assert_eq!(
|
||||
writes.len(),
|
||||
1,
|
||||
"exactly one task write to the live terminal"
|
||||
);
|
||||
assert!(writes[0].contains("Analyse §17"), "task body written");
|
||||
assert!(writes[0].contains("[IdeA · tâche"), "delegated-task prefix present");
|
||||
assert!(writes[0].contains("idea_reply") || writes[0].contains("ticket"), "carries ticket/idea_reply cue");
|
||||
assert!(
|
||||
writes[0].contains("[IdeA · tâche"),
|
||||
"delegated-task prefix present"
|
||||
);
|
||||
assert!(
|
||||
writes[0].contains("idea_reply") || writes[0].contains("ticket"),
|
||||
"carries ticket/idea_reply cue"
|
||||
);
|
||||
// No PTY spawned: the live terminal was reused.
|
||||
assert!(fx.pty.spawns().is_empty(), "live target reused, not respawned");
|
||||
assert!(
|
||||
fx.pty.spawns().is_empty(),
|
||||
"live target reused, not respawned"
|
||||
);
|
||||
|
||||
// The target renders its result via idea_reply ⇒ resolves the head ticket.
|
||||
fx.service
|
||||
@ -1230,6 +1256,124 @@ async fn idea_reply_marks_emitter_idle() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Régression (garde RAII de fin de tour) — LE test décisif : un `ask_agent` dont le
|
||||
/// **futur est abandonné (drop)** alors qu'il attendait la réponse doit laisser la cible
|
||||
/// `Idle`, **pas** `Busy` à vie. Avant le fix, aucune branche du `match`/`select!` ne
|
||||
/// s'exécutait sur un drop ⇒ l'agent restait `Busy` et toute délégation suivante était
|
||||
/// mise en file derrière ce tour fantôme sans jamais être livrée.
|
||||
#[tokio::test]
|
||||
async fn dropped_ask_future_frees_busy_target() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
|
||||
// Le tour a démarré : la cible est Busy, un ticket est en file.
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
assert!(
|
||||
fx.mediator.busy_state(aid(1)).is_busy(),
|
||||
"cible Busy pendant le tour délégué"
|
||||
);
|
||||
|
||||
// Le demandeur interrompt son appel ⇒ le futur `ask_agent` est dropped.
|
||||
ask.abort();
|
||||
let _ = ask.await; // récolte la JoinError(Cancelled), ignorée.
|
||||
|
||||
// Le garde RAII a ramené la cible Idle ET retiré le ticket fantôme de la FIFO.
|
||||
await_until(|| !fx.mediator.busy_state(aid(1)).is_busy()).await;
|
||||
assert_eq!(
|
||||
fx.mediator.busy_state(aid(1)),
|
||||
AgentBusyState::Idle,
|
||||
"futur dropped ⇒ la cible est ramenée Idle par le garde (fix cause racine)"
|
||||
);
|
||||
assert_eq!(
|
||||
fx.mailbox.pending(&aid(1)),
|
||||
0,
|
||||
"ticket fantôme retiré de la FIFO au Drop du garde"
|
||||
);
|
||||
}
|
||||
|
||||
/// Régression (garde RAII) — une **2e délégation** vers une cible dont un 1er `ask` a
|
||||
/// été abandonné est bien livrée : l'agent n'est pas coincé `Busy`, son tour suivant
|
||||
/// démarre et peut être résolu par `idea_reply`.
|
||||
#[tokio::test]
|
||||
async fn second_delegation_delivered_after_dropped_ask() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
// 1er ask : démarre puis est abandonné (drop).
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask1 = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
ask1.abort();
|
||||
let _ = ask1.await;
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 0).await;
|
||||
|
||||
// 2e ask vers la MÊME cible : doit démarrer un nouveau tour (pas coincé derrière un
|
||||
// tour fantôme) et se laisser résoudre.
|
||||
let svc2 = Arc::clone(&fx.service);
|
||||
let ask2 = tokio::spawn(async move { svc2.dispatch(&project(), cmd(ASK_JSON)).await });
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
assert!(
|
||||
fx.mediator.busy_state(aid(1)).is_busy(),
|
||||
"le 2e tour démarre bien (cible Busy) — preuve qu'elle n'était pas coincée"
|
||||
);
|
||||
|
||||
fx.service
|
||||
.dispatch(&project(), reply_cmd(aid(1), "réponse au 2e tour"))
|
||||
.await
|
||||
.expect("reply ok");
|
||||
let out = timeout(TEST_GUARD, ask2)
|
||||
.await
|
||||
.expect("le 2e ask se termine")
|
||||
.expect("join ok")
|
||||
.expect("ask ok");
|
||||
assert_eq!(out.reply.as_deref(), Some("réponse au 2e tour"));
|
||||
assert_eq!(
|
||||
fx.mediator.busy_state(aid(1)),
|
||||
AgentBusyState::Idle,
|
||||
"cible Idle après résolution du 2e tour"
|
||||
);
|
||||
}
|
||||
|
||||
/// Régression (garde RAII) — la **branche erreur/timeout** (canal fermé) ramène aussi la
|
||||
/// cible `Idle`. Avant le fix, ces branches faisaient `cancel_head` mais jamais
|
||||
/// `mark_idle` ⇒ l'agent restait `Busy`. On déclenche la fermeture du canal en retirant
|
||||
/// le ticket de tête (même nettoyage que le timeout de tour).
|
||||
#[tokio::test]
|
||||
async fn cancelled_ask_marks_target_idle() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
assert!(fx.mediator.busy_state(aid(1)).is_busy());
|
||||
|
||||
// Retire le ticket de tête (drop du sender) ⇒ l'ask voit un canal fermé et part en
|
||||
// erreur typée (PROCESS), le MÊME nettoyage que le timeout de tour.
|
||||
let t = fx.mailbox.ticket_ids(&aid(1))[0];
|
||||
fx.mailbox.cancel_head(aid(1), t);
|
||||
let err = timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
.expect("ask retourne vite sur canal fermé")
|
||||
.expect("join ok")
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err.code(), "PROCESS" | "INVALID"),
|
||||
"ask sur canal fermé ⇒ erreur typée : {err:?}"
|
||||
);
|
||||
// Le garde a ramené la cible Idle (la FIFO peut avancer).
|
||||
assert_eq!(
|
||||
fx.mediator.busy_state(aid(1)),
|
||||
AgentBusyState::Idle,
|
||||
"branche erreur ⇒ cible Idle (garde RAII)"
|
||||
);
|
||||
}
|
||||
|
||||
/// A dead target is launched in the background (PTY) before the task is written.
|
||||
#[tokio::test]
|
||||
async fn ask_dead_target_launches_pty_then_writes_and_replies() {
|
||||
@ -1242,7 +1386,11 @@ async fn ask_dead_target_launches_pty_then_writes_and_replies() {
|
||||
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
// The dead target was launched as a PTY (spawn recorded, session registered).
|
||||
assert_eq!(fx.pty.spawns(), vec![sid(777)], "dead target launched as PTY");
|
||||
assert_eq!(
|
||||
fx.pty.spawns(),
|
||||
vec![sid(777)],
|
||||
"dead target launched as PTY"
|
||||
);
|
||||
assert_eq!(fx.sessions.session_for_agent(&aid(1)), Some(sid(777)));
|
||||
// The delegated task was written into the freshly-launched terminal (the Stdin
|
||||
// context injection may also write the persona, so we look for the task prefix).
|
||||
@ -1289,7 +1437,10 @@ async fn ask_success_publishes_agent_replied_event() {
|
||||
.events()
|
||||
.into_iter()
|
||||
.find_map(|e| match e {
|
||||
DomainEvent::AgentReplied { agent_id, reply_len } => Some((agent_id, reply_len)),
|
||||
DomainEvent::AgentReplied {
|
||||
agent_id,
|
||||
reply_len,
|
||||
} => Some((agent_id, reply_len)),
|
||||
_ => None,
|
||||
})
|
||||
.expect("AgentReplied must be published");
|
||||
@ -1358,7 +1509,11 @@ async fn reply_without_pending_ask_is_invalid() {
|
||||
.dispatch(&project(), reply_cmd(aid(7), "orphan result"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "INVALID", "orphan reply is typed INVALID: {err:?}");
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
"INVALID",
|
||||
"orphan reply is typed INVALID: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `Reply` resolves the HEAD ticket of the emitting agent's queue (positional
|
||||
@ -1417,8 +1572,12 @@ async fn ask_different_targets_run_in_parallel() {
|
||||
let mut inner = contexts.0.lock().unwrap();
|
||||
inner.manifest.entries.push(ManifestEntry::from_agent(&a));
|
||||
inner.manifest.entries.push(ManifestEntry::from_agent(&b));
|
||||
inner.contents.insert(a.context_path.clone(), "# a".to_owned());
|
||||
inner.contents.insert(b.context_path.clone(), "# b".to_owned());
|
||||
inner
|
||||
.contents
|
||||
.insert(a.context_path.clone(), "# a".to_owned());
|
||||
inner
|
||||
.contents
|
||||
.insert(b.context_path.clone(), "# b".to_owned());
|
||||
}
|
||||
let fx = ask_fixture(contexts);
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(801));
|
||||
@ -1498,10 +1657,10 @@ impl FileSystem for CapturingFs {
|
||||
Err(FsError::NotFound(path.as_str().to_owned()))
|
||||
}
|
||||
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((path.as_str().to_owned(), String::from_utf8_lossy(data).into_owned()));
|
||||
self.0.lock().unwrap().push((
|
||||
path.as_str().to_owned(),
|
||||
String::from_utf8_lossy(data).into_owned(),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
async fn exists(&self, _path: &RemotePath) -> Result<bool, FsError> {
|
||||
@ -1640,8 +1799,7 @@ fn ask_fixture_ex(
|
||||
.with_events(Arc::new(bus.clone()));
|
||||
|
||||
if let Some(p) = provider {
|
||||
service =
|
||||
service.with_mcp_runtime_provider(p as Arc<dyn application::McpRuntimeProvider>);
|
||||
service = service.with_mcp_runtime_provider(p as Arc<dyn application::McpRuntimeProvider>);
|
||||
}
|
||||
|
||||
AskFixtureEx {
|
||||
@ -1677,7 +1835,11 @@ async fn f1_ask_dead_target_injects_provider_runtime_into_mcp_json() {
|
||||
let calls = provider.calls();
|
||||
assert_eq!(calls.len(), 1, "provider interrogé exactement une fois");
|
||||
assert_eq!(calls[0].0, project().id, "interrogé pour le bon projet");
|
||||
assert_eq!(calls[0].1, aid(1), "interrogé pour la cible relancée (= --requester)");
|
||||
assert_eq!(
|
||||
calls[0].1,
|
||||
aid(1),
|
||||
"interrogé pour la cible relancée (= --requester)"
|
||||
);
|
||||
|
||||
// La déclaration `.mcp.json` écrite porte les valeurs sentinelles du runtime.
|
||||
let decl = fx
|
||||
@ -1780,13 +1942,20 @@ async fn f2_ask_codex_target_is_invalid_no_launch() {
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "INVALID", "garde F2 = erreur typée Invalid: {err:?}");
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
"INVALID",
|
||||
"garde F2 = erreur typée Invalid: {err:?}"
|
||||
);
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("idea_*") || msg.contains("pont") || msg.contains(".mcp.json"),
|
||||
"message explicite sur le pont non supporté: {msg}"
|
||||
);
|
||||
assert!(fx.pty.spawns().is_empty(), "aucun lancement sur cible refusée");
|
||||
assert!(
|
||||
fx.pty.spawns().is_empty(),
|
||||
"aucun lancement sur cible refusée"
|
||||
);
|
||||
assert_eq!(fx.mailbox.pending(&aid(1)), 0, "aucun ticket enfilé");
|
||||
}
|
||||
|
||||
@ -1856,7 +2025,10 @@ fn ask_fixture_c3(contexts: FakeContexts) -> AskFixtureC3 {
|
||||
None,
|
||||
));
|
||||
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
|
||||
let close = Arc::new(CloseTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions)));
|
||||
let close = Arc::new(CloseTerminal::new(
|
||||
Arc::new(pty.clone()),
|
||||
Arc::clone(&sessions),
|
||||
));
|
||||
let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts.clone())));
|
||||
let create_skill = Arc::new(CreateSkill::new(
|
||||
Arc::new(RecordingSkills::default()) as Arc<dyn SkillStore>,
|
||||
@ -1898,8 +2070,12 @@ fn seed_two_agents(contexts: &FakeContexts) {
|
||||
let mut inner = contexts.0.lock().unwrap();
|
||||
inner.manifest.entries.push(ManifestEntry::from_agent(&a));
|
||||
inner.manifest.entries.push(ManifestEntry::from_agent(&b));
|
||||
inner.contents.insert(a.context_path.clone(), "# a".to_owned());
|
||||
inner.contents.insert(b.context_path.clone(), "# b".to_owned());
|
||||
inner
|
||||
.contents
|
||||
.insert(a.context_path.clone(), "# a".to_owned());
|
||||
inner
|
||||
.contents
|
||||
.insert(b.context_path.clone(), "# b".to_owned());
|
||||
}
|
||||
|
||||
/// An `agent.message` from agent A (uuid requester) to a named target.
|
||||
@ -1920,15 +2096,19 @@ async fn ask_agent_routes_into_a_to_b_conversation_not_user_b() {
|
||||
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask = tokio::spawn(async move {
|
||||
svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(1), "beta", "do B")))
|
||||
svc.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(1), "beta", "do B")),
|
||||
)
|
||||
.await
|
||||
});
|
||||
await_until(|| fx.mailbox.pending(&aid(2)) == 1).await;
|
||||
|
||||
// The registry now holds the A↔B thread (agent 1 ↔ agent 2), not User↔B.
|
||||
let a_to_b = fx
|
||||
.conversations
|
||||
.resolve(ConversationParty::agent(aid(1)), ConversationParty::agent(aid(2)));
|
||||
let a_to_b = fx.conversations.resolve(
|
||||
ConversationParty::agent(aid(1)),
|
||||
ConversationParty::agent(aid(2)),
|
||||
);
|
||||
let user_b = fx
|
||||
.conversations
|
||||
.resolve(ConversationParty::User, ConversationParty::agent(aid(2)));
|
||||
@ -1941,7 +2121,10 @@ async fn ask_agent_routes_into_a_to_b_conversation_not_user_b() {
|
||||
"ask A→B resolved the A↔B pair"
|
||||
);
|
||||
// The live B session is bound to the A↔B thread (session keyed by conversation).
|
||||
assert!(a_to_b.session.is_live(), "A↔B thread bound to a live session");
|
||||
assert!(
|
||||
a_to_b.session.is_live(),
|
||||
"A↔B thread bound to a live session"
|
||||
);
|
||||
|
||||
fx.service
|
||||
.dispatch(&project(), reply_cmd(aid(2), "B done"))
|
||||
@ -1964,7 +2147,10 @@ async fn cycle_a_to_b_to_a_is_refused_before_deadlock() {
|
||||
// A→B in flight (held — B never replies during the test): edge A→B is posted.
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let _ask_ab = tokio::spawn(async move {
|
||||
svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(1), "beta", "to B")))
|
||||
svc.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(1), "beta", "to B")),
|
||||
)
|
||||
.await
|
||||
});
|
||||
await_until(|| fx.mailbox.pending(&aid(2)) == 1).await;
|
||||
@ -1972,13 +2158,19 @@ async fn cycle_a_to_b_to_a_is_refused_before_deadlock() {
|
||||
// Now B→A would close the cycle ⇒ typed INVALID, no enqueue on A, returns fast.
|
||||
let err = timeout(
|
||||
TEST_GUARD,
|
||||
fx.service
|
||||
.dispatch(&project(), cmd(&ask_from_agent_json(aid(2), "alpha", "back to A"))),
|
||||
fx.service.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(2), "alpha", "back to A")),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("cycle ask must return fast, never deadlock")
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "INVALID", "re-entrant cycle is typed INVALID: {err:?}");
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
"INVALID",
|
||||
"re-entrant cycle is typed INVALID: {err:?}"
|
||||
);
|
||||
assert!(
|
||||
err.to_string().contains("cycle") || err.to_string().contains("ré-entrante"),
|
||||
"explicit cycle message: {err}"
|
||||
@ -1999,7 +2191,10 @@ async fn edge_cleared_after_turn_allows_later_reverse_delegation() {
|
||||
// A→B completes.
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask_ab = tokio::spawn(async move {
|
||||
svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(1), "beta", "to B")))
|
||||
svc.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(1), "beta", "to B")),
|
||||
)
|
||||
.await
|
||||
});
|
||||
await_until(|| fx.mailbox.pending(&aid(2)) == 1).await;
|
||||
@ -2012,7 +2207,10 @@ async fn edge_cleared_after_turn_allows_later_reverse_delegation() {
|
||||
// Edge A→B is now gone ⇒ B→A is allowed (would only cycle if A→B still pending).
|
||||
let svc2 = Arc::clone(&fx.service);
|
||||
let ask_ba = tokio::spawn(async move {
|
||||
svc2.dispatch(&project(), cmd(&ask_from_agent_json(aid(2), "alpha", "now to A")))
|
||||
svc2.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(2), "alpha", "now to A")),
|
||||
)
|
||||
.await
|
||||
});
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
@ -2021,7 +2219,11 @@ async fn edge_cleared_after_turn_allows_later_reverse_delegation() {
|
||||
.await
|
||||
.expect("reply ok");
|
||||
let out = timeout(TEST_GUARD, ask_ba).await.unwrap().unwrap().unwrap();
|
||||
assert_eq!(out.reply.as_deref(), Some("A ok"), "reverse delegation allowed");
|
||||
assert_eq!(
|
||||
out.reply.as_deref(),
|
||||
Some("A ok"),
|
||||
"reverse delegation allowed"
|
||||
);
|
||||
}
|
||||
|
||||
/// C3 — reply correlates **by ticket** (deterministic id-keyed resolution). The agent
|
||||
@ -2064,7 +2266,11 @@ async fn reply_correlates_by_ticket() {
|
||||
.dispatch(&project(), reply_cmd_ticket(aid(2), bogus, "wrong"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "INVALID", "unknown ticket id ⇒ typed INVALID: {err:?}");
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
"INVALID",
|
||||
"unknown ticket id ⇒ typed INVALID: {err:?}"
|
||||
);
|
||||
let t2 = fx.mailbox.ticket_ids(&aid(2))[0];
|
||||
fx.service
|
||||
.dispatch(&project(), reply_cmd_ticket(aid(2), t2, "R2"))
|
||||
@ -2129,7 +2335,11 @@ async fn timeout_path_frees_queue_and_keeps_target_alive() {
|
||||
"closed-channel ask is a typed error: {err:?}"
|
||||
);
|
||||
// Queue freed, and the target B is still live (never killed by the failed ask).
|
||||
assert_eq!(fx.mailbox.pending(&aid(2)), 0, "queue freed after retirement");
|
||||
assert_eq!(
|
||||
fx.mailbox.pending(&aid(2)),
|
||||
0,
|
||||
"queue freed after retirement"
|
||||
);
|
||||
assert_eq!(
|
||||
fx.sessions.session_for_agent(&aid(2)),
|
||||
Some(sid(802)),
|
||||
@ -2232,7 +2442,10 @@ async fn submit_and_ask_share_one_fifo_per_agent() {
|
||||
// A delegation A→B blocks in B's FIFO (it awaits a reply).
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask = tokio::spawn(async move {
|
||||
svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(10), "architect", "deleg")))
|
||||
svc.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(10), "architect", "deleg")),
|
||||
)
|
||||
.await
|
||||
});
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
@ -2256,7 +2469,8 @@ async fn submit_and_ask_share_one_fifo_per_agent() {
|
||||
);
|
||||
|
||||
// Unblock the delegation so the spawned task ends cleanly.
|
||||
fx.mailbox.cancel_head(aid(1), fx.mailbox.ticket_ids(&aid(1))[0]);
|
||||
fx.mailbox
|
||||
.cancel_head(aid(1), fx.mailbox.ticket_ids(&aid(1))[0]);
|
||||
let _ = timeout(TEST_GUARD, ask).await;
|
||||
}
|
||||
|
||||
@ -2275,11 +2489,7 @@ async fn interrupt_preempts_without_enqueue_or_resolve() {
|
||||
.expect("interrupt succeeds");
|
||||
|
||||
assert_eq!(fx.mediator.preempts(), vec![aid(1)], "preempt called once");
|
||||
assert_eq!(
|
||||
fx.mailbox.pending(&aid(1)),
|
||||
0,
|
||||
"interrupt enqueues nothing"
|
||||
);
|
||||
assert_eq!(fx.mailbox.pending(&aid(1)), 0, "interrupt enqueues nothing");
|
||||
}
|
||||
|
||||
/// A human submit to an unknown agent id is a typed NotFound (never a panic).
|
||||
@ -2304,7 +2514,10 @@ async fn interrupt_unknown_agent_is_not_found() {
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "NOT_FOUND", "unknown agent interrupt: {err:?}");
|
||||
assert!(fx.mediator.preempts().is_empty(), "no preempt on unknown agent");
|
||||
assert!(
|
||||
fx.mediator.preempts().is_empty(),
|
||||
"no preempt on unknown agent"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -2323,13 +2536,13 @@ async fn interrupt_unknown_agent_is_not_found() {
|
||||
// in `conversation_record.rs`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use application::{OrchestratorOutcome, RecordTurn, RecordTurnProvider};
|
||||
use domain::ports::Clock;
|
||||
use domain::InputSource;
|
||||
use domain::{
|
||||
ConversationLog, ConversationTurn as DomainTurn, Handoff, HandoffStore, HandoffSummarizer,
|
||||
TurnId, TurnRole,
|
||||
};
|
||||
use application::{OrchestratorOutcome, RecordTurn, RecordTurnProvider};
|
||||
use domain::ports::Clock;
|
||||
use domain::InputSource;
|
||||
|
||||
/// A deterministic [`Clock`]: every persisted turn is stamped with this constant.
|
||||
struct FixedClock(i64);
|
||||
@ -2367,7 +2580,9 @@ impl ConversationLog for RecordingLog {
|
||||
turn: DomainTurn,
|
||||
) -> Result<(), StoreError> {
|
||||
if self.fail_append {
|
||||
return Err(StoreError::Io("recording log: forced append failure".to_owned()));
|
||||
return Err(StoreError::Io(
|
||||
"recording log: forced append failure".to_owned(),
|
||||
));
|
||||
}
|
||||
self.appends.lock().unwrap().push(turn);
|
||||
Ok(())
|
||||
@ -2399,11 +2614,7 @@ impl HandoffStore for NoopHandoffStore {
|
||||
async fn load(&self, conversation: ConversationId) -> Result<Option<Handoff>, StoreError> {
|
||||
Ok(self.0.lock().unwrap().get(&conversation).cloned())
|
||||
}
|
||||
async fn save(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
handoff: Handoff,
|
||||
) -> Result<(), StoreError> {
|
||||
async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError> {
|
||||
self.0.lock().unwrap().insert(conversation, handoff);
|
||||
Ok(())
|
||||
}
|
||||
@ -2419,7 +2630,10 @@ impl HandoffSummarizer for ConcatSummarizer {
|
||||
for t in new_turns {
|
||||
summary.push_str(&t.text);
|
||||
}
|
||||
let up_to = new_turns.last().map(|t| t.id).expect("at least one new turn");
|
||||
let up_to = new_turns
|
||||
.last()
|
||||
.map(|t| t.id)
|
||||
.expect("at least one new turn");
|
||||
Handoff::new(summary, up_to, None)
|
||||
}
|
||||
}
|
||||
@ -2564,7 +2778,11 @@ async fn p6b_user_ask_records_prompt_then_response_pair() {
|
||||
assert_eq!(out.reply.as_deref(), Some("the §17 answer"));
|
||||
|
||||
let appends = log.appends();
|
||||
assert_eq!(appends.len(), 2, "exactly two turns persisted (Prompt + Response)");
|
||||
assert_eq!(
|
||||
appends.len(),
|
||||
2,
|
||||
"exactly two turns persisted (Prompt + Response)"
|
||||
);
|
||||
|
||||
let prompt = &appends[0];
|
||||
let response = &appends[1];
|
||||
@ -2575,12 +2793,26 @@ async fn p6b_user_ask_records_prompt_then_response_pair() {
|
||||
);
|
||||
// Order + role.
|
||||
assert_eq!(prompt.role, TurnRole::Prompt, "first turn is the Prompt");
|
||||
assert_eq!(response.role, TurnRole::Response, "second turn is the Response");
|
||||
assert_eq!(
|
||||
response.role,
|
||||
TurnRole::Response,
|
||||
"second turn is the Response"
|
||||
);
|
||||
// Text: task then result.
|
||||
assert_eq!(prompt.text, "Analyse §17", "prompt text is the delegated task");
|
||||
assert_eq!(response.text, "the §17 answer", "response text is the reply result");
|
||||
assert_eq!(
|
||||
prompt.text, "Analyse §17",
|
||||
"prompt text is the delegated task"
|
||||
);
|
||||
assert_eq!(
|
||||
response.text, "the §17 answer",
|
||||
"response text is the reply result"
|
||||
);
|
||||
// Source: Human prompt (no agent requester), target-sourced response.
|
||||
assert_eq!(prompt.source, InputSource::Human, "User ask ⇒ Human prompt source");
|
||||
assert_eq!(
|
||||
prompt.source,
|
||||
InputSource::Human,
|
||||
"User ask ⇒ Human prompt source"
|
||||
);
|
||||
assert_eq!(
|
||||
response.source,
|
||||
InputSource::agent(aid(1)),
|
||||
@ -2603,8 +2835,11 @@ async fn p6b_agent_requester_records_pair_on_a_b_thread() {
|
||||
Arc::new(ConcatSummarizer) as Arc<dyn HandoffSummarizer>,
|
||||
));
|
||||
let provider = Arc::new(SharedRecordProvider(record)) as Arc<dyn RecordTurnProvider>;
|
||||
let fx =
|
||||
ask_fixture_with_record(FakeContexts::with_agent(&architect, "# persona"), provider, 0);
|
||||
let fx = ask_fixture_with_record(
|
||||
FakeContexts::with_agent(&architect, "# persona"),
|
||||
provider,
|
||||
0,
|
||||
);
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
// Requester is agent A (id 7). The orchestrator threads `requester` from the
|
||||
@ -2625,7 +2860,10 @@ async fn p6b_agent_requester_records_pair_on_a_b_thread() {
|
||||
// assert both turns landed on it.
|
||||
let convs = TestConversations::new();
|
||||
let expected = convs
|
||||
.resolve(ConversationParty::agent(a), ConversationParty::agent(aid(1)))
|
||||
.resolve(
|
||||
ConversationParty::agent(a),
|
||||
ConversationParty::agent(aid(1)),
|
||||
)
|
||||
.id;
|
||||
// (Resolution is deterministic per pair within one registry; we instead assert the
|
||||
// turns share a thread and the prompt source carries A — the load-bearing facts.)
|
||||
@ -2655,7 +2893,11 @@ async fn p6b_no_provider_is_silent_no_op() {
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
let out = run_ask_roundtrip(&fx, ASK_JSON, aid(1), "ok").await;
|
||||
assert_eq!(out.reply.as_deref(), Some("ok"), "ask succeeds without persistence");
|
||||
assert_eq!(
|
||||
out.reply.as_deref(),
|
||||
Some("ok"),
|
||||
"ask succeeds without persistence"
|
||||
);
|
||||
}
|
||||
|
||||
/// Provider wired but returns `None` for the root ⇒ ask still succeeds (best-effort
|
||||
@ -2668,7 +2910,11 @@ async fn p6b_provider_returns_none_is_silent_no_op() {
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
let out = run_ask_roundtrip(&fx, ASK_JSON, aid(1), "ok").await;
|
||||
assert_eq!(out.reply.as_deref(), Some("ok"), "ask succeeds when provider declines");
|
||||
assert_eq!(
|
||||
out.reply.as_deref(),
|
||||
Some("ok"),
|
||||
"ask succeeds when provider declines"
|
||||
);
|
||||
}
|
||||
|
||||
/// `RecordTurn` built on a log whose `append` always fails ⇒ persistence errors are
|
||||
@ -2693,5 +2939,263 @@ async fn p6b_failing_record_does_not_degrade_ask() {
|
||||
"a failing append must not turn a successful delegation into an error"
|
||||
);
|
||||
// The failing log recorded nothing (every append errored).
|
||||
assert!(failing_log.appends().is_empty(), "no turns stored when append fails");
|
||||
assert!(
|
||||
failing_log.appends().is_empty(),
|
||||
"no turns stored when append fails"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Lot 1b — auto-lancement d'une cible **froide** structurée sur le chemin `ask`
|
||||
// ===========================================================================
|
||||
//
|
||||
// Avant le Lot 1b, `ask_agent` ne routait vers `ask_structured` que si la cible avait
|
||||
// **déjà** une session structurée vivante ; une cible structurée froide tombait dans
|
||||
// `ensure_live_pty` (chemin PTY) — or une cible structurée n'a pas de PTY, donc le tour
|
||||
// restait bloqué `Busy` (débloquable seulement par un `idea_reply` explicite). Le Lot 1b
|
||||
// démarre la session structurée via le **même** launcher que le chemin PTY (qui route
|
||||
// §17.4 vers `launch_structured` et l'insère dans le registre **partagé**), puis route
|
||||
// vers `ask_structured` : le `Final` déterministe de la session débloque le tour et
|
||||
// marque l'agent `Idle` (`mark_idle`), **sans** `idea_reply` ni PTY.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use application::StructuredSessions;
|
||||
use domain::ports::{
|
||||
AgentSession, AgentSessionError, AgentSessionFactory, ReplyEvent, ReplyStream,
|
||||
};
|
||||
|
||||
/// Session structurée fake : `send()` rend un unique [`ReplyEvent::Final`] qui **écho**
|
||||
/// le prompt reçu — c'est ce `Final` que `drain_with_readiness` transforme en réponse
|
||||
/// du tour **et** en `mark_idle`. Aucun `idea_reply` n'est nécessaire.
|
||||
struct EchoSession {
|
||||
id: SessionId,
|
||||
}
|
||||
#[async_trait]
|
||||
impl AgentSession for EchoSession {
|
||||
fn id(&self) -> SessionId {
|
||||
self.id
|
||||
}
|
||||
fn conversation_id(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||
let stream: ReplyStream = Box::new(
|
||||
vec![ReplyEvent::Final {
|
||||
content: format!("structured: {prompt}"),
|
||||
}]
|
||||
.into_iter(),
|
||||
);
|
||||
Ok(stream)
|
||||
}
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Fabrique fake : `supports` = profil structuré, et `start` **compte** les démarrages
|
||||
/// (preuve qu'une session froide a bien été auto-lancée) en rendant une [`EchoSession`].
|
||||
#[derive(Clone, Default)]
|
||||
struct CountingFactory {
|
||||
starts: Arc<AtomicUsize>,
|
||||
next_id: Arc<Mutex<u128>>,
|
||||
}
|
||||
impl CountingFactory {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
starts: Arc::new(AtomicUsize::new(0)),
|
||||
next_id: Arc::new(Mutex::new(9000)),
|
||||
}
|
||||
}
|
||||
fn start_count(&self) -> usize {
|
||||
self.starts.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl AgentSessionFactory for CountingFactory {
|
||||
fn supports(&self, profile: &AgentProfile) -> bool {
|
||||
profile.structured_adapter.is_some()
|
||||
}
|
||||
async fn start(
|
||||
&self,
|
||||
_profile: &AgentProfile,
|
||||
_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 = {
|
||||
let mut n = self.next_id.lock().unwrap();
|
||||
let id = SessionId::from_uuid(Uuid::from_u128(*n));
|
||||
*n += 1;
|
||||
id
|
||||
};
|
||||
Ok(Arc::new(EchoSession { id }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Tout le câblage `ask` **structuré** : launcher + service voient le **même** registre
|
||||
/// `StructuredSessions`, et le launcher porte la fabrique (routage §17.4).
|
||||
struct StructuredAskFixture {
|
||||
service: Arc<OrchestratorService>,
|
||||
structured: Arc<StructuredSessions>,
|
||||
factory: CountingFactory,
|
||||
pty: FakePty,
|
||||
}
|
||||
|
||||
fn structured_ask_fixture(contexts: FakeContexts) -> StructuredAskFixture {
|
||||
let profiles = Arc::new(FakeProfiles::new(vec![claude_profile()]));
|
||||
let sessions = Arc::new(TerminalSessions::new());
|
||||
let pty = FakePty::new(sid(777));
|
||||
let bus = SpyBus::default();
|
||||
let mailbox = Arc::new(TestMailbox::new());
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let factory = CountingFactory::new();
|
||||
|
||||
let create = Arc::new(CreateAgentFromScratch::new(
|
||||
Arc::new(contexts.clone()),
|
||||
Arc::new(SeqIds::new()),
|
||||
Arc::new(bus.clone()),
|
||||
));
|
||||
// Launcher câblé **structuré** : pour un profil à `structured_adapter`, `execute`
|
||||
// route §17.4 → `launch_structured`, démarre la session via la fabrique et l'insère
|
||||
// dans CE registre partagé (le même `Arc` que le service ci-dessous).
|
||||
let launch = Arc::new(
|
||||
LaunchAgent::new(
|
||||
Arc::new(contexts.clone()),
|
||||
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
|
||||
Arc::new(FakeRuntime),
|
||||
Arc::new(FakeFs),
|
||||
Arc::new(pty.clone()),
|
||||
Arc::new(FakeSkills),
|
||||
Arc::clone(&sessions),
|
||||
Arc::new(bus.clone()),
|
||||
Arc::new(SeqIds::new()),
|
||||
Arc::new(FakeRecall),
|
||||
None,
|
||||
)
|
||||
.with_structured(
|
||||
Arc::new(factory.clone()) as Arc<dyn AgentSessionFactory>,
|
||||
Arc::clone(&structured),
|
||||
),
|
||||
);
|
||||
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
|
||||
let close = Arc::new(CloseTerminal::new(
|
||||
Arc::new(pty.clone()),
|
||||
Arc::clone(&sessions),
|
||||
));
|
||||
let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts.clone())));
|
||||
let create_skill = Arc::new(CreateSkill::new(
|
||||
Arc::new(RecordingSkills::default()) as Arc<dyn SkillStore>,
|
||||
Arc::new(SeqIds::new()),
|
||||
));
|
||||
let mediator = Arc::new(TestMediator::new(
|
||||
Arc::clone(&mailbox),
|
||||
Arc::new(pty.clone()) as Arc<dyn PtyPort>,
|
||||
));
|
||||
let conversations = Arc::new(TestConversations::new()) as Arc<dyn ConversationRegistry>;
|
||||
|
||||
let service = Arc::new(
|
||||
OrchestratorService::new(
|
||||
create,
|
||||
launch,
|
||||
list,
|
||||
close,
|
||||
update,
|
||||
create_skill,
|
||||
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
|
||||
Arc::clone(&sessions),
|
||||
)
|
||||
.with_input_mediator(
|
||||
Arc::clone(&mediator) as Arc<dyn InputMediator>,
|
||||
Arc::clone(&mailbox) as Arc<dyn AgentMailbox>,
|
||||
)
|
||||
.with_conversations(conversations)
|
||||
.with_events(Arc::new(bus.clone()))
|
||||
// Même registre que le launcher : `ask_agent` y relit la session fraîchement
|
||||
// insérée par `launch_structured`.
|
||||
.with_structured(Arc::clone(&structured)),
|
||||
);
|
||||
|
||||
StructuredAskFixture {
|
||||
service,
|
||||
structured,
|
||||
factory,
|
||||
pty,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cible **froide** structurée (adaptateur Claude, aucune session vivante) : `ask_agent`
|
||||
/// auto-lance sa session structurée via le launcher (factory.start appelé **une** fois),
|
||||
/// puis la draine — le `Final` débloque le round-trip **sans** `idea_reply` ni PTY.
|
||||
#[tokio::test]
|
||||
async fn ask_cold_structured_target_autolaunches_session_and_final_unblocks() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
|
||||
// Pré-condition : aucune session structurée vivante pour la cible (elle est froide).
|
||||
assert!(
|
||||
fx.structured.session_for_agent(&aid(1)).is_none(),
|
||||
"cible structurée froide : aucune session avant l'ask"
|
||||
);
|
||||
|
||||
let out = fx
|
||||
.service
|
||||
.dispatch(&project(), cmd(ASK_JSON))
|
||||
.await
|
||||
.expect("ask ok");
|
||||
|
||||
// Le `Final` de la session a rendu le contenu — sans aucun `idea_reply` dispatché.
|
||||
assert_eq!(
|
||||
out.reply.as_deref(),
|
||||
Some("structured: Analyse §17"),
|
||||
"le Final de la session auto-lancée débloque le tour"
|
||||
);
|
||||
// Exactement **un** démarrage de session structurée (la cible froide a été lancée).
|
||||
assert_eq!(
|
||||
fx.factory.start_count(),
|
||||
1,
|
||||
"une session structurée a été auto-lancée pour la cible froide"
|
||||
);
|
||||
// La session est désormais vivante dans le registre partagé (insérée par le launcher).
|
||||
assert!(
|
||||
fx.structured.session_for_agent(&aid(1)).is_some(),
|
||||
"la session auto-lancée est enregistrée dans StructuredSessions"
|
||||
);
|
||||
// Chemin structuré ⇒ **aucun** PTY spawné (la cible n'a pas de terminal).
|
||||
assert!(
|
||||
fx.pty.spawns().is_empty(),
|
||||
"cible structurée : aucun PTY spawné (chemin chat, pas terminal)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Réutilisation : une **deuxième** délégation vers la **même** cible (désormais chaude)
|
||||
/// ne relance **pas** de session — elle réutilise celle insérée au premier `ask`. Prouve
|
||||
/// que le `session_for_agent` court-circuite l'auto-lancement (idempotence du chemin).
|
||||
#[tokio::test]
|
||||
async fn ask_warm_structured_target_reuses_session_without_relaunch() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
|
||||
// 1er ask : auto-lance (start_count == 1).
|
||||
let _ = fx
|
||||
.service
|
||||
.dispatch(&project(), cmd(ASK_JSON))
|
||||
.await
|
||||
.expect("first ask ok");
|
||||
assert_eq!(fx.factory.start_count(), 1, "1er ask auto-lance la session");
|
||||
|
||||
// 2e ask : cible chaude ⇒ aucune relance.
|
||||
let out = fx
|
||||
.service
|
||||
.dispatch(&project(), cmd(ASK_JSON))
|
||||
.await
|
||||
.expect("second ask ok");
|
||||
assert_eq!(out.reply.as_deref(), Some("structured: Analyse §17"));
|
||||
assert_eq!(
|
||||
fx.factory.start_count(),
|
||||
1,
|
||||
"cible chaude : la session est réutilisée, aucune relance"
|
||||
);
|
||||
}
|
||||
|
||||
128
crates/application/tests/permission_usecases.rs
Normal file
128
crates/application/tests/permission_usecases.rs
Normal file
@ -0,0 +1,128 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::{
|
||||
ResolveAgentPermissions, ResolveAgentPermissionsInput, UpdateAgentPermissions,
|
||||
UpdateAgentPermissionsInput, UpdateProjectPermissions, UpdateProjectPermissionsInput,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use domain::ids::{AgentId, ProjectId};
|
||||
use domain::ports::{PermissionStore, StoreError};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::{PermissionSet, Posture, ProjectPermissions};
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakePermissionStore {
|
||||
doc: Mutex<ProjectPermissions>,
|
||||
saves: Mutex<usize>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PermissionStore for FakePermissionStore {
|
||||
async fn load_permissions(&self, _project: &Project) -> Result<ProjectPermissions, StoreError> {
|
||||
Ok(self.doc.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn save_permissions(
|
||||
&self,
|
||||
_project: &Project,
|
||||
permissions: &ProjectPermissions,
|
||||
) -> Result<(), StoreError> {
|
||||
*self.doc.lock().unwrap() = permissions.clone();
|
||||
*self.saves.lock().unwrap() += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn project() -> Project {
|
||||
Project::new(
|
||||
ProjectId::new_random(),
|
||||
"permissions",
|
||||
ProjectPath::new("/home/me/proj").unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_700_000_000_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_permissions_replaces_defaults_and_persists() {
|
||||
let store = Arc::new(FakePermissionStore::default());
|
||||
let use_case = UpdateProjectPermissions::new(store.clone());
|
||||
|
||||
let out = use_case
|
||||
.execute(UpdateProjectPermissionsInput {
|
||||
project: project(),
|
||||
permissions: Some(PermissionSet::new(vec![], Posture::Ask)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
out.permissions.project_defaults.unwrap().fallback(),
|
||||
Posture::Ask
|
||||
);
|
||||
assert_eq!(*store.saves.lock().unwrap(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_agent_permissions_adds_and_removes_sparse_override() {
|
||||
let store = Arc::new(FakePermissionStore::default());
|
||||
let use_case = UpdateAgentPermissions::new(store.clone());
|
||||
let agent = AgentId::new_random();
|
||||
|
||||
use_case
|
||||
.execute(UpdateAgentPermissionsInput {
|
||||
project: project(),
|
||||
agent_id: agent,
|
||||
permissions: Some(PermissionSet::new(vec![], Posture::Deny)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.doc
|
||||
.lock()
|
||||
.unwrap()
|
||||
.agent_permissions(agent)
|
||||
.unwrap()
|
||||
.fallback(),
|
||||
Posture::Deny
|
||||
);
|
||||
|
||||
let out = use_case
|
||||
.execute(UpdateAgentPermissionsInput {
|
||||
project: project(),
|
||||
agent_id: agent,
|
||||
permissions: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(out.permissions.agent_permissions(agent).is_none());
|
||||
assert_eq!(*store.saves.lock().unwrap(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_agent_permissions_returns_effective_policy() {
|
||||
let agent = AgentId::new_random();
|
||||
let store = Arc::new(FakePermissionStore {
|
||||
doc: Mutex::new(ProjectPermissions::new(
|
||||
Some(PermissionSet::new(vec![], Posture::Ask)),
|
||||
vec![],
|
||||
)),
|
||||
saves: Mutex::new(0),
|
||||
});
|
||||
let use_case = ResolveAgentPermissions::new(store);
|
||||
|
||||
let out = use_case
|
||||
.execute(ResolveAgentPermissionsInput {
|
||||
project: project(),
|
||||
agent_id: agent,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.effective.unwrap().fallback(), Posture::Ask);
|
||||
}
|
||||
@ -218,16 +218,26 @@ async fn first_run_true_when_not_configured_with_reference_catalogue() {
|
||||
assert!(out.is_first_run);
|
||||
// §17.3/D7: the wizard is seeded only with the *selectable* (structured-
|
||||
// drivable) profiles — Claude + Codex — not the full 4-profile catalogue.
|
||||
assert_eq!(out.reference_profiles.len(), 2, "selectable catalogue seeded");
|
||||
assert_eq!(
|
||||
out.reference_profiles.len(),
|
||||
2,
|
||||
"selectable catalogue seeded"
|
||||
);
|
||||
let commands: Vec<&str> = out
|
||||
.reference_profiles
|
||||
.iter()
|
||||
.map(|p| p.command.as_str())
|
||||
.collect();
|
||||
assert_eq!(commands, vec!["claude", "codex"], "only Claude/Codex offered");
|
||||
assert_eq!(
|
||||
commands,
|
||||
vec!["claude", "codex"],
|
||||
"only Claude/Codex offered"
|
||||
);
|
||||
// Every seeded profile is selectable (the gate the menu relies on).
|
||||
assert!(
|
||||
out.reference_profiles.iter().all(AgentProfile::is_selectable),
|
||||
out.reference_profiles
|
||||
.iter()
|
||||
.all(AgentProfile::is_selectable),
|
||||
"seeded profiles must all be selectable"
|
||||
);
|
||||
}
|
||||
|
||||
@ -99,10 +99,14 @@ fn delta(t: &str) -> ReplyEvent {
|
||||
ReplyEvent::TextDelta { text: t.to_owned() }
|
||||
}
|
||||
fn tool(l: &str) -> ReplyEvent {
|
||||
ReplyEvent::ToolActivity { label: l.to_owned() }
|
||||
ReplyEvent::ToolActivity {
|
||||
label: l.to_owned(),
|
||||
}
|
||||
}
|
||||
fn final_(c: &str) -> ReplyEvent {
|
||||
ReplyEvent::Final { content: c.to_owned() }
|
||||
ReplyEvent::Final {
|
||||
content: c.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -174,8 +178,9 @@ async fn empty_stream_is_io_error() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_decode_error_is_propagated() {
|
||||
let session =
|
||||
ScriptedSession::new(Script::Err(AgentSessionError::Decode("bad json".to_owned())));
|
||||
let session = ScriptedSession::new(Script::Err(AgentSessionError::Decode(
|
||||
"bad json".to_owned(),
|
||||
)));
|
||||
let out = send_blocking(&session, "x", None).await;
|
||||
assert_eq!(out, Err(AgentSessionError::Decode("bad json".to_owned())));
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
@ -303,6 +303,7 @@ impl AgentRuntime for FakeRuntime {
|
||||
context_plan: Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
sandbox: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -519,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()
|
||||
@ -538,6 +540,96 @@ impl AgentSessionFactory for FakeFactory {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FakeProviderSessionStore + FakeProviderSessionProvider (lot P8b)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use application::ProviderSessionProvider;
|
||||
use domain::{ConversationId, ProviderSessionStore};
|
||||
|
||||
/// In-memory [`ProviderSessionStore`] for the P8b launch tests: a
|
||||
/// `(conversation, provider_id) → resumable_id` map, observable after the launch.
|
||||
/// `set` can be made to fail (best-effort scenario) so we can prove a write error
|
||||
/// never degrades the launch.
|
||||
#[derive(Clone, Default)]
|
||||
struct FakeProviderSessionStore {
|
||||
entries: Arc<Mutex<HashMap<(ConversationId, String), String>>>,
|
||||
fail_set: bool,
|
||||
}
|
||||
|
||||
impl FakeProviderSessionStore {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
/// A store whose `set` always errors (best-effort proof).
|
||||
fn failing() -> Self {
|
||||
Self {
|
||||
entries: Arc::new(Mutex::new(HashMap::new())),
|
||||
fail_set: true,
|
||||
}
|
||||
}
|
||||
/// Pre-seeds a `(conversation, provider) → resumable_id` mapping synchronously,
|
||||
/// so a P8c launch reading via [`ProviderSessionStore::get`] resolves the engine
|
||||
/// resumable for that pair (mirrors what a previous P8b launch would have stored).
|
||||
fn seed_sync(&self, conversation: ConversationId, provider: &str, resumable_id: &str) {
|
||||
self.entries
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert((conversation, provider.to_owned()), resumable_id.to_owned());
|
||||
}
|
||||
/// Observed resumable id for a `(conversation, provider)` couple, if any.
|
||||
fn get_sync(&self, conversation: ConversationId, provider: &str) -> Option<String> {
|
||||
self.entries
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&(conversation, provider.to_owned()))
|
||||
.cloned()
|
||||
}
|
||||
fn len(&self) -> usize {
|
||||
self.entries.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderSessionStore for FakeProviderSessionStore {
|
||||
async fn get(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
provider_id: &str,
|
||||
) -> Result<Option<String>, StoreError> {
|
||||
Ok(self.get_sync(conversation, provider_id))
|
||||
}
|
||||
async fn set(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
provider_id: &str,
|
||||
resumable_id: &str,
|
||||
) -> Result<(), StoreError> {
|
||||
if self.fail_set {
|
||||
return Err(StoreError::Io("forced set failure".to_owned()));
|
||||
}
|
||||
self.entries.lock().unwrap().insert(
|
||||
(conversation, provider_id.to_owned()),
|
||||
resumable_id.to_owned(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ProviderSessionProvider`] that hands back the same store for any root (the
|
||||
/// launch tests use a single project root).
|
||||
#[derive(Clone)]
|
||||
struct FakeProviderSessionProvider(Arc<FakeProviderSessionStore>);
|
||||
|
||||
impl ProviderSessionProvider for FakeProviderSessionProvider {
|
||||
fn provider_session_store_for(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
) -> Option<Arc<dyn ProviderSessionStore>> {
|
||||
Some(Arc::clone(&self.0) as Arc<dyn ProviderSessionStore>)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builders
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -772,14 +864,21 @@ async fn non_structured_profile_takes_pty_path_unchanged() {
|
||||
|
||||
// PTY spawn appelé, factory jamais sollicitée.
|
||||
assert_eq!(f.pty.spawn_count(), 1, "pty path spawns");
|
||||
assert_eq!(f.factory.start_count(), 0, "factory not called for pty profile");
|
||||
assert_eq!(
|
||||
f.factory.start_count(),
|
||||
0,
|
||||
"factory not called for pty profile"
|
||||
);
|
||||
|
||||
// Session côté registre PTY, rien côté structuré.
|
||||
assert_eq!(f.sessions.session_for_agent(&f.agent.id), Some(sid(777)));
|
||||
assert!(f.structured.session_for_agent(&f.agent.id).is_none());
|
||||
|
||||
// output.structured = None ; session PTY classique.
|
||||
assert!(out.structured.is_none(), "no structured descriptor on pty path");
|
||||
assert!(
|
||||
out.structured.is_none(),
|
||||
"no structured descriptor on pty path"
|
||||
);
|
||||
assert_eq!(out.session.id, sid(777));
|
||||
}
|
||||
|
||||
@ -821,7 +920,11 @@ async fn structured_launch_new_in_other_cell_refuses_when_live_elsewhere() {
|
||||
"no second factory.start on a refused launch"
|
||||
);
|
||||
assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn");
|
||||
assert_eq!(f.structured.len(), 1, "still a single live structured session");
|
||||
assert_eq!(
|
||||
f.structured.len(),
|
||||
1,
|
||||
"still a single live structured session"
|
||||
);
|
||||
assert_eq!(
|
||||
f.structured.node_for_agent(&f.agent.id),
|
||||
Some(host),
|
||||
@ -883,7 +986,11 @@ async fn structured_relaunch_other_cell_with_conversation_id_rebinds() {
|
||||
"no second factory.start on explicit reattach"
|
||||
);
|
||||
assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn");
|
||||
assert_eq!(f.structured.len(), 1, "still a single live structured session");
|
||||
assert_eq!(
|
||||
f.structured.len(),
|
||||
1,
|
||||
"still a single live structured session"
|
||||
);
|
||||
assert_eq!(f.structured.node_for_agent(&f.agent.id), Some(target));
|
||||
let desc = out.structured.expect("descriptor on rebind");
|
||||
assert_eq!(desc.session_id, sid(500), "same live session id");
|
||||
@ -1008,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);
|
||||
@ -1035,7 +1142,11 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() {
|
||||
f.pty.kills().is_empty(),
|
||||
"no PTY kill for a structured live session"
|
||||
);
|
||||
assert_eq!(f.pty.spawn_count(), 0, "no PTY spawn (target is structured)");
|
||||
assert_eq!(
|
||||
f.pty.spawn_count(),
|
||||
0,
|
||||
"no PTY spawn (target is structured)"
|
||||
);
|
||||
|
||||
// Relance via la factory : un nouveau start (donc total 2).
|
||||
assert_eq!(
|
||||
@ -1047,12 +1158,25 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() {
|
||||
// `ChangeAgentProfileOutput` ne surface qu'un snapshot `relaunched` (TerminalSession)
|
||||
// — on vérifie donc le registre structuré + le snapshot.
|
||||
// Seed consumed id 600; the relaunch's session is the factory's next id (601).
|
||||
let relaunched = out.relaunched.expect("a live structured agent is relaunched");
|
||||
assert_eq!(relaunched.id, sid(601), "relaunch adopts the new structured id");
|
||||
assert_eq!(relaunched.node_id, host, "relaunch reopens in the same cell");
|
||||
let relaunched = out
|
||||
.relaunched
|
||||
.expect("a live structured agent is relaunched");
|
||||
assert_eq!(
|
||||
relaunched.id,
|
||||
sid(601),
|
||||
"relaunch adopts the new structured id"
|
||||
);
|
||||
assert_eq!(
|
||||
relaunched.node_id, host,
|
||||
"relaunch reopens in the same cell"
|
||||
);
|
||||
assert_eq!(f.structured.session_id_for_agent(&agent.id), Some(sid(601)));
|
||||
assert_eq!(f.structured.node_for_agent(&agent.id), Some(host));
|
||||
assert_eq!(f.structured.len(), 1, "single live structured session after swap");
|
||||
assert_eq!(
|
||||
f.structured.len(),
|
||||
1,
|
||||
"single live structured session after swap"
|
||||
);
|
||||
// Manifeste muté vers le nouveau profil.
|
||||
assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2)));
|
||||
}
|
||||
@ -1079,12 +1203,19 @@ async fn swap_pty_live_session_keeps_a1_kill_behaviour() {
|
||||
// A1 d'origine : le PTY vivant est tué.
|
||||
assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed");
|
||||
// Aucune session structurée n'a été créée ni shutdown.
|
||||
assert_eq!(f.factory.start_count(), 0, "no structured start on pty swap");
|
||||
assert_eq!(
|
||||
f.factory.start_count(),
|
||||
0,
|
||||
"no structured start on pty swap"
|
||||
);
|
||||
assert_eq!(f.factory.shutdown_count(), 0, "no structured shutdown");
|
||||
// Relance PTY : un spawn, même cellule.
|
||||
assert_eq!(f.pty.spawn_count(), 1, "the new engine spawns once");
|
||||
let relaunched = out.relaunched.expect("a live agent is relaunched");
|
||||
assert_eq!(relaunched.node_id, host, "relaunch reopens in the same cell");
|
||||
assert_eq!(
|
||||
relaunched.node_id, host,
|
||||
"relaunch reopens in the same cell"
|
||||
);
|
||||
assert_eq!(relaunched.id, sid(777));
|
||||
// The relaunched session lives in the PTY registry, not the structured one.
|
||||
assert_eq!(f.sessions.session_for_agent(&agent.id), Some(sid(777)));
|
||||
@ -1170,3 +1301,445 @@ async fn structured_profile_fresh_cell_resolves_to_none() {
|
||||
"fresh structured cell without a session block plans None"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// LOT P8b — écriture de `providers.json` au lancement structuré
|
||||
//
|
||||
// Prouve `persist_provider_session` (best-effort) : après un lancement structuré
|
||||
// exposant un id de session moteur, IdeA range `(pair, provider_key) →
|
||||
// engine_session_id` via le `ProviderSessionStore` câblé. Toutes les conditions de
|
||||
// skip (provider absent, id moteur absent) et la robustesse (set en échec) sont
|
||||
// couvertes — le lancement réussit toujours.
|
||||
// ===========================================================================
|
||||
|
||||
/// Profil **structuré Codex** (porte `StructuredAdapter::Codex`) pour prouver la clé
|
||||
/// provider `"codex"`.
|
||||
fn structured_codex_profile(id: ProfileId) -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
id,
|
||||
"Codex Structuré",
|
||||
"codex",
|
||||
Vec::new(),
|
||||
ContextInjection::convention_file("AGENTS.md").unwrap(),
|
||||
Some("codex --version".to_owned()),
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
}
|
||||
|
||||
/// Builds a `LaunchAgent.with_structured(...).with_provider_session_provider(...)`
|
||||
/// over the given profile/factory, returning the launcher, the agent and the
|
||||
/// observable provider store. When `provider` is `None`, no provider is wired
|
||||
/// (legacy path).
|
||||
fn launch_fixture_p8b(
|
||||
profile: AgentProfile,
|
||||
factory: FakeFactory,
|
||||
store: Option<Arc<FakeProviderSessionStore>>,
|
||||
) -> (Arc<LaunchAgent>, Agent) {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
|
||||
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
|
||||
let profiles = FakeProfiles::new(vec![profile]);
|
||||
let runtime = FakeRuntime::new();
|
||||
let fs = FakeFs::default();
|
||||
let pty = FakePty::new(sid(777));
|
||||
let sessions = Arc::new(TerminalSessions::new());
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let bus = SpyBus::default();
|
||||
let mut launch = LaunchAgent::new(
|
||||
Arc::new(contexts),
|
||||
Arc::new(profiles),
|
||||
Arc::new(runtime),
|
||||
Arc::new(fs),
|
||||
Arc::new(pty),
|
||||
Arc::new(FakeSkills),
|
||||
Arc::clone(&sessions),
|
||||
Arc::new(bus),
|
||||
Arc::new(SeqIds::new()),
|
||||
Arc::new(FakeRecall),
|
||||
None,
|
||||
)
|
||||
.with_structured(Arc::new(factory), Arc::clone(&structured));
|
||||
if let Some(store) = store {
|
||||
let provider =
|
||||
Arc::new(FakeProviderSessionProvider(store)) as Arc<dyn ProviderSessionProvider>;
|
||||
launch = launch.with_provider_session_provider(provider);
|
||||
}
|
||||
(Arc::new(launch), agent)
|
||||
}
|
||||
|
||||
/// **Cas nominal Claude** : provider câblé, profil structuré Claude, moteur exposant
|
||||
/// `conversation_id() == Some("engine-xyz")`, cellule portant une `conversation_id`
|
||||
/// UUID valide ⇒ le store contient `(pair, "claude") == "engine-xyz"`, où la clé de
|
||||
/// conversation est exactement le `pair_conversation_id` (= l'uuid d'entrée).
|
||||
#[tokio::test]
|
||||
async fn p8b_nominal_claude_writes_engine_id_under_pair_and_claude_key() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
let factory = FakeFactory::new(500, Some("engine-xyz"));
|
||||
let (launch, agent) = launch_fixture_p8b(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
// La cellule porte un id de paire UUID valide : c'est lui qui sert de clé.
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
launch.execute(input).await.expect("structured launch");
|
||||
|
||||
assert_eq!(
|
||||
store.get_sync(pair, "claude").as_deref(),
|
||||
Some("engine-xyz"),
|
||||
"engine resumable stored under (pair, claude)"
|
||||
);
|
||||
assert_eq!(store.len(), 1, "exactly one entry written");
|
||||
// La clé de conversation est bien le pair_conversation_id (l'uuid d'entrée), pas
|
||||
// l'id moteur : un lookup sous l'id moteur (non-UUID) ne renverrait rien.
|
||||
assert!(
|
||||
store.get_sync(pair, "codex").is_none(),
|
||||
"nothing written under a different provider key"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Cas nominal Codex** : même scénario, profil structuré Codex ⇒ clé provider
|
||||
/// `"codex"`.
|
||||
#[tokio::test]
|
||||
async fn p8b_nominal_codex_writes_engine_id_under_codex_key() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
let factory = FakeFactory::new(500, Some("engine-codex"));
|
||||
let (launch, agent) = launch_fixture_p8b(
|
||||
structured_codex_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(456));
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
launch.execute(input).await.expect("structured launch");
|
||||
|
||||
assert_eq!(
|
||||
store.get_sync(pair, "codex").as_deref(),
|
||||
Some("engine-codex"),
|
||||
"engine resumable stored under (pair, codex)"
|
||||
);
|
||||
assert_eq!(store.len(), 1);
|
||||
}
|
||||
|
||||
/// **provider_key correct** : test direct du contrat de persistance.
|
||||
#[test]
|
||||
fn p8b_provider_key_mapping_is_stable() {
|
||||
assert_eq!(StructuredAdapter::Claude.provider_key(), "claude");
|
||||
assert_eq!(StructuredAdapter::Codex.provider_key(), "codex");
|
||||
}
|
||||
|
||||
/// **No-op sans provider** : sans `with_provider_session_provider`, le lancement
|
||||
/// réussit et rien n'est écrit (le store, non câblé, reste vide — on en garde une
|
||||
/// copie pour le prouver).
|
||||
#[tokio::test]
|
||||
async fn p8b_no_provider_wired_writes_nothing_launch_ok() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
let factory = FakeFactory::new(500, Some("engine-xyz"));
|
||||
// store NON câblé (None) ⇒ aucune écriture possible.
|
||||
let (launch, agent) = launch_fixture_p8b(structured_profile(pid(9)), factory, None);
|
||||
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
launch
|
||||
.execute(input)
|
||||
.await
|
||||
.expect("launch ok without provider");
|
||||
|
||||
assert_eq!(store.len(), 0, "no provider wired ⇒ nothing written");
|
||||
}
|
||||
|
||||
/// **No-op sans id moteur** : la session fake n'expose aucun id
|
||||
/// (`conversation_id() == None`) ⇒ aucune écriture, le lancement réussit.
|
||||
#[tokio::test]
|
||||
async fn p8b_no_engine_id_writes_nothing_launch_ok() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
// Factory avec conversation_id None ⇒ session.conversation_id() == None.
|
||||
let factory = FakeFactory::new(500, None);
|
||||
let (launch, agent) = launch_fixture_p8b(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
launch
|
||||
.execute(input)
|
||||
.await
|
||||
.expect("launch ok without engine id");
|
||||
|
||||
assert_eq!(
|
||||
store.len(),
|
||||
0,
|
||||
"no engine session id ⇒ persist skipped, launch unaffected"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Best-effort** : un store dont `set` renvoie `Err` ⇒ `execute` réussit quand
|
||||
/// même (la sortie de lancement n'est pas dégradée : descripteur structuré présent,
|
||||
/// engine_session_id exposé).
|
||||
#[tokio::test]
|
||||
async fn p8b_store_set_error_does_not_fail_launch() {
|
||||
let store = Arc::new(FakeProviderSessionStore::failing());
|
||||
let factory = FakeFactory::new(500, Some("engine-xyz"));
|
||||
let (launch, agent) = launch_fixture_p8b(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
// Le lancement réussit malgré l'échec d'écriture du resumable.
|
||||
let out = launch
|
||||
.execute(input)
|
||||
.await
|
||||
.expect("launch must succeed despite store set failure");
|
||||
|
||||
// Sortie non dégradée : descripteur structuré + id moteur toujours exposés.
|
||||
let desc = out.structured.expect("structured descriptor still present");
|
||||
assert_eq!(desc.session_id, sid(500));
|
||||
assert_eq!(out.engine_session_id.as_deref(), Some("engine-xyz"));
|
||||
// Le store n'a (logiquement) rien persisté.
|
||||
assert_eq!(store.len(), 0, "failing set wrote nothing");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// LOT P8c — routage du `--resume` moteur via providers.json
|
||||
//
|
||||
// Prouve `resolve_session_plan` (privée) **par son contrat observable** : le
|
||||
// `SessionPlan` que `LaunchAgent::execute` transmet à `factory.start` (capturé par
|
||||
// la `FakeFactory`). Pour un profil **structuré** avec un store provider câblé, le
|
||||
// resume moteur est lu dans `providers.json` keyé par (id de paire, provider_key) —
|
||||
// **jamais** l'id de paire lui-même n'est passé en `--resume`.
|
||||
//
|
||||
// Le fallback (provider non câblé) et le chemin non structuré sont déjà attestés par
|
||||
// `structured_profile_with_cell_conversation_resolves_to_resume`,
|
||||
// `structured_profile_fresh_cell_resolves_to_none` (cas 3 structuré non câblé) et les
|
||||
// suites existantes ; on ajoute ici le **cœur P8c** : la lecture du store.
|
||||
// ===========================================================================
|
||||
|
||||
/// Variante de [`launch_fixture_p8b`] qui **rend aussi la factory** (clone partageant
|
||||
/// `starts` via `Arc`), pour observer le `SessionPlan` transmis à `start`.
|
||||
fn launch_fixture_p8c(
|
||||
profile: AgentProfile,
|
||||
factory: FakeFactory,
|
||||
store: Option<Arc<FakeProviderSessionStore>>,
|
||||
) -> (Arc<LaunchAgent>, Agent, FakeFactory) {
|
||||
let observed = factory.clone();
|
||||
let (launch, agent) = launch_fixture_p8b(profile, factory, store);
|
||||
(launch, agent, observed)
|
||||
}
|
||||
|
||||
/// **Cas 1 — Claude câblé, store contient l'engine id** : la cellule porte l'id de
|
||||
/// paire (UUID) ; le store mappe `(pair, "claude") → "engine-x"`. Le lancement doit
|
||||
/// transmettre `SessionPlan::Resume{ conversation_id: "engine-x" }` à la factory —
|
||||
/// **et surtout PAS** l'id de paire.
|
||||
#[tokio::test]
|
||||
async fn p8c_structured_claude_resumes_engine_id_from_store_not_pair() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
// Le moteur n'attribue pas d'id neuf (None) : le resume vient du store, pas du run.
|
||||
let factory = FakeFactory::new(500, None);
|
||||
let (launch, agent, observed) = launch_fixture_p8c(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
// Pré-remplit le store : (pair, "claude") → "engine-x".
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||
store.seed_sync(pair, "claude", "engine-x");
|
||||
|
||||
// La cellule porte l'id de **paire** (UUID), jamais l'id moteur.
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
launch
|
||||
.execute(input)
|
||||
.await
|
||||
.expect("structured launch resumes");
|
||||
|
||||
// Le SessionPlan transmis à factory.start est Resume avec l'**engine id du store**.
|
||||
let starts = observed.starts();
|
||||
assert_eq!(starts.len(), 1, "factory.start called once");
|
||||
assert_eq!(
|
||||
starts[0].1,
|
||||
SessionPlan::Resume {
|
||||
conversation_id: "engine-x".to_owned()
|
||||
},
|
||||
"resume must carry the engine resumable id from providers.json"
|
||||
);
|
||||
// GARDE-FOU EXPLICITE : ce n'est PAS l'id de paire qui part en --resume.
|
||||
match &starts[0].1 {
|
||||
SessionPlan::Resume { conversation_id } => {
|
||||
assert_ne!(
|
||||
conversation_id,
|
||||
&pair.to_string(),
|
||||
"the pair id must NEVER be passed as the engine --resume"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Resume, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// **Cas 1bis — Codex câblé, store contient l'engine id sous la clé `"codex"`** :
|
||||
/// même contrat, clé provider `"codex"`.
|
||||
#[tokio::test]
|
||||
async fn p8c_structured_codex_resumes_engine_id_from_store_codex_key() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
let factory = FakeFactory::new(500, None);
|
||||
let (launch, agent, observed) = launch_fixture_p8c(
|
||||
structured_codex_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(456));
|
||||
store.seed_sync(pair, "codex", "engine-codex-x");
|
||||
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
launch
|
||||
.execute(input)
|
||||
.await
|
||||
.expect("structured codex launch");
|
||||
|
||||
let starts = observed.starts();
|
||||
assert_eq!(starts.len(), 1);
|
||||
assert_eq!(
|
||||
starts[0].1,
|
||||
SessionPlan::Resume {
|
||||
conversation_id: "engine-codex-x".to_owned()
|
||||
},
|
||||
"codex resume reads its own provider key"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Cas 1ter — la clé provider discrimine** : le store ne contient l'engine id que
|
||||
/// sous `"codex"`, mais le profil est **Claude** ⇒ le lookup sous `"claude"` ne trouve
|
||||
/// rien ⇒ `SessionPlan::None` (premier lancement propre, jamais l'id de paire).
|
||||
#[tokio::test]
|
||||
async fn p8c_provider_key_mismatch_falls_back_to_none() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
let factory = FakeFactory::new(500, None);
|
||||
let (launch, agent, observed) = launch_fixture_p8c(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||
// Engine id rangé sous une AUTRE clé provider.
|
||||
store.seed_sync(pair, "codex", "engine-codex");
|
||||
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
launch.execute(input).await.expect("structured launch");
|
||||
|
||||
let starts = observed.starts();
|
||||
assert_eq!(starts.len(), 1);
|
||||
assert_eq!(
|
||||
starts[0].1,
|
||||
SessionPlan::None,
|
||||
"a claude profile must not pick up a codex-keyed resumable"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Cas 2 — store câblé mais vide** (`get` → `None`) : la cellule porte un id de
|
||||
/// paire valide mais aucun resumable n'a été rangé ⇒ `SessionPlan::None` (premier
|
||||
/// lancement propre). On vérifie aussi que l'id de paire n'est jamais transmis.
|
||||
#[tokio::test]
|
||||
async fn p8c_structured_store_empty_resolves_to_none() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
let factory = FakeFactory::new(500, None);
|
||||
let (launch, agent, observed) = launch_fixture_p8c(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
launch.execute(input).await.expect("structured launch");
|
||||
|
||||
let starts = observed.starts();
|
||||
assert_eq!(starts.len(), 1);
|
||||
assert_eq!(
|
||||
starts[0].1,
|
||||
SessionPlan::None,
|
||||
"empty store ⇒ clean first launch, never the pair id"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Cas 2bis — store câblé, cellule neuve (pas d'id de paire)** : aucun id à
|
||||
/// chercher ⇒ `SessionPlan::None`.
|
||||
#[tokio::test]
|
||||
async fn p8c_structured_store_wired_fresh_cell_resolves_to_none() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
let factory = FakeFactory::new(500, None);
|
||||
let (launch, agent, observed) = launch_fixture_p8c(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
// Cellule neuve : conversation_id = None.
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("fresh structured launch");
|
||||
|
||||
let starts = observed.starts();
|
||||
assert_eq!(starts.len(), 1);
|
||||
assert_eq!(
|
||||
starts[0].1,
|
||||
SessionPlan::None,
|
||||
"no cell id ⇒ nothing to look up ⇒ None"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Robustesse — id de paire non-UUID + store câblé** : un `conversation_id` qui ne
|
||||
/// parse pas en UUID (donnée legacy/corrompue) ⇒ pas de lookup possible ⇒
|
||||
/// `SessionPlan::None`, jamais l'id de paire passé tel quel en `--resume`.
|
||||
#[tokio::test]
|
||||
async fn p8c_non_uuid_pair_id_with_store_resolves_to_none() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
let factory = FakeFactory::new(500, None);
|
||||
let (launch, agent, observed) = launch_fixture_p8c(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some("not-a-uuid".to_owned());
|
||||
|
||||
launch.execute(input).await.expect("structured launch");
|
||||
|
||||
let starts = observed.starts();
|
||||
assert_eq!(starts.len(), 1);
|
||||
assert_eq!(
|
||||
starts[0].1,
|
||||
SessionPlan::None,
|
||||
"a non-UUID pair id must degrade to None, never resume the raw string"
|
||||
);
|
||||
}
|
||||
|
||||
@ -21,12 +21,8 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use application::{LiveAgentRegistry, LiveSessions, StructuredSessions, TerminalSessions};
|
||||
use domain::ports::{
|
||||
AgentSession, AgentSessionError, PtyHandle, ReplyStream,
|
||||
};
|
||||
use domain::{
|
||||
AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession,
|
||||
};
|
||||
use domain::ports::{AgentSession, AgentSessionError, PtyHandle, ReplyStream};
|
||||
use domain::{AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession};
|
||||
use uuid::Uuid;
|
||||
|
||||
// --- petits constructeurs déterministes ------------------------------------
|
||||
@ -110,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();
|
||||
@ -121,10 +135,7 @@ fn structured_live_agents_lists_triples() {
|
||||
|
||||
assert_eq!(
|
||||
live,
|
||||
vec![
|
||||
(aid(10), nid(100), sid(1)),
|
||||
(aid(20), nid(200), sid(2)),
|
||||
]
|
||||
vec![(aid(10), nid(100), sid(1)), (aid(20), nid(200), sid(2)),]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -366,11 +366,7 @@ mod tests {
|
||||
#[test]
|
||||
fn rejects_two_users() {
|
||||
assert_eq!(
|
||||
Conversation::try_new(
|
||||
conv_id(1),
|
||||
ConversationParty::User,
|
||||
ConversationParty::User
|
||||
),
|
||||
Conversation::try_new(conv_id(1), ConversationParty::User, ConversationParty::User),
|
||||
Err(ConversationError::TwoUsers)
|
||||
);
|
||||
}
|
||||
@ -428,10 +424,7 @@ mod tests {
|
||||
// A→B exists; B→C is fine (no path C→…→B).
|
||||
let mut g = WaitForGraph::new();
|
||||
g.add_edge(agent(1), agent(2)); // A waits B
|
||||
assert!(
|
||||
!g.would_cycle(agent(2), agent(3)),
|
||||
"A→B→C is acyclic"
|
||||
);
|
||||
assert!(!g.would_cycle(agent(2), agent(3)), "A→B→C is acyclic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -31,9 +31,7 @@ use crate::ports::StoreError;
|
||||
/// Newtype autour d'[`uuid::Uuid`], calqué sur [`crate::conversation::ConversationId`]
|
||||
/// / [`crate::mailbox::TicketId`]. Sa monotonie (un id frappé à l'`append`) sert de
|
||||
/// **curseur** à la relecture incrémentale ([`ConversationLog::read`] avec `since`).
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
|
||||
)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct TurnId(pub uuid::Uuid);
|
||||
|
||||
@ -199,11 +197,7 @@ pub struct Handoff {
|
||||
impl Handoff {
|
||||
/// Construit un handoff à partir de ses composants.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
summary_md: impl Into<String>,
|
||||
up_to: TurnId,
|
||||
objective: Option<String>,
|
||||
) -> Self {
|
||||
pub fn new(summary_md: impl Into<String>, up_to: TurnId, objective: Option<String>) -> Self {
|
||||
Self {
|
||||
summary_md: summary_md.into(),
|
||||
up_to,
|
||||
@ -235,11 +229,7 @@ pub trait HandoffStore: Send + Sync {
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] en cas d'échec d'écriture ou de sérialisation.
|
||||
async fn save(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
handoff: Handoff,
|
||||
) -> Result<(), StoreError>;
|
||||
async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
/// Le résumeur incrémental de handoff (port driving, lot P4).
|
||||
@ -435,7 +425,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn turn_role_serializes_camel_case() {
|
||||
assert_eq!(serde_json::to_string(&TurnRole::Prompt).unwrap(), "\"prompt\"");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&TurnRole::Prompt).unwrap(),
|
||||
"\"prompt\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&TurnRole::Response).unwrap(),
|
||||
"\"response\""
|
||||
@ -581,7 +574,11 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
let last2 = log.last(c, 2).await.unwrap();
|
||||
assert_eq!(texts(&last2), vec!["c", "d"], "the 2 last, in insertion order");
|
||||
assert_eq!(
|
||||
texts(&last2),
|
||||
vec!["c", "d"],
|
||||
"the 2 last, in insertion order"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -599,7 +596,10 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(texts(&log.read(c1, None).await.unwrap()), vec!["c1-a", "c1-b"]);
|
||||
assert_eq!(
|
||||
texts(&log.read(c1, None).await.unwrap()),
|
||||
vec!["c1-a", "c1-b"]
|
||||
);
|
||||
assert_eq!(texts(&log.read(c2, None).await.unwrap()), vec!["c2-a"]);
|
||||
// A cursor from one thread never leaks tours from another.
|
||||
assert_eq!(texts(&log.last(c2, 10).await.unwrap()), vec!["c2-a"]);
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
//! presentation layer (ARCHITECTURE §3.2).
|
||||
|
||||
use crate::ids::{AgentId, ProfileId, ProjectId, SessionId, SkillId, TemplateId};
|
||||
use crate::mailbox::TicketId;
|
||||
use crate::memory::MemorySlug;
|
||||
use crate::template::TemplateVersion;
|
||||
|
||||
@ -70,6 +71,19 @@ pub enum DomainEvent {
|
||||
/// `true` when a turn is in flight, `false` when the agent is idle.
|
||||
busy: bool,
|
||||
},
|
||||
/// An agent's **liveness** (alive/stalled) changed (lot 2, chantier
|
||||
/// readiness/heartbeat). Emitted **once per transition** by the stall detector:
|
||||
/// `Alive→Stalled` when no proof of liveness arrived for longer than the profile's
|
||||
/// `stall_after_ms`, and `Stalled→Alive` when a late battement (delta / tool
|
||||
/// activity / heartbeat) revives it or the agent returns to `Idle`. Discrete,
|
||||
/// low-frequency beacon (no spam) relayed to the front so the mediated-input view
|
||||
/// can badge a frozen agent. Purely advisory — the FIFO and the turn keep running.
|
||||
AgentLivenessChanged {
|
||||
/// The agent whose liveness changed.
|
||||
agent_id: AgentId,
|
||||
/// The new liveness state.
|
||||
liveness: crate::input::AgentLiveness,
|
||||
},
|
||||
/// An agent's runtime profile was changed (hot-swap of the AI engine).
|
||||
AgentProfileChanged {
|
||||
/// The agent.
|
||||
@ -173,6 +187,77 @@ pub enum DomainEvent {
|
||||
/// Whether the in-process ONNX capability (`localOnnx`) is compiled in.
|
||||
vector_onnx_enabled: bool,
|
||||
},
|
||||
/// A delegation is ready to be injected into the agent's **native terminal**
|
||||
/// (ARCHITECTURE §20.2/§20.3). Published when a turn *starts* (Idle→Busy); the
|
||||
/// backend stays the authority of the FIFO/busy state and **no longer writes the
|
||||
/// turn into the PTY** — the frontend cell runs the write-portal handshake and
|
||||
/// writes `text` + `submit_sequence` through the single PTY writer (the front).
|
||||
/// Discrete, low-frequency (one per delegation). Relayed to the front as
|
||||
/// `delegationReady` like every other [`DomainEvent`].
|
||||
DelegationReady {
|
||||
/// The target agent whose terminal will receive the delegation.
|
||||
agent_id: AgentId,
|
||||
/// The mailbox ticket correlating this turn (acked back via
|
||||
/// `delegation_delivered`); the requester's `ask` is woken by `idea_reply`.
|
||||
ticket: TicketId,
|
||||
/// The task text to inject (written without a trailing `\n` by the portal).
|
||||
text: String,
|
||||
/// Target profile's submit sequence (the cell applies it after the text).
|
||||
/// `None` ⇒ the front applies its default (`"\r"`); never hard-coded in the
|
||||
/// domain.
|
||||
submit_sequence: Option<String>,
|
||||
/// 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.
|
||||
@ -181,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
|
||||
);
|
||||
|
||||
@ -80,6 +80,29 @@ pub enum AgentBusyState {
|
||||
},
|
||||
}
|
||||
|
||||
/// Whether an agent currently shows **signs of life** during a turn (lot 2,
|
||||
/// chantier readiness/heartbeat — détection « Stalled »).
|
||||
///
|
||||
/// Orthogonal to [`AgentBusyState`]: an agent can be `Busy` **and** `Alive` (it is
|
||||
/// working, producing deltas/heartbeats) or `Busy` **and** `Stalled` (no proof of
|
||||
/// liveness for longer than its profile's `stall_after_ms`). Only meaningful while
|
||||
/// `Busy`; an `Idle` agent is considered `Alive` (its turn ended cleanly).
|
||||
///
|
||||
/// Published to the front (`AgentLivenessChanged`) **once per transition** so the UI
|
||||
/// can badge a frozen agent without event spam. Derived from the per-agent
|
||||
/// `last_seen_ms` heartbeat, never from parsing the model output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "liveness")]
|
||||
pub enum AgentLiveness {
|
||||
/// The agent is producing proof of liveness (deltas / tool activity /
|
||||
/// heartbeats) within its `stall_after_ms` window — or is idle.
|
||||
Alive,
|
||||
/// No proof of liveness for longer than the profile's `stall_after_ms`: the
|
||||
/// agent is presumed frozen. Advisory only — the FIFO and the turn keep running
|
||||
/// (a late heartbeat flips it back to [`Self::Alive`]).
|
||||
Stalled,
|
||||
}
|
||||
|
||||
impl AgentBusyState {
|
||||
/// Whether a turn is currently in flight.
|
||||
#[must_use]
|
||||
@ -97,6 +120,29 @@ impl AgentBusyState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-agent submit configuration (ARCHITECTURE §20.3), resolved from the target
|
||||
/// agent's profile and carried to the [`InputMediator`] at bind time so it can be
|
||||
/// echoed on a [`crate::events::DomainEvent::DelegationReady`].
|
||||
///
|
||||
/// Both fields stay `Option`: the **default** (`"\r"`, ~60 ms) is applied at the
|
||||
/// point of use (the frontend write-portal), never hard-coded in the domain — the
|
||||
/// domain only transports the declared values.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SubmitConfig {
|
||||
/// The profile's `submit_sequence` (e.g. `"\r"`). `None` ⇒ front default.
|
||||
pub sequence: Option<String>,
|
||||
/// The profile's `submit_delay_ms`. `None` ⇒ front default (~60 ms).
|
||||
pub delay_ms: Option<u32>,
|
||||
}
|
||||
|
||||
impl SubmitConfig {
|
||||
/// Builds a submit config from the two optional profile fields.
|
||||
#[must_use]
|
||||
pub const fn new(sequence: Option<String>, delay_ms: Option<u32>) -> Self {
|
||||
Self { sequence, delay_ms }
|
||||
}
|
||||
}
|
||||
|
||||
/// The single convergence point of **all** of an agent's input (one FIFO/agent),
|
||||
/// with `enqueue` (Envoyer) and `preempt` (Interrompre) kept **distinct**, plus the
|
||||
/// busy state.
|
||||
@ -108,12 +154,14 @@ pub trait InputMediator: Send + Sync {
|
||||
/// Envoyer = enqueue: appends `ticket` to `agent`'s FIFO and returns the
|
||||
/// [`PendingReply`] to await (the mailbox reply type, reused).
|
||||
///
|
||||
/// **Delivery** (cadrage C3 §5.2): the implementation also **writes** the turn
|
||||
/// into the agent's input stream — *this* is the single, serialized write path
|
||||
/// that replaces the orchestrator's former ad-hoc PTY write. The write target is
|
||||
/// the [`PtyHandle`] previously registered with [`InputMediator::bind_handle`];
|
||||
/// without one, the enqueue still queues the ticket (forward, never reject) and
|
||||
/// the orchestrator falls back to its own delivery.
|
||||
/// **Delivery** (ARCHITECTURE §20.2/§20.3): the mediator **no longer writes the
|
||||
/// turn into the PTY** (the `\n` band-aid is gone — it never submitted in raw mode
|
||||
/// and produced a "double chat"). Instead, on the enqueue that **starts a turn**
|
||||
/// (Idle→Busy), the adapter publishes a [`crate::events::DomainEvent::DelegationReady`]
|
||||
/// carrying the task text + ticket + the target's [`SubmitConfig`]; the frontend
|
||||
/// cell (sole owner of the terminal) runs the write-portal handshake and writes the
|
||||
/// text + submit sequence through the single PTY writer. The mediator stays the
|
||||
/// authority of the FIFO/busy state and correlation only.
|
||||
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply;
|
||||
|
||||
/// Registers (or refreshes) the live input [`PtyHandle`] of `agent` so a later
|
||||
@ -134,26 +182,74 @@ pub trait InputMediator: Send + Sync {
|
||||
/// only returns `Idle` on the explicit signal or the per-turn timeout (fallback
|
||||
/// «en cas de doute → reste Busy mais la file accepte»; never a false `Idle`).
|
||||
///
|
||||
/// Default: delegates to [`InputMediator::bind_handle`], ignoring the pattern (a
|
||||
/// mediator that does not observe the output stream). The infra adapter overrides
|
||||
/// it to arm the watcher.
|
||||
/// It also records the target's [`SubmitConfig`] (ARCHITECTURE §20.3) so the
|
||||
/// adapter can echo `submit_sequence`/`submit_delay_ms` on the
|
||||
/// [`crate::events::DomainEvent::DelegationReady`] published when a turn starts.
|
||||
/// The bind is the natural carrier: both the prompt pattern and the submit config
|
||||
/// are per-agent profile data the orchestrator resolves at the same time, so they
|
||||
/// travel together (no extra ticket field, no second resolve).
|
||||
///
|
||||
/// Default: delegates to [`InputMediator::bind_handle`], ignoring the pattern and
|
||||
/// submit config (a mediator that does not observe the output stream). The infra
|
||||
/// adapter overrides it to arm the watcher and stash the submit config.
|
||||
fn bind_handle_with_prompt(
|
||||
&self,
|
||||
agent: AgentId,
|
||||
handle: PtyHandle,
|
||||
_prompt_ready_pattern: Option<String>,
|
||||
_submit: SubmitConfig,
|
||||
) {
|
||||
self.bind_handle(agent, handle);
|
||||
}
|
||||
|
||||
/// Whether this mediator owns the turn-delivery write for `agent` (i.e. it has a
|
||||
/// bound handle **and** a PTY port). When `false`, the orchestrator must deliver
|
||||
/// the turn itself. Default: `false`.
|
||||
/// **Deprecated since ARCHITECTURE §20**: the mediator never writes the turn into
|
||||
/// the PTY anymore — the **frontend** always delivers (via the write-portal). Kept
|
||||
/// for source compatibility; it now always returns `false`. Callers should stop
|
||||
/// branching on it (the orchestrator no longer falls back to its own PTY write).
|
||||
#[must_use]
|
||||
fn delivers_turn(&self, _agent: AgentId) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Déclare qu'`agent` vient d'être **lancé à froid** : la livraison de son tout
|
||||
/// premier tour doit être *gatée* sur le prompt-ready (la `DelegationReady` est
|
||||
/// différée jusqu'à l'apparition du prompt du CLI), pour éviter d'écrire la tâche
|
||||
/// avant que le CLI ait fini de booter (premier tour perdu sinon).
|
||||
///
|
||||
/// À n'appeler **que** lorsqu'un prompt-ready watcher sera effectivement armé (le
|
||||
/// profil porte un `prompt_ready_pattern` non vide). Sans watcher pour le libérer,
|
||||
/// gater le premier tour le bloquerait indéfiniment ; dans ce cas l'orchestrateur ne
|
||||
/// doit **pas** appeler `mark_starting` et l'`enqueue` livre la tâche immédiatement
|
||||
/// (chemin chaud, fallback sûr, zéro régression).
|
||||
///
|
||||
/// Default: no-op (a mediator that does not gate cold starts).
|
||||
fn mark_starting(&self, _agent: AgentId) {}
|
||||
|
||||
/// Signal de **readiness de démarrage** : le pont MCP de `agent` vient de se
|
||||
/// connecter (son CLI est up et a chargé les outils `idea_*`). Si un premier tour
|
||||
/// a été différé par [`InputMediator::mark_starting`] (démarrage à froid), c'est le
|
||||
/// moment de le livrer ⇒ draine le `DelegationReady` retenu. **Contrairement à
|
||||
/// `prompt_ready`, aucun repli `mark_idle`** : c'est un signal de DÉMARRAGE, pas de
|
||||
/// fin de tour. Idempotent : un second appel (ou après que `prompt_ready` ait déjà
|
||||
/// drainé) ne trouve rien et est un no-op. En OR avec le prompt-ready : le premier
|
||||
/// arrivé draine, l'autre est no-op.
|
||||
///
|
||||
/// Default: no-op (médiateur qui ne gate pas les démarrages à froid).
|
||||
fn release_cold_start(&self, _agent: AgentId) {}
|
||||
|
||||
/// Déclare si une **cellule terminal du frontend** est montée pour `agent`
|
||||
/// (`true` au montage du write-portal, `false` au démontage). C'est le frontend
|
||||
/// qui possède l'écriture physique du PTY *quand une cellule existe* (cas d'un
|
||||
/// agent épinglé dans le layout) ; un agent **délégué en arrière-plan n'a aucune
|
||||
/// cellule**, donc personne ne consomme `DelegationReady` côté UI. Le médiateur
|
||||
/// utilise cet état pour décider, à la livraison d'un tour, entre **publier
|
||||
/// l'événement** (cellule présente ⇒ le front écrit) et **écrire lui-même** la
|
||||
/// tâche dans le PTY (agent headless ⇒ sinon le tour est perdu).
|
||||
///
|
||||
/// Default: no-op (médiateur sans notion de cellule front — tout passe par
|
||||
/// l'événement, comportement historique).
|
||||
fn set_front_attached(&self, _agent: AgentId, _attached: bool) {}
|
||||
|
||||
/// Interrompre = preempt: signals the running turn to stop (Échap/stop). This
|
||||
/// is **not** an enqueue and correlates **no** ticket.
|
||||
fn preempt(&self, agent: AgentId);
|
||||
@ -161,6 +257,29 @@ pub trait InputMediator: Send + Sync {
|
||||
/// Marks `agent` free (prompt-ready or explicit signal) so its FIFO advances.
|
||||
fn mark_idle(&self, agent: AgentId);
|
||||
|
||||
/// Records a **proof of liveness** (« battement ») for `agent` — called on every
|
||||
/// non-terminal turn event (text delta, tool activity, [`crate::ports::ReplyEvent::Heartbeat`])
|
||||
/// by the drain loop. Refreshes the per-agent `last_seen` timestamp so the stall
|
||||
/// detector ([`crate::events::DomainEvent::AgentLivenessChanged`], lot 2) knows the
|
||||
/// agent is still working; a battement arriving after a `Stalled` transition flips it
|
||||
/// back to [`AgentLiveness::Alive`].
|
||||
///
|
||||
/// Default: no-op (a mediator that does not track liveness). The infra adapter
|
||||
/// `MediatedInbox` overrides it to refresh `last_seen` and publish an
|
||||
/// `AgentLivenessChanged{Stalled→Alive}` recovery on the first late battement.
|
||||
fn mark_alive(&self, _agent: AgentId) {}
|
||||
|
||||
/// Declares the agent's **stall threshold** (its profile's
|
||||
/// [`crate::profile::LivenessStrategy::stall_after_ms`]) so the stall detector knows
|
||||
/// how long without a battement means "frozen" (lot 2). The orchestrator resolves it
|
||||
/// from the target's profile and calls this **before** the enqueue that starts a
|
||||
/// turn; the adapter stashes it and arms a fresh liveness window when the turn
|
||||
/// begins. `None` ⇒ no stall detection for this agent (legacy / no `liveness` block —
|
||||
/// zero regression).
|
||||
///
|
||||
/// Default: no-op (a mediator that does not track liveness).
|
||||
fn set_stall_threshold(&self, _agent: AgentId, _stall_after_ms: Option<u32>) {}
|
||||
|
||||
/// The current [`AgentBusyState`] of `agent`.
|
||||
fn busy_state(&self, agent: AgentId) -> AgentBusyState;
|
||||
}
|
||||
|
||||
@ -44,9 +44,13 @@ pub mod mailbox;
|
||||
pub mod markdown;
|
||||
pub mod memory;
|
||||
pub mod orchestrator;
|
||||
pub mod permission;
|
||||
pub mod ports;
|
||||
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;
|
||||
@ -61,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};
|
||||
@ -74,7 +78,8 @@ pub use skill::{Skill, SkillRef, SkillScope};
|
||||
pub use template::{AgentTemplate, TemplateVersion};
|
||||
|
||||
pub use profile::{
|
||||
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, SessionStrategy,
|
||||
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, LivenessStrategy,
|
||||
McpServerWiring, RateLimitPattern, SessionStrategy,
|
||||
};
|
||||
|
||||
pub use mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId};
|
||||
@ -84,7 +89,11 @@ pub use conversation::{
|
||||
ConversationSession, SessionRef, WaitForGraph,
|
||||
};
|
||||
|
||||
pub use input::{AgentBusyState, InputMediator, InputSource};
|
||||
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,
|
||||
@ -113,6 +122,18 @@ pub use layout::{
|
||||
|
||||
pub use events::{DomainEvent, OrchestrationSource};
|
||||
|
||||
pub use permission::{
|
||||
render_permission_summary, resolve as resolve_permissions, AgentPermissionOverride, Capability,
|
||||
CommandMatcher, CommandRule, Effect, EffectivePermissions, Glob, PathScope, PermissionError,
|
||||
PermissionProjection, PermissionProjector, PermissionRule, PermissionSet, Posture,
|
||||
ProjectedFile, ProjectionContext, ProjectPermissions, ProjectorKey, PERMISSIONS_VERSION,
|
||||
};
|
||||
|
||||
pub use sandbox::{
|
||||
compile_sandbox_plan, PathAccess, PathGrant, SandboxContext, SandboxEnforcer, SandboxError,
|
||||
SandboxKind, SandboxPlan, SandboxStatus,
|
||||
};
|
||||
|
||||
pub use orchestrator::{
|
||||
OrchestratorCommand, OrchestratorError, OrchestratorRequest, OrchestratorVisibility,
|
||||
};
|
||||
@ -122,7 +143,8 @@ pub use ports::{
|
||||
EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, EmbedderProfileStore,
|
||||
EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem,
|
||||
FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator,
|
||||
MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PreparedContext,
|
||||
ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort,
|
||||
RemoteError, RemoteHost, RemotePath, RuntimeError, SpawnSpec, StoreError, TemplateStore,
|
||||
MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PermissionStore,
|
||||
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle,
|
||||
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, SpawnSpec,
|
||||
StoreError, TemplateStore,
|
||||
};
|
||||
|
||||
@ -40,16 +40,7 @@ use crate::input::InputSource;
|
||||
/// correlation is positional (head of the FIFO), the id only lets the caller name
|
||||
/// the exact ticket it wants retired on timeout ([`AgentMailbox::cancel_head`]).
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
PartialOrd,
|
||||
Ord,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct TicketId(pub uuid::Uuid);
|
||||
@ -189,9 +180,7 @@ pub struct PendingReply {
|
||||
impl PendingReply {
|
||||
/// Wraps a reply future built by the adapter (e.g. over a one-shot receiver).
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
inner: Pin<Box<dyn Future<Output = Result<String, MailboxError>> + Send>>,
|
||||
) -> Self {
|
||||
pub fn new(inner: Pin<Box<dyn Future<Output = Result<String, MailboxError>> + Send>>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
@ -286,10 +275,7 @@ mod tests {
|
||||
assert_eq!(t.task, "do X");
|
||||
// Back-compat default: human source, nil conversation.
|
||||
assert_eq!(t.source, InputSource::Human);
|
||||
assert_eq!(
|
||||
t.conversation,
|
||||
ConversationId::from_uuid(uuid::Uuid::nil())
|
||||
);
|
||||
assert_eq!(t.conversation, ConversationId::from_uuid(uuid::Uuid::nil()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -322,9 +308,6 @@ mod tests {
|
||||
#[test]
|
||||
fn mailbox_errors_are_distinct_and_typed() {
|
||||
let a = AgentId::from_uuid(uuid::Uuid::from_u128(1));
|
||||
assert_ne!(
|
||||
MailboxError::NoPendingRequest(a),
|
||||
MailboxError::Cancelled
|
||||
);
|
||||
assert_ne!(MailboxError::NoPendingRequest(a), MailboxError::Cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
1482
crates/domain/src/permission.rs
Normal file
1482
crates/domain/src/permission.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -29,9 +29,10 @@ 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;
|
||||
use crate::profile::{AgentProfile, EmbedderProfile};
|
||||
use crate::project::{Project, ProjectPath};
|
||||
use crate::remote::RemoteKind;
|
||||
@ -97,6 +98,10 @@ pub struct SpawnSpec {
|
||||
pub env: Vec<(String, String)>,
|
||||
/// How the context is injected, if any.
|
||||
pub context_plan: Option<ContextInjectionPlan>,
|
||||
/// OS-sandbox plan to enforce on the spawned process (lot LP4). `None` ⇒ no
|
||||
/// enforcement (the agent runs natively); the field is wired but unused until
|
||||
/// the Landlock adapter (LP4-1) and launch path (LP4-2) consume it.
|
||||
pub sandbox: Option<crate::sandbox::SandboxPlan>,
|
||||
}
|
||||
|
||||
/// The agent context prepared for injection (content + the on-disk path it maps
|
||||
@ -209,6 +214,37 @@ pub enum ReplyEvent {
|
||||
/// Libellé humain-lisible de l'activité.
|
||||
label: String,
|
||||
},
|
||||
/// **Preuve de vivacité non terminale** (model-agnostique) : l'agent est
|
||||
/// toujours en train de travailler. Émis par l'adapter quand le moteur signale
|
||||
/// un battement de cœur natif **sans contenu utile** (handshake/init, fenêtre de
|
||||
/// limite de débit, début/fin de tour côté moteur…). Sert à distinguer « agent
|
||||
/// vivant mais lent » d'« agent bloqué » (readiness/heartbeat, lot 1).
|
||||
///
|
||||
/// **Jamais terminal** : un `Heartbeat` ne clôt **pas** le flux — il s'intercale
|
||||
/// comme un delta (ignoré par les consommateurs synchrones) et le flux continue
|
||||
/// 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).
|
||||
@ -220,10 +256,40 @@ pub enum ReplyEvent {
|
||||
|
||||
/// Flux borné d'événements de réponse d'UN tour (ARCHITECTURE §17.1). Se termine
|
||||
/// après le [`ReplyEvent::Final`] (ou sur erreur). Calqué sur [`OutputStream`],
|
||||
/// mais **typé** : deltas de texte → activités d'outil → un `Final` déterministe,
|
||||
/// plutôt que des octets bruts.
|
||||
/// mais **typé** : deltas de texte → activités d'outil → battements de cœur*
|
||||
/// ([`ReplyEvent::Heartbeat`], non terminaux, en nombre quelconque et entrelacés) →
|
||||
/// **un** `Final` terminal déterministe, plutôt que des octets bruts. Seul le `Final`
|
||||
/// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -513,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.
|
||||
@ -522,6 +595,7 @@ pub trait AgentSessionFactory: Send + Sync {
|
||||
ctx: &PreparedContext,
|
||||
cwd: &ProjectPath,
|
||||
session: &SessionPlan,
|
||||
sandbox: Option<&crate::sandbox::SandboxPlan>,
|
||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError>;
|
||||
}
|
||||
|
||||
@ -604,6 +678,21 @@ pub trait FileSystem: Send + Sync {
|
||||
/// [`FsError`] on failure.
|
||||
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError>;
|
||||
|
||||
/// Removes a single file. A **missing** file is treated as success (idempotent
|
||||
/// delete), so this is safe to call best-effort.
|
||||
///
|
||||
/// Defaults to a **no-op `Ok(())`** so existing adapters and in-memory test
|
||||
/// doubles keep compiling unchanged; the real adapter overrides it with an
|
||||
/// actual delete. Callers that rely on the file actually being gone must use an
|
||||
/// adapter that overrides this (the local FS does).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`FsError`] on a failure other than not-found.
|
||||
async fn remove_file(&self, path: &RemotePath) -> Result<(), FsError> {
|
||||
let _ = path;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Creates a directory and all missing parents.
|
||||
///
|
||||
/// # Errors
|
||||
@ -1050,6 +1139,27 @@ pub trait AgentContextStore: Send + Sync {
|
||||
) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
/// Reads/writes a project's `.ideai/permissions.json`.
|
||||
#[async_trait]
|
||||
pub trait PermissionStore: Send + Sync {
|
||||
/// Loads the project's permission document. Missing file returns the default
|
||||
/// empty document.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on I/O or deserialisation failure.
|
||||
async fn load_permissions(&self, project: &Project) -> Result<ProjectPermissions, StoreError>;
|
||||
|
||||
/// Saves the project's permission document.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on I/O or serialisation failure.
|
||||
async fn save_permissions(
|
||||
&self,
|
||||
project: &Project,
|
||||
permissions: &ProjectPermissions,
|
||||
) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
/// Git operations for a project. Named `GitPort` to avoid clashing with the
|
||||
/// [`crate::git::GitRepository`] *entity* (state image).
|
||||
#[async_trait]
|
||||
@ -1184,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;
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::DomainError;
|
||||
use crate::ids::ProfileId;
|
||||
use crate::permission::ProjectorKey;
|
||||
|
||||
/// Strategy for injecting an agent's `.md` context into the launched CLI.
|
||||
///
|
||||
@ -118,6 +119,119 @@ impl SessionStrategy {
|
||||
}
|
||||
}
|
||||
|
||||
/// Réglages de **vivacité** (readiness/heartbeat) d'un profil IA — place ménagée
|
||||
/// pour le lot 2 (chantier readiness/heartbeat). Donnée **déclarative** (pas de code
|
||||
/// par CLI — Open/Closed), calquée sur [`SessionStrategy`] / [`McpCapability`].
|
||||
///
|
||||
/// Deux seuils optionnels :
|
||||
/// - `stall_after_ms` : délai sans **aucune** preuve de vivacité (delta / activité /
|
||||
/// `ReplyEvent::Heartbeat`) au bout duquel l'agent est présumé **bloqué**
|
||||
/// (`ReadinessSignal::Stalled`) — détection au lot 2 ;
|
||||
/// - `turn_timeout_ms` : durée maximale d'**un tour** avant le garde-fou
|
||||
/// (`ReadinessSignal::TimedOut`) — remplacement des timeouts en dur au lot 2.
|
||||
///
|
||||
/// **Lot 1 : champs présents mais non consommés** — on ne fait que ménager la place
|
||||
/// (le modèle de sérialisation et l'API sont figés ici pour éviter une migration au
|
||||
/// lot 2). `None` (défaut, et valeur des profils existants) ⇒ comportement actuel.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LivenessStrategy {
|
||||
/// Délai (ms) sans preuve de vivacité avant de présumer l'agent bloqué.
|
||||
/// `None` ⇒ pas de détection de stagnation (lot 2).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stall_after_ms: Option<u32>,
|
||||
/// Durée maximale (ms) d'un tour avant le garde-fou de timeout. `None` ⇒ pas de
|
||||
/// garde-fou par profil (lot 2).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub turn_timeout_ms: Option<u32>,
|
||||
}
|
||||
|
||||
impl LivenessStrategy {
|
||||
/// Construit une stratégie de vivacité validée (parse-don't-validate, comme
|
||||
/// [`SessionStrategy::new`]).
|
||||
///
|
||||
/// # Errors
|
||||
/// Renvoie [`DomainError::EmptyField`] si un seuil fourni vaut `0` (un seuil de
|
||||
/// `0 ms` n'a pas de sens : `None` est la façon d'exprimer « pas de seuil »).
|
||||
pub const fn new(
|
||||
stall_after_ms: Option<u32>,
|
||||
turn_timeout_ms: Option<u32>,
|
||||
) -> Result<Self, DomainError> {
|
||||
if let Some(0) = stall_after_ms {
|
||||
return Err(DomainError::EmptyField {
|
||||
field: "liveness.stallAfterMs",
|
||||
});
|
||||
}
|
||||
if let Some(0) = turn_timeout_ms {
|
||||
return Err(DomainError::EmptyField {
|
||||
field: "liveness.turnTimeoutMs",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
stall_after_ms,
|
||||
turn_timeout_ms,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@ -134,6 +248,24 @@ pub enum StructuredAdapter {
|
||||
Codex,
|
||||
}
|
||||
|
||||
impl StructuredAdapter {
|
||||
/// Clé **stable par famille de moteur** (provider), indépendante de l'uuid
|
||||
/// d'instance d'un profil. Sert de clé dans `providers.json` (lot P8b) : un swap
|
||||
/// d'un profil vers un autre profil de **la même famille** réutilise la même
|
||||
/// entrée (resumable partagé), et le fichier reste lisible à l'œil.
|
||||
///
|
||||
/// Mapping : `Claude → "claude"`, `Codex → "codex"` (aligné sur la sérialisation
|
||||
/// camelCase de l'enum, mais figé ici comme **contrat de persistance** : ce
|
||||
/// mapping ne doit pas dériver si la représentation serde change).
|
||||
#[must_use]
|
||||
pub const fn provider_key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Claude => "claude",
|
||||
Self::Codex => "codex",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport du serveur MCP IdeA exposé à une CLI (détail invisible au domaine).
|
||||
///
|
||||
/// `stdio` = défaut robuste cross-OS ; `socket` = optimisation (point ouvert).
|
||||
@ -175,6 +307,22 @@ pub enum McpConfigStrategy {
|
||||
/// Nom de la variable d'environnement.
|
||||
var: String,
|
||||
},
|
||||
/// Écrire un fichier de conf MCP **TOML** au chemin (relatif au run dir isolé
|
||||
/// §14.1) attendu par la CLI, et pousser `home_env` (le nom d'une variable
|
||||
/// d'environnement) vers le **dossier parent** de `target` pour isoler la CLI
|
||||
/// de sa config globale. C'est le pendant Codex de [`Self::ConfigFile`] : Codex
|
||||
/// lit ses serveurs MCP dans `$CODEX_HOME/config.toml` (défaut `~/.codex`), table
|
||||
/// TOML `[mcp_servers.<nom>]`. IdeA écrit ce `config.toml` DANS le run dir de
|
||||
/// l'agent et pointe `CODEX_HOME={runDir}/.codex` pour ne JAMAIS toucher au
|
||||
/// `~/.codex` global (isolation par agent, miroir du `.mcp.json` de Claude).
|
||||
TomlConfigHome {
|
||||
/// Chemin relatif sûr du fichier `config.toml` (convention :
|
||||
/// `".codex/config.toml"`).
|
||||
target: String,
|
||||
/// Nom de la variable d'environnement pointée sur le **dossier parent** de
|
||||
/// `target` (ex. `"CODEX_HOME"`).
|
||||
home_env: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl McpConfigStrategy {
|
||||
@ -209,6 +357,24 @@ impl McpConfigStrategy {
|
||||
crate::validation::valid_env_var(&var)?;
|
||||
Ok(Self::Env { var })
|
||||
}
|
||||
|
||||
/// Constructeur validé `TomlConfigHome` (pendant Codex de [`Self::config_file`]).
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`DomainError::PathNotRelativeSafe`] si `target` est absolu ou contient `..`
|
||||
/// (même validation que [`Self::config_file`]),
|
||||
/// - [`DomainError::InvalidEnvVar`] si `home_env` n'est pas un identifiant de
|
||||
/// variable d'environnement valide.
|
||||
pub fn toml_config_home(
|
||||
target: impl Into<String>,
|
||||
home_env: impl Into<String>,
|
||||
) -> Result<Self, DomainError> {
|
||||
let target = target.into();
|
||||
let home_env = home_env.into();
|
||||
crate::validation::relative_safe(&target)?;
|
||||
crate::validation::valid_env_var(&home_env)?;
|
||||
Ok(Self::TomlConfigHome { target, home_env })
|
||||
}
|
||||
}
|
||||
|
||||
/// Capacité MCP d'un profil : COMMENT déclarer le serveur MCP IdeA à cette CLI,
|
||||
@ -235,6 +401,128 @@ impl McpCapability {
|
||||
}
|
||||
}
|
||||
|
||||
/// Donnée de **wiring** du serveur MCP IdeA (`command` + `args` + `transport`),
|
||||
/// factorisée pour servir de **source unique** aux deux sérialisations qui en
|
||||
/// dérivaient séparément (et risquaient de diverger) : la déclaration `.mcp.json`
|
||||
/// de Claude (JSON) et la table `[mcp_servers.idea]` de Codex (TOML).
|
||||
///
|
||||
/// Pure (aucune I/O, aucune dépendance) : les deux encodeurs construisent une
|
||||
/// chaîne à la main, donc le domaine reste sans dépendance (`serde_json`/`toml`
|
||||
/// non requis). Le contenu (exe, endpoint, …) est calculé par l'appelant — le
|
||||
/// domaine ne fait que la **mise en forme**.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpServerWiring {
|
||||
/// Commande à lancer (le binaire IdeA en mode `mcp-server`, ou `"idea"` en
|
||||
/// déclaration minimale dégradée).
|
||||
pub command: String,
|
||||
/// Arguments passés à `command` (ex. `["mcp-server", "--endpoint", …]`).
|
||||
pub args: Vec<String>,
|
||||
/// Transport du serveur MCP (surfacé dans les deux formats).
|
||||
pub transport: McpTransport,
|
||||
}
|
||||
|
||||
impl McpServerWiring {
|
||||
/// Construit le wiring depuis ses parties.
|
||||
#[must_use]
|
||||
pub const fn new(command: String, args: Vec<String>, transport: McpTransport) -> Self {
|
||||
Self {
|
||||
command,
|
||||
args,
|
||||
transport,
|
||||
}
|
||||
}
|
||||
|
||||
/// Étiquette stable du transport, identique pour les deux formats.
|
||||
#[must_use]
|
||||
const fn transport_label(&self) -> &'static str {
|
||||
match self.transport {
|
||||
McpTransport::Stdio => "stdio",
|
||||
McpTransport::Socket => "socket",
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode un document **`.mcp.json`** complet (Claude Code et CLIs apparentées) :
|
||||
/// `{ "mcpServers": { "idea": { command, args, transport } } }`. Chaque chaîne est
|
||||
/// échappée en littéral JSON (chemins avec espaces/backslash/quotes restent
|
||||
/// valides).
|
||||
#[must_use]
|
||||
pub fn to_mcp_json(&self) -> String {
|
||||
let command = json_string(&self.command);
|
||||
let args = if self.args.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let joined = self
|
||||
.args
|
||||
.iter()
|
||||
.map(|a| format!("\n {}", json_string(a)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!("{joined}\n ")
|
||||
};
|
||||
let transport = self.transport_label();
|
||||
format!(
|
||||
r#"{{
|
||||
"mcpServers": {{
|
||||
"idea": {{
|
||||
"command": {command},
|
||||
"args": [{args}],
|
||||
"transport": "{transport}"
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Encode la table **`[mcp_servers.idea]`** d'un `config.toml` Codex :
|
||||
/// `command`, `args` (tableau TOML), `transport`. Chaque chaîne est échappée en
|
||||
/// chaîne basique TOML (équivalent du `json_string` : espaces/backslash/quotes
|
||||
/// restent valides).
|
||||
#[must_use]
|
||||
pub fn to_config_toml(&self) -> String {
|
||||
let command = toml_string(&self.command);
|
||||
let args = self
|
||||
.args
|
||||
.iter()
|
||||
.map(|a| toml_string(a))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let transport = self.transport_label();
|
||||
format!(
|
||||
"[mcp_servers.idea]\ncommand = {command}\nargs = [{args}]\ntransport = \"{transport}\"\n"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Échappe `s` en **littéral de chaîne JSON** (guillemets inclus) pour les chemins
|
||||
/// exe/endpoint avec espaces, backslash ou quotes.
|
||||
fn json_string(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
/// Échappe `s` en **chaîne basique TOML** (guillemets inclus). Les chaînes
|
||||
/// basiques TOML utilisent les mêmes séquences d'échappement que JSON pour `"`,
|
||||
/// `\`, et les contrôles.
|
||||
fn toml_string(s: &str) -> String {
|
||||
// Le jeu d'échappement requis par une chaîne basique TOML coïncide avec celui de
|
||||
// JSON pour les caractères qui nous concernent (chemins, flags).
|
||||
json_string(s)
|
||||
}
|
||||
|
||||
/// Declarative runtime configuration for one AI CLI.
|
||||
///
|
||||
/// Invariants:
|
||||
@ -298,6 +586,13 @@ pub struct AgentProfile {
|
||||
/// échappé présent dans la sortie. Un moteur regex pourra être ajouté plus tard
|
||||
/// comme variante déclarative (Open/Closed) si le besoin se confirme.
|
||||
///
|
||||
/// **Rang (chantier readiness/heartbeat, lot 1) : signal de repli n°3.** Depuis
|
||||
/// l'introduction de la fin-de-tour structurée ([`crate::ports::ReplyEvent::Final`]
|
||||
/// ⇒ [`crate::readiness::ReadinessSignal::TurnEnded`], signal n°1) et du signal
|
||||
/// explicite `idea_reply` (n°2), ce sniff littéral est **rétrogradé** au rang de
|
||||
/// repli : il ne sert plus que pour les agents **TUI/PTY sans adapter structuré**.
|
||||
/// Conservé tel quel pour la rétro-compat (jamais supprimé).
|
||||
///
|
||||
/// `None` (défaut, et valeur des profils existants) ⇒ **aucune** détection par
|
||||
/// motif : l'agent ne repasse `Idle` que sur signal explicite (`idea_reply`) ou via
|
||||
/// le garde-fou du timeout par tour. Conforme au fallback « en cas de doute → reste
|
||||
@ -307,6 +602,66 @@ pub struct AgentProfile {
|
||||
/// un profil sans motif sérialise exactement comme avant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_ready_pattern: Option<String>,
|
||||
/// Réglages de **vivacité** (readiness/heartbeat, chantier lot 1). `None` (défaut,
|
||||
/// et valeur des profils existants) ⇒ comportement actuel. **Lot 1** : champ
|
||||
/// présent mais **non consommé** (place ménagée pour les seuils de stagnation /
|
||||
/// timeout de tour du lot 2).
|
||||
///
|
||||
/// `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 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
|
||||
/// la TUI), puis **cette séquence seule** après un court délai.
|
||||
///
|
||||
/// `None` ⇒ défaut `"\r"` appliqué **au point d'usage** (front), jamais codé
|
||||
/// en dur dans le domaine (model-agnostic, déclaratif). Une autre TUI peut
|
||||
/// exiger une séquence différente → c'est précisément pourquoi c'est donnée.
|
||||
///
|
||||
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** : un profil
|
||||
/// sans cette clé sérialise exactement comme avant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub submit_sequence: Option<String>,
|
||||
/// Délai (ms) entre l'écriture du texte et celle de [`Self::submit_sequence`]
|
||||
/// (§20.3, fix Bug 1). Évite que la TUI absorbe la soumission comme un paste.
|
||||
///
|
||||
/// `None` ⇒ défaut (~60 ms) appliqué **au point d'usage** (front), jamais codé
|
||||
/// en dur dans le domaine.
|
||||
///
|
||||
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub submit_delay_ms: Option<u32>,
|
||||
/// Clé du **projecteur de permissions** par-CLI (lot LP3) : QUEL adapter
|
||||
/// traduit les [`crate::permission::EffectivePermissions`] résolues en config
|
||||
/// de permission concrète de cette CLI au lancement. Donnée **déclarative**
|
||||
/// (Open/Closed), calquée sur [`StructuredAdapter`].
|
||||
///
|
||||
/// `None` (défaut, et valeur des profils existants) ⇒ **aucune** projection :
|
||||
/// la CLI garde son prompting natif (invariant produit de
|
||||
/// [`crate::permission::resolve`]). `Some(_)` ⇒ IdeA matérialise la config de
|
||||
/// permission de cette CLI au lancement.
|
||||
///
|
||||
/// `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 projector: Option<ProjectorKey>,
|
||||
}
|
||||
|
||||
/// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel).
|
||||
@ -443,6 +798,11 @@ impl AgentProfile {
|
||||
structured_adapter: None,
|
||||
mcp: None,
|
||||
prompt_ready_pattern: None,
|
||||
liveness: None,
|
||||
rate_limit_pattern: None,
|
||||
submit_sequence: None,
|
||||
submit_delay_ms: None,
|
||||
projector: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -473,6 +833,50 @@ impl AgentProfile {
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe la [`LivenessStrategy`] (readiness/heartbeat, lot 1) et renvoie
|
||||
/// le profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
||||
/// profils sans réglage de vivacité ne l'appellent simplement pas.
|
||||
#[must_use]
|
||||
pub const fn with_liveness(mut self, liveness: LivenessStrategy) -> Self {
|
||||
self.liveness = Some(liveness);
|
||||
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.
|
||||
#[must_use]
|
||||
pub fn with_submit_sequence(mut self, sequence: impl Into<String>) -> Self {
|
||||
self.submit_sequence = Some(sequence.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe le [`Self::submit_delay_ms`] (§20.3, fix Bug 1) et renvoie le
|
||||
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel).
|
||||
#[must_use]
|
||||
pub const fn with_submit_delay_ms(mut self, delay_ms: u32) -> Self {
|
||||
self.submit_delay_ms = Some(delay_ms);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe la [`ProjectorKey`] de permissions (lot LP3) et renvoie le
|
||||
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
||||
/// profils sans projection de permission ne l'appellent simplement pas.
|
||||
#[must_use]
|
||||
pub const fn with_projector(mut self, projector: ProjectorKey) -> Self {
|
||||
self.projector = Some(projector);
|
||||
self
|
||||
}
|
||||
|
||||
/// Indique si ce profil peut être **proposé à la sélection/création** d'un
|
||||
/// agent (§17.3, lot D7). Source **unique** de vérité : un profil n'est
|
||||
/// sélectionnable que s'il porte un [`StructuredAdapter`], c'est-à-dire s'il
|
||||
@ -487,6 +891,33 @@ impl AgentProfile {
|
||||
pub fn is_selectable(&self) -> bool {
|
||||
self.structured_adapter.is_some()
|
||||
}
|
||||
|
||||
/// **Source de vérité UNIQUE** de la whitelist des couples (adaptateur structuré
|
||||
/// × stratégie MCP) qu'IdeA **matérialise réellement** pour exposer les outils
|
||||
/// `idea_*` à la CLI — donc les seuls couples vers lesquels la délégation
|
||||
/// inter-agents (`idea_ask_agent`/`idea_reply`) peut router une cible.
|
||||
///
|
||||
/// Un profil déclare bien une [`McpConfigStrategy`], mais ce n'est honoré que si
|
||||
/// IdeA sait écrire la config que **cette** CLI lit nativement :
|
||||
/// - `Claude` + `ConfigFile { target == ".mcp.json" }` ⇒ Claude lit `.mcp.json`
|
||||
/// dans son cwd (run dir isolé) ;
|
||||
/// - `Codex` + `TomlConfigHome { .. }` ⇒ Codex lit `$CODEX_HOME/config.toml`,
|
||||
/// qu'IdeA isole dans le run dir via `home_env` ;
|
||||
/// - tout autre couple (y compris `mcp` absent) ⇒ `false` : repli fichier
|
||||
/// `.ideai/requests` + prose, le pont natif n'est pas branché.
|
||||
///
|
||||
/// Cette fonction centralise le critère pour que la garde applicative
|
||||
/// ([`crate`] côté application) et la matérialisation ne puissent pas diverger.
|
||||
#[must_use]
|
||||
pub fn materializes_idea_bridge(&self) -> bool {
|
||||
match (self.structured_adapter, self.mcp.as_ref().map(|c| &c.config)) {
|
||||
(Some(StructuredAdapter::Claude), Some(McpConfigStrategy::ConfigFile { target })) => {
|
||||
target == ".mcp.json"
|
||||
}
|
||||
(Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -593,9 +1024,8 @@ mod mcp_tests {
|
||||
#[test]
|
||||
fn mcp_config_strategy_uses_tagged_camel_case() {
|
||||
// The `strategy` tag and camelCase rename are part of the wire contract.
|
||||
let json = serde_json::to_string(
|
||||
&McpConfigStrategy::config_file(".mcp.json").expect("valid"),
|
||||
)
|
||||
let json =
|
||||
serde_json::to_string(&McpConfigStrategy::config_file(".mcp.json").expect("valid"))
|
||||
.expect("serialise");
|
||||
assert!(json.contains("\"strategy\":\"configFile\""), "got: {json}");
|
||||
}
|
||||
@ -633,7 +1063,12 @@ mod mcp_tests {
|
||||
#[test]
|
||||
fn config_file_accepts_safe_relative_target() {
|
||||
let s = McpConfigStrategy::config_file(".mcp.json").expect("safe relative");
|
||||
assert_eq!(s, McpConfigStrategy::ConfigFile { target: ".mcp.json".to_owned() });
|
||||
assert_eq!(
|
||||
s,
|
||||
McpConfigStrategy::ConfigFile {
|
||||
target: ".mcp.json".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -645,7 +1080,12 @@ mod mcp_tests {
|
||||
#[test]
|
||||
fn flag_accepts_non_empty() {
|
||||
let s = McpConfigStrategy::flag("--mcp-config {path}").expect("non-empty");
|
||||
assert_eq!(s, McpConfigStrategy::Flag { flag: "--mcp-config {path}".to_owned() });
|
||||
assert_eq!(
|
||||
s,
|
||||
McpConfigStrategy::Flag {
|
||||
flag: "--mcp-config {path}".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -657,7 +1097,12 @@ mod mcp_tests {
|
||||
#[test]
|
||||
fn env_accepts_valid_name() {
|
||||
let s = McpConfigStrategy::env("IDEA_MCP_SERVER").expect("valid");
|
||||
assert_eq!(s, McpConfigStrategy::Env { var: "IDEA_MCP_SERVER".to_owned() });
|
||||
assert_eq!(
|
||||
s,
|
||||
McpConfigStrategy::Env {
|
||||
var: "IDEA_MCP_SERVER".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// -- Lot C5 : prompt_ready_pattern (détection retour-de-prompt) --------------
|
||||
@ -703,4 +1148,397 @@ mod mcp_tests {
|
||||
assert_eq!(profile, back);
|
||||
assert_eq!(back.prompt_ready_pattern.as_deref(), Some("\n> "));
|
||||
}
|
||||
|
||||
// -- §20 : submit_sequence / submit_delay_ms (portail d'écriture) ------------
|
||||
|
||||
#[test]
|
||||
fn profile_default_has_no_submit_fields() {
|
||||
let p = profile_without_mcp();
|
||||
assert!(p.submit_sequence.is_none());
|
||||
assert!(p.submit_delay_ms.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_without_submit_fields_omits_keys_in_json() {
|
||||
let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
|
||||
assert!(
|
||||
!json.contains("submitSequence"),
|
||||
"no submitSequence key when None (zero regression); got: {json}"
|
||||
);
|
||||
assert!(
|
||||
!json.contains("submitDelayMs"),
|
||||
"no submitDelayMs key when None (zero regression); got: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_json_without_submit_fields_deserialises_to_none() {
|
||||
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 p: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
|
||||
assert!(p.submit_sequence.is_none());
|
||||
assert!(p.submit_delay_ms.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_submit_fields_set_and_round_trip_camel_case() {
|
||||
let p = profile_without_mcp()
|
||||
.with_submit_sequence("\r")
|
||||
.with_submit_delay_ms(60);
|
||||
assert_eq!(p.submit_sequence.as_deref(), Some("\r"));
|
||||
assert_eq!(p.submit_delay_ms, Some(60));
|
||||
|
||||
let json = serde_json::to_string(&p).expect("serialise");
|
||||
assert!(json.contains("submitSequence"), "key present: {json}");
|
||||
assert!(json.contains("submitDelayMs"), "key present: {json}");
|
||||
|
||||
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
|
||||
assert_eq!(p, back);
|
||||
assert_eq!(back.submit_sequence.as_deref(), Some("\r"));
|
||||
assert_eq!(back.submit_delay_ms, Some(60));
|
||||
}
|
||||
|
||||
// -- Lot 1 : liveness (readiness/heartbeat) — non-régression de sérialisation --
|
||||
|
||||
#[test]
|
||||
fn profile_default_has_no_liveness() {
|
||||
// Profils existants (via `new`) : aucun réglage de vivacité.
|
||||
assert!(profile_without_mcp().liveness.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_without_liveness_omits_key_in_json() {
|
||||
let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
|
||||
assert!(
|
||||
!json.contains("liveness"),
|
||||
"a profile without liveness must NOT serialise the key (zero regression); got: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_json_without_liveness_deserialises_to_none() {
|
||||
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.liveness.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_liveness_sets_and_round_trips_camel_case() {
|
||||
let liveness = LivenessStrategy::new(Some(30_000), Some(600_000)).expect("valid liveness");
|
||||
let profile = profile_without_mcp().with_liveness(liveness);
|
||||
assert_eq!(profile.liveness, Some(liveness));
|
||||
|
||||
let json = serde_json::to_string(&profile).expect("serialise");
|
||||
assert!(json.contains("liveness"), "key present: {json}");
|
||||
assert!(json.contains("stallAfterMs"), "camelCase field: {json}");
|
||||
assert!(json.contains("turnTimeoutMs"), "camelCase field: {json}");
|
||||
|
||||
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
|
||||
assert_eq!(profile, back);
|
||||
assert_eq!(back.liveness, Some(liveness));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn liveness_omits_unset_thresholds_in_json() {
|
||||
// Un seul seuil fixé : l'autre est `None` ⇒ sa clé est omise.
|
||||
let liveness = LivenessStrategy::new(None, Some(600_000)).expect("valid liveness");
|
||||
let json = serde_json::to_string(&liveness).expect("serialise");
|
||||
assert!(
|
||||
!json.contains("stallAfterMs"),
|
||||
"an unset stall threshold must be omitted; got: {json}"
|
||||
);
|
||||
assert!(json.contains("turnTimeoutMs"), "set threshold present: {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn liveness_new_rejects_zero_thresholds() {
|
||||
assert!(matches!(
|
||||
LivenessStrategy::new(Some(0), None).unwrap_err(),
|
||||
DomainError::EmptyField { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
LivenessStrategy::new(None, Some(0)).unwrap_err(),
|
||||
DomainError::EmptyField { .. }
|
||||
));
|
||||
// Les deux None : valide (= « aucun seuil »).
|
||||
assert!(LivenessStrategy::new(None, None).is_ok());
|
||||
}
|
||||
|
||||
// -- Codex : surface MCP `TomlConfigHome` (pont inter-agents Codex) ----------
|
||||
|
||||
#[test]
|
||||
fn toml_config_home_round_trips_with_tagged_strategy() {
|
||||
let strategy = McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME")
|
||||
.expect("valid toml config home");
|
||||
let json = serde_json::to_string(&strategy).expect("serialise");
|
||||
|
||||
// Wire contract: tagged enum (`strategy` tag) + camelCase variant & fields.
|
||||
assert!(
|
||||
json.contains("\"strategy\":\"tomlConfigHome\""),
|
||||
"tagged camelCase variant expected; got: {json}"
|
||||
);
|
||||
assert!(
|
||||
json.contains("\"target\":\".codex/config.toml\""),
|
||||
"target field expected; got: {json}"
|
||||
);
|
||||
// NB: `rename_all = "camelCase"` on this enum renames *variants*, not the
|
||||
// fields of a struct variant, so `home_env` stays snake_case on the wire.
|
||||
assert!(
|
||||
json.contains("\"home_env\":\"CODEX_HOME\""),
|
||||
"home_env field expected; got: {json}"
|
||||
);
|
||||
|
||||
let back: McpConfigStrategy = serde_json::from_str(&json).expect("deserialise");
|
||||
assert_eq!(strategy, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_config_home_rejects_absolute_and_parent_target() {
|
||||
let abs = McpConfigStrategy::toml_config_home("/abs/x", "CODEX_HOME").unwrap_err();
|
||||
assert!(matches!(abs, DomainError::PathNotRelativeSafe { .. }));
|
||||
|
||||
let parent = McpConfigStrategy::toml_config_home("../escape", "CODEX_HOME").unwrap_err();
|
||||
assert!(matches!(parent, DomainError::PathNotRelativeSafe { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_config_home_rejects_invalid_home_env() {
|
||||
// Empty home_env: first char is None ⇒ invalid identifier.
|
||||
let empty = McpConfigStrategy::toml_config_home(".codex/config.toml", "").unwrap_err();
|
||||
assert!(matches!(empty, DomainError::InvalidEnvVar { .. }));
|
||||
|
||||
// Illegal character in the env var name.
|
||||
let illegal =
|
||||
McpConfigStrategy::toml_config_home(".codex/config.toml", "BAD-NAME").unwrap_err();
|
||||
assert!(matches!(illegal, DomainError::InvalidEnvVar { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materializes_idea_bridge_matrix() {
|
||||
// Claude + `.mcp.json` ConfigFile ⇒ bridge materialised.
|
||||
let claude = profile_without_mcp()
|
||||
.with_structured_adapter(StructuredAdapter::Claude)
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::config_file(".mcp.json").expect("valid target"),
|
||||
McpTransport::Stdio,
|
||||
));
|
||||
assert!(claude.materializes_idea_bridge());
|
||||
|
||||
// Codex + TomlConfigHome ⇒ bridge materialised (the key point: Codex used to
|
||||
// be refused before this surface existed).
|
||||
let codex = profile_without_mcp()
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME")
|
||||
.expect("valid toml config home"),
|
||||
McpTransport::Stdio,
|
||||
));
|
||||
assert!(codex.materializes_idea_bridge());
|
||||
|
||||
// Codex WITHOUT any MCP capability ⇒ no bridge.
|
||||
let codex_no_mcp =
|
||||
profile_without_mcp().with_structured_adapter(StructuredAdapter::Codex);
|
||||
assert!(!codex_no_mcp.materializes_idea_bridge());
|
||||
|
||||
// Codex + wrong strategy (`.mcp.json` ConfigFile, Claude's shape) ⇒ no bridge.
|
||||
let codex_wrong_strategy = profile_without_mcp()
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::config_file(".mcp.json").expect("valid target"),
|
||||
McpTransport::Stdio,
|
||||
));
|
||||
assert!(!codex_wrong_strategy.materializes_idea_bridge());
|
||||
}
|
||||
|
||||
// -- Lot LP3 : projector (clé du projecteur de permissions par-CLI) ----------
|
||||
|
||||
#[test]
|
||||
fn profile_default_has_no_projector() {
|
||||
// Profils existants (via `new`) : aucune clé de projecteur ⇒ prompting natif.
|
||||
assert!(profile_without_mcp().projector.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_without_projector_omits_key_in_json() {
|
||||
let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
|
||||
assert!(
|
||||
!json.contains("projector"),
|
||||
"a profile without a projector must NOT serialise the key (zero regression); got: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_json_without_projector_deserialises_to_none() {
|
||||
// JSON produit avant l'existence du champ `projector` : aucune clé `projector`.
|
||||
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.projector.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_projector_sets_and_round_trips_camel_case() {
|
||||
let profile = profile_without_mcp().with_projector(ProjectorKey::Claude);
|
||||
assert_eq!(profile.projector, Some(ProjectorKey::Claude));
|
||||
|
||||
let json = serde_json::to_string(&profile).expect("serialise");
|
||||
assert!(
|
||||
json.contains("\"projector\":\"claude\""),
|
||||
"projector key present in stable wire form: {json}"
|
||||
);
|
||||
|
||||
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
|
||||
assert_eq!(profile, back);
|
||||
assert_eq!(back.projector, Some(ProjectorKey::Claude));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_server_wiring_encodes_expected_toml() {
|
||||
// A command path with a space and a backslash exercises TOML escaping.
|
||||
let wiring = McpServerWiring::new(
|
||||
"/opt/My Apps\\idea".to_owned(),
|
||||
vec![
|
||||
"mcp-server".to_owned(),
|
||||
"--endpoint".to_owned(),
|
||||
"/tmp/sock 1".to_owned(),
|
||||
],
|
||||
McpTransport::Stdio,
|
||||
);
|
||||
let toml = wiring.to_config_toml();
|
||||
|
||||
// Table header present.
|
||||
assert!(
|
||||
toml.contains("[mcp_servers.idea]"),
|
||||
"table header expected; got: {toml}"
|
||||
);
|
||||
// Command path escaped as a TOML basic string (backslash doubled, space kept).
|
||||
assert!(
|
||||
toml.contains("command = \"/opt/My Apps\\\\idea\""),
|
||||
"escaped command path expected; got: {toml}"
|
||||
);
|
||||
// Args preserved in order, as a TOML array.
|
||||
assert!(
|
||||
toml.contains("args = [\"mcp-server\", \"--endpoint\", \"/tmp/sock 1\"]"),
|
||||
"args in order expected; got: {toml}"
|
||||
);
|
||||
// Transport surfaced.
|
||||
assert!(
|
||||
toml.contains("transport = \"stdio\""),
|
||||
"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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
182
crates/domain/src/readiness.rs
Normal file
182
crates/domain/src/readiness.rs
Normal file
@ -0,0 +1,182 @@
|
||||
//! Politique de **readiness** (« fin-de-tour ») model-agnostique (chantier
|
||||
//! readiness/heartbeat, lot 1).
|
||||
//!
|
||||
//! Objet **pur** (aucune I/O, aucune dépendance externe) qui classe un signal
|
||||
//! observable d'un tour d'agent en un [`ReadinessSignal`] normalisé. Le but : que
|
||||
//! l'application puisse décider de marquer un agent `Idle`
|
||||
//! ([`crate::input::InputMediator::mark_idle`]) sur un **signal déterministe**
|
||||
//! (`Final` du flux structuré) plutôt que de dépendre uniquement d'un `idea_reply`
|
||||
//! explicite ou d'un sniff littéral de prompt PTY.
|
||||
//!
|
||||
//! # Hiérarchie des signaux de fin-de-tour (rappel cadrage)
|
||||
//!
|
||||
//! 1. **Signal n°1 — fin de tour structurée** : [`ReplyEvent::Final`] émis par
|
||||
//! l'adapter (Claude `type:"result"`, Codex `agent_message`/`item.completed`).
|
||||
//! Déterministe, model-agnostique ⇒ classé [`ReadinessSignal::TurnEnded`].
|
||||
//! 2. **Signal n°2 — `idea_reply` explicite** : l'agent appelle l'outil MCP
|
||||
//! [`crate::ports`]/délégation. Premier arrivé gagne avec le n°1.
|
||||
//! 3. **Signal n°3 — repli `prompt_ready_pattern`** : sniff littéral du sigil de
|
||||
//! prompt dans la sortie PTY ([`crate::profile::AgentProfile::prompt_ready_pattern`]).
|
||||
//! **Rétrogradé** au rang de repli depuis ce lot : il ne sert que pour les agents
|
||||
//! TUI/PTY sans adapter structuré (rétro-compat, jamais supprimé).
|
||||
//!
|
||||
//! Les variantes [`ReadinessSignal::Stalled`]/[`ReadinessSignal::TimedOut`] sont la
|
||||
//! place réservée au **lot 2** (détection de stagnation, remplacement des timeouts) :
|
||||
//! elles existent dans le vocabulaire mais ne sont **pas** produites par
|
||||
//! [`ReadinessPolicy::classify`] dans ce lot.
|
||||
|
||||
use crate::ports::ReplyEvent;
|
||||
|
||||
/// Signal de readiness normalisé, model-agnostique, qu'une [`ReadinessPolicy`]
|
||||
/// déduit d'un événement observable du tour.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReadinessSignal {
|
||||
/// Le tour est **déterministiquement terminé** : l'agent a rendu son `Final`.
|
||||
/// C'est le signal n°1, model-agnostique — il doit réveiller le `pending` et
|
||||
/// marquer l'agent `Idle`.
|
||||
TurnEnded,
|
||||
/// Un `idea_reply` explicite a été observé (signal n°2). N'est **pas** produit
|
||||
/// par [`ReadinessPolicy::classify`] (qui ne voit que des [`ReplyEvent`]) : il
|
||||
/// est porté par le chemin de délégation, présent ici pour compléter le
|
||||
/// vocabulaire et le rendre explicite.
|
||||
ExplicitReply,
|
||||
/// Le sigil de prompt PTY (repli n°3) est apparu. Idem : non produit par
|
||||
/// `classify`, présent pour nommer le signal de repli legacy.
|
||||
PromptReady,
|
||||
/// L'agent semble **bloqué** (aucune preuve de vivacité depuis un seuil). Place
|
||||
/// réservée au **lot 2** — non produit dans ce lot.
|
||||
Stalled,
|
||||
/// 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
|
||||
/// [`ReadinessSignal`]. Sans état, sans I/O : un simple `match` sur le contrat de
|
||||
/// port universel [`ReplyEvent`], pour que la décision « ce tour est-il fini ? »
|
||||
/// vive dans le **domaine** et reste testable sans process ni réseau.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct ReadinessPolicy;
|
||||
|
||||
impl ReadinessPolicy {
|
||||
/// Classe un [`ReplyEvent`] en signal de readiness.
|
||||
///
|
||||
/// - [`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.
|
||||
#[must_use]
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn final_classifies_as_turn_ended() {
|
||||
let ev = ReplyEvent::Final {
|
||||
content: "fini".to_owned(),
|
||||
};
|
||||
assert_eq!(
|
||||
ReadinessPolicy::classify(&ev),
|
||||
Some(ReadinessSignal::TurnEnded)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deltas_activities_and_heartbeats_are_non_terminal() {
|
||||
assert_eq!(
|
||||
ReadinessPolicy::classify(&ReplyEvent::TextDelta { text: "x".into() }),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
ReadinessPolicy::classify(&ReplyEvent::ToolActivity { label: "lit".into() }),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
ReadinessPolicy::classify(&ReplyEvent::Heartbeat),
|
||||
None,
|
||||
"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);
|
||||
}
|
||||
}
|
||||
715
crates/domain/src/sandbox.rs
Normal file
715
crates/domain/src/sandbox.rs
Normal file
@ -0,0 +1,715 @@
|
||||
//! OS-sandbox **contract & planning** (lot LP4-0, domaine pur).
|
||||
//!
|
||||
//! This module owns the *declarative plan* an OS sandbox adapter applies, plus
|
||||
//! the **pure** translation from the resolved [`EffectivePermissions`] into that
|
||||
//! plan. It is the domain half of the «sandbox OS» concern that the permission
|
||||
//! model (`permission.rs`) deliberately left out of scope.
|
||||
//!
|
||||
//! Like the rest of `domain`, it is **pure** (ARCHITECTURE dependency rule): no
|
||||
//! `tokio`, no `std::fs`, no `std::process`, no `landlock`. The concrete Landlock
|
||||
//! adapter (lot LP4-1) and the launch-path wiring (lot LP4-2) live in
|
||||
//! `infrastructure`/`application`; here we only define the contract
|
||||
//! ([`SandboxEnforcer`], [`SandboxPlan`]) and compute the plan
|
||||
//! ([`compile_sandbox_plan`]).
|
||||
//!
|
||||
//! ## Reality bound: Landlock locks **files only**
|
||||
//!
|
||||
//! Landlock can only restrict **filesystem** access. The command capability
|
||||
//! ([`crate::permission::Capability::ExecuteBash`]) cannot be enforced by the OS
|
||||
//! sandbox — argv matching is not a kernel concept — so command rules stay
|
||||
//! **advisory** (honoured by the CLI's own prompting via the LP3 projection),
|
||||
//! never OS-locked. [`compile_sandbox_plan`] therefore only ever derives grants
|
||||
//! from the three **file** capabilities.
|
||||
//!
|
||||
//! ## The fail-closed, per-access-class translation invariant
|
||||
//!
|
||||
//! Landlock is **additive only**: a grant opens a whole directory subtree and a
|
||||
//! sub-path cannot be carved back out. So whenever a `Deny` would fall inside (or
|
||||
//! over) an `Allow`'s granted root without a directory boundary cleanly
|
||||
//! separating them, we **drop the allow** rather than grant a root that would
|
||||
//! leak the denied path. We always prefer losing an allow to leaking a deny — a
|
||||
//! conservative under-approximation.
|
||||
//!
|
||||
//! Crucially this is computed **per access class** (read vs write/delete), never
|
||||
//! capability-blind: a `Deny Write` fences only the write grants, it never
|
||||
//! amputates a `Read` allow. This maximises agent autonomy — over-restricting a
|
||||
//! read because some write was denied would force the agent to keep asking, the
|
||||
//! opposite of the product goal. It is the same per-`capability`+target deny-wins
|
||||
//! as [`crate::permission`], lifted to the directory granularity Landlock works
|
||||
//! at and made fail-closed within each class. Encoded as a testable invariant in
|
||||
//! [`compile_sandbox_plan`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::permission::{Capability, Effect, EffectivePermissions, Posture};
|
||||
|
||||
/// The set of filesystem accesses a [`PathGrant`] opens on its root, as an
|
||||
/// additive bit set.
|
||||
///
|
||||
/// Hand-rolled (no external `bitflags` crate — the domain forbids extra deps).
|
||||
/// The three bits are **independent**: [`PathAccess::RW`] does not imply
|
||||
/// [`PathAccess::RO`]; a grant carries exactly the accesses that were posed.
|
||||
///
|
||||
/// Capability → bit mapping used by [`compile_sandbox_plan`]:
|
||||
/// [`Capability::Read`] ⇒ [`PathAccess::RO`], [`Capability::Write`] and
|
||||
/// [`Capability::Delete`] ⇒ [`PathAccess::RW`]. [`PathAccess::EXEC`] is reserved
|
||||
/// for the adapter/future use — the current permission model carries no file
|
||||
/// execute capability, so the compiler never emits it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct PathAccess(u8);
|
||||
|
||||
impl PathAccess {
|
||||
/// Read access.
|
||||
pub const RO: Self = Self(0b001);
|
||||
/// Write access (create / modify / delete).
|
||||
pub const RW: Self = Self(0b010);
|
||||
/// Execute access (reserved; not emitted by [`compile_sandbox_plan`]).
|
||||
pub const EXEC: Self = Self(0b100);
|
||||
|
||||
/// The empty access set.
|
||||
#[must_use]
|
||||
pub const fn empty() -> Self {
|
||||
Self(0)
|
||||
}
|
||||
|
||||
/// Whether `self` contains **all** bits of `other`.
|
||||
#[must_use]
|
||||
pub const fn contains(self, other: Self) -> bool {
|
||||
self.0 & other.0 == other.0
|
||||
}
|
||||
|
||||
/// The union of `self` and `other`.
|
||||
#[must_use]
|
||||
pub const fn union(self, other: Self) -> Self {
|
||||
Self(self.0 | other.0)
|
||||
}
|
||||
|
||||
/// Adds the bits of `other` to `self` in place.
|
||||
pub fn insert(&mut self, other: Self) {
|
||||
self.0 |= other.0;
|
||||
}
|
||||
|
||||
/// Whether no bit is set.
|
||||
#[must_use]
|
||||
pub const fn is_empty(self) -> bool {
|
||||
self.0 == 0
|
||||
}
|
||||
|
||||
/// The raw bits.
|
||||
#[must_use]
|
||||
pub const fn bits(self) -> u8 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::BitOr for PathAccess {
|
||||
type Output = Self;
|
||||
fn bitor(self, rhs: Self) -> Self {
|
||||
self.union(rhs)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::BitOrAssign for PathAccess {
|
||||
fn bitor_assign(&mut self, rhs: Self) {
|
||||
self.insert(rhs);
|
||||
}
|
||||
}
|
||||
|
||||
/// One granted directory/file root and the accesses it opens.
|
||||
///
|
||||
/// `abs_root` is an **absolute** path (the project root joined with the glob's
|
||||
/// static prefix). The adapter applies it as a Landlock `path_beneath` rule.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PathGrant {
|
||||
/// Absolute root the grant opens (file or directory subtree).
|
||||
pub abs_root: String,
|
||||
/// Accesses opened on that root.
|
||||
pub access: PathAccess,
|
||||
}
|
||||
|
||||
/// The declarative, OS-neutral plan an OS sandbox adapter enforces.
|
||||
///
|
||||
/// It is a **value** (a plan), never an action: the adapter ([`SandboxEnforcer`])
|
||||
/// turns it into a concrete ruleset. `allowed` may legally be empty (a policy that
|
||||
/// posed a posture but no file allow) — that is **not** the same as «no plan»;
|
||||
/// `compile_sandbox_plan` returns `None` for «no plan».
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SandboxPlan {
|
||||
/// The granted roots (deduplicated, deterministic order).
|
||||
pub allowed: Vec<PathGrant>,
|
||||
/// The default posture for paths matched by no grant (mirrors
|
||||
/// [`EffectivePermissions::fallback`]).
|
||||
pub default_posture: Posture,
|
||||
}
|
||||
|
||||
/// Immutable inputs [`compile_sandbox_plan`] interpolates into the absolute roots
|
||||
/// it emits. Borrowed: a plan is computed at the launch site, never stored.
|
||||
pub struct SandboxContext<'a> {
|
||||
/// Absolute project root (glob static prefixes are joined onto this).
|
||||
pub project_root: &'a str,
|
||||
/// Absolute isolated run dir of the agent (`.ideai/run/<agent-id>/`).
|
||||
///
|
||||
/// Reserved for the adapter (the run dir must stay reachable by the agent);
|
||||
/// the pure file-rule translation does not consume it.
|
||||
pub run_dir: &'a str,
|
||||
}
|
||||
|
||||
/// Which concrete OS-sandbox mechanism an [`SandboxEnforcer`] implements.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SandboxKind {
|
||||
/// Linux Landlock LSM.
|
||||
Landlock,
|
||||
/// No OS sandbox available on this platform/build.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// The outcome of applying a [`SandboxPlan`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SandboxStatus {
|
||||
/// The plan was fully enforced by the OS.
|
||||
Enforced,
|
||||
/// No OS sandbox is available; nothing was enforced (the caller keeps the
|
||||
/// advisory LP3 projection as its only guard).
|
||||
Unsupported,
|
||||
/// The plan was only **partially** enforced; the string explains what was
|
||||
/// dropped (e.g. an older Landlock ABI lacking a needed access right).
|
||||
Degraded(String),
|
||||
}
|
||||
|
||||
/// Errors an [`SandboxEnforcer::enforce`] may return.
|
||||
///
|
||||
/// An adapter raises these only for **fail-closed** situations it must surface to
|
||||
/// the caller (kernel too old when a `Deny` posture demands enforcement, ruleset
|
||||
/// application failure). A platform that simply has no sandbox does **not** error:
|
||||
/// it returns [`SandboxStatus::Unsupported`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum SandboxError {
|
||||
/// The running kernel / Landlock ABI is too old to honour a plan that must be
|
||||
/// enforced (fail-closed: a required `Deny` cannot be guaranteed).
|
||||
#[error("kernel/landlock too old to enforce sandbox: {0}")]
|
||||
KernelTooOld(String),
|
||||
/// Building or applying the OS ruleset failed.
|
||||
#[error("failed to apply sandbox ruleset: {0}")]
|
||||
Ruleset(String),
|
||||
}
|
||||
|
||||
/// The OS-sandbox **port**: applies a [`SandboxPlan`] to the current process.
|
||||
///
|
||||
/// ## Liskov contract
|
||||
///
|
||||
/// `enforce` is a one-shot, **irreversible** tightening of the *current* process
|
||||
/// and is meant to be called **after `fork`, before `exec`, in the child** — never
|
||||
/// on the IdeA process itself. Every implementation must honour:
|
||||
///
|
||||
/// - it only ever **removes** access (a sandbox never grants more than the host);
|
||||
/// - an [`SandboxKind::Unsupported`] adapter is a valid no-op: its `enforce`
|
||||
/// returns `Ok(`[`SandboxStatus::Unsupported`]`)` and changes nothing;
|
||||
/// - `enforce` is total for any well-formed [`SandboxPlan`]; it returns
|
||||
/// [`SandboxError`] only for the fail-closed cases above.
|
||||
pub trait SandboxEnforcer: Send + Sync {
|
||||
/// Which mechanism this enforcer implements.
|
||||
fn kind(&self) -> SandboxKind;
|
||||
|
||||
/// Applies `plan` to the current process (post-fork / pre-exec, in the child).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`SandboxError`] only for fail-closed situations (cf. the type docs); an
|
||||
/// absent sandbox returns `Ok(`[`SandboxStatus::Unsupported`]`)`.
|
||||
fn enforce(&self, plan: &SandboxPlan) -> Result<SandboxStatus, SandboxError>;
|
||||
}
|
||||
|
||||
/// Compiles the resolved [`EffectivePermissions`] into a [`SandboxPlan`].
|
||||
///
|
||||
/// **Pure**: only computes a plan; writes nothing, touches no I/O.
|
||||
///
|
||||
/// ## Invariants
|
||||
///
|
||||
/// 1. **`eff == None ⇒ None`** — nothing posed ⇒ no sandbox ⇒ the agent runs
|
||||
/// natively (mirrors [`crate::permission::resolve`]'s product invariant). A
|
||||
/// `Some` result (even with an empty `allowed`) means IdeA has a policy to
|
||||
/// enforce.
|
||||
/// 2. Only the three **file** capabilities feed the grants; bash rules are
|
||||
/// skipped (Landlock cannot lock command execution — they stay advisory).
|
||||
/// 3. **Per-access-class, fail-closed roots**: grants are computed **separately
|
||||
/// for each access class** so an agent keeps the widest autonomy possible.
|
||||
/// - The **RO** class is fed by `Allow Read`; its fences are `Deny Read`.
|
||||
/// - The **RW** class is fed by `Allow Write` **and** `Allow Delete`; its
|
||||
/// fences are `Deny Write` **and** `Deny Delete`.
|
||||
///
|
||||
/// Within a class, each `Allow` glob is reduced to its *static prefix* root and
|
||||
/// **dropped** if a fence **of the same class** overlaps it (equal, ancestor, or
|
||||
/// descendant) — an additive sandbox cannot carve a sub-path deny out of a
|
||||
/// granted subtree, so we lose the allow rather than leak the deny. A fence of
|
||||
/// the **other** class has **no effect**: a `Deny Write` never amputates an
|
||||
/// `Allow Read` (that would over-restrict and force the agent to keep asking).
|
||||
/// This is exactly the per-`capability`+target deny-wins of
|
||||
/// [`crate::permission`], applied at the (coarser) directory granularity Landlock
|
||||
/// allows, fail-closed inside each class.
|
||||
///
|
||||
/// The surviving roots of both classes are then merged by `abs_root`, unioning
|
||||
/// their accesses: a root surviving only in RO ⇒ [`PathAccess::RO`]; surviving
|
||||
/// in both ⇒ `RO | RW`; surviving only in RW ⇒ [`PathAccess::RW`].
|
||||
#[must_use]
|
||||
pub fn compile_sandbox_plan(
|
||||
eff: Option<&EffectivePermissions>,
|
||||
ctx: &SandboxContext,
|
||||
) -> Option<SandboxPlan> {
|
||||
let eff = eff?;
|
||||
|
||||
// 1. Collect the static-prefix fences of every Deny file rule, **per access
|
||||
// class** (RO = Deny Read; RW = Deny Write/Delete). A fence only ever
|
||||
// blocks allows of its own class. Kept relative for the overlap tests.
|
||||
let mut ro_fences: Vec<String> = Vec::new();
|
||||
let mut rw_fences: Vec<String> = Vec::new();
|
||||
for rule in eff.rules() {
|
||||
if rule.effect() != Effect::Deny {
|
||||
continue;
|
||||
}
|
||||
let fences = match access_class(rule.capability()) {
|
||||
Some(PathAccess::RO) => &mut ro_fences,
|
||||
Some(PathAccess::RW) => &mut rw_fences,
|
||||
// Non-file (ExecuteBash) ⇒ no class ⇒ never a filesystem fence.
|
||||
_ => continue,
|
||||
};
|
||||
for glob in rule.paths().globs() {
|
||||
fences.push(static_prefix(glob.pattern()));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. For each Allow file rule, reduce every glob to its static-prefix root and
|
||||
// keep it only if no same-class fence overlaps it. Merge by relative root,
|
||||
// unioning the access bits across classes.
|
||||
let mut grants: Vec<(String, PathAccess)> = Vec::new();
|
||||
for rule in eff.rules() {
|
||||
if rule.effect() != Effect::Allow {
|
||||
continue;
|
||||
}
|
||||
let Some(access) = access_class(rule.capability()) else {
|
||||
continue;
|
||||
};
|
||||
let fences: &[String] = if access == PathAccess::RO {
|
||||
&ro_fences
|
||||
} else {
|
||||
&rw_fences
|
||||
};
|
||||
for glob in rule.paths().globs() {
|
||||
let rel = static_prefix(glob.pattern());
|
||||
if fences.iter().any(|d| paths_conflict(&rel, d)) {
|
||||
// A same-class deny falls inside/over this root ⇒ we cannot grant
|
||||
// it for this class without leaking the deny.
|
||||
continue;
|
||||
}
|
||||
match grants.iter_mut().find(|(r, _)| *r == rel) {
|
||||
Some((_, acc)) => acc.insert(access),
|
||||
None => grants.push((rel, access)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Join the surviving relative roots onto the absolute project root.
|
||||
let base = ctx.project_root.trim_end_matches('/');
|
||||
let allowed = grants
|
||||
.into_iter()
|
||||
.map(|(rel, access)| PathGrant {
|
||||
abs_root: join_root(base, &rel),
|
||||
access,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Some(SandboxPlan {
|
||||
allowed,
|
||||
default_posture: eff.fallback(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The access **class** a file capability belongs to: [`Capability::Read`] ⇒
|
||||
/// [`PathAccess::RO`]; [`Capability::Write`]/[`Capability::Delete`] ⇒
|
||||
/// [`PathAccess::RW`]. Non-file capabilities ([`Capability::ExecuteBash`]) have no
|
||||
/// class (`None`) — they never produce a filesystem grant or fence.
|
||||
fn access_class(cap: Capability) -> Option<PathAccess> {
|
||||
match cap {
|
||||
Capability::Read => Some(PathAccess::RO),
|
||||
Capability::Write | Capability::Delete => Some(PathAccess::RW),
|
||||
Capability::ExecuteBash => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The **static prefix** of a glob: the leading literal directory path before the
|
||||
/// first glob metacharacter (`*`, `?`, `[`), with any trailing `/` trimmed.
|
||||
///
|
||||
/// - `"**"` / `"*.rs"` ⇒ `""` (the project root itself),
|
||||
/// - `"src/**"` / `"src/*.rs"` ⇒ `"src"`,
|
||||
/// - `"a/b/**/*.rs"` ⇒ `"a/b"`,
|
||||
/// - `"src/foo.rs"` (no metachar) ⇒ `"src/foo.rs"` (the file itself).
|
||||
fn static_prefix(pattern: &str) -> String {
|
||||
let first_meta = pattern.find(['*', '?', '[']);
|
||||
let literal = match first_meta {
|
||||
// Cut back to the last '/' *before* the metacharacter: everything up to
|
||||
// and including that slash is a settled directory path.
|
||||
Some(idx) => match pattern[..idx].rfind('/') {
|
||||
Some(slash) => &pattern[..slash],
|
||||
None => "",
|
||||
},
|
||||
// No metacharacter: the whole pattern is a literal path.
|
||||
None => pattern,
|
||||
};
|
||||
literal.trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
/// Whether two project-relative roots overlap such that an additive sandbox could
|
||||
/// not keep them apart: they are equal, or one is a path-ancestor of the other.
|
||||
fn paths_conflict(a: &str, b: &str) -> bool {
|
||||
a == b || is_descendant(a, b) || is_descendant(b, a)
|
||||
}
|
||||
|
||||
/// Whether `child` is a **strict** path-descendant of `ancestor` (component
|
||||
/// boundary aware). The empty string denotes the project root, an ancestor of
|
||||
/// every non-empty path.
|
||||
fn is_descendant(child: &str, ancestor: &str) -> bool {
|
||||
if ancestor.is_empty() {
|
||||
return !child.is_empty();
|
||||
}
|
||||
child.len() > ancestor.len()
|
||||
&& child.starts_with(ancestor)
|
||||
&& child.as_bytes()[ancestor.len()] == b'/'
|
||||
}
|
||||
|
||||
/// Joins a relative root onto the absolute base. An empty `rel` denotes the base
|
||||
/// itself.
|
||||
fn join_root(base: &str, rel: &str) -> String {
|
||||
if rel.is_empty() {
|
||||
base.to_string()
|
||||
} else {
|
||||
format!("{base}/{rel}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::permission::{PathScope, PermissionRule, PermissionSet, resolve};
|
||||
|
||||
// ---- helpers ---------------------------------------------------------
|
||||
|
||||
const ROOT: &str = "/home/anthony/proj";
|
||||
|
||||
fn ctx() -> SandboxContext<'static> {
|
||||
SandboxContext {
|
||||
project_root: ROOT,
|
||||
run_dir: "/home/anthony/proj/.ideai/run/agent-x",
|
||||
}
|
||||
}
|
||||
|
||||
fn scope(patterns: &[&str]) -> PathScope {
|
||||
PathScope::new(patterns.iter().map(|s| s.to_string())).unwrap()
|
||||
}
|
||||
|
||||
fn file_rule(cap: Capability, effect: Effect, patterns: &[&str]) -> PermissionRule {
|
||||
PermissionRule::file(cap, effect, scope(patterns)).unwrap()
|
||||
}
|
||||
|
||||
/// Resolves a single-set policy into `EffectivePermissions` (project set only).
|
||||
fn eff(rules: Vec<PermissionRule>, fallback: Posture) -> EffectivePermissions {
|
||||
let set = PermissionSet::new(rules, fallback);
|
||||
resolve(Some(&set), None).unwrap()
|
||||
}
|
||||
|
||||
fn grant<'a>(plan: &'a SandboxPlan, abs_root: &str) -> Option<&'a PathGrant> {
|
||||
plan.allowed.iter().find(|g| g.abs_root == abs_root)
|
||||
}
|
||||
|
||||
// ---- invariant 1: presence of a policy != non-empty grants ----------
|
||||
|
||||
#[test]
|
||||
fn none_eff_yields_no_plan() {
|
||||
// Nothing posed ⇒ no sandbox ⇒ the agent runs natively.
|
||||
assert!(compile_sandbox_plan(None, &ctx()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_with_no_allow_still_yields_some_plan() {
|
||||
// A posed policy with an empty `allowed` is NOT the same as "no plan":
|
||||
// it must compile to `Some` with an empty grant list.
|
||||
let e = eff(vec![], Posture::Deny);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).expect("a posed policy yields a plan");
|
||||
assert!(
|
||||
plan.allowed.is_empty(),
|
||||
"no allow rule ⇒ no grant, but still a plan"
|
||||
);
|
||||
|
||||
// Same with only a Deny file rule present (still a policy, still no grant).
|
||||
let e = eff(
|
||||
vec![file_rule(Capability::Write, Effect::Deny, &["secret/**"])],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).expect("deny-only is still a plan");
|
||||
assert!(plan.allowed.is_empty());
|
||||
}
|
||||
|
||||
// ---- invariant 2: capability → access mapping; bash never a grant ----
|
||||
|
||||
#[test]
|
||||
fn read_maps_to_ro() {
|
||||
let e = eff(
|
||||
vec![file_rule(Capability::Read, Effect::Allow, &["src/**"])],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
let g = grant(&plan, "/home/anthony/proj/src").expect("src granted");
|
||||
assert_eq!(g.access, PathAccess::RO);
|
||||
assert!(!g.access.contains(PathAccess::RW));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_and_delete_map_to_rw() {
|
||||
let e = eff(
|
||||
vec![
|
||||
file_rule(Capability::Write, Effect::Allow, &["out/**"]),
|
||||
file_rule(Capability::Delete, Effect::Allow, &["tmp/**"]),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
assert_eq!(
|
||||
grant(&plan, "/home/anthony/proj/out").unwrap().access,
|
||||
PathAccess::RW
|
||||
);
|
||||
assert_eq!(
|
||||
grant(&plan, "/home/anthony/proj/tmp").unwrap().access,
|
||||
PathAccess::RW
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_only_policy_produces_no_path_grant() {
|
||||
// ExecuteBash is never translated to a PathGrant (Landlock = files only).
|
||||
let e = eff(
|
||||
vec![
|
||||
PermissionRule::bash(Effect::Allow, vec![]),
|
||||
PermissionRule::bash(
|
||||
Effect::Deny,
|
||||
vec![crate::permission::CommandRule::new(
|
||||
crate::permission::CommandMatcher::prefix("rm ").unwrap(),
|
||||
Effect::Deny,
|
||||
)],
|
||||
),
|
||||
],
|
||||
Posture::Allow,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
assert!(
|
||||
plan.allowed.is_empty(),
|
||||
"a purely-bash policy yields zero PathGrant"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- invariant 3: fail-closed glob → static-prefix translation -------
|
||||
|
||||
#[test]
|
||||
fn root_glob_with_same_class_deny_drops_root_grant() {
|
||||
// Allow Read `**` (root) + a single SAME-CLASS Deny Read file ⇒ the RO root
|
||||
// grant is abandoned: an additive sandbox cannot carve the denied file back
|
||||
// out of the granted subtree (fail-closed within the RO class).
|
||||
let e = eff(
|
||||
vec![
|
||||
file_rule(Capability::Read, Effect::Allow, &["**"]),
|
||||
file_rule(Capability::Read, Effect::Deny, &["secret.txt"]),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
assert!(
|
||||
plan.allowed.is_empty(),
|
||||
"a same-class deny under the root fence drops the root grant"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descendant_same_class_deny_drops_overlapping_allow() {
|
||||
// Allow Read `src/**` (root `src`) + Deny Read `src/secret/**`
|
||||
// (root `src/secret`): the same-class deny is a descendant of the granted
|
||||
// root ⇒ the RO grant on `src` is dropped (fail-closed within RO).
|
||||
let e = eff(
|
||||
vec![
|
||||
file_rule(Capability::Read, Effect::Allow, &["src/**"]),
|
||||
file_rule(Capability::Read, Effect::Deny, &["src/secret/**"]),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
assert!(
|
||||
grant(&plan, "/home/anthony/proj/src").is_none(),
|
||||
"an inside same-class deny abandons the enclosing allow"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_class_deny_does_not_amputate_read_allow() {
|
||||
// The DUAL of the case above and the key autonomy guarantee: a Deny of a
|
||||
// DIFFERENT class (Write) over a descendant must NOT touch the Read allow.
|
||||
// `Allow Read src/**` + `Deny Write src/secret/**` ⇒ `src` keeps its RO.
|
||||
let e = eff(
|
||||
vec![
|
||||
file_rule(Capability::Read, Effect::Allow, &["src/**"]),
|
||||
file_rule(Capability::Write, Effect::Deny, &["src/secret/**"]),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
assert_eq!(
|
||||
grant(&plan, "/home/anthony/proj/src").map(|g| g.access),
|
||||
Some(PathAccess::RO),
|
||||
"a Deny Write must never amputate an Allow Read (per-class autonomy)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disjoint_same_class_deny_keeps_allow() {
|
||||
// Allow Read `src/**` + SAME-CLASS Deny Read `other/**`: disjoint roots ⇒
|
||||
// grant `src` kept. Same-class fence so the test proves disjointness, not
|
||||
// merely a class mismatch.
|
||||
let e = eff(
|
||||
vec![
|
||||
file_rule(Capability::Read, Effect::Allow, &["src/**"]),
|
||||
file_rule(Capability::Read, Effect::Deny, &["other/**"]),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
assert_eq!(
|
||||
grant(&plan, "/home/anthony/proj/src").unwrap().access,
|
||||
PathAccess::RO,
|
||||
"a disjoint same-class deny does not touch the allow"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ancestor_same_class_deny_also_drops_allow() {
|
||||
// Deny Read `src/**` (root `src`) is an ancestor of Allow Read `src/sub/**`
|
||||
// (root `src/sub`) ⇒ same-class overlap ⇒ allow dropped (fence symmetry).
|
||||
let e = eff(
|
||||
vec![
|
||||
file_rule(Capability::Read, Effect::Allow, &["src/sub/**"]),
|
||||
file_rule(Capability::Read, Effect::Deny, &["src/**"]),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
assert!(grant(&plan, "/home/anthony/proj/src/sub").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_prefix_is_not_a_descendant() {
|
||||
// Component-boundary awareness: `src2` must NOT be treated as inside `src`
|
||||
// just because the string `src` is a prefix of `src2`. Uses a SAME-CLASS
|
||||
// (Deny Read) fence so the survival proves boundary-awareness, not a class
|
||||
// mismatch.
|
||||
let e = eff(
|
||||
vec![
|
||||
file_rule(Capability::Read, Effect::Allow, &["src2/**"]),
|
||||
file_rule(Capability::Read, Effect::Deny, &["src/**"]),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
assert!(
|
||||
grant(&plan, "/home/anthony/proj/src2").is_some(),
|
||||
"`src2` is a sibling of `src`, not a descendant"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accesses_union_on_a_shared_root() {
|
||||
// Read + Write allows on the same root merge into RO|RW on one grant.
|
||||
let e = eff(
|
||||
vec![
|
||||
file_rule(Capability::Read, Effect::Allow, &["src/**"]),
|
||||
file_rule(Capability::Write, Effect::Allow, &["src/**"]),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
let g = grant(&plan, "/home/anthony/proj/src").expect("single merged grant");
|
||||
assert!(g.access.contains(PathAccess::RO));
|
||||
assert!(g.access.contains(PathAccess::RW));
|
||||
assert_eq!(
|
||||
plan.allowed
|
||||
.iter()
|
||||
.filter(|g| g.abs_root == "/home/anthony/proj/src")
|
||||
.count(),
|
||||
1,
|
||||
"the two allows merge onto one root, not two grants"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_root_drops_rw_but_keeps_ro_under_a_write_deny() {
|
||||
// Direct proof of per-access-class granularity on a single root:
|
||||
// `Allow Read src/**` + `Allow Write src/**` + `Deny Write src/secret/**`.
|
||||
// The RW class is fenced (deny descendant) ⇒ RW dropped; the RO class has no
|
||||
// fence ⇒ RO survives. The grant on `src` ends up RO-only.
|
||||
let e = eff(
|
||||
vec![
|
||||
file_rule(Capability::Read, Effect::Allow, &["src/**"]),
|
||||
file_rule(Capability::Write, Effect::Allow, &["src/**"]),
|
||||
file_rule(Capability::Write, Effect::Deny, &["src/secret/**"]),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
let g = grant(&plan, "/home/anthony/proj/src").expect("RO survives the write deny");
|
||||
assert_eq!(
|
||||
g.access,
|
||||
PathAccess::RO,
|
||||
"RW class fenced out, RO class preserved on the same root"
|
||||
);
|
||||
assert!(!g.access.contains(PathAccess::RW), "the RW grant was dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_prefix_of_literal_file_is_the_file_itself() {
|
||||
// A metacharacter-free allow grants exactly that file path.
|
||||
let e = eff(
|
||||
vec![file_rule(Capability::Read, Effect::Allow, &["src/lib.rs"])],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
assert!(grant(&plan, "/home/anthony/proj/src/lib.rs").is_some());
|
||||
}
|
||||
|
||||
// ---- invariant 4: residual posture reflected in the plan -------------
|
||||
|
||||
#[test]
|
||||
fn default_posture_mirrors_resolved_fallback() {
|
||||
for posture in [Posture::Allow, Posture::Ask, Posture::Deny] {
|
||||
let e = eff(
|
||||
vec![file_rule(Capability::Read, Effect::Allow, &["src/**"])],
|
||||
posture,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
assert_eq!(plan.default_posture, posture);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_slash_on_project_root_is_normalised() {
|
||||
let e = eff(
|
||||
vec![file_rule(Capability::Read, Effect::Allow, &["src/**"])],
|
||||
Posture::Ask,
|
||||
);
|
||||
let ctx = SandboxContext {
|
||||
project_root: "/home/anthony/proj/",
|
||||
run_dir: "/x",
|
||||
};
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx).unwrap();
|
||||
assert!(
|
||||
grant(&plan, "/home/anthony/proj/src").is_some(),
|
||||
"the trailing slash must not produce `//`"
|
||||
);
|
||||
}
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
@ -194,13 +194,19 @@ fn leaf_returns_none_for_grid_node_id() {
|
||||
// L'id de la grille n'est pas une feuille.
|
||||
assert!(grid.leaf(node(50)).is_none());
|
||||
// La feuille imbriquée est bien retrouvée.
|
||||
assert_eq!(*grid.leaf(node(3)).expect("nested leaf"), leaf_cell(3, None));
|
||||
assert_eq!(
|
||||
*grid.leaf(node(3)).expect("nested leaf"),
|
||||
leaf_cell(3, None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_finds_single_root_leaf() {
|
||||
let tree = LayoutTree::single(leaf_cell(7, Some(70)));
|
||||
assert_eq!(*tree.leaf(node(7)).expect("root leaf"), leaf_cell(7, Some(70)));
|
||||
assert_eq!(
|
||||
*tree.leaf(node(7)).expect("root leaf"),
|
||||
leaf_cell(7, Some(70))
|
||||
);
|
||||
assert!(tree.leaf(node(8)).is_none());
|
||||
}
|
||||
|
||||
|
||||
@ -837,12 +837,7 @@ fn move_session_preserves_resume_fields_on_both_leaves() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Builds an agent-bearing leaf with explicit resume signals.
|
||||
fn agent_leaf_full(
|
||||
id: u128,
|
||||
agent: u128,
|
||||
conv: Option<&str>,
|
||||
running: bool,
|
||||
) -> LeafCell {
|
||||
fn agent_leaf_full(id: u128, agent: u128, conv: Option<&str>, running: bool) -> LeafCell {
|
||||
LeafCell {
|
||||
id: node(id),
|
||||
session: None,
|
||||
|
||||
@ -150,7 +150,9 @@ fn profile_with_adapter_roundtrips_and_uses_camel_case() {
|
||||
#[test]
|
||||
fn with_structured_adapter_sets_only_that_field() {
|
||||
let before = pty_profile();
|
||||
let after = before.clone().with_structured_adapter(StructuredAdapter::Codex);
|
||||
let after = before
|
||||
.clone()
|
||||
.with_structured_adapter(StructuredAdapter::Codex);
|
||||
|
||||
// Le seul champ muté :
|
||||
assert_eq!(after.structured_adapter, Some(StructuredAdapter::Codex));
|
||||
@ -275,7 +277,10 @@ fn agent_session_error_is_std_error_and_equates() {
|
||||
AgentSessionError::Start("a".into()),
|
||||
AgentSessionError::Start("b".into())
|
||||
);
|
||||
assert_ne!(AgentSessionError::Timeout, AgentSessionError::Io("t".into()));
|
||||
assert_ne!(
|
||||
AgentSessionError::Timeout,
|
||||
AgentSessionError::Io("t".into())
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -335,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)),
|
||||
@ -389,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.
|
||||
|
||||
@ -194,4 +194,94 @@ mod tests {
|
||||
let reg = InMemoryConversationRegistry::new();
|
||||
assert!(reg.get(ConversationId::new_random()).is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bloc 1 — déterminisme du registre (§19 : la clé de persistance = id de paire,
|
||||
// déterministe et **stable au redémarrage**). Scelle l'invariant que Main a
|
||||
// rattrapé : deux instances neuves du registre (= deux démarrages de l'IDE)
|
||||
// doivent dériver **le même** id pour la même paire, et cet id doit être
|
||||
// **exactement** `ConversationId::for_pair`, sinon la clé sous laquelle le
|
||||
// handoff/log est rangé dériverait au redémarrage.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn resolve_is_stable_across_registry_restart() {
|
||||
// Deux instances neuves (by_pair/by_id vides) simulent deux démarrages de
|
||||
// l'IDE : la même paire User↔Agent doit produire le **même** id.
|
||||
let a = agent(7);
|
||||
let first = InMemoryConversationRegistry::new()
|
||||
.resolve(ConversationParty::User, a)
|
||||
.id;
|
||||
let second = InMemoryConversationRegistry::new()
|
||||
.resolve(ConversationParty::User, a)
|
||||
.id;
|
||||
assert_eq!(
|
||||
first, second,
|
||||
"id de paire stable au redémarrage (registre neuf ⇒ même id)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_is_stable_across_registry_restart_agent_agent() {
|
||||
// Même garantie pour une paire Agent↔Agent (dérivation XOR commutative).
|
||||
let x = agent(11);
|
||||
let y = agent(13);
|
||||
let first = InMemoryConversationRegistry::new().resolve(x, y).id;
|
||||
let second = InMemoryConversationRegistry::new().resolve(y, x).id;
|
||||
assert_eq!(
|
||||
first, second,
|
||||
"id de paire Agent↔Agent stable au redémarrage, insensible à l'ordre"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_id_equals_for_pair_user_agent() {
|
||||
// Alignement de clé : l'id que le registre matérialise == le repli pur
|
||||
// `for_pair` == `from_uuid(agent)` (la clé que P8a/`resolve_conversation`
|
||||
// dérivent pour la paire canonique User↔Agent).
|
||||
let agent_id = AgentId::from_uuid(uuid::Uuid::from_u128(42));
|
||||
let party = ConversationParty::agent(agent_id);
|
||||
let resolved = InMemoryConversationRegistry::new()
|
||||
.resolve(ConversationParty::User, party)
|
||||
.id;
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ConversationId::for_pair(ConversationParty::User, party),
|
||||
"resolve == for_pair (alignement de clé)"
|
||||
);
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ConversationId::from_uuid(agent_id.as_uuid()),
|
||||
"User↔Agent ⇒ id == uuid de l'agent (repli resolve_conversation)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_id_equals_for_pair_agent_agent_commutative() {
|
||||
// Commutativité et alignement sur `for_pair` pour Agent↔Agent.
|
||||
let x = agent(101);
|
||||
let y = agent(202);
|
||||
let reg = InMemoryConversationRegistry::new();
|
||||
let id_xy = reg.resolve(x, y).id;
|
||||
let id_yx = reg.resolve(y, x).id;
|
||||
assert_eq!(id_xy, id_yx, "resolve(a,b) == resolve(b,a)");
|
||||
assert_eq!(
|
||||
id_xy,
|
||||
ConversationId::for_pair(x, y),
|
||||
"resolve == for_pair (Agent↔Agent)"
|
||||
);
|
||||
assert_eq!(reg.len(), 1, "une seule conversation pour la paire {{a,b}}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_pairs_yield_distinct_ids_across_kinds() {
|
||||
// Deux paires distinctes ⇒ deux ids distincts (pas de collision de clé).
|
||||
let reg = InMemoryConversationRegistry::new();
|
||||
let user_a = reg.resolve(ConversationParty::User, agent(1)).id;
|
||||
let user_b = reg.resolve(ConversationParty::User, agent(2)).id;
|
||||
let a_b = reg.resolve(agent(1), agent(2)).id;
|
||||
assert_ne!(user_a, user_b, "User↔A ≠ User↔B");
|
||||
assert_ne!(user_a, a_b, "User↔A ≠ A↔B");
|
||||
assert_ne!(user_b, a_b, "User↔B ≠ A↔B");
|
||||
}
|
||||
}
|
||||
|
||||
@ -191,11 +191,7 @@ impl HandoffStore for FsHandoffStore {
|
||||
deserialize(&content).map(Some)
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
handoff: Handoff,
|
||||
) -> Result<(), StoreError> {
|
||||
async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError> {
|
||||
let body = serialize(&handoff);
|
||||
|
||||
let dir = self.conversation_dir(conversation);
|
||||
|
||||
@ -54,6 +54,15 @@ impl FileSystem for LocalFileSystem {
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_file(&self, path: &RemotePath) -> Result<(), FsError> {
|
||||
match fs::remove_file(path.as_str()).await {
|
||||
Ok(()) => Ok(()),
|
||||
// Idempotent best-effort delete: an already-absent file is success.
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(map_io(path, &e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
|
||||
fs::create_dir_all(path.as_str())
|
||||
.await
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -24,12 +24,17 @@ pub mod input;
|
||||
pub mod inspector;
|
||||
pub mod mailbox;
|
||||
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;
|
||||
@ -38,10 +43,10 @@ pub use conversation_log::{
|
||||
};
|
||||
pub use eventbus::TokioBroadcastEventBus;
|
||||
pub use fileguard::RwFileGuard;
|
||||
pub use input::{MediatedInbox, MillisClock, SystemMillisClock};
|
||||
pub use fs::LocalFileSystem;
|
||||
pub use git::Git2Repository;
|
||||
pub use id::UuidGenerator;
|
||||
pub use input::{MediatedInbox, MillisClock, SystemMillisClock};
|
||||
pub use inspector::ClaudeTranscriptInspector;
|
||||
pub use mailbox::InMemoryMailbox;
|
||||
pub use orchestrator::mcp::{McpServer, MemoryTransport, StdioTransport};
|
||||
@ -49,10 +54,16 @@ pub use orchestrator::{
|
||||
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
|
||||
REQUESTS_SUBDIR,
|
||||
};
|
||||
pub use permission::{ClaudePermissionProjector, CodexPermissionProjector};
|
||||
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;
|
||||
@ -61,8 +72,8 @@ pub use store::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT};
|
||||
pub use store::{
|
||||
embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector,
|
||||
AdaptiveMemoryRecall, EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
||||
FsMemoryStore, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, HashEmbedder,
|
||||
IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, VectorMemoryRecall,
|
||||
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
|
||||
VECTOR_ONNX_ENABLED,
|
||||
FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore, FsSkillStore,
|
||||
FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo,
|
||||
StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
|
||||
@ -192,13 +192,19 @@ mod tests {
|
||||
let p1 = mb.enqueue(a, ticket(10, "first"));
|
||||
let p2 = mb.enqueue(a, ticket(11, "second"));
|
||||
assert_eq!(mb.pending(&a), 2);
|
||||
assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(10))));
|
||||
assert_eq!(
|
||||
mb.head_ticket(&a),
|
||||
Some(TicketId::from_uuid(uuid::Uuid::from_u128(10)))
|
||||
);
|
||||
|
||||
// First resolve goes to the FIRST (head) ticket.
|
||||
mb.resolve(a, "r1".to_owned()).unwrap();
|
||||
assert_eq!(p1.await.unwrap(), "r1");
|
||||
// Now the second is at the head.
|
||||
assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(11))));
|
||||
assert_eq!(
|
||||
mb.head_ticket(&a),
|
||||
Some(TicketId::from_uuid(uuid::Uuid::from_u128(11)))
|
||||
);
|
||||
mb.resolve(a, "r2".to_owned()).unwrap();
|
||||
assert_eq!(p2.await.unwrap(), "r2");
|
||||
}
|
||||
@ -238,7 +244,10 @@ mod tests {
|
||||
// Caller of ticket 10 timed out: retire exactly its head ticket.
|
||||
mb.cancel_head(a, TicketId::from_uuid(uuid::Uuid::from_u128(10)));
|
||||
assert_eq!(mb.pending(&a), 1);
|
||||
assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(11))));
|
||||
assert_eq!(
|
||||
mb.head_ticket(&a),
|
||||
Some(TicketId::from_uuid(uuid::Uuid::from_u128(11)))
|
||||
);
|
||||
|
||||
// The cancelled pending resolves to Cancelled (its sender was dropped).
|
||||
assert_eq!(p1.await, Err(MailboxError::Cancelled));
|
||||
@ -256,6 +265,9 @@ mod tests {
|
||||
// Try to cancel a ticket that is NOT the head ⇒ nothing retired.
|
||||
mb.cancel_head(a, TicketId::from_uuid(uuid::Uuid::from_u128(99)));
|
||||
assert_eq!(mb.pending(&a), 1);
|
||||
assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(10))));
|
||||
assert_eq!(
|
||||
mb.head_ticket(&a),
|
||||
Some(TicketId::from_uuid(uuid::Uuid::from_u128(10)))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,10 +18,12 @@
|
||||
//! duplicated here.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use application::OrchestratorService;
|
||||
use domain::{DomainEvent, OrchestrationSource, Project};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::jsonrpc::{
|
||||
error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError,
|
||||
@ -32,6 +34,18 @@ use super::tools::{self, ToolMapError};
|
||||
/// The MCP protocol version this server speaks (advertised on `initialize`).
|
||||
const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
|
||||
|
||||
/// **Server-side safety net** bounding the synchronous `idea_ask_agent` rendezvous.
|
||||
///
|
||||
/// `idea_ask_agent` is the only tool whose `tools/call` *blocks* awaiting another
|
||||
/// agent's `idea_reply` (a synchronous rendezvous, resolved deep in the application
|
||||
/// layer). The application layer already bounds the rendezvous itself, but this
|
||||
/// adapter adds its own **finite** outer bound so a wedged target can never park a
|
||||
/// `tools/call` task forever: on expiry the call returns a clean JSON-RPC error
|
||||
/// instead of hanging. Generous on purpose (delegated turns can be very long), but
|
||||
/// never infinite. Every other tool (`idea_reply`, `idea_list_agents`, …) is left
|
||||
/// untouched — they don't rendezvous.
|
||||
const ASK_RENDEZVOUS_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
/// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`].
|
||||
///
|
||||
/// Cheap to clone the dependencies it holds; one instance serves one project's
|
||||
@ -53,6 +67,18 @@ pub struct McpServer {
|
||||
/// frozen `"mcp"` placeholder; empty ⇒ the legacy `"mcp"` label (back-compat for
|
||||
/// M2 callers and connections that arrive without a requester).
|
||||
requester: String,
|
||||
/// Sink optionnel de **readiness de démarrage** : appelé avec le `requester` brut
|
||||
/// (handshake) la première fois que le peer émet `initialize` (son CLI est up et
|
||||
/// parle MCP). La composition root y branche
|
||||
/// `OrchestratorService::release_agent_cold_start` pour livrer un 1er tour différé
|
||||
/// (fix race cold-launch, signal MCP). L'infra ne connaît pas `AgentId` : la
|
||||
/// composition root parse le `requester`. `None` ⇒ no-op.
|
||||
ready_sink: Option<Arc<dyn Fn(&str) + Send + Sync>>,
|
||||
/// Finite outer bound applied **only** to the `idea_ask_agent` rendezvous (see
|
||||
/// [`ASK_RENDEZVOUS_TIMEOUT`]). Carried per instance so tests can shrink it to a
|
||||
/// few milliseconds without polluting the public API; production code always uses
|
||||
/// the default.
|
||||
ask_rendezvous_timeout: Duration,
|
||||
}
|
||||
|
||||
impl McpServer {
|
||||
@ -66,6 +92,8 @@ impl McpServer {
|
||||
project,
|
||||
events: None,
|
||||
requester: String::new(),
|
||||
ready_sink: None,
|
||||
ask_rendezvous_timeout: ASK_RENDEZVOUS_TIMEOUT,
|
||||
}
|
||||
}
|
||||
|
||||
@ -79,6 +107,17 @@ impl McpServer {
|
||||
self
|
||||
}
|
||||
|
||||
/// Attache un sink de **readiness de démarrage** : appelé avec l'id (handshake
|
||||
/// `requester`) de l'agent connecté la première fois qu'il émet `initialize` (son CLI
|
||||
/// est up et parle MCP). La composition root y branche
|
||||
/// `OrchestratorService::release_agent_cold_start` pour livrer un éventuel 1er tour
|
||||
/// différé (fix race cold-launch, signal MCP). Additif : sans sink, no-op.
|
||||
#[must_use]
|
||||
pub fn with_ready_sink(mut self, ready_sink: Arc<dyn Fn(&str) + Send + Sync>) -> Self {
|
||||
self.ready_sink = Some(ready_sink);
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns a per-connection clone of this server tagged with the connected
|
||||
/// peer's `requester` id (the loopback handshake's `requester`, cadrage v5 §1.4).
|
||||
///
|
||||
@ -94,9 +133,22 @@ impl McpServer {
|
||||
project: self.project.clone(),
|
||||
events: self.events.clone(),
|
||||
requester: requester.into(),
|
||||
ready_sink: self.ready_sink.clone(),
|
||||
ask_rendezvous_timeout: self.ask_rendezvous_timeout,
|
||||
}
|
||||
}
|
||||
|
||||
/// **Test seam** (doc-hidden): shrinks the `idea_ask_agent` rendezvous bound
|
||||
/// (see [`ASK_RENDEZVOUS_TIMEOUT`]) so a wedged-target test need not wait the
|
||||
/// full production timeout. Doc-hidden so it does not widen the documented public
|
||||
/// surface; production code never calls it.
|
||||
#[doc(hidden)]
|
||||
#[must_use]
|
||||
pub fn with_ask_rendezvous_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.ask_rendezvous_timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
/// Serves JSON-RPC messages from `transport`, tagging every processed
|
||||
/// `tools/call` with `requester` as the delegating agent (cadrage v5 §1.4).
|
||||
///
|
||||
@ -113,21 +165,78 @@ impl McpServer {
|
||||
/// Every inbound line is handled in isolation: a malformed line or an unknown
|
||||
/// method yields a JSON-RPC error response, **never a panic** and never a
|
||||
/// dropped connection. Notifications (no `id`) are processed without a reply.
|
||||
///
|
||||
/// **Full-duplex / non-blocking (anti-wedge).** A request whose handling blocks —
|
||||
/// notably `idea_ask_agent`, which awaits another agent's `idea_reply` in a
|
||||
/// synchronous rendezvous — must **not** stall the read loop: otherwise a single
|
||||
/// in-flight ask parks the whole connection and every later call (even a
|
||||
/// rendezvous-free `idea_list_agents`) is never even read. So each inbound message
|
||||
/// is handled on its own spawned task that owns a cheap per-call clone of the
|
||||
/// server; finished responses are funnelled back through an `mpsc` channel and
|
||||
/// written by the same loop. Responses carry their JSON-RPC `id`, so out-of-order
|
||||
/// completion is fine (the full-duplex bridge correlates by id). Notifications
|
||||
/// (`handle_raw` ⇒ `None`) emit nothing.
|
||||
pub async fn serve<T: Transport>(&self, transport: &mut T) {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<Option<Vec<u8>>>();
|
||||
// Number of spawned handler tasks not yet observed on `rx`. While `> 0`, an
|
||||
// EOF on the read side must NOT exit immediately: we keep draining `rx` so
|
||||
// already-computed responses still reach the transport (graceful shutdown).
|
||||
// A response-less notification decrements without ever sending — see below.
|
||||
let mut in_flight: usize = 0;
|
||||
// Once the peer closed its read side, stop accepting new inbound; only drain.
|
||||
let mut reading = true;
|
||||
loop {
|
||||
let raw = match transport.recv().await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(TransportError::Closed) => break,
|
||||
Err(TransportError::Io(_)) => break,
|
||||
};
|
||||
if let Some(response) = self.handle_raw(&raw).await {
|
||||
let Ok(bytes) = serde_json::to_vec(&response) else {
|
||||
continue;
|
||||
};
|
||||
tokio::select! {
|
||||
// Drain ready responses first so a flood of inbound never starves
|
||||
// writes; correctness does not depend on the bias.
|
||||
biased;
|
||||
|
||||
outbound = rx.recv() => {
|
||||
// `tx` is held by the loop, so `recv` only yields `None` once it
|
||||
// is dropped — which never happens before the loop returns.
|
||||
let Some(slot) = outbound else { break };
|
||||
in_flight -= 1;
|
||||
if let Some(bytes) = slot {
|
||||
if transport.send(&bytes).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Peer gone and every spawned task accounted for ⇒ done.
|
||||
if !reading && in_flight == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
incoming = transport.recv(), if reading => {
|
||||
let raw = match incoming {
|
||||
Ok(bytes) => bytes,
|
||||
// Peer closed / I/O error: stop reading but keep draining the
|
||||
// responses of tasks still in flight before returning.
|
||||
Err(TransportError::Closed) | Err(TransportError::Io(_)) => {
|
||||
reading = false;
|
||||
if in_flight == 0 {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Own a cheap clone (Arc/String/Project) so the task is `'static`
|
||||
// and never borrows the transport. Every spawned task sends
|
||||
// exactly one slot back (`Some(bytes)` for a reply, `None` for a
|
||||
// notification) so `in_flight` is always reconciled.
|
||||
in_flight += 1;
|
||||
let server = self.for_requester(self.requester.clone());
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let slot = match server.handle_raw(&raw).await {
|
||||
Some(response) => serde_json::to_vec(&response).ok(),
|
||||
None => None,
|
||||
};
|
||||
// Receiver dropped ⇒ the loop has stopped; discard.
|
||||
let _ = tx.send(slot);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -176,7 +285,10 @@ impl McpServer {
|
||||
params: Option<Value>,
|
||||
) -> Result<Value, JsonRpcError> {
|
||||
match method {
|
||||
"initialize" => Ok(self.initialize_result()),
|
||||
"initialize" => {
|
||||
self.notify_ready();
|
||||
Ok(self.initialize_result())
|
||||
}
|
||||
"tools/list" => Ok(self.tools_list_result()),
|
||||
"tools/call" => self.tools_call(params.unwrap_or(Value::Null)).await,
|
||||
other => Err(JsonRpcError::new(
|
||||
@ -186,6 +298,16 @@ impl McpServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Notifie le sink de readiness de démarrage avec l'identité du peer connecté
|
||||
/// (handshake `requester`). No-op si pas de sink ou requester vide (peer legacy/anonyme).
|
||||
fn notify_ready(&self) {
|
||||
if let Some(sink) = &self.ready_sink {
|
||||
if !self.requester.is_empty() {
|
||||
sink(&self.requester);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `initialize` result: protocol version, server identity, and the fact
|
||||
/// that we expose tools.
|
||||
fn initialize_result(&self) -> Value {
|
||||
@ -219,9 +341,7 @@ impl McpServer {
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
JsonRpcError::new(error_codes::INVALID_PARAMS, "missing tool `name`")
|
||||
})?
|
||||
.ok_or_else(|| JsonRpcError::new(error_codes::INVALID_PARAMS, "missing tool `name`"))?
|
||||
.to_owned();
|
||||
let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
|
||||
|
||||
@ -230,7 +350,25 @@ impl McpServer {
|
||||
let command =
|
||||
tools::map_tool_call(&name, &arguments, &self.requester).map_err(map_err_to_jsonrpc)?;
|
||||
|
||||
let result = self.service.dispatch(&self.project, command).await;
|
||||
// `idea_ask_agent` is the only tool that blocks on a synchronous rendezvous
|
||||
// (the target's `idea_reply`). Bound *only* that path with a finite outer
|
||||
// timeout (server-side safety net, see [`ASK_RENDEZVOUS_TIMEOUT`]): on expiry
|
||||
// return a clean JSON-RPC error instead of parking the task forever. Every
|
||||
// other tool dispatches unbounded — they never rendezvous.
|
||||
let dispatch = self.service.dispatch(&self.project, command);
|
||||
let result = if name == "idea_ask_agent" {
|
||||
match tokio::time::timeout(self.ask_rendezvous_timeout, dispatch).await {
|
||||
Ok(result) => result,
|
||||
Err(_elapsed) => {
|
||||
return Err(JsonRpcError::new(
|
||||
error_codes::INTERNAL_ERROR,
|
||||
"idea_ask_agent : aucune réponse de la cible avant le timeout du rendezvous",
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dispatch.await
|
||||
};
|
||||
// Surface the processed delegation on the bus, tagged as the MCP door — the
|
||||
// twin of the file watcher's publish. `ok` mirrors the dispatch outcome; the
|
||||
// action is the tool name. No-op when no sink is attached.
|
||||
@ -238,7 +376,10 @@ impl McpServer {
|
||||
match result {
|
||||
Ok(outcome) => {
|
||||
// The text the agent sees: the reply for an `ask`, else the detail.
|
||||
let text = outcome.reply.clone().unwrap_or_else(|| outcome.detail.clone());
|
||||
let text = outcome
|
||||
.reply
|
||||
.clone()
|
||||
.unwrap_or_else(|| outcome.detail.clone());
|
||||
Ok(tool_result_text(&text, false))
|
||||
}
|
||||
// A failed IdeA command is reported as a tool execution error
|
||||
|
||||
@ -242,9 +242,8 @@ pub fn map_tool_call(
|
||||
.ok_or_else(|| ToolMapError::BadArguments(name.to_owned()))?;
|
||||
|
||||
// Small helpers to pull optional string fields out of the arguments object.
|
||||
let s = |key: &str| -> Option<String> {
|
||||
args.get(key).and_then(Value::as_str).map(str::to_owned)
|
||||
};
|
||||
let s =
|
||||
|key: &str| -> Option<String> { args.get(key).and_then(Value::as_str).map(str::to_owned) };
|
||||
|
||||
// Translate the tool into the wire-level request shape the watcher already
|
||||
// uses (v2 `type`), then let `validate` enforce the invariants once.
|
||||
@ -361,7 +360,9 @@ fn base() -> OrchestratorRequest {
|
||||
/// missing its node, with a precise field error).
|
||||
fn parse_node_id(value: Option<&Value>) -> Option<domain::NodeId> {
|
||||
let raw = value?.as_str()?;
|
||||
uuid::Uuid::parse_str(raw).ok().map(domain::NodeId::from_uuid)
|
||||
uuid::Uuid::parse_str(raw)
|
||||
.ok()
|
||||
.map(domain::NodeId::from_uuid)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -413,7 +414,9 @@ mod tests {
|
||||
fn stop_update_and_skill_map_to_their_commands() {
|
||||
assert_eq!(
|
||||
map("idea_stop_agent", &json!({ "target": "Dev" })).unwrap(),
|
||||
OrchestratorCommand::StopAgent { name: "Dev".to_owned() }
|
||||
OrchestratorCommand::StopAgent {
|
||||
name: "Dev".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
map(
|
||||
|
||||
@ -108,9 +108,7 @@ impl MemoryTransport {
|
||||
/// signal EOF. Returns the transport and a receiver of everything the server
|
||||
/// `send`s back.
|
||||
#[must_use]
|
||||
pub fn scripted(
|
||||
messages: Vec<Vec<u8>>,
|
||||
) -> (Self, mpsc::UnboundedReceiver<Vec<u8>>) {
|
||||
pub fn scripted(messages: Vec<Vec<u8>>) -> (Self, mpsc::UnboundedReceiver<Vec<u8>>) {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
(
|
||||
Self {
|
||||
|
||||
362
crates/infrastructure/src/permission/claude.rs
Normal file
362
crates/infrastructure/src/permission/claude.rs
Normal file
@ -0,0 +1,362 @@
|
||||
//! Claude Code permission projector (lot LP3-2).
|
||||
//!
|
||||
//! Produces the `.claude/settings.local.json` seed written into an agent run dir:
|
||||
//! full project autonomy (`bypassPermissions` + broad Read/Edit/Write/Bash) with
|
||||
//! the project root granted as an additional working directory, while keeping
|
||||
//! destructive/out-of-project commands denied. The translation is extracted
|
||||
//! verbatim from the former `claude_settings_seed` in `lifecycle.rs`.
|
||||
|
||||
use domain::permission::{
|
||||
Capability, CommandMatcher, Effect, EffectivePermissions, PermissionProjection,
|
||||
PermissionProjector, PermissionRule, Posture, ProjectedFile, ProjectionContext, ProjectorKey,
|
||||
};
|
||||
|
||||
use super::json_escape;
|
||||
|
||||
/// Run-dir-relative path of the owned Claude settings seed.
|
||||
const SETTINGS_REL_PATH: &str = ".claude/settings.local.json";
|
||||
|
||||
/// Projects [`EffectivePermissions`] into Claude Code's `settings.local.json`.
|
||||
///
|
||||
/// Pure: `project` only computes the JSON; the launch path materialises it. A
|
||||
/// `Replace`-owned file (clobbered at launch, removed on swap-away).
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct ClaudePermissionProjector;
|
||||
|
||||
impl PermissionProjector for ClaudePermissionProjector {
|
||||
fn key(&self) -> ProjectorKey {
|
||||
ProjectorKey::Claude
|
||||
}
|
||||
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
// Product invariant: nothing posed ⇒ nothing projected (native prompting).
|
||||
let Some(_) = eff else {
|
||||
return PermissionProjection::empty();
|
||||
};
|
||||
let contents = claude_settings_seed(ctx.project_root, eff);
|
||||
PermissionProjection {
|
||||
files: vec![ProjectedFile::Replace {
|
||||
rel_path: SETTINGS_REL_PATH.to_owned(),
|
||||
contents,
|
||||
}],
|
||||
args: Vec::new(),
|
||||
env: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn owned_replace_paths(&self) -> Vec<String> {
|
||||
vec![SETTINGS_REL_PATH.to_owned()]
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the Claude Code permission seed. `project_root` is embedded verbatim
|
||||
/// (JSON-escaped) and granted as an additional working directory, since the cwd is
|
||||
/// the run dir and the agent works on the root above it.
|
||||
fn claude_settings_seed(project_root: &str, permissions: Option<&EffectivePermissions>) -> String {
|
||||
let root = json_escape(project_root);
|
||||
let default_mode = match permissions.map(EffectivePermissions::fallback) {
|
||||
Some(Posture::Deny) => "plan",
|
||||
Some(Posture::Ask) => "acceptEdits",
|
||||
Some(Posture::Allow) | None => "bypassPermissions",
|
||||
};
|
||||
let allow = claude_permission_entries(permissions, Effect::Allow);
|
||||
let deny = claude_permission_entries(permissions, Effect::Deny);
|
||||
let default_allow = [
|
||||
"Read".to_owned(),
|
||||
"Edit".to_owned(),
|
||||
"Write".to_owned(),
|
||||
"Bash".to_owned(),
|
||||
];
|
||||
let allow = json_string_array(if allow.is_empty() {
|
||||
&default_allow
|
||||
} else {
|
||||
&allow
|
||||
});
|
||||
let deny = json_string_array(&merge_default_deny(deny));
|
||||
format!(
|
||||
r#"{{
|
||||
"permissions": {{
|
||||
"defaultMode": "{default_mode}",
|
||||
"additionalDirectories": [
|
||||
"{root}"
|
||||
],
|
||||
"allow": {allow},
|
||||
"deny": {deny}
|
||||
}},
|
||||
"skipDangerousModePermissionPrompt": true,
|
||||
"enabledMcpjsonServers": ["idea"],
|
||||
"sandbox": {{
|
||||
"enabled": false
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
fn merge_default_deny(mut deny: Vec<String>) -> Vec<String> {
|
||||
for item in [
|
||||
"Bash(sudo *)",
|
||||
"Bash(rm -rf /)",
|
||||
"Bash(rm -rf /*)",
|
||||
"Bash(rm -rf ~)",
|
||||
"Bash(rm -rf ~/)",
|
||||
"Bash(rm -rf ~/*)",
|
||||
"Bash(rm -rf $HOME*)",
|
||||
"Bash(mkfs*)",
|
||||
"Bash(dd if=*)",
|
||||
"Bash(shutdown*)",
|
||||
"Bash(reboot*)",
|
||||
] {
|
||||
if !deny.iter().any(|existing| existing == item) {
|
||||
deny.push(item.to_owned());
|
||||
}
|
||||
}
|
||||
deny
|
||||
}
|
||||
|
||||
fn claude_permission_entries(
|
||||
permissions: Option<&EffectivePermissions>,
|
||||
effect: Effect,
|
||||
) -> Vec<String> {
|
||||
let Some(permissions) = permissions else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for rule in permissions.rules() {
|
||||
if rule.effect() != effect {
|
||||
continue;
|
||||
}
|
||||
match rule.capability() {
|
||||
Capability::Read => push_path_entries(&mut out, "Read", rule),
|
||||
Capability::Write => {
|
||||
push_path_entries(&mut out, "Edit", rule);
|
||||
push_path_entries(&mut out, "Write", rule);
|
||||
}
|
||||
Capability::Delete => push_delete_entries(&mut out, rule),
|
||||
Capability::ExecuteBash => push_bash_entries(&mut out, rule),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn push_path_entries(out: &mut Vec<String>, capability: &str, rule: &PermissionRule) {
|
||||
if rule.paths().is_empty() {
|
||||
out.push(capability.to_owned());
|
||||
return;
|
||||
}
|
||||
for glob in rule.paths().globs() {
|
||||
out.push(format!("{capability}({})", glob.pattern()));
|
||||
}
|
||||
}
|
||||
|
||||
fn push_delete_entries(out: &mut Vec<String>, rule: &PermissionRule) {
|
||||
if rule.paths().is_empty() {
|
||||
out.push("Bash(rm *)".to_owned());
|
||||
return;
|
||||
}
|
||||
for glob in rule.paths().globs() {
|
||||
out.push(format!("Bash(rm {})", glob.pattern()));
|
||||
}
|
||||
}
|
||||
|
||||
fn push_bash_entries(out: &mut Vec<String>, rule: &PermissionRule) {
|
||||
if rule.commands().is_empty() {
|
||||
out.push("Bash".to_owned());
|
||||
return;
|
||||
}
|
||||
for cmd in rule.commands() {
|
||||
if cmd.effect != rule.effect() {
|
||||
continue;
|
||||
}
|
||||
out.push(format!("Bash({})", command_matcher_pattern(&cmd.matcher)));
|
||||
}
|
||||
}
|
||||
|
||||
fn command_matcher_pattern(matcher: &CommandMatcher) -> String {
|
||||
match matcher {
|
||||
CommandMatcher::Exact(value) => value.clone(),
|
||||
CommandMatcher::Prefix(value) => format!("{value}*"),
|
||||
CommandMatcher::Glob(glob) => glob.pattern().to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_string_array(items: &[String]) -> String {
|
||||
if items.is_empty() {
|
||||
return "[]".to_owned();
|
||||
}
|
||||
let body = items
|
||||
.iter()
|
||||
.map(|item| format!(" \"{}\"", json_escape(item)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n");
|
||||
format!("[\n{body}\n ]")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::permission::{resolve, PathScope, PermissionSet};
|
||||
use serde_json::Value;
|
||||
|
||||
fn ctx<'a>(root: &'a str, run_dir: &'a str) -> ProjectionContext<'a> {
|
||||
ProjectionContext {
|
||||
project_root: root,
|
||||
run_dir,
|
||||
}
|
||||
}
|
||||
|
||||
fn path_scope(patterns: &[&str]) -> PathScope {
|
||||
PathScope::new(patterns.iter().map(ToString::to_string)).unwrap()
|
||||
}
|
||||
|
||||
/// Builds an [`EffectivePermissions`] from a single (project) set via the
|
||||
/// domain API, exactly like the `permission` unit tests do.
|
||||
fn eff_with(rules: Vec<PermissionRule>, fallback: Posture) -> EffectivePermissions {
|
||||
resolve(Some(&PermissionSet::new(rules, fallback)), None).unwrap()
|
||||
}
|
||||
|
||||
/// Projects and returns the parsed `settings.local.json` value, asserting the
|
||||
/// projection's structural contract (1 Replace file, no args/env) along the way.
|
||||
fn project_json(eff: &EffectivePermissions, root: &str) -> Value {
|
||||
let proj = ClaudePermissionProjector.project(Some(eff), &ctx(root, "/run/agent"));
|
||||
assert!(proj.args.is_empty(), "Claude projection carries no args");
|
||||
assert!(proj.env.is_empty(), "Claude projection carries no env");
|
||||
assert_eq!(proj.files.len(), 1, "exactly one file projected");
|
||||
match &proj.files[0] {
|
||||
ProjectedFile::Replace { rel_path, contents } => {
|
||||
assert_eq!(rel_path, SETTINGS_REL_PATH);
|
||||
serde_json::from_str(contents).expect("the produced settings is valid JSON")
|
||||
}
|
||||
ProjectedFile::MergeToml { .. } => panic!("Claude must emit a Replace file"),
|
||||
}
|
||||
}
|
||||
|
||||
fn str_array(value: &Value) -> Vec<String> {
|
||||
value
|
||||
.as_array()
|
||||
.expect("array")
|
||||
.iter()
|
||||
.map(|v| v.as_str().expect("string").to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---- product invariant + ownership ----------------------------------
|
||||
|
||||
#[test]
|
||||
fn project_none_is_empty() {
|
||||
let proj = ClaudePermissionProjector.project(None, &ctx("/proj", "/run"));
|
||||
assert!(proj.files.is_empty());
|
||||
assert!(proj.args.is_empty());
|
||||
assert!(proj.env.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_replace_paths_is_the_settings_file() {
|
||||
assert_eq!(
|
||||
ClaudePermissionProjector.owned_replace_paths(),
|
||||
vec![SETTINGS_REL_PATH.to_owned()]
|
||||
);
|
||||
}
|
||||
|
||||
// ---- (1) posture → defaultMode --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn default_mode_maps_each_posture() {
|
||||
for (posture, mode) in [
|
||||
(Posture::Allow, "bypassPermissions"),
|
||||
(Posture::Ask, "acceptEdits"),
|
||||
(Posture::Deny, "plan"),
|
||||
] {
|
||||
let json = project_json(&eff_with(vec![], posture), "/proj");
|
||||
assert_eq!(
|
||||
json["permissions"]["defaultMode"], mode,
|
||||
"posture {posture:?} should map to defaultMode {mode}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- (2) deny-wins: deny entry surfaces in the deny list -------------
|
||||
|
||||
#[test]
|
||||
fn specific_deny_with_broad_allow_appears_in_deny_list() {
|
||||
let rules = vec![
|
||||
PermissionRule::file(Capability::Write, Effect::Deny, path_scope(&[".ideai/**"]))
|
||||
.unwrap(),
|
||||
PermissionRule::file(Capability::Write, Effect::Allow, path_scope(&["**"])).unwrap(),
|
||||
];
|
||||
let json = project_json(&eff_with(rules, Posture::Allow), "/proj");
|
||||
|
||||
let deny = str_array(&json["permissions"]["deny"]);
|
||||
// A Write capability fans out to both Edit(..) and Write(..) entries.
|
||||
assert!(deny.contains(&"Edit(.ideai/**)".to_owned()), "deny={deny:?}");
|
||||
assert!(deny.contains(&"Write(.ideai/**)".to_owned()), "deny={deny:?}");
|
||||
|
||||
let allow = str_array(&json["permissions"]["allow"]);
|
||||
assert!(
|
||||
allow.contains(&"Edit(**)".to_owned()) && allow.contains(&"Write(**)".to_owned()),
|
||||
"the broad allow stays in the allow list; allow={allow:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- (3) additionalDirectories carries the (escaped) project root ----
|
||||
|
||||
#[test]
|
||||
fn additional_directories_contains_project_root_escaped() {
|
||||
// A Windows-ish path with a backslash AND a quote exercises JSON escaping;
|
||||
// parsing it back must yield the original raw path verbatim.
|
||||
let root = r#"C:\Users\a"b\proj"#;
|
||||
let json = project_json(&eff_with(vec![], Posture::Allow), root);
|
||||
let dirs = str_array(&json["permissions"]["additionalDirectories"]);
|
||||
assert_eq!(dirs, vec![root.to_owned()]);
|
||||
}
|
||||
|
||||
// ---- (4) hard-coded destructive guardrails --------------------------
|
||||
|
||||
#[test]
|
||||
fn default_deny_guardrails_are_present() {
|
||||
let json = project_json(&eff_with(vec![], Posture::Allow), "/proj");
|
||||
let deny = str_array(&json["permissions"]["deny"]);
|
||||
for guard in [
|
||||
"Bash(sudo *)",
|
||||
"Bash(rm -rf /)",
|
||||
"Bash(rm -rf ~)",
|
||||
"Bash(rm -rf $HOME*)",
|
||||
"Bash(mkfs*)",
|
||||
"Bash(dd if=*)",
|
||||
"Bash(shutdown*)",
|
||||
"Bash(reboot*)",
|
||||
] {
|
||||
assert!(deny.contains(&guard.to_owned()), "missing guardrail {guard}; deny={deny:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- (5) valid JSON + expected static shape -------------------------
|
||||
|
||||
#[test]
|
||||
fn produced_settings_has_expected_static_shape() {
|
||||
// `project_json` already proved the document parses; assert the fixed keys.
|
||||
let json = project_json(&eff_with(vec![], Posture::Ask), "/proj");
|
||||
assert_eq!(json["enabledMcpjsonServers"][0], "idea");
|
||||
assert_eq!(json["skipDangerousModePermissionPrompt"], true);
|
||||
assert_eq!(json["sandbox"]["enabled"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_rules_fall_back_to_broad_default_allow() {
|
||||
let json = project_json(&eff_with(vec![], Posture::Allow), "/proj");
|
||||
let allow = str_array(&json["permissions"]["allow"]);
|
||||
assert_eq!(
|
||||
allow,
|
||||
vec![
|
||||
"Read".to_owned(),
|
||||
"Edit".to_owned(),
|
||||
"Write".to_owned(),
|
||||
"Bash".to_owned()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
190
crates/infrastructure/src/permission/codex.rs
Normal file
190
crates/infrastructure/src/permission/codex.rs
Normal file
@ -0,0 +1,190 @@
|
||||
//! Codex CLI permission projector (lot LP3-2).
|
||||
//!
|
||||
//! Produces the **permission-relevant** part of Codex's `config.toml`
|
||||
//! (`sandbox_mode` / `approval_policy`) plus the matching launch args
|
||||
//! (`--sandbox` / `--ask-for-approval`). The posture→mode derivation is extracted
|
||||
//! verbatim from the former `codex_sandbox_mode` / `codex_approval_policy` /
|
||||
//! `apply_codex_cli_permission_args` in `lifecycle.rs`.
|
||||
//!
|
||||
//! Unlike Claude's seed, Codex's `config.toml` is **co-owned** (it also carries the
|
||||
//! `mcp_servers.idea` table and the `projects.*` trust entries, which are MCP/trust
|
||||
//! concerns, not permissions). The projector therefore emits a
|
||||
//! [`ProjectedFile::MergeToml`] limited to the two permission keys it manages —
|
||||
//! everything else in the file is preserved by the fold, and the file is **never**
|
||||
//! deleted on swap (hence an empty `owned_replace_paths`).
|
||||
|
||||
use domain::permission::{
|
||||
EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
|
||||
ProjectionContext, ProjectorKey,
|
||||
};
|
||||
|
||||
use super::toml_string;
|
||||
|
||||
/// Run-dir-relative path of Codex's `config.toml`. Codex reads its config from
|
||||
/// `$CODEX_HOME/config.toml`; IdeA isolates `CODEX_HOME` to `{runDir}/.codex`, so
|
||||
/// the file lives at `.codex/config.toml` relative to the run dir.
|
||||
const CONFIG_REL_PATH: &str = ".codex/config.toml";
|
||||
|
||||
/// The two top-level keys this projector manages in `config.toml`. Everything else
|
||||
/// (MCP table, trust entries, user keys) is preserved by the merge.
|
||||
const MANAGED_KEYS: [&str; 2] = ["sandbox_mode", "approval_policy"];
|
||||
|
||||
/// Projects [`EffectivePermissions`] into Codex's sandbox/approval config + args.
|
||||
///
|
||||
/// Pure: `project` only computes the plan; the launch path merges the TOML fragment
|
||||
/// and appends the args.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct CodexPermissionProjector;
|
||||
|
||||
impl PermissionProjector for CodexPermissionProjector {
|
||||
fn key(&self) -> ProjectorKey {
|
||||
ProjectorKey::Codex
|
||||
}
|
||||
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
_ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
// Product invariant: nothing posed ⇒ nothing projected. Codex keeps its
|
||||
// native sandbox/approval defaults (no args, no managed keys written).
|
||||
let Some(permissions) = eff else {
|
||||
return PermissionProjection::empty();
|
||||
};
|
||||
let sandbox = codex_sandbox_mode(permissions);
|
||||
let approval = codex_approval_policy(permissions);
|
||||
|
||||
// Permission-only TOML fragment (escaped exactly like the former
|
||||
// `set_top_level_toml_value`). The mcp_servers/trust tables are NOT a
|
||||
// permission concern and stay with the MCP wiring (LP3-3).
|
||||
let contents = format!(
|
||||
"sandbox_mode = {}\napproval_policy = {}\n",
|
||||
toml_string(sandbox),
|
||||
toml_string(approval),
|
||||
);
|
||||
|
||||
PermissionProjection {
|
||||
files: vec![ProjectedFile::MergeToml {
|
||||
rel_path: CONFIG_REL_PATH.to_owned(),
|
||||
managed_tables: Vec::new(),
|
||||
managed_keys: MANAGED_KEYS.iter().map(|k| (*k).to_owned()).collect(),
|
||||
contents,
|
||||
}],
|
||||
args: vec![
|
||||
"--sandbox".to_owned(),
|
||||
sandbox.to_owned(),
|
||||
"--ask-for-approval".to_owned(),
|
||||
approval.to_owned(),
|
||||
],
|
||||
env: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn owned_replace_paths(&self) -> Vec<String> {
|
||||
// config.toml is co-owned (MergeToml), never an owned Replace file.
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_sandbox_mode(permissions: &EffectivePermissions) -> &'static str {
|
||||
match permissions.fallback() {
|
||||
Posture::Deny => "read-only",
|
||||
Posture::Ask | Posture::Allow => "workspace-write",
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_approval_policy(permissions: &EffectivePermissions) -> &'static str {
|
||||
match permissions.fallback() {
|
||||
Posture::Allow => "never",
|
||||
Posture::Ask | Posture::Deny => "on-request",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::permission::{resolve, PermissionSet};
|
||||
|
||||
fn ctx<'a>() -> ProjectionContext<'a> {
|
||||
ProjectionContext {
|
||||
project_root: "/proj",
|
||||
run_dir: "/run/agent",
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an [`EffectivePermissions`] with the given fallback posture via the
|
||||
/// domain API (only the fallback drives Codex's sandbox/approval derivation).
|
||||
fn eff(fallback: Posture) -> EffectivePermissions {
|
||||
resolve(Some(&PermissionSet::new(vec![], fallback)), None).unwrap()
|
||||
}
|
||||
|
||||
// ---- product invariant + ownership ----------------------------------
|
||||
|
||||
#[test]
|
||||
fn project_none_is_empty() {
|
||||
let proj = CodexPermissionProjector.project(None, &ctx());
|
||||
assert!(proj.files.is_empty());
|
||||
assert!(proj.args.is_empty());
|
||||
assert!(proj.env.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_replace_paths_is_empty() {
|
||||
// config.toml is co-owned (MergeToml) ⇒ nothing to clean up on swap.
|
||||
assert!(CodexPermissionProjector.owned_replace_paths().is_empty());
|
||||
}
|
||||
|
||||
// ---- (6) posture → sandbox_mode / approval_policy + (7) args↔contents
|
||||
// coherence + MergeToml shape -------------------------------
|
||||
|
||||
#[test]
|
||||
fn posture_maps_sandbox_and_approval_in_file_and_args() {
|
||||
for (posture, sandbox, approval) in [
|
||||
(Posture::Deny, "read-only", "on-request"),
|
||||
(Posture::Ask, "workspace-write", "on-request"),
|
||||
(Posture::Allow, "workspace-write", "never"),
|
||||
] {
|
||||
let proj = CodexPermissionProjector.project(Some(&eff(posture)), &ctx());
|
||||
assert!(proj.env.is_empty(), "Codex projection carries no env");
|
||||
|
||||
// -- The single MergeToml file, with the two managed permission keys.
|
||||
assert_eq!(proj.files.len(), 1, "exactly one file projected");
|
||||
match &proj.files[0] {
|
||||
ProjectedFile::MergeToml {
|
||||
rel_path,
|
||||
managed_tables,
|
||||
managed_keys,
|
||||
contents,
|
||||
} => {
|
||||
assert_eq!(rel_path, CONFIG_REL_PATH);
|
||||
assert!(managed_tables.is_empty(), "no managed tables");
|
||||
assert_eq!(
|
||||
managed_keys,
|
||||
&vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()]
|
||||
);
|
||||
assert!(
|
||||
contents.contains(&format!("sandbox_mode = \"{sandbox}\"")),
|
||||
"posture {posture:?}: contents={contents:?}"
|
||||
);
|
||||
assert!(
|
||||
contents.contains(&format!("approval_policy = \"{approval}\"")),
|
||||
"posture {posture:?}: contents={contents:?}"
|
||||
);
|
||||
}
|
||||
ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
|
||||
}
|
||||
|
||||
// -- (7) Args reflect the SAME values as the TOML, in CLI order.
|
||||
assert_eq!(
|
||||
proj.args,
|
||||
vec![
|
||||
"--sandbox".to_owned(),
|
||||
sandbox.to_owned(),
|
||||
"--ask-for-approval".to_owned(),
|
||||
approval.to_owned(),
|
||||
],
|
||||
"posture {posture:?}: args must mirror the TOML values"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user