merge(develop): intègre permissions/sandbox OS + pont Codex inter-agents

Intègre dans develop le travail terminé et testé porté par
wip/p8c-checkpoint-before-codex :
- enforcement OS des permissions (Landlock) bout-en-bout (LP4-0→LP4-4)
- pont Codex inter-agents + readiness/heartbeat
- layouts UI responsive + setup agent Git
Tests crates cœur (domain/application/infrastructure) verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 13:56:34 +02:00
132 changed files with 16395 additions and 1835 deletions

View File

@ -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
}
]
}

View File

116
.ideai/agents/git.md Normal file
View 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
View File

View File

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@ -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)."}

View File

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

View File

@ -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"}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,3 +3,5 @@
- [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.

View 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]].

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

38
.ideai/permissions.json Normal file
View 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"
}
}

View File

@ -2027,4 +2027,137 @@ 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 ~5080 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 12 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.
---
*Document maintenu par l'Agent Architecture — base du jalon « cadrage architecture » avant tout code applicatif.*

View File

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

32
Cargo.lock generated
View File

@ -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,6 +1943,7 @@ dependencies = [
"domain",
"fastembed",
"git2",
"landlock",
"notify",
"portable-pty",
"reqwest 0.12.28",
@ -2131,6 +2152,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"

View File

@ -175,11 +175,17 @@ 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. 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,
}
}

View File

@ -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,11 @@ 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;
let output = state
.launch_agent
.execute(LaunchAgentInput {
@ -1193,7 +1281,11 @@ pub async fn agent_send(
let bridge = std::sync::Arc::clone(&state.chat_bridge);
std::thread::spawn(move || {
for event in stream {
let chunk = crate::chat::chunk_from_event(event);
// 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,31 +1298,6 @@ pub async fn agent_send(
Ok(())
}
/// `submit_agent_input` — the human **Envoyer** path (cadrage C4 §4.2).
///
/// 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.
///
/// # 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).
#[tauri::command]
pub async fn submit_agent_input(
request: SubmitAgentInputRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
state
.orchestrator_service
.submit_human_input(&project, agent_id, request.text)
.await
.map(|_| ())
.map_err(ErrorDto::from)
}
/// `interrupt_agent` — the **Interrompre** path (cadrage C4 §4.2).
///
/// Routes to [`OrchestratorService::interrupt_agent`], which `preempt`s the agent's
@ -1255,6 +1322,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).
///

View File

@ -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)
// ---------------------------------------------------------------------------

View File

@ -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 {
@ -195,6 +227,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 +277,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 +297,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,
@ -343,3 +414,40 @@ 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");
}
}

View File

@ -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,9 @@ pub fn run() {
commands::launch_agent,
commands::change_agent_profile,
commands::agent_send,
commands::submit_agent_input,
commands::interrupt_agent,
commands::delegation_delivered,
commands::set_front_attached,
commands::reattach_agent_chat,
commands::close_agent_session,
commands::list_resumable_agents,

View File

@ -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(());
// Source closed: drain and hand the writer back for half-close.
writer
.flush()
.await
.map_err(|e| format!("{dst} flush failed: {e}"))?;
return Ok(writer);
}
lp_write
.write_all(cli_line.as_bytes())
writer
.write_all(line.as_bytes())
.await
.map_err(|e| format!("loopback write failed: {e}"))?;
lp_write
.map_err(|e| format!("{dst} write failed: {e}"))?;
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());
}
cli_out
.write_all(lp_line.as_bytes())
.await
.map_err(|e| format!("stdout write failed: {e}"))?;
cli_out
.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,
)
.await
.unwrap();
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,12 +577,9 @@ 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),
)
.await
.expect("connect_loopback must not hang");
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,13 +634,10 @@ 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),
)
.await
.expect("no hang")
.expect("connect to live endpoint");
let conn = tokio::time::timeout(Duration::from_secs(10), connect_loopback(&endpoint))
.await
.expect("no hang")
.expect("connect to live endpoint");
let (lp_read, lp_write) = tokio::io::split(conn);
tokio::time::timeout(
@ -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}");

View File

@ -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")
.ok()
.or_else(|| std::env::current_exe().ok().map(|p| p.to_string_lossy().into_owned()))
std::env::var("APPIMAGE").ok().or_else(|| {
std::env::current_exe()
.ok()
.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

View File

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

View File

@ -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();

View File

@ -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();

View File

@ -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());

View File

@ -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);
}

View File

@ -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");
assert_eq!(
mcp.config,
McpConfigStrategy::ConfigFile { target: ".mcp.json".to_owned() },
"profile `{slug}` should declare `.mcp.json`"
);
assert_eq!(mcp.transport, McpTransport::Stdio);
}
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()
},
"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

View File

@ -16,22 +16,20 @@ mod usecases;
pub(crate) use lifecycle::unique_md_path;
pub(crate) use lifecycle::ReattachDecision;
pub use structured::send_blocking;
pub use structured::{drain_with_readiness, send_blocking};
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,
ProviderSessionProvider,
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,

View File

@ -14,7 +14,10 @@
use std::time::Duration;
use domain::input::InputMediator;
use domain::ids::AgentId;
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent};
use domain::readiness::{ReadinessPolicy, ReadinessSignal};
/// Envoie `prompt` à la session vivante puis **draine le flux de réponse jusqu'au
/// [`ReplyEvent::Final`]**, et retourne son contenu agrégé.
@ -39,14 +42,83 @@ pub async fn send_blocking(
session: &dyn AgentSession,
prompt: &str,
timeout: Option<Duration>,
) -> Result<String, AgentSessionError> {
drain_bounded_events(session, prompt, timeout, |_event| {}, |_signal| {}).await
}
/// 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).
pub async fn drain_with_readiness(
session: &dyn AgentSession,
prompt: &str,
timeout: Option<Duration>,
mediator: &dyn InputMediator,
agent: AgentId,
) -> Result<String, 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| {
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<String, 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,
}
}
@ -56,18 +128,124 @@ pub async fn send_blocking(
/// 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`].
///
/// Chaque événement est classé par [`ReadinessPolicy`] et le signal éventuel est
/// remonté à `on_signal` (le `Final` ⇒ [`ReadinessSignal::TurnEnded`]). Deltas,
/// activités et heartbeats sont non terminaux ⇒ ignorés par le rendez-vous synchrone.
async fn drain_to_final(
session: &dyn AgentSession,
prompt: &str,
mut on_event: impl FnMut(&ReplyEvent),
mut on_signal: impl FnMut(ReadinessSignal),
) -> Result<String, AgentSessionError> {
let stream = session.send(prompt).await?;
for event in stream {
// 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);
}
if let ReplyEvent::Final { content } = event {
return Ok(content);
}
// TextDelta / ToolActivity : ignorés par le rendez-vous synchrone.
// TextDelta / ToolActivity / Heartbeat : non terminaux, ignorés ici.
}
Err(AgentSessionError::Io(
"le flux de réponse s'est terminé sans événement Final".to_string(),
))
}
#[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)"
);
}
}

View File

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

View File

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

View File

@ -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,20 @@ 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, ProviderSessionProvider,
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, reference_profile_id, reference_profiles, selectable_reference_profiles,
send_blocking, 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, StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
AGENT_MEMORY_RECALL_BUDGET,
};
pub use conversation::RecordTurn;
pub use embedder::{
@ -77,6 +79,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,

View File

@ -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),

View File

@ -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,21 +968,145 @@ 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!(
"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))
}
// 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) => 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.
///
/// The operator types into IdeA's mediated input; this resolves the `User↔Agent`
@ -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,20 +1392,92 @@ impl OrchestratorService {
})
.await?;
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"
))
})?;
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"
))
})?;
// 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"
))
})
}
/// Binds `conversation` to `session` in both the terminal registry (fast
@ -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"
);
}
}

View 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>,
}

View File

@ -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()

View File

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

View File

@ -30,24 +30,29 @@ use domain::events::DomainEvent;
use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::layout::Workspace;
use domain::markdown::MarkdownDoc;
use domain::permission::{
EffectivePermissions, PermissionProjection, PermissionProjector, ProjectedFile,
ProjectionContext, ProjectorKey,
};
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall,
OutputStream, PreparedContext, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort,
RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
OutputStream, PermissionStore, PreparedContext, ProfileStore, ProjectStore, PtyError,
PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
};
use domain::profile::{AgentProfile, ContextInjection};
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
use domain::{
LayoutId, LayoutNode, LayoutTree, LeafCell, MemoryIndexEntry, NodeId, PtySize, SessionId,
SessionKind, SkillId,
LayoutId, LayoutNode, LayoutTree, LeafCell, MemoryIndexEntry, NodeId, PermissionSet, Posture,
ProjectPermissions, PtySize, SessionId, SessionKind, SkillId,
};
use uuid::Uuid;
use application::{
ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, TerminalSessions,
ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, PermissionProjectorRegistry,
TerminalSessions,
};
// ---------------------------------------------------------------------------
@ -241,6 +246,8 @@ struct FakeFsInner {
files: HashMap<String, Vec<u8>>,
dirs: HashSet<String>,
write_count: usize,
/// Paths passed to `remove_file`, in order (lot LP3-4 cleanup assertions).
removed: Vec<String>,
}
#[derive(Default, Clone)]
@ -262,6 +269,14 @@ impl FakeFs {
fn write_count(&self) -> usize {
self.0.lock().unwrap().write_count
}
/// Whether a file is currently present in the fake's state.
fn has_file(&self, path: &str) -> bool {
self.0.lock().unwrap().files.contains_key(path)
}
/// The ordered list of paths passed to `remove_file` (lot LP3-4).
fn removed(&self) -> Vec<String> {
self.0.lock().unwrap().removed.clone()
}
}
#[async_trait]
@ -289,6 +304,15 @@ impl FileSystem for FakeFs {
self.0.lock().unwrap().dirs.insert(path.as_str().to_owned());
Ok(())
}
/// Overrides the no-op default (lot LP3-4): records the path and actually
/// removes it from the fake's state, so a deletion is assertable. Idempotent —
/// removing an absent file still succeeds (best-effort cleanup contract).
async fn remove_file(&self, path: &RemotePath) -> Result<(), FsError> {
let mut inner = self.0.lock().unwrap();
inner.removed.push(path.as_str().to_owned());
inner.files.remove(path.as_str());
Ok(())
}
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
Ok(Vec::new())
}
@ -339,6 +363,7 @@ impl AgentRuntime for FakeRuntime {
context_plan: Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
sandbox: None,
})
}
}
@ -368,6 +393,16 @@ impl FakePty {
fn kills(&self) -> Vec<SessionId> {
self.kills.lock().unwrap().clone()
}
/// Args of the most recent spawn (used to assert folded projection args on a
/// relaunch, lot LP3-4).
fn last_spawn_args(&self) -> Vec<String> {
self.spawns
.lock()
.unwrap()
.last()
.map(|s| s.args.clone())
.unwrap_or_default()
}
}
#[async_trait]
@ -675,10 +710,7 @@ impl domain::HandoffStore for FakeHandoffs {
}
impl application::HandoffProvider for FakeHandoffs {
fn handoff_store_for(
&self,
_root: &ProjectPath,
) -> Option<Arc<dyn domain::HandoffStore>> {
fn handoff_store_for(&self, _root: &ProjectPath) -> Option<Arc<dyn domain::HandoffStore>> {
Some(Arc::new(self.clone()))
}
}
@ -771,6 +803,492 @@ fn change_input(agent_id: AgentId, profile_id: ProfileId) -> ChangeAgentProfileI
}
}
// ---------------------------------------------------------------------------
// LP3-4 — permission-file cleanup at swap: fakes, projectors, fixture
// ---------------------------------------------------------------------------
/// In-memory [`PermissionStore`] returning a fixed document (so the relaunch's
/// projection resolves a non-empty `eff` and actually writes the new CLI config).
struct FakePermissionStore(ProjectPermissions);
#[async_trait]
impl PermissionStore for FakePermissionStore {
async fn load_permissions(&self, _project: &Project) -> Result<ProjectPermissions, StoreError> {
Ok(self.0.clone())
}
async fn save_permissions(
&self,
_project: &Project,
_permissions: &ProjectPermissions,
) -> Result<(), StoreError> {
Ok(())
}
}
/// Faithful Claude projector double (LP3-2 mapping): owns the `Replace` settings
/// seed `.claude/settings.local.json`.
struct FakeClaudeProjector;
impl PermissionProjector for FakeClaudeProjector {
fn key(&self) -> ProjectorKey {
ProjectorKey::Claude
}
fn project(
&self,
eff: Option<&EffectivePermissions>,
ctx: &ProjectionContext,
) -> PermissionProjection {
if eff.is_none() {
return PermissionProjection::empty();
}
let contents = format!(
"{{\"permissions\":{{\"additionalDirectories\":[\"{}\"]}}}}\n",
ctx.project_root
);
PermissionProjection {
files: vec![ProjectedFile::Replace {
rel_path: ".claude/settings.local.json".to_owned(),
contents,
}],
args: Vec::new(),
env: Vec::new(),
}
}
fn owned_replace_paths(&self) -> Vec<String> {
vec![".claude/settings.local.json".to_owned()]
}
}
/// Faithful Codex projector double (LP3-2 mapping): a co-owned `MergeToml` + the
/// `--sandbox`/`--ask-for-approval` args. Owns **no** `Replace` file.
struct FakeCodexProjector;
impl PermissionProjector for FakeCodexProjector {
fn key(&self) -> ProjectorKey {
ProjectorKey::Codex
}
fn project(
&self,
eff: Option<&EffectivePermissions>,
_ctx: &ProjectionContext,
) -> PermissionProjection {
if eff.is_none() {
return PermissionProjection::empty();
}
PermissionProjection {
files: vec![ProjectedFile::MergeToml {
rel_path: ".codex/config.toml".to_owned(),
managed_tables: Vec::new(),
managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()],
contents: "sandbox_mode = \"workspace-write\"\napproval_policy = \"never\"\n"
.to_owned(),
}],
args: vec![
"--sandbox".to_owned(),
"workspace-write".to_owned(),
"--ask-for-approval".to_owned(),
"never".to_owned(),
],
env: Vec::new(),
}
}
fn owned_replace_paths(&self) -> Vec<String> {
Vec::new()
}
}
/// A registry carrying both faithful projector doubles.
fn full_registry() -> Arc<PermissionProjectorRegistry> {
Arc::new(
PermissionProjectorRegistry::new()
.with(Arc::new(FakeClaudeProjector))
.with(Arc::new(FakeCodexProjector)),
)
}
/// A Claude profile (convention `CLAUDE.md` ⇒ legacy Claude selection).
fn claude_profile(id: ProfileId) -> AgentProfile {
profile(id)
}
/// A Codex profile carrying an explicit `ProjectorKey::Codex` (and the Codex
/// structured adapter, so the legacy fallback would also select Codex).
fn codex_profile(id: ProfileId) -> AgentProfile {
AgentProfile::new(
id,
"OpenAI Codex CLI",
"codex",
Vec::new(),
ContextInjection::convention_file("AGENTS.md").unwrap(),
Some("codex --version".to_owned()),
"{agentRunDir}",
None,
)
.unwrap()
.with_structured_adapter(StructuredAdapter::Codex)
.with_projector(ProjectorKey::Codex)
}
/// The (stable) run dir of `agent` under the test project root.
fn run_dir_of(agent: &AgentId) -> String {
format!("{ROOT}/.ideai/run/{agent}")
}
/// Wires a swap fixture with a projector registry on BOTH the swap and the
/// composed relaunch, plus an optional permission document on the relaunch (so
/// the relaunch's projection is non-empty). Mirrors [`fixture_with_profiles`].
async fn fixture_with_projection(
agent: &Agent,
profiles: Vec<AgentProfile>,
registry: Arc<PermissionProjectorRegistry>,
perm_doc: Option<ProjectPermissions>,
) -> Fixture {
let contexts = FakeContexts::with_agent(agent, "# persona");
let profiles = FakeProfiles::new(profiles);
let store = FakeStore::default();
let fs = FakeFs::default();
let pty = FakePty::new(sid(777));
let sessions = Arc::new(TerminalSessions::new());
let bus = SpyBus::default();
let runtime = FakeRuntime::new();
let handoffs = FakeHandoffs::default();
store.save(&project()).await;
let mut launch = LaunchAgent::new(
Arc::new(contexts.clone()),
Arc::new(profiles.clone()),
Arc::new(runtime.clone()),
Arc::new(fs.clone()),
Arc::new(pty.clone()),
Arc::new(FakeSkills),
Arc::clone(&sessions),
Arc::new(bus.clone()),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall),
None,
)
.with_handoff_provider(Arc::new(handoffs.clone()))
.with_permission_projectors(Arc::clone(&registry));
if let Some(doc) = perm_doc {
launch = launch.with_permission_store(Arc::new(FakePermissionStore(doc)));
}
let swap = ChangeAgentProfile::new(
Arc::new(contexts.clone()),
Arc::new(profiles),
Arc::new(store),
Arc::new(fs.clone()),
Arc::clone(&sessions),
Arc::new(pty.clone()),
Arc::new(launch),
Arc::new(bus.clone()),
)
.with_permission_projectors(Arc::clone(&registry));
Fixture {
swap,
contexts,
fs,
pty,
bus,
sessions,
runtime,
handoffs,
}
}
/// The full `permissions.json` posing a project-level `Allow` policy ⇒ a relaunch
/// resolves `Some(eff)` and the projector writes the new CLI config.
fn allow_perm_doc() -> ProjectPermissions {
ProjectPermissions::new(Some(PermissionSet::new(vec![], Posture::Allow)), Vec::new())
}
const CLAUDE_SEED_REL: &str = ".claude/settings.local.json";
const CODEX_CONFIG_REL: &str = ".codex/config.toml";
/// Seeds a live agent session + a persisted layout cell hosting it, so the swap
/// reaches its relaunch step (kill → relaunch in the same cell).
fn seed_live_for_relaunch(f: &Fixture, agent: &AgentId) {
let host = nid(1);
seed_live_agent_session(&f.sessions, *agent, host, sid(42));
let tree = LayoutTree::single(agent_leaf(host, Some(*agent), Some(&pair_uuid()), true));
seed_layouts(&f.fs, lid(1), &tree);
}
/// A stable UUID-shaped pair id used by the relaunch scenarios.
fn pair_uuid() -> String {
Uuid::from_u128(0xABCD).to_string()
}
// ===========================================================================
// LP3-4 — cleanup of orphan permission files at a cross-profile swap
// ===========================================================================
/// (1) **Claude→Codex (phare)**: the pre-existing `.claude/settings.local.json`
/// is removed at the swap, and the relaunch projects the Codex sandbox config +
/// args. The orphan Replace file of the old projector is cleaned up; the new
/// projector (Codex) owns no Replace file, so nothing protects it from deletion.
#[tokio::test]
async fn swap_claude_to_codex_removes_claude_seed_and_projects_codex() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture_with_projection(
&agent,
vec![claude_profile(pid(1)), codex_profile(pid(2))],
full_registry(),
Some(allow_perm_doc()),
)
.await;
let run_dir = run_dir_of(&agent.id);
let seed_path = format!("{run_dir}/{CLAUDE_SEED_REL}");
let codex_path = format!("{run_dir}/{CODEX_CONFIG_REL}");
// The old Claude seed exists in the (stable) run dir before the swap.
f.fs.put(&seed_path, b"{\"permissions\":{}}");
seed_live_for_relaunch(&f, &agent.id);
f.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("swap succeeds");
// The orphan Claude seed was deleted (recorded AND gone from the FS state).
assert!(
f.fs.removed().iter().any(|p| p == &seed_path),
"the orphan .claude seed must be removed; removed={:?}",
f.fs.removed()
);
assert!(!f.fs.has_file(&seed_path), "the seed is gone from the FS state");
// The relaunch projected the Codex sandbox config + args.
assert!(
f.fs.has_file(&codex_path),
"the relaunch must project the Codex config"
);
let toml = String::from_utf8(f.fs.read_file(&codex_path).unwrap()).unwrap();
assert!(toml.contains("sandbox_mode = \"workspace-write\""), "{toml}");
assert!(
f.pty.last_spawn_args().contains(&"--sandbox".to_owned()),
"sandbox args folded into the relaunch spawn: {:?}",
f.pty.last_spawn_args()
);
}
/// (2) **Claude→Claude**: `owned(old) owned(new)` is empty, so the seed is NOT
/// removed (it is re-clobbered by the relaunch, never deleted).
#[tokio::test]
async fn swap_claude_to_claude_does_not_remove_seed() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture_with_projection(
&agent,
vec![claude_profile(pid(1)), claude_profile(pid(2))],
full_registry(),
Some(allow_perm_doc()),
)
.await;
let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));
f.fs.put(&seed_path, b"{\"permissions\":{}}");
seed_live_for_relaunch(&f, &agent.id);
f.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("swap succeeds");
assert!(
!f.fs.removed().iter().any(|p| p == &seed_path),
"same-family swap must NOT delete the shared seed; removed={:?}",
f.fs.removed()
);
// It is still present (re-clobbered by the relaunch's projection).
assert!(f.fs.has_file(&seed_path), "the seed survives the same-family swap");
}
/// (3) **Codex→Claude**: Codex owns no Replace file ⇒ nothing is removed on the
/// cleanup side; the relaunch writes the Claude seed; the co-owned
/// `.codex/config.toml` is never deleted.
#[tokio::test]
async fn swap_codex_to_claude_removes_nothing_and_keeps_codex_config() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture_with_projection(
&agent,
vec![codex_profile(pid(1)), claude_profile(pid(2))],
full_registry(),
Some(allow_perm_doc()),
)
.await;
let run_dir = run_dir_of(&agent.id);
let codex_path = format!("{run_dir}/{CODEX_CONFIG_REL}");
let seed_path = format!("{run_dir}/{CLAUDE_SEED_REL}");
// A pre-existing co-owned Codex config that must survive the swap.
f.fs.put(&codex_path, b"sandbox_mode = \"read-only\"\n");
seed_live_for_relaunch(&f, &agent.id);
f.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("swap succeeds");
// Nothing removed (Codex has no Replace-owned path).
assert!(
f.fs.removed().is_empty(),
"Codex→Claude removes no permission file; removed={:?}",
f.fs.removed()
);
// The co-owned Codex config is untouched by cleanup…
assert!(f.fs.has_file(&codex_path), "the .codex/config.toml is never deleted");
assert!(
!f.fs.removed().iter().any(|p| p == &codex_path),
"the .codex/config.toml is not in the removed list"
);
// …and the relaunch projected the new Claude seed.
assert!(f.fs.has_file(&seed_path), "the relaunch writes the Claude seed");
}
/// (4a) **No-op (no registry)**: without a projector registry wired on the swap,
/// no cleanup runs at all — even on a Claude→Codex swap with the seed present.
#[tokio::test]
async fn swap_without_registry_removes_nothing() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
// The default fixture wires NO projector registry on the swap.
let f = fixture_with_profiles(&agent, vec![claude_profile(pid(1)), codex_profile(pid(2))]).await;
let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));
f.fs.put(&seed_path, b"{\"permissions\":{}}");
f.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("swap succeeds");
assert!(
f.fs.removed().is_empty(),
"no registry ⇒ no cleanup; removed={:?}",
f.fs.removed()
);
assert!(f.fs.has_file(&seed_path), "the seed is left untouched");
}
/// (4b) **No-op (previous profile deleted)**: if the old profile id is no longer
/// in the store, the cleanup is skipped (the old projector can't be resolved) and
/// the swap still succeeds.
#[tokio::test]
async fn swap_with_unknown_previous_profile_skips_cleanup() {
// Agent records pid(1), but the store only knows pid(2) (the target) and pid(3):
// pid(1) was deleted between launch and swap.
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture_with_projection(
&agent,
vec![codex_profile(pid(2)), claude_profile(pid(3))],
full_registry(),
Some(allow_perm_doc()),
)
.await;
let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));
f.fs.put(&seed_path, b"{\"permissions\":{}}");
f.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("swap succeeds even with an unknown previous profile");
assert!(
f.fs.removed().is_empty(),
"unknown previous profile ⇒ cleanup skipped; removed={:?}",
f.fs.removed()
);
}
/// (5) **Best-effort**: when the orphan seed file does NOT exist on disk, the
/// delete is still attempted (idempotent) and the swap succeeds without error.
#[tokio::test]
async fn swap_claude_to_codex_succeeds_when_seed_absent() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture_with_projection(
&agent,
vec![claude_profile(pid(1)), codex_profile(pid(2))],
full_registry(),
Some(allow_perm_doc()),
)
.await;
// Intentionally do NOT seed `.claude/settings.local.json`.
let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));
f.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("swap succeeds even when the orphan file is already gone");
// The delete was attempted (best-effort, idempotent) on the missing path.
assert!(
f.fs.removed().iter().any(|p| p == &seed_path),
"a remove was still attempted on the absent seed; removed={:?}",
f.fs.removed()
);
}
/// (6) **Non-regression P8d**: the cleanup must not disturb the preserved pair id.
/// A live Claude→Codex swap with a handoff seeded under the leaf's pair id still
/// re-injects that handoff into the new engine's convention file (proving the
/// pair `conversation_id` survived the cleanup step unchanged).
#[tokio::test]
async fn swap_with_cleanup_preserves_pair_id_and_handoff() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture_with_projection(
&agent,
vec![claude_profile(pid(1)), codex_profile(pid(2))],
full_registry(),
Some(allow_perm_doc()),
)
.await;
// Seed the orphan Claude seed (so cleanup actually fires) and a handoff under
// the leaf's pair id.
let run_dir = run_dir_of(&agent.id);
f.fs.put(&format!("{run_dir}/{CLAUDE_SEED_REL}"), b"{}");
let pair = pair_uuid();
f.handoffs.seed(&pair, "État au dernier tour : LP3-4.", Some("objectif"));
let host = nid(1);
seed_live_agent_session(&f.sessions, agent.id, host, sid(42));
let tree = LayoutTree::single(agent_leaf(host, Some(agent.id), Some(&pair), true));
seed_layouts(&f.fs, lid(1), &tree);
f.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("swap succeeds");
// The cleanup fired…
assert!(
f.fs.removed()
.iter()
.any(|p| p == &format!("{run_dir}/{CLAUDE_SEED_REL}")),
"the orphan seed was cleaned up"
);
// …and the pair id was preserved on the persisted leaf (P8d invariant).
let (conv, running) = leaf_state(&f.fs, host).expect("leaf persisted");
assert_eq!(conv.as_deref(), Some(pair.as_str()), "pair id preserved");
assert!(!running, "engine running flag reset by invalidate_engine_link");
// …and the handoff (keyed by that pair id) was re-injected into the new engine's
// convention file — proving the relaunch threaded the preserved pair id. (The
// shared FakeRuntime always materialises the convention file as `CLAUDE.md`.)
let conv_md = String::from_utf8(
f.fs.read_file(&format!("{run_dir}/CLAUDE.md"))
.expect("the relaunch wrote the new engine's convention file"),
)
.unwrap();
assert!(
conv_md.contains("LP3-4"),
"handoff re-injected under the preserved pair id: {conv_md}"
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@ -860,7 +1378,11 @@ async fn success_mutates_manifest_to_new_profile() {
.await
.expect("swap succeeds");
assert_eq!(out.agent.profile_id, pid(2), "returned agent carries new profile");
assert_eq!(
out.agent.profile_id,
pid(2),
"returned agent carries new profile"
);
assert_eq!(
f.contexts.profile_of(&agent.id),
Some(pid(2)),
@ -1017,7 +1539,10 @@ async fn live_agent_is_killed_and_relaunched_in_same_cell() {
assert_eq!(f.pty.spawn_count(), 1, "the new engine spawns once");
// The relaunched session is returned and pinned on the SAME cell N.
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), "relaunch adopts the new PTY id");
// The relaunched session is registered and tagged for this agent.
assert!(matches!(
@ -1111,8 +1636,11 @@ async fn live_swap_relaunches_with_preserved_pair_id_and_no_engine_resume() {
// A UUID-shaped pair id (so resolve_handoff can parse it) and a seeded handoff.
let pair = "11111111-1111-1111-1111-111111111111";
f.handoffs
.seed(pair, "Résumé : on a fini l'étape 2.", Some("Livrer le lot P8d"));
f.handoffs.seed(
pair,
"Résumé : on a fini l'étape 2.",
Some("Livrer le lot P8d"),
);
// Live agent on cell N with a foreign engine resumable cache.
let host = nid(5);
@ -1138,7 +1666,10 @@ async fn live_swap_relaunches_with_preserved_pair_id_and_no_engine_resume() {
// The old PTY was killed and a single relaunch happened in the SAME cell.
assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed");
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"
);
// The pair id is preserved on the persisted leaf; the engine cache is cleared.
assert_eq!(
@ -1171,7 +1702,10 @@ async fn live_swap_relaunches_with_preserved_pair_id_and_no_engine_resume() {
// The old engine resumable is NEVER replayed: the relaunch's SessionPlan is
// None (providers.json[new provider] is empty ⇒ fresh engine, fidelity carried
// by the handoff). It is emphatically NOT Resume{"engine-old"}.
let plan = f.runtime.last_plan().expect("the relaunch prepared an invocation");
let plan = f
.runtime
.last_plan()
.expect("the relaunch prepared an invocation");
assert_eq!(
plan,
SessionPlan::None,

View File

@ -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());

View 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)"
);
}

View File

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

View File

@ -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")))
.await
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,21 +2147,30 @@ 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")))
.await
svc.dispatch(
&project(),
cmd(&ask_from_agent_json(aid(1), "beta", "to B")),
)
.await
});
await_until(|| fx.mailbox.pending(&aid(2)) == 1).await;
// 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,8 +2191,11 @@ 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")))
.await
svc.dispatch(
&project(),
cmd(&ask_from_agent_json(aid(1), "beta", "to B")),
)
.await
});
await_until(|| fx.mailbox.pending(&aid(2)) == 1).await;
fx.service
@ -2012,8 +2207,11 @@ 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")))
.await
svc2.dispatch(
&project(),
cmd(&ask_from_agent_json(aid(2), "alpha", "now to A")),
)
.await
});
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
fx.service
@ -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,8 +2442,11 @@ 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")))
.await
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"
);
}

View 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);
}

View File

@ -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"
);
}

View File

@ -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())));
}

View File

@ -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()
@ -570,10 +572,10 @@ impl FakeProviderSessionStore {
/// 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(),
);
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> {
@ -606,10 +608,10 @@ impl ProviderSessionStore for FakeProviderSessionStore {
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());
self.entries.lock().unwrap().insert(
(conversation, provider_id.to_owned()),
resumable_id.to_owned(),
);
Ok(())
}
}
@ -862,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));
}
@ -911,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),
@ -973,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");
@ -1098,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);
@ -1125,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!(
@ -1137,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)));
}
@ -1169,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)));
@ -1321,7 +1362,8 @@ fn launch_fixture_p8b(
)
.with_structured(Arc::new(factory), Arc::clone(&structured));
if let Some(store) = store {
let provider = Arc::new(FakeProviderSessionProvider(store)) as Arc<dyn ProviderSessionProvider>;
let provider =
Arc::new(FakeProviderSessionProvider(store)) as Arc<dyn ProviderSessionProvider>;
launch = launch.with_provider_session_provider(provider);
}
(Arc::new(launch), agent)
@ -1335,8 +1377,11 @@ fn launch_fixture_p8b(
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)));
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));
@ -1406,7 +1451,10 @@ async fn p8b_no_provider_wired_writes_nothing_launch_ok() {
let mut input = launch_input(agent.id);
input.conversation_id = Some(pair.to_string());
launch.execute(input).await.expect("launch ok without provider");
launch
.execute(input)
.await
.expect("launch ok without provider");
assert_eq!(store.len(), 0, "no provider wired ⇒ nothing written");
}
@ -1418,14 +1466,20 @@ 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 (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");
launch
.execute(input)
.await
.expect("launch ok without engine id");
assert_eq!(
store.len(),
@ -1441,8 +1495,11 @@ async fn p8b_no_engine_id_writes_nothing_launch_ok() {
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 (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);
@ -1498,8 +1555,11 @@ 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)));
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));
@ -1509,7 +1569,10 @@ async fn p8c_structured_claude_resumes_engine_id_from_store_not_pair() {
let mut input = launch_input(agent.id);
input.conversation_id = Some(pair.to_string());
launch.execute(input).await.expect("structured launch resumes");
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();
@ -1552,7 +1615,10 @@ async fn p8c_structured_codex_resumes_engine_id_from_store_codex_key() {
let mut input = launch_input(agent.id);
input.conversation_id = Some(pair.to_string());
launch.execute(input).await.expect("structured codex launch");
launch
.execute(input)
.await
.expect("structured codex launch");
let starts = observed.starts();
assert_eq!(starts.len(), 1);
@ -1572,8 +1638,11 @@ async fn p8c_structured_codex_resumes_engine_id_from_store_codex_key() {
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 (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.
@ -1600,8 +1669,11 @@ async fn p8c_provider_key_mismatch_falls_back_to_none() {
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 (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);
@ -1624,8 +1696,11 @@ async fn p8c_structured_store_empty_resolves_to_none() {
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)));
let (launch, agent, observed) = launch_fixture_p8c(
structured_profile(pid(9)),
factory,
Some(Arc::clone(&store)),
);
// Cellule neuve : conversation_id = None.
launch
@ -1649,8 +1724,11 @@ async fn p8c_structured_store_wired_fresh_cell_resolves_to_none() {
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 (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());

View File

@ -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 ------------------------------------
@ -121,10 +117,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)),]
);
}

View File

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

View File

@ -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"]);

View File

@ -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,28 @@ 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>,
},
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
PtyOutput {
/// The session.

View File

@ -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;
}

View File

@ -44,9 +44,12 @@ 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 remote;
pub mod skill;
pub mod template;
@ -74,7 +77,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, SessionStrategy,
};
pub use mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId};
@ -84,7 +88,9 @@ 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 conversation_log::{
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
@ -113,6 +119,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 +140,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, SpawnSpec, StoreError,
TemplateStore,
};

View File

@ -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);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -32,6 +32,7 @@ use crate::events::DomainEvent;
use crate::ids::{AgentId, 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,17 @@ 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,
/// **É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,8 +236,10 @@ 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>;
// ---------------------------------------------------------------------------
@ -513,6 +531,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 +547,7 @@ pub trait AgentSessionFactory: Send + Sync {
ctx: &PreparedContext,
cwd: &ProjectPath,
session: &SessionPlan,
sandbox: Option<&crate::sandbox::SandboxPlan>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError>;
}
@ -604,6 +630,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 +1091,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]

View File

@ -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,61 @@ 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,
})
}
}
/// Adapter d'**exécution structurée** qui pilote un profil IA (ARCHITECTURE §17).
///
/// Déclaratif, Open/Closed (comme [`EmbedderStrategy`]) : un profil déclare quel
@ -193,6 +249,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 {
@ -227,6 +299,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,
@ -253,6 +343,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:
@ -316,6 +528,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
@ -325,6 +544,51 @@ 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>,
/// 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).
@ -461,6 +725,10 @@ impl AgentProfile {
structured_adapter: None,
mcp: None,
prompt_ready_pattern: None,
liveness: None,
submit_sequence: None,
submit_delay_ms: None,
projector: None,
})
}
@ -491,6 +759,41 @@ 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 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
@ -505,6 +808,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)]
@ -611,10 +941,9 @@ 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"),
)
.expect("serialise");
let json =
serde_json::to_string(&McpConfigStrategy::config_file(".mcp.json").expect("valid"))
.expect("serialise");
assert!(json.contains("\"strategy\":\"configFile\""), "got: {json}");
}
@ -651,7 +980,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]
@ -663,7 +997,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]
@ -675,7 +1014,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) --------------
@ -721,4 +1065,305 @@ 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}"
);
}
}

View File

@ -0,0 +1,111 @@
//! 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,
}
/// 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::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::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"
);
}
}

View 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 `//`"
);
}
}

View File

@ -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());
}

View File

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

View File

@ -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)));

View File

@ -22,6 +22,10 @@ serde = { workspace = true }
serde_json = { workspace = true }
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 +40,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.

View File

@ -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);

View File

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

View File

@ -24,10 +24,12 @@ pub mod input;
pub mod inspector;
pub mod mailbox;
pub mod orchestrator;
pub mod permission;
pub mod process;
pub mod pty;
pub mod remote;
pub mod runtime;
pub mod sandbox;
pub mod session;
pub mod store;
@ -38,10 +40,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 +51,14 @@ 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 sandbox::{default_enforcer, NoopSandbox};
pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory};
#[cfg(feature = "vector-onnx")]
pub use store::OnnxEmbedder;
@ -61,8 +67,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,
};

View File

@ -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)))
);
}
}

View File

@ -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,19 +165,76 @@ 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;
};
if transport.send(&bytes).await.is_err() {
break;
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

View File

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

View File

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

View 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()
]
);
}
}

View 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"
);
}
}
}

View File

@ -0,0 +1,46 @@
//! Per-CLI **permission projectors** (lot LP3-2).
//!
//! Concrete implementations of the domain port
//! [`domain::permission::PermissionProjector`]: they translate the resolved
//! [`domain::permission::EffectivePermissions`] into a CLI-specific
//! [`domain::permission::PermissionProjection`] — a **plan** (files + args + env),
//! never an action. Writing/merging the plan into the agent run dir is the launch
//! path's job (lot LP3-3); the projectors here stay **pure** (no `FileSystem`, no
//! I/O), exactly like the domain trait demands.
//!
//! This is an **extraction**: the translation rules (postures → allow/deny/ask
//! lists for Claude, posture → sandbox/approval modes for Codex) are moved here
//! verbatim from `application/src/agent/lifecycle.rs`, only reshaped to return a
//! `PermissionProjection`. The rules themselves are unchanged.
mod claude;
mod codex;
pub use claude::ClaudePermissionProjector;
pub use codex::CodexPermissionProjector;
/// Minimal JSON string escaper for embedding a filesystem path / permission entry
/// in a settings document (handles the characters that actually occur in paths:
/// backslash, quote, control chars). Extracted verbatim from `lifecycle.rs`.
pub(crate) fn json_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
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
}
/// Escapes `s` as a TOML basic string (quotes included). The escape set required
/// by a TOML basic string coincides with JSON's for the characters that concern
/// us (paths, mode keywords). Extracted verbatim from `lifecycle.rs`.
pub(crate) fn toml_string(s: &str) -> String {
format!("\"{}\"", json_escape(s))
}

View File

@ -42,9 +42,10 @@ use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use async_trait::async_trait;
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySystem};
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySystem, SlavePty};
use domain::ports::{ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort, SpawnSpec};
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
use domain::terminal::PtySize;
use domain::SessionId;
@ -143,16 +144,32 @@ struct LivePty {
#[derive(Default)]
pub struct PortablePtyAdapter {
sessions: Mutex<HashMap<SessionId, LivePty>>,
/// Optional OS-sandbox enforcer (lot LP4-1). `None` ⇒ no sandboxing at all
/// (the default, zero behaviour change). When set **and** a [`SpawnSpec`]
/// carries a [`SandboxPlan`], the plan is enforced on the child at spawn
/// (see [`spawn_command_sandboxed`]).
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
}
impl PortablePtyAdapter {
/// Creates an empty adapter.
/// Creates an empty adapter (no OS sandbox).
#[must_use]
pub fn new() -> Self {
Self {
sessions: Mutex::new(HashMap::new()),
sandbox_enforcer: None,
}
}
/// Additive builder: wires an OS-sandbox [`SandboxEnforcer`] (lot LP4-1). With
/// it set, any [`SpawnSpec`] whose `sandbox` is `Some` has that plan enforced on
/// the spawned child. Without it (the default), no spawn is ever sandboxed —
/// `new()`'s behaviour is unchanged.
#[must_use]
pub fn with_sandbox_enforcer(mut self, enforcer: Arc<dyn SandboxEnforcer>) -> Self {
self.sandbox_enforcer = Some(enforcer);
self
}
}
/// Maps the domain [`PtySize`] to the `portable-pty` one.
@ -165,6 +182,54 @@ fn to_pty_size(size: PtySize) -> portable_pty::PtySize {
}
}
/// Spawns the child **under an OS sandbox** (lot LP4-1).
///
/// ## Why a dedicated thread instead of a `pre_exec` hook
///
/// `portable-pty` 0.9 exposes **no** user-injectable `pre_exec` closure: its
/// `CommandBuilder` has no such method, and `SlavePty::spawn_command` installs its
/// *own* internal `pre_exec` (for `setsid` / controlling-tty) before exec, with no
/// extension point. So we cannot run `enforcer.enforce(plan)` in the child's
/// `pre_exec` directly.
///
/// Instead we lean on a guaranteed kernel property: **a Landlock domain is
/// inherited across `fork` and preserved across `execve`**. We therefore restrict a
/// **dedicated throwaway thread** with `enforce`, then call `spawn_command` *from
/// that thread*. `std::process::Command` (which `spawn_command` drives) forks from
/// the calling thread — and because `portable-pty` always sets a `pre_exec`, std is
/// forced down the `fork`+`exec` path (never `posix_spawn`) — so the child inherits
/// the restricted thread's domain. The throwaway thread then dies, taking its
/// (irreversible) restriction with it, leaving IdeA's other threads untouched.
///
/// On `enforce` returning `Err` (fail-closed, e.g. a `Deny` posture on a kernel
/// without Landlock) the spawn is failed and **no child runs**. `Ok(Unsupported /
/// Degraded)` proceeds (best-effort).
///
/// Everything is **moved** into the thread (no borrow), so no `Sync` bound is
/// required of the slave; the resulting `Child` is `Send` and returned to the
/// caller.
fn spawn_command_sandboxed(
slave: Box<dyn SlavePty + Send>,
cmd: CommandBuilder,
enforcer: Arc<dyn SandboxEnforcer>,
plan: SandboxPlan,
) -> Result<Box<dyn Child + Send + Sync>, PtyError> {
let join = std::thread::spawn(move || -> Result<Box<dyn Child + Send + Sync>, PtyError> {
// Restrict THIS thread; the forked child inherits the domain.
if let Err(e) = enforcer.enforce(&plan) {
return Err(PtyError::Spawn(format!("sandbox enforcement failed: {e}")));
}
let child = slave
.spawn_command(cmd)
.map_err(|e| PtyError::Spawn(e.to_string()))?;
// The slave is held by the child; drop our copy so EOF propagates on exit.
drop(slave);
Ok(child)
});
join.join()
.map_err(|_| PtyError::Spawn("sandbox spawn thread panicked".to_owned()))?
}
/// Builds the `portable-pty` command from a domain [`SpawnSpec`].
fn to_command(spec: &SpawnSpec) -> CommandBuilder {
let mut cmd = CommandBuilder::new(&spec.command);
@ -185,12 +250,22 @@ impl PtyPort for PortablePtyAdapter {
.map_err(|e| PtyError::Spawn(e.to_string()))?;
let cmd = to_command(&spec);
let child = pair
.slave
.spawn_command(cmd)
.map_err(|e| PtyError::Spawn(e.to_string()))?;
// The slave is held by the child; drop our copy so EOF propagates on exit.
drop(pair.slave);
// Move the slave out of the pair (the master stays usable) so it can be
// either spawned directly or handed to the sandboxed-spawn thread.
let slave = pair.slave;
let child = match (spec.sandbox.clone(), self.sandbox_enforcer.clone()) {
// Sandboxed spawn: a plan AND an enforcer are present.
(Some(plan), Some(enforcer)) => spawn_command_sandboxed(slave, cmd, enforcer, plan)?,
// No plan or no enforcer ⇒ plain spawn (the default path, unchanged).
_ => {
let child = slave
.spawn_command(cmd)
.map_err(|e| PtyError::Spawn(e.to_string()))?;
// The slave is held by the child; drop our copy so EOF propagates.
drop(slave);
child
}
};
let writer = pair
.master
@ -371,3 +446,178 @@ mod tests {
assert_eq!(hub.snapshot(), b"abc".to_vec());
}
}
/// **End-to-end PTY × Landlock enforcement (lot LP4-3).**
///
/// These tests prove the OS sandbox is *really live on the raw PTY path*: a real
/// [`PortablePtyAdapter`] wired with the Landlock enforcer, a [`SpawnSpec`] whose
/// `sandbox = Some(plan)`, and a harmless `sh -c` that tries to write **inside** the
/// granted root (must succeed) and **outside** it (must be blocked by the kernel).
/// No AI CLI is launched — zero tokens. The whole chain enforcer → spec.sandbox →
/// `spawn` → `spawn_command_sandboxed` → restricted thread → fork/exec is exercised.
#[cfg(all(test, target_os = "linux"))]
mod sandbox_e2e_tests {
use super::*;
use crate::sandbox::LandlockSandbox;
use domain::sandbox::{PathAccess, PathGrant, SandboxPlan, SandboxStatus};
use domain::terminal::PtySize;
use domain::Posture;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
/// A unique temp dir for one test (no external tempfile dep), mirroring the
/// helper used by the Landlock adapter tests.
fn fresh_dir(tag: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let p = std::env::temp_dir().join(format!("idea-pty-sandbox-{tag}-{nanos}"));
std::fs::create_dir_all(&p).unwrap();
p
}
/// Probes whether the running kernel actually enforces Landlock, exactly the way
/// the launch path would observe it: enforce a tiny RW plan on a throwaway thread
/// (the restriction is irreversible, so it must not run on the test thread) and
/// report `false` when the adapter returns [`SandboxStatus::Unsupported`]. Lets us
/// skip cleanly on CI / old kernels without a Landlock LSM, like the adapter tests.
fn landlock_is_enforced() -> bool {
let probe = fresh_dir("probe");
let plan = SandboxPlan {
allowed: vec![PathGrant {
abs_root: probe.to_string_lossy().into_owned(),
access: PathAccess::RW,
}],
default_posture: Posture::Ask,
};
let status = std::thread::spawn(move || LandlockSandbox::new().enforce(&plan))
.join()
.unwrap()
.expect("enforce must not error under an Ask posture");
let _ = std::fs::remove_dir_all(&probe);
!matches!(status, SandboxStatus::Unsupported)
}
/// Waits (bounded) for `path` to appear, returning whether it showed up in time.
fn wait_for(path: &Path, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if path.exists() {
return true;
}
std::thread::sleep(Duration::from_millis(25));
}
path.exists()
}
/// THE high-fidelity end-to-end proof. With the enforcer wired and a plan that
/// grants RW on `allowed` only, a child `sh` writing to **both** an out-of-grant
/// path and the granted path must have its out-of-grant write blocked while the
/// in-grant write lands on disk. The script writes the denied target *first*, so
/// once the allowed marker exists we know the denied write was already attempted
/// (and fenced) — making the assertion deterministic without racing the child.
#[tokio::test]
async fn pty_spawn_enforces_sandbox_plan_end_to_end() {
if !landlock_is_enforced() {
eprintln!("skipping pty_spawn_enforces_sandbox_plan_end_to_end: \
Landlock not available/enforced on this kernel");
return;
}
let allowed = fresh_dir("allowed");
let denied = fresh_dir("denied");
let allowed_marker = allowed.join("in.txt");
let denied_marker = denied.join("out.txt");
// RW on `allowed` only ⇒ the write class is handled and fenced to that root;
// reads stay globally unrestricted so `sh`/libc still load normally.
let plan = SandboxPlan {
allowed: vec![PathGrant {
abs_root: allowed.to_string_lossy().into_owned(),
access: PathAccess::RW,
}],
default_posture: Posture::Ask,
};
// Denied write FIRST, then the allowed write: the allowed marker appearing is
// a happens-after signal that the denied attempt already ran.
let script = format!(
"echo outside > '{}'; echo inside > '{}'",
denied_marker.display(),
allowed_marker.display()
);
let spec = SpawnSpec {
command: "sh".to_owned(),
args: vec!["-c".to_owned(), script],
cwd: domain::project::ProjectPath::new(allowed.to_string_lossy().into_owned())
.expect("temp dir is absolute"),
env: vec![],
context_plan: None,
sandbox: Some(plan),
};
let adapter =
PortablePtyAdapter::new().with_sandbox_enforcer(crate::sandbox::default_enforcer());
let handle = adapter
.spawn(spec, PtySize { rows: 24, cols: 80 })
.await
.expect("sandboxed spawn must succeed (Ask posture never fails closed)");
// Wait for the in-grant write to land (the child's last action).
assert!(
wait_for(&allowed_marker, Duration::from_secs(5)),
"in-grant write must succeed: {allowed_marker:?} never appeared — \
the sandbox may have wrongly fenced the granted root"
);
// THE GUARANTEE: the out-of-grant write was attempted before the marker and
// must have been blocked by Landlock, so the file must not exist.
assert!(
!denied_marker.exists(),
"SANDBOX BREACH: out-of-grant write succeeded ({denied_marker:?} exists) — \
the Landlock plan was NOT enforced on the PTY child"
);
let _ = adapter.kill(&handle).await;
let _ = std::fs::remove_dir_all(&allowed);
let _ = std::fs::remove_dir_all(&denied);
}
/// Companion sanity check: with the **same** adapter+enforcer but a [`SpawnSpec`]
/// carrying `sandbox = None`, the out-of-grant write must succeed — proving the
/// previous test's block comes from the *enforced plan*, not from some ambient PTY
/// restriction. (Guards against a false-positive where writes fail for unrelated
/// reasons.) `denied` here is just an ordinary writable temp dir.
#[tokio::test]
async fn pty_spawn_without_plan_does_not_sandbox() {
let denied = fresh_dir("noplan");
let marker = denied.join("out.txt");
let script = format!("echo outside > '{}'", marker.display());
let spec = SpawnSpec {
command: "sh".to_owned(),
args: vec!["-c".to_owned(), script],
cwd: domain::project::ProjectPath::new(denied.to_string_lossy().into_owned())
.expect("temp dir is absolute"),
env: vec![],
context_plan: None,
sandbox: None,
};
let adapter =
PortablePtyAdapter::new().with_sandbox_enforcer(crate::sandbox::default_enforcer());
let handle = adapter
.spawn(spec, PtySize { rows: 24, cols: 80 })
.await
.expect("plain spawn must succeed");
assert!(
wait_for(&marker, Duration::from_secs(5)),
"without a sandbox plan the write must succeed: {marker:?} never appeared"
);
let _ = adapter.kill(&handle).await;
let _ = std::fs::remove_dir_all(&denied);
}
}

View File

@ -38,7 +38,9 @@ impl LocalHost {
Self {
fs: Arc::new(LocalFileSystem::new()),
spawner: Arc::new(LocalProcessSpawner::new()),
pty: Arc::new(PortablePtyAdapter::new()),
pty: Arc::new(
PortablePtyAdapter::new().with_sandbox_enforcer(crate::sandbox::default_enforcer()),
),
}
}
}

View File

@ -77,6 +77,7 @@ impl CliAgentRuntime {
cwd,
env: Vec::new(),
context_plan: None,
sandbox: None,
})
}
@ -219,6 +220,7 @@ impl AgentRuntime for CliAgentRuntime {
cwd: resolved_cwd,
env: Vec::new(),
context_plan: Some(plan),
sandbox: None,
})
}
}

View File

@ -0,0 +1,479 @@
//! [`LandlockSandbox`] — the Linux [`SandboxEnforcer`] backed by the kernel
//! Landlock LSM (lot LP4-1), via the `landlock` crate, **best-effort / ABI-compat**.
//!
//! # What it does
//!
//! [`SandboxEnforcer::enforce`] translates a domain [`SandboxPlan`] into a Landlock
//! ruleset and restricts the **current thread** (the contract says `enforce` runs
//! post-fork / pre-exec in the child, so the restriction is inherited by the exec'd
//! program — Landlock domains survive `execve`).
//!
//! # Per-access-class handling (mirrors the LP4-0 compile invariant)
//!
//! Landlock works by *handling* a set of access rights — everything **not** granted
//! is then denied **for those handled rights only**. We therefore handle a right
//! class **only if the plan actually posed a grant of that class**:
//!
//! - some `RO` grant present ⇒ handle the **read** class (`from_read`): reads become
//! restricted to the granted roots;
//! - some `RW` grant present ⇒ handle the **write** class (`from_write`): writes /
//! create / delete become restricted to the granted roots.
//!
//! A write-only policy thus leaves **reads globally unrestricted** (so the agent can
//! still load `libc`, read `/etc`, …) and only fences writes — exactly the autonomy
//! the per-access-class compile guarantees. The flip side: a policy that restricts
//! reads governs *all* reads, so the compiled plan must include the system read
//! paths the program needs; that enrichment is the launch-path's concern (LP4-2),
//! not this adapter's — here we translate the plan faithfully.
//!
//! # Fallback policy
//!
//! On a kernel/ABI without (sufficient) Landlock, `restrict_self` reports
//! [`RulesetStatus::NotEnforced`]. We then apply the product fallback: **fail-open**
//! ([`SandboxStatus::Unsupported`]) for any posture **except** [`Posture::Deny`],
//! where we **fail-closed** with [`SandboxError::KernelTooOld`] (a `Deny` posture
//! cannot be silently dropped). Partial enforcement (older ABI missing some rights)
//! maps to [`SandboxStatus::Degraded`].
use domain::sandbox::{
PathAccess, SandboxEnforcer, SandboxError, SandboxKind, SandboxPlan, SandboxStatus,
};
use domain::Posture;
// Leading `::` selects the extern `landlock` crate (this module is also named
// `landlock`, so the disambiguation matters).
use ::landlock::{
path_beneath_rules, AccessFs, BitFlags, CompatLevel, Compatible, Ruleset, RulesetAttr,
RulesetCreatedAttr, RulesetStatus, ABI,
};
/// The Landlock ABI we author rules against. `ABI::V1` (Linux ≥ 5.13) is the broad
/// floor; `CompatLevel::BestEffort` lets newer kernels run it unchanged and older /
/// absent support degrade rather than hard-error.
const TARGET_ABI: ABI = ABI::V1;
/// The Linux Landlock [`SandboxEnforcer`].
#[derive(Debug, Default, Clone, Copy)]
pub struct LandlockSandbox;
impl LandlockSandbox {
/// Builds the Landlock enforcer.
#[must_use]
pub fn new() -> Self {
Self
}
}
/// The Landlock access rights a single grant opens, intersected later with the
/// handled set. An `RW` grant also opens the read rights (writing a tree usually
/// implies reading it) — harmless when reads are ungoverned, correct when they are.
fn grant_access(access: PathAccess, abi: ABI) -> BitFlags<AccessFs> {
let mut acc = BitFlags::<AccessFs>::empty();
if access.contains(PathAccess::RO) {
acc |= AccessFs::from_read(abi);
}
if access.contains(PathAccess::RW) {
acc |= AccessFs::from_read(abi) | AccessFs::from_write(abi);
}
if access.contains(PathAccess::EXEC) {
// The domain never emits EXEC today; honoured for completeness.
acc |= AccessFs::Execute;
}
acc
}
impl SandboxEnforcer for LandlockSandbox {
fn kind(&self) -> SandboxKind {
SandboxKind::Landlock
}
fn enforce(&self, plan: &SandboxPlan) -> Result<SandboxStatus, SandboxError> {
let abi = TARGET_ABI;
// Landlock is an **additive allowlist** — it can only *remove* access, i.e.
// it is deny-by-default. A `Posture::Allow` fallback means the opposite,
// **allow-by-default**: paths matched by no grant must stay reachable. An
// allowlist cannot express that (it would lock everything down to the granted
// roots, closing the CLI binary, its libs and `~/.<cli>` home — the very
// "failed to open terminal" symptom). So under an Allow fallback we enforce
// nothing: the policy's only teeth are explicit Denies, which are deny-islands
// under an allow-all that an additive sandbox cannot carve out — they remain
// advisory via the LP3 projection. This is a property of the allowlist
// mechanism, hence it lives in the adapter, not the pure plan.
if plan.default_posture == Posture::Allow {
return Ok(SandboxStatus::Enforced);
}
// Handle a right class only if the plan posed a grant of that class, so an
// unposed class stays globally unrestricted (per-access-class autonomy).
let mut handled = BitFlags::<AccessFs>::empty();
for grant in &plan.allowed {
if grant.access.contains(PathAccess::RO) {
handled |= AccessFs::from_read(abi);
}
if grant.access.contains(PathAccess::RW) {
handled |= AccessFs::from_write(abi);
}
if grant.access.contains(PathAccess::EXEC) {
handled |= AccessFs::Execute;
}
}
if handled.is_empty() {
// No file restriction to apply (empty / bash-only plan): nothing for
// Landlock to enforce. A `Deny` posture's teeth, if any, then live only
// in the advisory LP3 projection — not this adapter's concern.
return Ok(SandboxStatus::Enforced);
}
let mut ruleset = Ruleset::default()
.set_compatibility(CompatLevel::BestEffort)
.handle_access(handled)
.and_then(Ruleset::create)
.map_err(|e| SandboxError::Ruleset(e.to_string()))?;
for grant in &plan.allowed {
let access = grant_access(grant.access, abi) & handled;
if access.is_empty() {
continue;
}
// `path_beneath_rules` silently skips a path it cannot open (a grant on
// a not-yet-existing root just adds no rule — best-effort, never fatal).
ruleset = ruleset
.add_rules(path_beneath_rules([grant.abs_root.as_str()], access))
.map_err(|e: ::landlock::RulesetError| SandboxError::Ruleset(e.to_string()))?;
}
let status = ruleset
.restrict_self()
.map_err(|e| SandboxError::Ruleset(e.to_string()))?;
status_from_ruleset(status.ruleset, plan.default_posture)
}
}
/// Maps the kernel's [`RulesetStatus`] (after `restrict_self`) plus the plan's
/// fallback [`Posture`] to the domain outcome. **Pure** (no I/O, no kernel): the
/// security-critical decision — including the *fail-closed only under `Deny`*
/// branch — lives here so it is unit-testable without a Landlock-incapable kernel.
///
/// - [`RulesetStatus::FullyEnforced`] ⇒ `Ok(`[`SandboxStatus::Enforced`]`)`
/// - [`RulesetStatus::PartiallyEnforced`] ⇒ `Ok(`[`SandboxStatus::Degraded`]`)`
/// - [`RulesetStatus::NotEnforced`] + [`Posture::Deny`] ⇒
/// `Err(`[`SandboxError::KernelTooOld`]`)` (fail-closed)
/// - [`RulesetStatus::NotEnforced`] + any other posture ⇒
/// `Ok(`[`SandboxStatus::Unsupported`]`)` (fail-open + warning)
fn status_from_ruleset(
status: RulesetStatus,
posture: Posture,
) -> Result<SandboxStatus, SandboxError> {
match status {
RulesetStatus::FullyEnforced => Ok(SandboxStatus::Enforced),
RulesetStatus::PartiallyEnforced => Ok(SandboxStatus::Degraded(
"landlock ABI compatibility reduced the enforced access rights (best-effort)"
.to_owned(),
)),
RulesetStatus::NotEnforced => {
if posture == Posture::Deny {
Err(SandboxError::KernelTooOld(
"landlock unavailable on this kernel but the policy posture is Deny \
(fail-closed)"
.to_owned(),
))
} else {
Ok(SandboxStatus::Unsupported)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::sandbox::{PathGrant, SandboxPlan};
use std::path::PathBuf;
/// A unique temp dir for one test (no external tempfile dep).
fn fresh_dir(tag: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let p = std::env::temp_dir().join(format!("idea-landlock-{tag}-{nanos}"));
std::fs::create_dir_all(&p).unwrap();
p
}
/// Real-kernel integration test: a write-only plan that grants RW on one dir
/// must let writes there succeed and block writes elsewhere with EACCES, while
/// leaving reads unrestricted. Runs the enforcement on a dedicated thread (the
/// restriction is irreversible for that thread) and **skips** if the running
/// kernel has no Landlock (CI / old kernels), like the SSH/WSL gated tests.
#[test]
fn landlock_write_only_plan_fences_writes_to_the_grant() {
let allowed = fresh_dir("allowed");
let denied = fresh_dir("denied");
let allowed_for_thread = allowed.clone();
let denied_for_thread = denied.clone();
let plan = SandboxPlan {
allowed: vec![PathGrant {
abs_root: allowed.to_string_lossy().into_owned(),
access: PathAccess::RW,
}],
default_posture: Posture::Ask,
};
let outcome = std::thread::spawn(move || {
let status = LandlockSandbox::new()
.enforce(&plan)
.expect("enforce must not error under an Ask posture");
// The thread is now sandboxed: try a write inside and outside the grant.
let ok_write = std::fs::write(allowed_for_thread.join("inside.txt"), b"hi");
let ko_write = std::fs::write(denied_for_thread.join("outside.txt"), b"nope");
(status, ok_write, ko_write.map_err(|e| e.kind()))
})
.join()
.unwrap();
let (status, ok_write, ko_write) = outcome;
if status == SandboxStatus::Unsupported {
eprintln!("skipping: Landlock not available on this kernel");
// Clean up best-effort and bail (no enforcement happened).
let _ = std::fs::remove_dir_all(&allowed);
let _ = std::fs::remove_dir_all(&denied);
return;
}
assert!(
matches!(status, SandboxStatus::Enforced | SandboxStatus::Degraded(_)),
"expected an enforced/degraded sandbox, got {status:?}"
);
assert!(
ok_write.is_ok(),
"write inside the granted root must succeed, got {ok_write:?}"
);
assert_eq!(
ko_write,
Err(std::io::ErrorKind::PermissionDenied),
"write outside the granted root must be blocked (EACCES)"
);
let _ = std::fs::remove_dir_all(&allowed);
let _ = std::fs::remove_dir_all(&denied);
}
/// An empty plan (no file grant) has nothing to enforce ⇒ `Enforced`, no error.
#[test]
fn empty_plan_is_a_noop_enforced() {
let plan = SandboxPlan {
allowed: vec![],
default_posture: Posture::Ask,
};
// Run on a throwaway thread to avoid restricting the test runner thread.
let status = std::thread::spawn(move || LandlockSandbox::new().enforce(&plan))
.join()
.unwrap()
.unwrap();
assert_eq!(status, SandboxStatus::Enforced);
}
/// **Bash-only / empty plan under a `Deny` posture.** A bash-only policy compiles
/// (LP4-0) to an empty `allowed`; even combined with `Posture::Deny` the adapter
/// has no file right to *handle*, so it must early-return `Enforced` WITHOUT
/// restricting anything — and crucially WITHOUT `KernelTooOld`: the fail-closed
/// branch is only ever reached once a class was actually handled.
#[test]
fn empty_plan_under_deny_posture_is_enforced_without_restriction() {
let plan = SandboxPlan {
allowed: vec![],
default_posture: Posture::Deny,
};
let status = std::thread::spawn(move || LandlockSandbox::new().enforce(&plan))
.join()
.unwrap()
.expect("an empty plan never fails closed (nothing handled)");
assert_eq!(status, SandboxStatus::Enforced);
}
/// **Allow fallback ⇒ no OS restriction even with grants posed.** Setting the
/// policy to allow-by-default (and, in the UI, every option to Allow) emits RO|RW
/// grants scoped to the project root. An allowlist sandbox would then close every
/// read/write OUTSIDE that root — locking out the CLI binary, its libs and home,
/// the exact "failed to open terminal" bug. Under `Posture::Allow` the adapter must
/// instead enforce nothing: from the (would-be) sandboxed thread, a read AND a
/// write OUTSIDE the only grant must both still succeed.
#[test]
fn allow_fallback_does_not_restrict_even_with_grants() {
let granted = fresh_dir("allow-granted");
let outside = fresh_dir("allow-outside");
std::fs::write(outside.join("pre.txt"), b"pre").unwrap();
let outside_t = outside.clone();
let plan = SandboxPlan {
allowed: vec![PathGrant {
abs_root: granted.to_string_lossy().into_owned(),
access: PathAccess::RO.union(PathAccess::RW),
}],
default_posture: Posture::Allow,
};
let (status, read_outside, write_outside) = std::thread::spawn(move || {
let status = LandlockSandbox::new()
.enforce(&plan)
.expect("an Allow fallback never fails closed");
let read = std::fs::read(outside_t.join("pre.txt"));
let write =
std::fs::write(outside_t.join("new.txt"), b"x").map_err(|e| e.kind());
(status, read, write)
})
.join()
.unwrap();
assert_eq!(
status,
SandboxStatus::Enforced,
"Allow fallback is a no-op enforcement, not an error or a lock-down"
);
assert!(
read_outside.is_ok(),
"Allow fallback must keep reads outside the grant open, got {read_outside:?}"
);
assert_eq!(
write_outside,
Ok(()),
"Allow fallback must keep writes outside the grant open"
);
let _ = std::fs::remove_dir_all(&granted);
let _ = std::fs::remove_dir_all(&outside);
}
/// **CRITICAL SAFETY PROPERTY — IdeA is never sandboxed.** The adapter restricts
/// the *current thread*; the launch path runs it on a throwaway thread. This test
/// proves the Landlock domain is confined to that throwaway thread and does **not**
/// leak onto the thread that spawned it (which simulates the IdeA process): after
/// the child has sandboxed itself, the parent thread must still read/write OUTSIDE
/// the plan's roots. A leak here would be catastrophic (IdeA itself locked down).
#[test]
fn enforcement_is_confined_to_the_enforcing_thread_idea_is_never_sandboxed() {
let granted = fresh_dir("safety-granted");
let outside = fresh_dir("safety-outside");
let granted_t = granted.clone();
let outside_t = outside.clone();
let plan = SandboxPlan {
allowed: vec![PathGrant {
abs_root: granted.to_string_lossy().into_owned(),
access: PathAccess::RW,
}],
default_posture: Posture::Ask,
};
// Enforce on a dedicated thread, exactly as the launch path does, and report
// whether the sandbox was actually live there (write outside must be blocked).
let (status, child_outside_write) = std::thread::spawn(move || {
let status = LandlockSandbox::new()
.enforce(&plan)
.expect("enforce must not error under an Ask posture");
let _ = granted_t; // (writable in the child; not asserted here)
let child_outside =
std::fs::write(outside_t.join("from-child.txt"), b"x").map_err(|e| e.kind());
(status, child_outside)
})
.join()
.unwrap();
if status == SandboxStatus::Unsupported {
eprintln!("skipping: Landlock not available on this kernel");
let _ = std::fs::remove_dir_all(&granted);
let _ = std::fs::remove_dir_all(&outside);
return;
}
// Sanity: the sandbox really was active on the throwaway thread.
assert_eq!(
child_outside_write,
Err(std::io::ErrorKind::PermissionDenied),
"the throwaway thread must be sandboxed (write outside the grant blocked)"
);
// THE GUARANTEE: the parent thread (≈ the IdeA process) is not a descendant of
// the throwaway thread, so the domain must not have leaked onto it.
let idea_write = std::fs::write(outside.join("from-idea.txt"), b"idea");
assert!(
idea_write.is_ok(),
"SAFETY VIOLATION: IdeA's own thread was sandboxed — write outside the plan \
failed ({idea_write:?}); the Landlock domain leaked off the throwaway thread"
);
let idea_read = std::fs::read(outside.join("from-idea.txt"));
assert!(
idea_read.is_ok(),
"IdeA must still read outside the plan roots, got {idea_read:?}"
);
let _ = std::fs::remove_dir_all(&granted);
let _ = std::fs::remove_dir_all(&outside);
}
/// **READ (RO) dimension.** A plan with a single RO grant: from the sandboxed
/// child, reading a file UNDER the granted root succeeds, while reading a file
/// OUTSIDE is denied (EACCES). This also documents the behaviour DevBackend
/// flagged: as soon as *any* RO grant is posed the read class is handled, so
/// **every** read outside the granted roots is closed — a realistic read-restricted
/// plan must therefore include the needed system paths (an LP4-2 concern).
#[test]
fn read_only_plan_fences_reads_to_the_grant() {
let granted = fresh_dir("ro-granted");
let outside = fresh_dir("ro-outside");
// Files must exist before enforcement (creation is a write).
std::fs::write(granted.join("in.txt"), b"inside").unwrap();
std::fs::write(outside.join("out.txt"), b"outside").unwrap();
let granted_t = granted.clone();
let outside_t = outside.clone();
let plan = SandboxPlan {
allowed: vec![PathGrant {
abs_root: granted.to_string_lossy().into_owned(),
access: PathAccess::RO,
}],
default_posture: Posture::Ask,
};
let (status, ok_read, ko_read) = std::thread::spawn(move || {
let status = LandlockSandbox::new()
.enforce(&plan)
.expect("enforce must not error under an Ask posture");
let ok = std::fs::read(granted_t.join("in.txt"));
let ko = std::fs::read(outside_t.join("out.txt")).map_err(|e| e.kind());
(status, ok, ko)
})
.join()
.unwrap();
if status == SandboxStatus::Unsupported {
eprintln!("skipping: Landlock not available on this kernel");
let _ = std::fs::remove_dir_all(&granted);
let _ = std::fs::remove_dir_all(&outside);
return;
}
assert!(
matches!(status, SandboxStatus::Enforced | SandboxStatus::Degraded(_)),
"expected an enforced/degraded sandbox, got {status:?}"
);
assert!(
ok_read.is_ok(),
"read inside the granted RO root must succeed, got {ok_read:?}"
);
assert_eq!(
ko_read,
Err(std::io::ErrorKind::PermissionDenied),
"once a RO grant is posed, reads outside the granted roots are closed (EACCES)"
);
let _ = std::fs::remove_dir_all(&granted);
let _ = std::fs::remove_dir_all(&outside);
}
}

View File

@ -0,0 +1,82 @@
//! OS-sandbox adapters (lot LP4-1) — concrete [`domain::sandbox::SandboxEnforcer`]
//! implementations and the cfg-selected default constructor.
//!
//! - [`LandlockSandbox`] (Linux only) enforces a [`domain::sandbox::SandboxPlan`]
//! via the kernel Landlock LSM.
//! - [`NoopSandbox`] (everywhere) enforces nothing — the non-Linux / fallback path.
//!
//! [`default_enforcer`] returns the right one for the build target as a
//! `Arc<dyn SandboxEnforcer>`, ready for the composition root (LP4-3) to inject.
//! Nothing here is wired into the launch path yet (that is LP4-2): until then the
//! enforcer stays unused and `SpawnSpec.sandbox` stays `None`, so there is zero
//! runtime behaviour change.
use std::sync::Arc;
use domain::sandbox::SandboxEnforcer;
mod noop;
pub use noop::NoopSandbox;
#[cfg(target_os = "linux")]
mod landlock;
#[cfg(target_os = "linux")]
pub use landlock::LandlockSandbox;
/// The default OS-sandbox enforcer for the current build target: [`LandlockSandbox`]
/// on Linux, [`NoopSandbox`] everywhere else.
#[cfg(target_os = "linux")]
#[must_use]
pub fn default_enforcer() -> Arc<dyn SandboxEnforcer> {
Arc::new(LandlockSandbox::new())
}
/// The default OS-sandbox enforcer for the current build target: [`NoopSandbox`]
/// on non-Linux platforms (no OS sandbox wired yet).
#[cfg(not(target_os = "linux"))]
#[must_use]
pub fn default_enforcer() -> Arc<dyn SandboxEnforcer> {
Arc::new(NoopSandbox::new())
}
#[cfg(test)]
mod tests {
use super::*;
use domain::sandbox::{SandboxKind, SandboxPlan, SandboxStatus};
use domain::Posture;
#[test]
fn noop_enforcer_is_unsupported_and_never_errors() {
use domain::sandbox::PathGrant;
let noop = NoopSandbox::new();
assert_eq!(noop.kind(), SandboxKind::Unsupported);
// Across every posture — and even with grants present — the no-op enforces
// nothing and never fails closed: there is no OS sandbox here to fail closed
// against (the Deny posture's teeth live only in the advisory LP3 projection).
for posture in [Posture::Allow, Posture::Ask, Posture::Deny] {
let plan = SandboxPlan {
allowed: vec![PathGrant {
abs_root: "/whatever".to_owned(),
access: domain::sandbox::PathAccess::RW,
}],
default_posture: posture,
};
assert_eq!(
noop.enforce(&plan).unwrap(),
SandboxStatus::Unsupported,
"no-op must return Unsupported (never Err) under posture {posture:?}"
);
}
}
#[test]
fn default_enforcer_matches_the_build_target() {
let enforcer = default_enforcer();
#[cfg(target_os = "linux")]
assert_eq!(enforcer.kind(), SandboxKind::Landlock);
#[cfg(not(target_os = "linux"))]
assert_eq!(enforcer.kind(), SandboxKind::Unsupported);
}
}

View File

@ -0,0 +1,33 @@
//! [`NoopSandbox`] — the no-op [`SandboxEnforcer`] for platforms without an OS
//! sandbox (Windows, macOS) and the universal fallback (lot LP4-1).
//!
//! It compiles **everywhere** and never restricts anything: `enforce` always
//! returns [`SandboxStatus::Unsupported`] and never errors. The caller keeps the
//! advisory LP3 projection as its only guard.
use domain::sandbox::{SandboxEnforcer, SandboxError, SandboxKind, SandboxPlan, SandboxStatus};
/// A [`SandboxEnforcer`] that enforces nothing (non-Linux / fallback).
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopSandbox;
impl NoopSandbox {
/// Builds the no-op enforcer.
#[must_use]
pub fn new() -> Self {
Self
}
}
impl SandboxEnforcer for NoopSandbox {
fn kind(&self) -> SandboxKind {
SandboxKind::Unsupported
}
fn enforce(&self, _plan: &SandboxPlan) -> Result<SandboxStatus, SandboxError> {
// No OS sandbox here: nothing enforced, never an error (the fail-closed
// posture handling is a Landlock-only concern; on an unsupported platform
// there is nothing to fail closed *against*).
Ok(SandboxStatus::Unsupported)
}
}

View File

@ -13,12 +13,13 @@
//! (2026-06-09) ; **seule [`parse_event`] (et la composition de la commande) porte
//! le format**, pas la machinerie ni le reste de l'adapter.
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use serde_json::Value;
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
use domain::SessionId;
use super::process::{run_turn, SpawnLine};
@ -47,10 +48,10 @@ pub struct ParsedLine {
/// Le flux est du **JSONL** (un objet JSON par ligne). Types réels :
///
/// - `{"type":"system","subtype":"init","session_id":"<uuid>","cwd":…,"tools":…,…}`
/// ⇒ capture le `session_id` (= id de conversation pour la reprise), **aucun**
/// événement émis.
/// ⇒ capture le `session_id` (= id de conversation pour la reprise) **et** émet un
/// [`ReplyEvent::Heartbeat`] (preuve de vivacité non terminale : la CLI a démarré).
/// - `{"type":"rate_limit_event","rate_limit_info":{…},"session_id":"…"}`
/// ⇒ **ignoré** (comme tout `type` inconnu).
/// ⇒ [`ReplyEvent::Heartbeat`] (pas de contenu, mais le moteur est vivant).
/// - `{"type":"assistant","message":{"role":"assistant","content":[
/// {"type":"text","text":"…"} | {"type":"tool_use","name":"…", …}
/// ], …},"session_id":"…","parent_tool_use_id":null}`
@ -81,7 +82,12 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
.map(str::to_owned);
let events = match value.get("type").and_then(Value::as_str) {
Some("system") => Vec::new(), // init/handshake : on ne capte que le session_id.
// init/handshake : on capte le session_id ET on émet un battement de cœur
// (preuve de vivacité non terminale : la CLI a démarré et répond).
Some("system") => vec![ReplyEvent::Heartbeat],
// Fenêtre de limite de débit : pas de contenu, mais le moteur est vivant ⇒
// battement de cœur (readiness/heartbeat lot 1), plus ignoré.
Some("rate_limit_event") => vec![ReplyEvent::Heartbeat],
Some("assistant") => assistant_events(&value),
Some("result") => value
.get("result")
@ -92,7 +98,7 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
}]
})
.unwrap_or_default(),
_ => Vec::new(), // type inconnu / non pertinent (rate_limit_event, …) : ignoré.
_ => Vec::new(), // type inconnu / non pertinent : ignoré (robustesse).
};
Ok(ParsedLine { events, session_id })
@ -149,24 +155,36 @@ pub struct ClaudeSdkSession {
cwd: String,
/// Id de conversation **du moteur** Claude, capté au premier tour, `None` avant.
conversation_id: Mutex<Option<String>>,
/// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque
/// [`SpawnLine`]. `None` ⇒ aucun sandboxing (drain async natif).
sandbox: Option<SandboxPlan>,
/// Enforcer OS **par instance** (lot LP4-4), passé à [`run_turn`]. `None` ⇒ pas
/// de sandboxing même si un plan est présent (cohérent avec le chemin PTY).
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
}
impl ClaudeSdkSession {
/// Construit l'adapter. `command` est le binaire à lancer (injecté ⇒ testable
/// avec un fake CLI) ; `seed_conversation_id` amorce la reprise (`SessionPlan::
/// Resume` côté factory) ou reste `None` pour une conversation neuve.
/// Resume` côté factory) ou reste `None` pour une conversation neuve. `sandbox` /
/// `sandbox_enforcer` (lot LP4-4) pilotent le sandboxing OS du tour ; `None`/`None`
/// ⇒ chemin natif inchangé.
#[must_use]
pub fn new(
id: SessionId,
command: impl Into<String>,
cwd: impl Into<String>,
seed_conversation_id: Option<String>,
sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
) -> Self {
Self {
id,
command: command.into(),
cwd: cwd.into(),
conversation_id: Mutex::new(seed_conversation_id),
sandbox,
sandbox_enforcer,
}
}
@ -196,6 +214,7 @@ impl ClaudeSdkSession {
cwd: self.cwd.clone(),
env: Vec::new(),
stdin: None,
sandbox: self.sandbox.clone(),
}
}
}
@ -212,17 +231,26 @@ impl AgentSession for ClaudeSdkSession {
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
let spec = self.build_spawn_line(prompt);
let raw_lines = run_turn(&spec, None).await?;
let raw_lines = run_turn(&spec, None, self.sandbox_enforcer.as_ref()).await?;
let mut events = Vec::new();
let mut captured_id = None;
for line in &raw_lines {
'lines: for line in &raw_lines {
let parsed = parse_event(line)?;
if let Some(id) = parsed.session_id {
captured_id = Some(id);
}
// Aplatit : une ligne `assistant` multi-blocs rend plusieurs événements.
events.extend(parsed.events);
// Le `Final` est **terminal** (contrat de port) : on arrête d'émettre dès
// qu'on l'a vu, pour qu'aucun heartbeat de fin (`rate_limit_event` tardif…)
// ne le suive dans le flux.
for event in parsed.events {
let is_final = matches!(event, ReplyEvent::Final { .. });
events.push(event);
if is_final {
break 'lines;
}
}
}
// Persiste le session_id capté (pivot de reprise) avant de rendre le flux.
if let Some(id) = captured_id {

View File

@ -10,12 +10,13 @@
//! le format. **Seule [`parse_event`] (et la composition de la commande) porte le
//! format Codex** ; la machinerie reste inchangée.
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use serde_json::Value;
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
use domain::SessionId;
use super::process::{run_turn, SpawnLine};
@ -43,13 +44,13 @@ pub struct ParsedLine {
///
/// - `{"type":"thread.started","thread_id":"<id>"}` ⇒ capte le `thread_id`
/// (= id de conversation pour la reprise), **aucun** événement émis.
/// - `{"type":"turn.started"}` ⇒ ignoré.
/// - `{"type":"turn.started"}` ⇒ [`ReplyEvent::Heartbeat`] (vivacité non terminale).
/// - `{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"…"}}`
/// ⇒ si `item.type=="agent_message"` ⇒ [`ReplyEvent::Final`] (`content` = `item.text`,
/// c'est la réponse) ; sinon (`reasoning`/`command`/autre) ⇒
/// [`ReplyEvent::ToolActivity`] (`label` = `item.type`).
/// - `{"type":"turn.completed","usage":{…}}` ⇒ ignoré (le `Final` vient de
/// l'`agent_message`).
/// - `{"type":"turn.completed","usage":{…}}` ⇒ [`ReplyEvent::Heartbeat`] (le `Final`
/// vient de l'`agent_message`, pas de `turn.completed`).
///
/// Ligne vide ⇒ ignorée ; type inconnu ⇒ ignoré sans erreur ; JSON illisible ⇒
/// [`AgentSessionError::Decode`] (jamais de JSON brut propagé).
@ -75,6 +76,10 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
.and_then(Value::as_str)
.map(str::to_owned);
}
// Début/fin de tour côté moteur : pas de contenu, mais preuve de vivacité ⇒
// battement de cœur non terminal (readiness/heartbeat lot 1). Le `Final` vient
// toujours de l'`agent_message`, jamais de `turn.completed`.
Some("turn.started") | Some("turn.completed") => events.push(ReplyEvent::Heartbeat),
Some("item.completed") => {
if let Some(item) = value.get("item") {
match item.get("type").and_then(Value::as_str) {
@ -94,7 +99,7 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
}
}
}
// turn.started / turn.completed / type inconnu : ignoré (robustesse).
// type inconnu : ignoré (robustesse).
_ => {}
}
@ -119,23 +124,35 @@ pub struct CodexExecSession {
cwd: String,
/// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant.
conversation_id: Mutex<Option<String>>,
/// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque
/// [`SpawnLine`]. `None` ⇒ aucun sandboxing (drain async natif).
sandbox: Option<SandboxPlan>,
/// Enforcer OS **par instance** (lot LP4-4), passé à [`run_turn`]. `None` ⇒ pas
/// de sandboxing même si un plan est présent (cohérent avec le chemin PTY).
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
}
impl CodexExecSession {
/// Construit l'adapter. `command` est injecté (⇒ testable avec un fake CLI) ;
/// `seed_conversation_id` amorce la reprise ou reste `None` (conversation neuve).
/// `sandbox` / `sandbox_enforcer` (lot LP4-4) pilotent le sandboxing OS ;
/// `None`/`None` ⇒ chemin natif inchangé.
#[must_use]
pub fn new(
id: SessionId,
command: impl Into<String>,
cwd: impl Into<String>,
seed_conversation_id: Option<String>,
sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
) -> Self {
Self {
id,
command: command.into(),
cwd: cwd.into(),
conversation_id: Mutex::new(seed_conversation_id),
sandbox,
sandbox_enforcer,
}
}
@ -172,6 +189,7 @@ impl CodexExecSession {
cwd: self.cwd.clone(),
env: Vec::new(),
stdin: None,
sandbox: self.sandbox.clone(),
}
}
}
@ -188,16 +206,25 @@ impl AgentSession for CodexExecSession {
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
let spec = self.build_spawn_line(prompt);
let raw_lines = run_turn(&spec, None).await?;
let raw_lines = run_turn(&spec, None, self.sandbox_enforcer.as_ref()).await?;
let mut events = Vec::new();
let mut captured_id = None;
for line in &raw_lines {
'lines: for line in &raw_lines {
let parsed = parse_event(line)?;
if let Some(id) = parsed.conversation_id {
captured_id = Some(id);
}
events.extend(parsed.events);
// Le `Final` est **terminal** (contrat de port) : on arrête d'émettre dès
// qu'on l'a vu, pour qu'aucun heartbeat de fin (`turn.completed` postérieur)
// ne le suive dans le flux.
for event in parsed.events {
let is_final = matches!(event, ReplyEvent::Final { .. });
events.push(event);
if is_final {
break 'lines;
}
}
}
if let Some(id) = captured_id {
*self.conversation_id.lock().expect("mutex sain") = Some(id);

View File

@ -84,6 +84,7 @@ impl FakeCli {
cwd: "/".to_owned(),
env: Vec::new(),
stdin: None,
sandbox: None,
}
}
}
@ -208,14 +209,17 @@ pub(crate) mod harness {
}
other => panic!("le dernier événement doit être Final, vu: {other:?}"),
}
// Les événements avant le Final ne sont que des deltas / activités.
// Les événements avant le Final ne sont que des deltas / activités / heartbeats
// (tous **non terminaux** ; le heartbeat est une preuve de vivacité, lot 1).
for e in &events[..events.len() - 1] {
assert!(
matches!(
e,
ReplyEvent::TextDelta { .. } | ReplyEvent::ToolActivity { .. }
ReplyEvent::TextDelta { .. }
| ReplyEvent::ToolActivity { .. }
| ReplyEvent::Heartbeat
),
"avant le Final, seuls deltas/activités sont permis, vu: {e:?}"
"avant le Final, seuls deltas/activités/heartbeats sont permis, vu: {e:?}"
);
}

View File

@ -16,6 +16,7 @@ use domain::ports::{
};
use domain::profile::{AgentProfile, StructuredAdapter};
use domain::project::ProjectPath;
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
use domain::SessionId;
use super::claude::ClaudeSdkSession;
@ -23,17 +24,36 @@ use super::codex::CodexExecSession;
/// Fabrique infra des sessions structurées, sélectionnée par le profil.
///
/// Sans état : elle instancie l'adapter au vol depuis le profil (le binaire à
/// Quasi sans état : elle instancie l'adapter au vol depuis le profil (le binaire à
/// lancer = `profile.command`), de sorte qu'un seul exemplaire injecté au
/// composition root sert tous les agents (jumeau de `CliAgentRuntime`).
#[derive(Debug, Default, Clone, Copy)]
pub struct StructuredSessionFactory;
/// composition root sert tous les agents (jumeau de `CliAgentRuntime`). Le seul état
/// porté est l'**enforcer de sandbox OS** optionnel (lot LP4-4), injecté **par
/// instance** au composition root (jumeau de `PortablePtyAdapter::with_sandbox_enforcer`)
/// et apparié au plan **par lancement** dans [`start`](AgentSessionFactory::start).
#[derive(Clone, Default)]
pub struct StructuredSessionFactory {
/// Enforcer OS optionnel passé aux adapters structurés. `None` ⇒ aucun
/// sandboxing (chemin natif inchangé, zéro régression).
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
}
impl StructuredSessionFactory {
/// Construit la fabrique.
/// Construit la fabrique (sans enforcer : chemin natif).
#[must_use]
pub const fn new() -> Self {
Self
pub fn new() -> Self {
Self {
sandbox_enforcer: None,
}
}
/// Builder additif : câble un [`SandboxEnforcer`] OS (lot LP4-4). Jumeau exact de
/// [`crate::PortablePtyAdapter::with_sandbox_enforcer`]. Avec lui, tout lancement
/// structuré dont le plan (`SpawnSpec.sandbox`) est `Some` voit ce plan appliqué
/// sur l'enfant. Sans lui (défaut), aucun tour n'est sandboxé.
#[must_use]
pub fn with_sandbox_enforcer(mut self, enforcer: Arc<dyn SandboxEnforcer>) -> Self {
self.sandbox_enforcer = Some(enforcer);
self
}
}
@ -62,6 +82,7 @@ impl AgentSessionFactory for StructuredSessionFactory {
_ctx: &PreparedContext,
cwd: &ProjectPath,
session: &SessionPlan,
sandbox: Option<&SandboxPlan>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
let adapter = profile.structured_adapter.ok_or_else(|| {
AgentSessionError::Start(format!(
@ -75,14 +96,25 @@ impl AgentSessionFactory for StructuredSessionFactory {
let cwd = cwd.as_str().to_owned();
let seed = seed_conversation_id(session);
// Appariement (lot LP4-4) : plan **par lancement** (param) + enforcer **par
// instance** (champ). Tous deux sont relayés à l'adapter, qui remplira
// `SpawnLine.sandbox` et passera l'enforcer à `run_turn`. `plan == None` ⇒
// l'adapter reste sur le drain async natif.
let plan = sandbox.cloned();
let enforcer = self.sandbox_enforcer.clone();
// NOTE : le contexte (`_ctx`) est injecté par `LaunchAgent` (D3) via le
// convention file dans le run dir *avant* l'appel à la factory (le `.md` est
// déjà écrit) ; l'adapter n'a donc qu'à lancer la CLI dans ce cwd. Aucune
// injection supplémentaire n'incombe ici en mode structuré (la CLI lit son
// fichier conventionnel — CLAUDE.md / AGENTS.md — depuis le cwd).
let session: Arc<dyn AgentSession> = match adapter {
StructuredAdapter::Claude => Arc::new(ClaudeSdkSession::new(id, command, cwd, seed)),
StructuredAdapter::Codex => Arc::new(CodexExecSession::new(id, command, cwd, seed)),
StructuredAdapter::Claude => {
Arc::new(ClaudeSdkSession::new(id, command, cwd, seed, plan, enforcer))
}
StructuredAdapter::Codex => {
Arc::new(CodexExecSession::new(id, command, cwd, seed, plan, enforcer))
}
};
Ok(session)
}

View File

@ -25,6 +25,11 @@ pub mod conformance;
pub mod factory;
pub mod process;
/// Tests bout-en-bout de l'enforcement Landlock sur le chemin structuré (lot LP4-4),
/// Linux uniquement (pair du module `pty::sandbox_e2e_tests` du lot LP4-3).
#[cfg(all(test, target_os = "linux"))]
mod sandbox_e2e;
pub use claude::ClaudeSdkSession;
pub use codex::CodexExecSession;
pub use conformance::FakeCli;
@ -84,7 +89,7 @@ mod tests {
#[tokio::test]
async fn run_turn_drains_every_line_in_order() {
let fake = FakeCli::printing(&["ligne-1", "ligne-2", "ligne-3"]);
let lines = run_turn(&fake.spawn_line(), None)
let lines = run_turn(&fake.spawn_line(), None, None)
.await
.expect("run_turn réussit");
assert_eq!(lines, vec!["ligne-1", "ligne-2", "ligne-3"]);
@ -98,31 +103,33 @@ mod tests {
cwd: "/".to_owned(),
env: Vec::new(),
stdin: None,
sandbox: None,
};
let err = run_turn(&spec, None).await.expect_err("doit échouer");
let err = run_turn(&spec, None, None).await.expect_err("doit échouer");
assert!(matches!(err, AgentSessionError::Start(_)), "vu: {err:?}");
}
// -- parse_event Claude (format RÉEL vérifié 2026-06-09) --------------
#[test]
fn claude_parse_init_captures_session_id_without_event() {
fn claude_parse_init_captures_session_id_and_heartbeats() {
let parsed = claude::parse_event(
r#"{"type":"system","subtype":"init","session_id":"conv-123","cwd":"/tmp","tools":[],"model":"claude-opus-4-8"}"#,
)
.expect("parse ok");
assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
assert!(parsed.events.is_empty());
// L'init capte le session_id ET émet un heartbeat (vivacité non terminale, lot 1).
assert_eq!(parsed.events, vec![ReplyEvent::Heartbeat]);
}
#[test]
fn claude_parse_rate_limit_event_is_ignored() {
fn claude_parse_rate_limit_event_is_heartbeat() {
let parsed = claude::parse_event(
r#"{"type":"rate_limit_event","rate_limit_info":{"x":1},"session_id":"conv-123"}"#,
)
.expect("parse ok");
// Type inconnu/non pertinent : ignoré (mais session_id tout de même capté).
assert!(parsed.events.is_empty());
// Plus ignoré : preuve de vivacité ⇒ heartbeat (mais session_id tout de même capté).
assert_eq!(parsed.events, vec![ReplyEvent::Heartbeat]);
assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
}
@ -165,7 +172,9 @@ mod tests {
assert_eq!(
parsed.events,
vec![
ReplyEvent::TextDelta { text: "un".to_owned() },
ReplyEvent::TextDelta {
text: "un".to_owned()
},
ReplyEvent::ToolActivity {
label: "Read".to_owned()
},
@ -216,13 +225,18 @@ mod tests {
#[test]
fn codex_parse_thread_started_message_and_final() {
let sess = codex::parse_event(r#"{"type":"thread.started","thread_id":"cx-9"}"#)
.expect("ok");
let sess =
codex::parse_event(r#"{"type":"thread.started","thread_id":"cx-9"}"#).expect("ok");
assert_eq!(sess.conversation_id.as_deref(), Some("cx-9"));
// Le handshake ne capte que le thread_id, sans événement (pas un heartbeat).
assert!(sess.events.is_empty());
// turn.started / turn.completed ⇒ heartbeat (vivacité non terminale, lot 1).
let started = codex::parse_event(r#"{"type":"turn.started"}"#).expect("ok");
assert!(started.events.is_empty());
assert_eq!(started.events, vec![ReplyEvent::Heartbeat]);
let completed =
codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#).expect("ok");
assert_eq!(completed.events, vec![ReplyEvent::Heartbeat]);
let msg = codex::parse_event(
r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"fini"}}"#,
@ -234,11 +248,6 @@ mod tests {
content: "fini".to_owned()
}]
);
let completed =
codex::parse_event(r#"{"type":"turn.completed","usage":{"input_tokens":10}}"#)
.expect("ok");
assert!(completed.events.is_empty());
}
#[test]
@ -281,6 +290,8 @@ mod tests {
fake.command(),
"/",
None,
None,
None,
));
assert_agent_session_contract(session, "claude-conv-1", "réponse Claude").await;
}
@ -293,6 +304,8 @@ mod tests {
fake.command(),
"/",
None,
None,
None,
));
assert_agent_session_contract(session, "codex-conv-1", "réponse Codex").await;
}
@ -302,7 +315,7 @@ mod tests {
#[tokio::test]
async fn stream_is_closed_after_final() {
let fake = FakeCli::printing(&claude_script());
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None);
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
let stream = session.send("x").await.expect("send ok");
let events: Vec<_> = stream.collect();
let after_final = events
@ -320,7 +333,7 @@ mod tests {
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
"{ ceci n'est pas du json",
]);
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None);
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
match session.send("x").await {
Err(AgentSessionError::Decode(_)) => {}
Err(other) => panic!("attendu Decode, vu: {other:?}"),
@ -371,7 +384,11 @@ mod tests {
expected.insert("gemini", false);
expected.insert("aider", false);
assert_eq!(profiles.len(), 4, "catalogue has the four reference profiles");
assert_eq!(
profiles.len(),
4,
"catalogue has the four reference profiles"
);
for profile in &profiles {
let selectable = profile.is_selectable();
assert_eq!(
@ -397,7 +414,7 @@ mod tests {
// Claude : la session démarre et respecte le contrat via le fake CLI.
let claude = structured_profile(StructuredAdapter::Claude, &fake.command());
let session = factory
.start(&claude, &prepared_ctx(), &cwd(), &SessionPlan::None)
.start(&claude, &prepared_ctx(), &cwd(), &SessionPlan::None, None)
.await
.expect("start Claude ok");
let content = drain_final(session.as_ref()).await;
@ -407,7 +424,7 @@ mod tests {
let fake_cx = FakeCli::printing(&codex_script());
let codex = structured_profile(StructuredAdapter::Codex, &fake_cx.command());
let session_cx = factory
.start(&codex, &prepared_ctx(), &cwd(), &SessionPlan::None)
.start(&codex, &prepared_ctx(), &cwd(), &SessionPlan::None, None)
.await
.expect("start Codex ok");
let content_cx = drain_final(session_cx.as_ref()).await;
@ -427,6 +444,7 @@ mod tests {
&SessionPlan::Resume {
conversation_id: "repris-42".to_owned(),
},
None,
)
.await
.expect("start resume ok");
@ -518,8 +536,9 @@ mod tests {
cwd: "/".to_owned(),
env: Vec::new(),
stdin: None,
sandbox: None,
};
let err = run_turn(&spec, Some(Duration::from_millis(50)))
let err = run_turn(&spec, Some(Duration::from_millis(50)), None)
.await
.expect_err("doit expirer");
assert!(matches!(err, AgentSessionError::Timeout), "vu: {err:?}");
@ -673,10 +692,9 @@ mod tests {
label: "reasoning".into()
}]
);
let c = codex::parse_event(
r#"{"type":"item.completed","item":{"id":"i1","type":"command"}}"#,
)
.unwrap();
let c =
codex::parse_event(r#"{"type":"item.completed","item":{"id":"i1","type":"command"}}"#)
.unwrap();
assert_eq!(
c.events,
vec![ReplyEvent::ToolActivity {
@ -707,7 +725,12 @@ mod tests {
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message"}}"#,
)
.unwrap();
assert_eq!(m.events, vec![ReplyEvent::Final { content: String::new() }]);
assert_eq!(
m.events,
vec![ReplyEvent::Final {
content: String::new()
}]
);
}
/// `thread.started` capte le `thread_id` ; pas d'événement.
@ -718,19 +741,13 @@ mod tests {
assert!(p.events.is_empty());
}
/// Ligne vide / type inconnu / turn.started / turn.completed ⇒ ignorés sans erreur.
/// Ligne vide / type inconnu ⇒ ignorés sans erreur. (turn.started/completed sont
/// désormais des heartbeats : couverts par `codex_parse_thread_started_message_and_final`.)
#[test]
fn codex_empty_and_unknown_ignored() {
assert_eq!(codex::parse_event("").unwrap(), Default::default());
assert!(codex::parse_event(r#"{"type":"heartbeat"}"#)
.unwrap()
.events
.is_empty());
assert!(codex::parse_event(r#"{"type":"turn.started"}"#)
.unwrap()
.events
.is_empty());
assert!(codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#)
// Un `type` inconnu reste ignoré (robustesse), pas un heartbeat.
assert!(codex::parse_event(r#"{"type":"telemetry"}"#)
.unwrap()
.events
.is_empty());
@ -738,8 +755,12 @@ mod tests {
// ---- Machinerie process via FakeCli ---------------------------------
/// Deltas PUIS Final : le flux ne contient rien après le `Final` (déjà couvert
/// pour Claude ; ici on le prouve aussi pour Codex, substituabilité Liskov).
/// Deltas PUIS Final : **exactement un** `Final`, et aucun autre `Final` après lui
/// (substituabilité Liskov). Note (lot 1) : un `turn.completed` postérieur émet un
/// `Heartbeat` non terminal — légitimement après le `Final` —, donc on ne teste plus
/// « rien après le Final » mais « pas de second Final, et seul un heartbeat peut
/// suivre ». Le rendez-vous synchrone (`drain_to_final`) s'arrête de toute façon au
/// premier `Final`.
#[tokio::test]
async fn codex_stream_closed_after_final() {
let fake = FakeCli::printing(&[
@ -748,14 +769,21 @@ mod tests {
r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"fin"}}"#,
r#"{"type":"turn.completed","usage":{}}"#,
]);
let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None);
let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
let events: Vec<_> = s.send("x").await.expect("send").collect();
let after = events
let finals = events
.iter()
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
.count();
assert_eq!(finals, 1, "exactement un Final");
// Après le Final, seuls des événements non terminaux (heartbeat) peuvent suivre.
let after_final_terminals = events
.iter()
.skip_while(|e| !matches!(e, ReplyEvent::Final { .. }))
.skip(1)
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
.count();
assert_eq!(after, 0);
assert_eq!(after_final_terminals, 0, "aucun second Final après le premier");
}
/// LIMITE/ÉCART (à arbitrer) : un flux SANS `Final` ne provoque PAS d'erreur au
@ -769,7 +797,7 @@ mod tests {
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"a"}]}}"#,
]);
let s = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None);
let s = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
let events: Vec<_> = s.send("x").await.expect("send ok").collect();
let finals = events
.iter()
@ -785,7 +813,7 @@ mod tests {
#[tokio::test]
async fn run_turn_empty_output_is_ok() {
let fake = FakeCli::printing(&[]);
let lines = run_turn(&fake.spawn_line(), None).await.expect("ok");
let lines = run_turn(&fake.spawn_line(), None, None).await.expect("ok");
assert!(lines.is_empty());
}
@ -795,7 +823,7 @@ mod tests {
let fake = FakeCli::printing(&["pong"]);
let mut spec = fake.spawn_line();
spec.stdin = Some("ping".to_owned());
let lines = run_turn(&spec, None).await.expect("ok");
let lines = run_turn(&spec, None, None).await.expect("ok");
assert_eq!(lines, vec!["pong"]);
}
@ -856,8 +884,9 @@ mod tests {
cwd: "/".to_owned(),
env: Vec::new(),
stdin: None,
sandbox: None,
};
let err = run_turn(&spec, Some(Duration::from_millis(50)))
let err = run_turn(&spec, Some(Duration::from_millis(50)), None)
.await
.expect_err("doit expirer");
assert!(matches!(err, AgentSessionError::Timeout), "vu: {err:?}");
@ -878,6 +907,8 @@ mod tests {
cmd.clone(),
"/",
Some("resume-id".to_owned()),
None,
None,
);
// conversation_id amorcé avant tout tour.
assert_eq!(session.conversation_id().as_deref(), Some("resume-id"));
@ -922,6 +953,8 @@ mod tests {
cmd.clone(),
"/",
Some("cx-id".to_owned()),
None,
None,
);
let _ = session.send("vas-y").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
@ -944,7 +977,7 @@ mod tests {
r#"{"type":"system","subtype":"init","session_id":"captured-1"}"#,
r#"{"type":"result","subtype":"success","result":"r","session_id":"captured-1"}"#,
]);
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None);
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
assert_eq!(session.conversation_id(), None);
let _ = session.send("t1").await.expect("t1");
assert_eq!(session.conversation_id().as_deref(), Some("captured-1"));
@ -983,6 +1016,7 @@ mod tests {
&SessionPlan::Resume {
conversation_id: "cx-resume".to_owned(),
},
None,
)
.await
.expect("start resume codex");
@ -1005,6 +1039,7 @@ mod tests {
&SessionPlan::Assign {
conversation_id: "ignored-by-engine".to_owned(),
},
None,
)
.await
.expect("start assign");
@ -1029,7 +1064,7 @@ mod tests {
)
.expect("profil valide");
match factory
.start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None)
.start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None, None)
.await
{
Err(AgentSessionError::Start(_)) => {}
@ -1052,6 +1087,8 @@ mod tests {
fake.command(),
"/",
None,
None,
None,
));
assert_agent_session_contract(session, "cx-0", "direct").await;
}
@ -1080,11 +1117,13 @@ mod tests {
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"a"},{"type":"tool_use","name":"T"},{"type":"text","text":"b"}]},"session_id":"flow-1","parent_tool_use_id":null}"#,
r#"{"type":"result","subtype":"success","is_error":false,"result":"final-ok","session_id":"flow-1","num_turns":1}"#,
]);
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None);
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
let events: Vec<ReplyEvent> = session.send("x").await.expect("send ok").collect();
assert_eq!(
events,
vec![
// L'init `system` émet un heartbeat (vivacité non terminale, lot 1).
ReplyEvent::Heartbeat,
ReplyEvent::TextDelta { text: "a".into() },
ReplyEvent::ToolActivity { label: "T".into() },
ReplyEvent::TextDelta { text: "b".into() },
@ -1092,7 +1131,7 @@ mod tests {
content: "final-ok".into()
},
],
"le flot complet doit aplatir les 3 blocs PUIS un seul Final"
"heartbeat d'init, puis les 3 blocs aplatis, PUIS un seul Final"
);
// Un seul Final, en dernière position (redondant mais explicite).
assert_eq!(
@ -1115,7 +1154,7 @@ mod tests {
r#"{"type":"system","subtype":"init","session_id":"new-1"}"#,
r#"{"type":"result","subtype":"success","result":"r","session_id":"new-1"}"#,
]);
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None);
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
let _ = session.send("bonjour").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
let args: Vec<&str> = recorded.lines().collect();
@ -1147,7 +1186,7 @@ mod tests {
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
]);
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None);
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
let _ = session.send("salut").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
let args: Vec<&str> = recorded.lines().collect();
@ -1160,7 +1199,11 @@ mod tests {
assert!(args.contains(&"--skip-git-repo-check"), "vu: {args:?}");
assert!(args.contains(&"salut"), "vu: {args:?}");
// `exec` est bien la 1re sous-commande (position 0 de l'argv).
assert_eq!(args.first(), Some(&"exec"), "exec doit ouvrir l'argv, vu: {args:?}");
assert_eq!(
args.first(),
Some(&"exec"),
"exec doit ouvrir l'argv, vu: {args:?}"
);
let _ = std::fs::remove_file(&cmd);
let _ = std::fs::remove_file(&argv);
}
@ -1185,7 +1228,7 @@ mod tests {
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
]);
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None);
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
let _ = session.send("salut").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
let args: Vec<&str> = recorded.lines().collect();
@ -1219,6 +1262,8 @@ mod tests {
cmd.clone(),
"/",
Some("cx-id".to_owned()),
None,
None,
);
let _ = session.send("vas-y").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");

Some files were not shown because too many files have changed in this diff Show More