merge(orchestrator): intègre le chantier orchestrator-designation dans develop
Modèle de désignation d'orchestrateur (AgentManifest + may_write_directly), sink de diagnostic du rendez-vous inter-agents, et durcissement du portail d'écriture de délégation côté frontend. QA verte : application (suite complète + orchestrator_service 45), infrastructure input (35), frontend vitest (384) et tsc. Résidu qualifié : 8 tests e2e app-tauri qui bindent un vrai socket Unix échouent en EPERM — contrainte sandbox d'environnement (reproduite avec une sonde Node minimale), pas un défaut du code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -5,35 +5,35 @@
|
||||
"agentId": "a6ced819-b893-4213-b003-9e9dc79b9641",
|
||||
"name": "Main",
|
||||
"mdPath": "agents/main.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce4c09c3da4",
|
||||
"synchronized": false
|
||||
},
|
||||
{
|
||||
"agentId": "dce19c75-9669-4e45-b8de-9950025157da",
|
||||
"name": "Architect",
|
||||
"mdPath": "agents/architect.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce4c09c3da4",
|
||||
"synchronized": false
|
||||
},
|
||||
{
|
||||
"agentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f",
|
||||
"name": "DevBackend",
|
||||
"mdPath": "agents/devbackend.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce4c09c3da4",
|
||||
"synchronized": false
|
||||
},
|
||||
{
|
||||
"agentId": "af7f86da-76bc-48e1-9900-71f45a624800",
|
||||
"name": "DevFrontend",
|
||||
"mdPath": "agents/devfrontend.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce4c09c3da4",
|
||||
"synchronized": false
|
||||
},
|
||||
{
|
||||
"agentId": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5",
|
||||
"name": "QA",
|
||||
"mdPath": "agents/qa.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce4c09c3da4",
|
||||
"synchronized": false
|
||||
},
|
||||
{
|
||||
|
||||
@ -1,187 +1,111 @@
|
||||
# IdeA — Contexte & Méthode de travail
|
||||
# Main — Orchestrateur IdeA
|
||||
|
||||
> Ce document définit **mon rôle**, **la méthode de développement** et **la vision produit** du projet IdeA.
|
||||
> Il fait autorité sur la façon dont le projet est piloté. Toute évolution de méthode doit être répercutée ici.
|
||||
> Tu es **Main**, l'agent chef d'orchestre du projet IdeA. Ton rôle est de piloter les agents spécialisés, pas d'écrire le code applicatif toi-même.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mon rôle : chef d'orchestre, pas développeur
|
||||
## 1. Règle centrale : tu ne codes pas
|
||||
|
||||
Je **n'écris pas de code moi-même**. Mon rôle est de **piloter des agents** qui réalisent le travail.
|
||||
Je suis responsable de :
|
||||
Tu **n'implémentes pas directement les features** et tu ne corriges pas toi-même le code de production.
|
||||
|
||||
- Découper le travail en tâches claires et autonomes.
|
||||
- Attribuer chaque tâche aux bons agents.
|
||||
- Garantir que le cycle de développement/test est respecté.
|
||||
- Faire respecter les principes d'architecture (SOLID, Hexagonal).
|
||||
- Maintenir la cohérence globale du projet et de ce document.
|
||||
- Arbitrer et valider avant toute action irréversible ou sortante.
|
||||
Tu peux lire le projet, analyser, découper le travail, mettre à jour les contextes/mémoires, lancer des commandes de vérification et relayer les résultats. Pour toute feature ou correction applicative, tu passes par les agents spécialisés :
|
||||
|
||||
- **Architect** pour cadrer l'architecture, les ports, contrats, DTO, frontières et impacts.
|
||||
- **Git** pour décider de la branche, faire les commits et décider des merges locaux.
|
||||
- **DevBackend** pour le code Rust/backend.
|
||||
- **DevFrontend** pour le code TypeScript/React/UI.
|
||||
- **QA** pour écrire/exécuter les tests et produire les rapports d'échec.
|
||||
|
||||
Exception limitée : tu peux modifier les fichiers de contexte, mémoire, documentation de pilotage et configuration d'orchestration quand la demande porte précisément là-dessus.
|
||||
|
||||
---
|
||||
|
||||
## 2. Les agents
|
||||
## 2. Outils de délégation obligatoires
|
||||
|
||||
### 2.1 Agent Architecture (1 pour tout le projet)
|
||||
- Garant de l'architecture globale : **Hexagonale (Ports & Adapters)** et principes **SOLID**.
|
||||
- Définit les frontières (domaine / application / infrastructure), les ports, les contrats.
|
||||
- Valide que chaque nouvelle feature respecte la structure avant son développement.
|
||||
- Tient à jour la cartographie d'architecture et les conventions.
|
||||
Pour déléguer, utilise uniquement les outils IdeA natifs :
|
||||
|
||||
### 2.2 Agents de Développement
|
||||
- Écrivent le code des features.
|
||||
- Respectent strictement l'architecture définie par l'agent Architecture.
|
||||
- Code **propre, structuré, stable**.
|
||||
- Reçoivent les rapports d'erreurs des agents de test et corrigent.
|
||||
- `idea_list_agents` pour identifier les agents disponibles.
|
||||
- `idea_ask_agent` pour confier une tâche et recevoir une réponse synchrone.
|
||||
- `idea_launch_agent` pour lancer ou rattacher un agent si nécessaire.
|
||||
|
||||
### 2.3 Agents de Test
|
||||
- **Chaque agent de développement est appairé avec un agent de test dédié.**
|
||||
- Écrivent et exécutent les **tests unitaires** des features implémentées ou modifiées.
|
||||
- Produisent un **rapport d'erreurs** clair quand un test échoue.
|
||||
- Re-testent après chaque correction.
|
||||
N'utilise jamais les subagents natifs du fournisseur IA pour ce projet.
|
||||
|
||||
Quand tu reçois une tâche préfixée `[IdeA · tâche de … · ticket …]`, tu dois répondre avec `idea_reply(result=…, ticket=…)`. Une réponse texte seule ne débloque pas l'agent appelant.
|
||||
|
||||
---
|
||||
|
||||
## 3. Le cycle de développement (boucle obligatoire)
|
||||
## 3. Cycle obligatoire de développement
|
||||
|
||||
Pour **chaque** feature implémentée ou modifiée :
|
||||
Pour chaque feature ou correction applicative :
|
||||
|
||||
```
|
||||
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)
|
||||
```text
|
||||
1. Architect valide le découpage, les ports/contrats et les frontières.
|
||||
2. Git décide de la branche de travail locale.
|
||||
3. DevBackend et/ou DevFrontend implémente selon le périmètre.
|
||||
4. QA écrit/exécute les tests pertinents.
|
||||
5. Si tests KO : tu relaies le rapport réel au dev concerné, puis retour QA.
|
||||
6. Si tests OK : tu demandes à Git de committer et de décider du merge local éventuel.
|
||||
```
|
||||
|
||||
**Règle d'or :** aucune feature n'est considérée terminée tant que ses tests ne passent pas.
|
||||
Je relaie fidèlement les résultats : si des tests échouent, je le dis avec la sortie réelle.
|
||||
Aucune feature n'est considérée terminée sans sortie de test verte réelle. Si un test échoue, relaie la commande, la sortie et le diagnostic sans enjoliver.
|
||||
|
||||
---
|
||||
|
||||
## 4. Principes de code
|
||||
## 4. Répartition des responsabilités
|
||||
|
||||
- **SOLID** appliqué au maximum.
|
||||
- **Architecture Hexagonale** (Ports & Adapters) : le domaine métier est isolé des détails techniques (UI, terminal, git, SSH, système de fichiers...).
|
||||
- Le cœur métier ne dépend d'aucun framework ni d'aucune dépendance externe.
|
||||
- Tests unitaires systématiques ; couverture des features critiques.
|
||||
- Code lisible, cohérent avec le style existant, faiblement couplé, fortement cohésif.
|
||||
**Architect** est propriétaire de l'architecture hexagonale, SOLID, des ports/adapters, des contrats, DTO, modules, invariants et de la cartographie. Si un choix technique touche ces frontières, demande-lui d'abord.
|
||||
|
||||
**DevBackend** écrit le backend Rust dans le respect de la cartographie d'Architect.
|
||||
|
||||
**DevFrontend** écrit l'UI TypeScript/React dans le respect des gateways/adapters définis.
|
||||
|
||||
**QA** écrit et exécute les tests. QA ne valide que sur preuve par commande réelle.
|
||||
|
||||
**Git** est propriétaire de la topologie locale du dépôt : branches, commits, merges/rebases locaux. Ne demande pas à l'utilisateur s'il faut brancher, committer ou merger ; sollicite Git, qui tranche. Aucune action sortante (`push`, publication, PR distante) sans validation explicite utilisateur.
|
||||
|
||||
---
|
||||
|
||||
## 5. Vision produit : IdeA
|
||||
## 5. Produit : repères nécessaires à Main
|
||||
|
||||
**IdeA est un IDE next-gen 100 % IA.** On n'y code pas : **on gère des IA.**
|
||||
IdeA est un IDE next-gen 100 % IA : l'utilisateur ne code pas directement, il organise et pilote des agents IA.
|
||||
|
||||
### Fonctionnalités clés
|
||||
- **Multi-projets en parallèle** : un **onglet par projet**.
|
||||
- **Fenêtre = espace de travail** où l'on **organise plusieurs terminaux** librement.
|
||||
- **Agents par projet** : chaque projet a ses propres agents.
|
||||
- **Agents templates** : agents réutilisables, ajoutables à plusieurs projets.
|
||||
- **Création d'agents** : depuis zéro ou à partir d'un template.
|
||||
- **Synchronisation template → agents** : option « garder l'agent à jour ».
|
||||
Si le template est mis à jour, les agents qui en sont issus (avec l'option activée) reçoivent la mise à jour.
|
||||
- **Contextes d'agents stockés en `.md`** (toujours).
|
||||
- **Création de projet** = définition de son **project root**.
|
||||
Repères produit stables :
|
||||
|
||||
### Intégrations
|
||||
- **Git** intégré.
|
||||
- **Développement distant SSH** : travailler sur un projet hébergé sur une autre machine via SSH.
|
||||
- **Développement WSL** : travailler sur une WSL depuis Windows.
|
||||
- Un onglet par projet, multi-fenêtres OS supporté.
|
||||
- Espace de travail organisé en terminaux/cellules redimensionnables.
|
||||
- Agents par projet, templates globaux, synchronisation template vers agents.
|
||||
- Contextes d'agents toujours en Markdown dans `.ideai/` côté projet.
|
||||
- Profils IA déclaratifs et éditables ; aucun profil présumé au premier lancement.
|
||||
- Git, SSH et WSL intégrés à terme.
|
||||
- Stack validée : Tauri v2, Rust, TypeScript + React, xterm.js, portable-pty, git2/libgit2, russh/ssh2, `wsl.exe`.
|
||||
|
||||
### Plateformes & livraison
|
||||
- Cible : **macOS, Linux, Windows**.
|
||||
- Première phase de compilation : **Linux et Windows**.
|
||||
- Livraison :
|
||||
- **Windows** : `setup.exe`.
|
||||
- **Linux** : **AppImage** (doit fonctionner sur les différentes distributions).
|
||||
Les détails d'architecture, de ports, de layout et de découpage technique appartiennent à Architect, pas à Main. Pour ces détails, consulte ou mandate Architect au lieu de les porter dans ton contexte.
|
||||
|
||||
---
|
||||
|
||||
## 6. Stack technique (validée)
|
||||
## 6. Mémoires projet à consulter selon besoin
|
||||
|
||||
- **Shell applicatif** : **Tauri v2** (binaires légers, performants, multi-OS, AppImage + installeur `setup.exe`/NSIS Windows natifs).
|
||||
- **Cœur / backend** : **Rust** — stabilité, performance, et expression idiomatique du domaine hexagonal (ports = traits, adapters = implémentations).
|
||||
- **Frontend / UI** : **TypeScript + React**.
|
||||
- **Terminaux** : **xterm.js** (rendu) + **portable-pty** (PTY côté Rust).
|
||||
- **Git** : **libgit2** via `git2` (Rust).
|
||||
- **SSH** : `russh` / `ssh2` (Rust).
|
||||
- **WSL** : invocation de `wsl.exe` depuis le backend.
|
||||
Utilise la mémoire projet comme référence légère, sans tout recopier dans ton contexte :
|
||||
|
||||
## 7. Layout des terminaux (exigence produit)
|
||||
|
||||
Disposition en **grille redimensionnable de type tableur (Excel)** :
|
||||
|
||||
- Splits redimensionnables horizontaux **et** verticaux.
|
||||
- L'utilisateur peut **définir le nombre de colonnes dans une ligne** et **le nombre de lignes dans une colonne**, indépendamment par zone.
|
||||
- Possibilité de **fusionner des cellules** (ex. fusionner deux colonnes sur une ligne), à la manière des cellules fusionnées d'un tableur.
|
||||
- Chaque cellule de la grille héberge un terminal.
|
||||
- → Modèle de layout récursif/imbriqué (pas une grille rigide uniforme) à concevoir par l'agent Architecture.
|
||||
|
||||
## 8. Stockage des contextes & liaison aux templates
|
||||
|
||||
- **Templates d'agents** : stockés dans l'**IDE** (dossier de données utilisateur global de l'app, hors projet).
|
||||
- **Agents de projet** : leurs `.md` sont stockés dans un dossier **`.ideai/`** à la racine du project root.
|
||||
*(Nom choisi pour éviter toute collision avec le `.idea` de JetBrains.)*
|
||||
- **Manifeste de liaison** dans `.ideai/` (ex. `.ideai/agents.json`) qui mappe pour chaque agent de projet :
|
||||
- le `.md` de l'agent,
|
||||
- le template d'origine (le cas échéant),
|
||||
- `synchronized: true/false`,
|
||||
- la **version du template** au dernier sync (pour détecter qu'une mise à jour est disponible).
|
||||
- **Synchro template → agents** : quand un template est mis à jour, les agents liés avec `synchronized: true` reçoivent la MAJ.
|
||||
|
||||
## 9. Moteur IA : adaptateur de CLI flexible (Port `AgentRuntime`)
|
||||
|
||||
Chaque IA est décrite par un **profil déclaratif** (config éditable, pas du code), implémentation d'un **Port** `AgentRuntime` côté domaine. Deux variables clés par IA :
|
||||
|
||||
1. **Commande de lancement** + arguments (ex. `claude`, `codex`, `gemini`, `aider`).
|
||||
2. **Stratégie d'injection du contexte `.md`** :
|
||||
- `conventionFile` : écrire/symlink le `.md` vers le fichier attendu par la CLI (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`…).
|
||||
- `flag` : passer le chemin via un argument.
|
||||
- `stdin` : piper le contenu.
|
||||
- `env` : passer via variable d'environnement.
|
||||
|
||||
Exemple de profil :
|
||||
```json
|
||||
{
|
||||
"id": "claude-code",
|
||||
"name": "Claude Code",
|
||||
"command": "claude",
|
||||
"args": [],
|
||||
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
|
||||
"detect": "claude --version",
|
||||
"cwd": "{projectRoot}"
|
||||
}
|
||||
```
|
||||
|
||||
**Profils intégrés (références) :** Claude Code (`claude` → `CLAUDE.md`), OpenAI Codex CLI (`codex` → `AGENTS.md`), Gemini CLI (`gemini` → `GEMINI.md`), Aider (`aider` → args/message).
|
||||
|
||||
**Règles produit :**
|
||||
- **Premier lancement de l'IDE** : un assistant (first-run) **demande à l'utilisateur** quels profils d'IA configurer. On ne présume rien par défaut.
|
||||
- Les commandes des profils sont **pré-remplies mais éditables**.
|
||||
- L'utilisateur peut **ajouter sa propre commande CLI** (profil custom) pour n'importe quelle IA.
|
||||
|
||||
**Lancement d'un agent :** à l'**activation de l'agent**, on ouvre une cellule terminal (PTY) avec le bon `cwd`, on injecte le contexte `.md`, et on **auto-lance** la CLI du profil.
|
||||
|
||||
## 10. Fenêtres & onglets
|
||||
|
||||
- **Par défaut : un onglet par projet** (comme les IDE classiques).
|
||||
- **Drag & drop d'un onglet** hors de la fenêtre → **crée une nouvelle fenêtre OS** portant ce projet.
|
||||
- **Multi-fenêtres OS supporté** ; chaque fenêtre possède un ou plusieurs onglets/projets.
|
||||
|
||||
## 11. Feuille de route
|
||||
|
||||
1. **Cadrage architecture complet d'abord** (jalon en cours) : l'agent Architecture produit la cartographie complète — domaine, ports, adapters, modules, arborescence — **avant tout code**.
|
||||
2. Puis MVP incrémental selon le cycle dev/test de la section 3.
|
||||
|
||||
## 12. Autonomie d'exécution dans le projet
|
||||
|
||||
L'utilisateur m'accorde un **accès large et autonome** sur le dossier du projet : je peux lire, créer, modifier des fichiers et exécuter les commandes de développement (cargo, npm, npx, git, etc.) **sans demander confirmation à chaque fois**.
|
||||
|
||||
- Concrètement, ces autorisations sont matérialisées dans `.claude/settings.local.json` (mode `acceptEdits` + `Bash`/`Read`/`Edit`/`Write` autorisés), pas dans ce document — CONTEXT.md ne fait que **documenter l'intention**.
|
||||
- **Garde-fous conservés** : les actions destructrices ou hors-projet restent bloquées (`sudo`, `rm -rf` sur `/`/`~`/`$HOME`, `mkfs`, `dd`, `shutdown`/`reboot`…).
|
||||
- L'esprit du rôle (§1) ne change pas : je reste **chef d'orchestre**. L'autonomie porte sur l'exécution mécanique, pas sur l'arbitrage des décisions produit/archi, ni sur les **actions sortantes** (push, publication) qui restent soumises à validation explicite.
|
||||
- `agent-context-memory-and-profile-handoff` : contexte, mémoire durable, état live, handoff de profil.
|
||||
- `idea-product-directives-main-handoff` : directives produit pour robustesse, persistance, handoff cross-profile, sobriété UX.
|
||||
- `remaining-work-idea-agent-control-ide` : état des acquis et chantiers restants.
|
||||
- `mcp-bridge-and-delegation-runtime-notes` : pièges runtime du pont MCP et rebuild AppImage.
|
||||
- `permissions-sandbox-system-state` : permissions/sandbox et risque résiduel.
|
||||
- `session-limit-handling-design` : limites de session et reprise auto annulable.
|
||||
- `git-owns-commit-merge-decisions` : Git décide commits/branches/merges locaux.
|
||||
- `conversation-rotation-safety-design` : rotation sûre des conversations.
|
||||
|
||||
---
|
||||
|
||||
*Dernière mise à jour : 2026-06-05*
|
||||
## 7. Décisions et garde-fous
|
||||
|
||||
Tu arbitres les décisions produit et de pilotage, mais tu ne remplaces pas les agents spécialisés dans leur domaine.
|
||||
|
||||
Tu peux agir de façon autonome dans le project root pour lire, organiser, lancer les commandes de dev/test et mettre à jour les contextes. Les actions destructrices, hors-projet ou sortantes restent interdites sans validation explicite.
|
||||
|
||||
Si la demande utilisateur contredit le cycle, rappelle brièvement la règle et applique le cycle. Si le contexte d'un agent manque une consigne qui relève de son rôle, mets à jour ce contexte au lieu de gonfler celui de Main.
|
||||
|
||||
---
|
||||
|
||||
*Dernière mise à jour : 2026-06-20*
|
||||
File diff suppressed because one or more lines are too long
@ -50,3 +50,9 @@
|
||||
{"id":"2acee2f9-cf46-4f65-8131-8a66751c55e0","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781676775264,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tâche QA — tests unitaires LS8-backend (filet humain niveau 3), branche `feature/agent-session-limits`. DevBackend a implémenté (build 0 erreur/0 warning, suites domain/application/app-tauri déjà vertes, non commité).\n\nÀ COUVRIR — nouvelle méthode `SessionLimitService::confirm_human_resume(agent_id, node_id, conversation_id, resets_at_ms: i64)` dans `crates/application/src/agent/session_limit.rs`. Teste via les fakes existants (Clock/Scheduler/EventBus) du fichier `crates/application/tests/session_limit_service.rs` (suis ce style). Cas (cadrage Architect) :\n(a) heure FUTURE ⇒ publie `AgentRateLimited{Some(resets_at_ms)}` puis `AgentResumeScheduled{fire_at_ms == resets_at_ms}` + un ScheduleId armé sur le scheduler.\n(b) heure PASSÉE (resets_at_ms < now) ⇒ clamp : `fire_at_ms == now` (reprise quasi-immédiate). Vérifie l'événement `AgentResumeScheduled` avec fire_at_ms == now.\n(c) DÉDOUBLONNAGE croisé : `confirm_human_resume` APRÈS un `on_rate_limited` déjà armé pour le même agent ⇒ le précédent réveil est désarmé, une seule entrée armée (et l'inverse : `on_rate_limited` après `confirm_human_resume`). Vérifie qu'il n'y a qu'un seul armement actif.\n(d) ANNULABILITÉ : `cancel_resume(agent_id)` après `confirm_human_resume` ⇒ publie `AgentResumeCancelled` et renvoie true (l'armement humain s'annule par la même voie).\n(e) PARITÉ auto/humain : même séquence d'événements qu'`on_rate_limited` cas Scheduled (à reset identique).\n\nÀ COUVRIR — commande `set_resume_at` (app-tauri) si testable au niveau intégration comme `session_limit_wiring.rs` : au minimum le cas `node_for_agent == None` ⇒ erreur NOT_FOUND (pas d'armement orphelin). Si le câblage rend ce test trop lourd (besoin d'une cellule vivante), note-le et concentre-toi sur la couverture application (a–e) qui est l'essentiel.\n\nExécute (`cargo test -p application` + `-p app-tauri` si pertinent). RENDS-MOI via idea_reply : commande exacte, résultat réel (passés/échoués), rapport d'erreurs complet si rouge. Si un test révèle un bug (notamment sur le dédoublonnage croisé ou le clamp passé), décris-le précisément (attendu vs obtenu) — je relaierai à DevBackend. Ne commit rien."}
|
||||
{"id":"a2c0999e-227a-4cfb-99f5-2e8b80ce84f0","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781676917946,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"QA LS8-backend (filet humain niveau 3) — VERT. Aucun bug d'implémentation trouvé (ni sur le dédoublonnage croisé, ni sur le clamp passé). Rien commité.\n\n## Fichiers de test modifiés (2 — ajouts only, fakes existants réutilisés)\n- `crates/application/tests/session_limit_service.rs` : +6 tests `confirm_human_resume` (style des fakes Clock/Scheduler/EventBus existants).\n- `crates/app-tauri/tests/session_limit_wiring.rs` : +2 tests (précondition NOT_FOUND + parité runtime humaine sur le vrai bus).\n\n## Commandes exactes lancées + résultats réels\n1. `cargo test -p application --test session_limit_service`\n → **15 passed; 0 failed** (10 existants + 5 nouveaux).\n2. `cargo test -p app-tauri --test session_limit_wiring`\n → **4 passed; 0 failed** (2 existants + 2 nouveaux).\n3. `cargo test -p application` (régression complète) → **toutes suites vertes, 0 failed**.\n4. `cargo test -p app-tauri` (régression complète) → **toutes suites vertes, 0 failed**.\n\n## Couverture application (a–e) — l'essentiel\n- **(a) heure FUTURE** `confirm_human_resume_future_arms_and_emits_in_order` : exactement 1 `arm(fire_at_ms==resets_at_ms, ResumeAgent{agent,node,conv})`, 1 ScheduleId émis, events `AgentRateLimited{Some(reset)}` PUIS `AgentResumeScheduled{fire_at_ms==reset}` dans cet ordre. ✓\n- **(b) heure PASSÉE** `confirm_human_resume_past_reset_clamps_fire_at_to_now` : `fire_at_ms == now` (clamp anti-passé) ; `AgentRateLimited` garde l'heure brute passée, `AgentResumeScheduled` porte le `now` clampé. ✓\n- **(c) dédoublonnage CROISÉ** (les deux sens) :\n - `confirm_human_resume_after_auto_dedups_single_active_arm` : confirm APRÈS on_rate_limited → l'ancien ScheduleId auto est cancel-é (`cancels == [issued[0]]`), pas d'`AgentResumeCancelled` (silencieux). Unicité prouvée : 1er `cancel_resume`→true, 2e→false. ✓\n - `auto_after_confirm_human_resume_dedups_single_active_arm` : on_rate_limited APRÈS confirm → symétrique, même invariant d'unicité. ✓\n- **(d) ANNULABILITÉ** `cancel_resume_after_confirm_human_resume_returns_true_and_emits_cancelled` : renvoie `true` + publie `AgentResumeCancelled` (même voie que l'auto). ✓\n- **(e) PARITÉ auto/humain** `confirm_human_resume_is_event_for_event_identical_to_auto_scheduled` : à reset futur identique, `bus.events()` ET `scheduler.armed()` strictement égaux entre `confirm_human_resume` et `on_rate_limited`. La source Human vs Structured n'a aucun effet observable. ✓\n\n## Couverture app-tauri `set_resume_at`\n- **NOT_FOUND** `set_resume_at_resolves_no_cell_for_an_agent_without_a_live_session` : ✓ couvert au niveau précondition. NOTE : la commande `#[tauri::command] set_resume_at` exige `State<AppState>`, non constructible hors runtime Tauri — le harnais wiring existant n'invoque jamais les commandes, seulement le service. J'ai donc testé la **précondition exacte** qui produit le NOT_FOUND : sur un `AppState::build` neuf, `structured_sessions.node_for_agent(unknown)` ET `terminal_sessions.node_for_agent(unknown)` renvoient `None` → la branche `ok_or_else(NotFound)` (commands.rs:1420-1428) est prise → aucun armement orphelin. C'est la couverture maximale réaliste sans faire tourner Tauri.\n- **Parité runtime** `confirm_human_resume_arms_a_cancellable_resume_over_the_real_bus` : sur le vrai `TokioBroadcastEventBus`, `confirm_human_resume` publie `AgentRateLimited` puis `AgentResumeScheduled` et l'armement est annulable (`cancel_resume`→true), exactement comme la branche auto déjà testée. ✓\n\n## Observation (non bloquante, pas un bug)\n`confirm_human_resume` est total et défensif : le cas `ResumePlan::HumanFallback` y est inatteignable (`resets_at_ms` toujours `Some`) → traité en no-op via `if let Scheduled`. Conforme au cadrage Architect ; pas de test dédié (branche morte par construction).\n\nBuild app-tauri OK (0 erreur). Rien n'a été commité."}
|
||||
{"id":"7c627a21-5a9f-470e-9137-a63697ae87da","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781677172091,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tâche QA — validation LS8-front (formulaire de saisie d'heure, filet humain niveau 3) sur `feature/agent-session-limits`. DevFrontend a implémenté ET ajusté/ajouté des tests (typecheck propre, `npx vitest run src/features/agents/ src/adapters/mock/` = 109 tests verts). Ton rôle : valider la couverture, exécuter, et signaler tout trou ou bug.\n\nPoints à vérifier/exécuter :\n1. Helper pur `timeInputToEpochMs(value, now)` (AgentLimitBadge.tsx) : cas couverts ? « HH:MM » valide → aujourd'hui à cette heure ; instant passé dans la journée → epoch < now (le backend clampe, pas le front) ; malformé/vide/`25:00`/`12:60` → `null`. Ajoute les cas manquants si besoin.\n2. Soumission du formulaire (état suspected SANS heure) ⇒ `onSetResumeAt` / action hook `setResumeAt` appelée avec l'epoch attendu ; bouton désactivé si saisie vide ou `busy` ; saisie malformée (`null`) ne déclenche PAS d'appel.\n3. Action hook `useAgents.setResumeAt(agentId, resetsAtMs)` : délègue bien au port `input.setResumeAt`, aucune mutation optimiste (vérifie que l'état ne bascule QUE sur réception de `agentResumeScheduled`, pas avant). Mock instrumenté `resumeArmings`.\n4. Bascule complète : agent suspected sans heure → submit form → émettre `agentResumeScheduled` (via mock) → le badge passe à l'état nominal (countdown + bouton Annuler), le formulaire disparaît.\n5. Non-régression : tout `src/features/agents/` + `src/adapters/mock/` vert. Confirme le compteur gateways corrigé (14) dans mock.test.ts.\n\nExécute `npx vitest run src/features/agents/ src/adapters/mock/` (+ `tsc --noEmit` si tu veux). RENDS-MOI via idea_reply : commande exacte, résultat réel (passés/échoués), trous comblés (fichiers/tests ajoutés), et tout bug trouvé (attendu vs obtenu, fichier:ligne) — je relaierai à DevFrontend. Ne commit rien."}
|
||||
{"id":"414e8769-7a3c-4617-b391-f97bc5490877","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781886707511,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"QA rapide correctif IdeA Codex structured runtime: j'ai ajouté `PreparedContext.project_root`, relayé ce champ depuis `LaunchAgent`, puis `StructuredSessionFactory` passe `vec![ctx.project_root]` à `CodexExecSession`. `CodexExecSession` ajoute maintenant `--add-dir <project-root>` après `--sandbox workspace-write` et avant le prompt, sans `--ask-for-approval`. Tests ciblés verts: `cargo test -p infrastructure codex_new_conversation_command_carries_exact_args`, `cargo test -p infrastructure codex_resume_command_carries_exact_args`; `cargo check --workspace` vert avec warning préexistant `may_write_directly`. Peux-tu relire le risque principal et indiquer s'il manque un test critique avant rebuild AppImage ?"}
|
||||
{"id":"0ba164a2-0479-442a-a992-3a6e519a825d","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781886791342,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"QA rapide terminée.\n\nVerdict : le risque principal est bien le **pont entre le run dir isolé et le vrai project root** pour `codex exec` : si `--add-dir <project-root>` manque, est mal ordonné, ou si `--ask-for-approval` revient, Codex peut soit rester confiné au run dir, soit échouer au démarrage avec l’argument inconnu. Les deux tests ciblés protègent correctement la composition argv de `CodexExecSession` en neuf et en reprise : ordre exact, `--sandbox workspace-write`, `--add-dir /project/root` avant le prompt, et absence de `--ask-for-approval`.\n\nRelecture du câblage :\n- `PreparedContext` porte maintenant `project_root: String` dans `domain::ports`.\n- `LaunchAgent` le remplit avec `input.project.root.as_str().to_owned()`.\n- `StructuredSessionFactory` passe `vec![ctx.project_root.clone()]` uniquement au bras `StructuredAdapter::Codex`.\n- `CodexExecSession::build_spawn_line` ajoute `--add-dir <root>` après `--sandbox workspace-write`, filtre les racines vides, puis pousse le prompt en dernier.\n\nTest critique manquant ? Pas bloquant pour rebuild AppImage selon moi, parce que le bug runtime immédiat est verrouillé au niveau où l’argv réel est construit. Le seul test de renfort que j’ajouterais si on veut fermer complètement la régression est un test **factory-level** : démarrer un profil Codex via `StructuredSessionFactory::start(...)` avec un `PreparedContext { project_root: \"/project/root\" }`, fake CLI enregistreur, `send()`, puis asserter que l’argv contient bien `--add-dir /project/root`. Les tests actuels instancient `CodexExecSession` directement, donc ils ne prouvent pas explicitement que la factory relaie toujours `ctx.project_root`. Ce n’est pas un risque suffisant pour bloquer le rebuild, mais c’est le garde-fou le plus utile à ajouter ensuite.\n\nSortie réelle relancée :\n\n`cargo test -p infrastructure codex_new_conversation_command_carries_exact_args` :\n```text\nrunning 1 test\ntest session::tests::codex_new_conversation_command_carries_exact_args ... ok\n\ntest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 220 filtered out; finished in 0.00s\n```\n\n`cargo test -p infrastructure codex_resume_command_carries_exact_args` :\n```text\nrunning 1 test\ntest session::tests::codex_resume_command_carries_exact_args ... ok\n\ntest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 220 filtered out; finished in 0.00s\n```\n\nWarning observé, conforme à ton signalement : `unused import: may_write_directly` dans `crates/application/src/orchestrator/context_guard.rs:29:25`."}
|
||||
{"id":"6ec1451a-fe4d-4e8d-be02-52425f1f8f88","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781903727049,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Diagnostic demandé par Main : teste uniquement l’écriture effective dans le `.git` du projet IdeA, de façon minimale et réversible. Crée un fichier temporaire unique sous `/home/anthony/Documents/Projects/IdeA/.git/`, supprime-le immédiatement si la création réussit, puis rapporte le résultat exact (`WRITE_OK` ou l’erreur noyau, notamment `Read-only file system`). Ne lance pas `git add`, `git commit`, ni aucune action Git destructive. Réponds via `idea_reply` si tu reçois ce message comme tâche déléguée."}
|
||||
{"id":"ccb44f24-80cb-45ee-a724-60313202ae96","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781903746184,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"Commande exécutée (sans git add/commit ni action Git destructive) : création d’un fichier temporaire unique sous `/home/anthony/Documents/Projects/IdeA/.git/`, suppression prévue uniquement en cas de succès.\n\nRésultat :\n```text\ntouch: cannot touch '/home/anthony/Documents/Projects/IdeA/.git/idea-write-test-2-1781903741512743602': Read-only file system\n```\n\nConclusion : écriture refusée par le noyau avec `Read-only file system`. Aucun fichier temporaire n’a été créé, donc aucune suppression n’était nécessaire."}
|
||||
{"id":"8bc3c570-3204-444d-ace5-ada62913f082","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781938370874,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Validation QA du chantier `orchestrator-designation` sur la branche courante `feature/orchestrator-designation`.\n\nContexte cycle:\n- Git a décidé de finir ce chantier sur la branche actuelle, sans switch/stash.\n- DevBackend vient de corriger `crates/application/src/orchestrator/context_guard.rs`.\n- Main ne code pas; tu dois valider/qualifier.\n\nHistorique des validations:\nAvant correction DevBackend:\n- `cargo test -p infrastructure input --lib`: OK, 35 passed.\n- `cargo test -p application --test orchestrator_service`: OK, 45 passed.\n- `cd frontend && npx vitest run`: OK, 41 files / 384 tests passed.\n- `cd frontend && npx tsc --noEmit`: OK.\n- `cargo test --workspace`: échouait à la compilation sur `context_guard.rs` (`may_write_directly` 3e arg manquant, `AgentManifest.orchestrator` mal initialisé).\n\nCorrection DevBackend:\n- `ProposeContext` charge `AgentManifest`, récupère `manifest.orchestrator_designation()`, puis appelle `may_write_directly(requester, &GuardedResource::ProjectContext, &designation)`.\n- Tests locaux `context_guard.rs` adaptés au modèle `AgentManifest { version, entries, orchestrator }`.\n- Le `FileGuard` reste un verrou; l'autorisation orchestrateur reste côté domaine.\n\nValidations DevBackend après correction:\n1. `cargo fmt --all && cargo test -p application --test orchestrator_service`\n - OK, `45 passed; 0 failed; 0 ignored`.\n2. `cargo test -p application`\n - OK, suite application verte.\n3. `cargo test --workspace`\n - Compile maintenant plus loin, puis échoue dans `app-tauri`:\n - `app-tauri` lib: `39 passed; 8 failed`.\n - Échecs: `mcp_bridge::tests::end_to_end_over_real_loopback`, `state::bind_endpoint_d1_tests::rebind_after_corpse_socket_succeeds`, et `state::mcp_e2e_loopback_tests::*`.\n - Cause observée: `PermissionDenied` / `Operation not permitted` lors du bind socket sous `/run/user/1000/idea-mcp/*.sock`, ex. `bind test listener: Os { code: 1, kind: PermissionDenied, message: \"Operation not permitted\" }`.\n\nTa mission QA:\n1. Relance les tests pertinents si possible.\n2. Qualifie le résidu `app-tauri`: régression code ou contrainte environnement/sandbox ?\n3. Donne à Main un verdict clair: chantier validable ou correction requise.\n4. Si correction requise, donne le rapport d'erreur exact pour DevBackend.\n5. Si validable sous contrainte, donne la liste de commandes vertes qui font foi et la réserve exacte.\n\nRéponds via `idea_reply` si cette tâche arrive via IdeA."}
|
||||
{"id":"abed1f56-1094-4d4d-b058-acc2dcd94ca3","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781938482886,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"Verdict QA: chantier `orchestrator-designation` validable sous contrainte d'environnement. Je ne vois pas de régression fonctionnelle liée à la correction `context_guard.rs`.\n\nCommandes vertes qui font foi:\n- `cargo fmt --all -- --check`: OK, aucune sortie.\n- `cargo test -p application --test orchestrator_service`: OK, `45 passed; 0 failed; 0 ignored`.\n- `cargo test -p application`: OK, suite application complète verte; inclut `orchestrator::context_guard::tests::orchestrator_writes_global_context_directly` et `agent_proposing_global_context_files_a_proposal_not_a_write`.\n- `cargo test -p infrastructure input --lib`: OK, `35 passed; 0 failed; 0 ignored; 187 filtered out`.\n- `cd frontend && npx vitest run`: OK, `41 passed (41)` files, `384 passed (384)` tests.\n- `cd frontend && npx tsc --noEmit`: OK, aucune sortie.\n\nRésidu `app-tauri`:\n- `cargo test -p app-tauri --lib`: ROUGE, `39 passed; 8 failed; 0 ignored`.\n- `cargo test --workspace`: ROUGE sur le même bloc `app-tauri`; la compilation va désormais plus loin et ne bloque plus sur `context_guard.rs`.\n\nÉchecs exacts observés:\n- `mcp_bridge::tests::end_to_end_over_real_loopback`, [crates/app-tauri/src/mcp_bridge.rs:577] : `bind test listener: Os { code: 1, kind: PermissionDenied, message: \"Operation not permitted\" }`.\n- `state::bind_endpoint_d1_tests::rebind_after_corpse_socket_succeeds`, [crates/app-tauri/src/state.rs:4787] : `lay corpse socket: Os { code: 1, kind: PermissionDenied, message: \"Operation not permitted\" }`.\n- `state::mcp_e2e_loopback_tests::{handshake_requester_propagates_over_real_loopback,list_agents_round_trips_over_real_loopback,ask_then_reply_round_trips_inline_over_real_loopback,ask_then_reply_round_trips_inline_over_real_loopback_codex,malformed_jsonrpc_after_handshake_errors_and_server_survives,orphan_reply_is_typed_error_over_real_loopback}`, [crates/app-tauri/src/state.rs:4208] : `M5e needs a real bound listener; bind_endpoint returned None for \"/run/user/1000/idea-mcp/<id>.sock\"`.\n\nQualification du résidu:\n- Contrainte environnement/sandbox, pas régression du chantier. Une sonde Node minimale dans ce même environnement échoue à créer un listener Unix avec `EPERM` même hors `/run/user/1000`:\n - `/run/user/1000/idea-mcp/codex-bind-probe.sock` => `EPERM: listen EPERM: operation not permitted`.\n - `/tmp/codex-bind-probe.sock` => `EPERM: listen EPERM: operation not permitted`.\n- Les 8 tests rouges sont précisément ceux qui exigent un vrai loopback Unix; les tests `mcp_serve_peer_tests` duplex/in-memory et les tests de wiring/domain restent verts.\n\nConclusion: correction DevBackend suffisante pour ce chantier. Pas de correction requise côté `orchestrator-designation`; réserve exacte: la validation globale `cargo test --workspace` reste impossible dans ce sandbox tant que les tests `app-tauri` nécessitant `listen()` sur socket Unix ne peuvent pas binder."}
|
||||
|
||||
@ -1,15 +1,9 @@
|
||||
---
|
||||
upTo: 6df9f7b7-fcc4-4b87-94db-33dce2d76f13
|
||||
upTo: 7f294345-a6f4-494a-aba5-7343fbbdff3f
|
||||
objective: Nouvelle feature cadrée par Architect, prête à démarrer le développement : « Gestion des limites de session des agents » (détection hiérarchique des limites de session + reprise auto annulable). C'est
|
||||
---
|
||||
**Objectif :** Nouvelle feature cadrée par Architect, prête à démarrer le développement : « Gestion des limites de session des agents » (détection hiérarchique des limites de session + reprise auto annulable). C'est
|
||||
|
||||
- **Response:** Décision (agent Git) — branche retenue : **`feature/agent-session-limits`**, créée **depuis `develop`**. **Raisonnement :** - Nouvelle feature indépendante (LS1→LS8) → conformément au modèle `main ← develop ← feature/*`, elle se développe sur sa propre branche `feature/*`. - Pas besoin de (re)créer `develop` : il existe déjà et est à jour (post-release 0.1.0, `git diff main develop` vide → contenu identique). C'est donc la bonne base de départ. - Nom dérivé du sujet, kebab-case : `feature/agent-session-limits`. **État git après manœuvre (LOCAL, aucun push) :** - Branche courante : `feature/agent-session-limits` @ `401c18a` (= base `develop`). - `develop` @ `401c18a` (inchangé) ← `main` @ `9a8a89f` (tag `v0.1.0`, inchangé). - `feature/agent-skills` @ `ef101db` (autre feature, intacte). **Note :** le working tree porte déjà des changements non commités hérités de l'état courant — notamment `ARCHITECTURE.md` (cadrage Architect) et un nouveau `.ideai/memory/session-limit-handling-design.md`, plus du runtime `.ideai/` (conversations/MEMORY.md). Ils n'ont pas été commités : DevBackend/QA les intégreront dans les commits de la feature au fil des lots. Le cadrage Architect (ARCHITECTURE.md + memory design) pourra faire le **premier commit** de la branche avant LS1 si tu le souhaites — dis-moi si tu veux que je le commite maintenant. Dev peut démarrer LS1 (domaine) sur `feature/agent-session-limits`. Après chaque lot vert, reviens vers moi pour décider d'un éventuel commit/merge. Aucune intégration vers `develop` tant que les tests des lots concernés ne sont pas verts (règle d'or).
|
||||
- **Prompt:** Feu vert pour le premier commit de la branche feature/agent-session-limits : commite le cadrage AVANT que le dev (LS1) ne démarre, pour isoler proprement le cadrage du code. Contenu à committer (cadrage uniquement) : ARCHITECTURE.md (nouvelle §21) + .ideai/memory/session-limit-handling-design.md + la mise à jour de .ideai/memory/MEMORY.md (pointeur). N'inclus PAS le runtime .ideai/ non pertinent (conversations, layouts, agents.json…) s'il pollue le diff — limite le commit aux fichiers du cadrage. Message de commit conforme au style du dépôt (français, conventionnel). Périmètre LOCAL uniquement — aucun push. Confirme le hash du commit et l'état git après.
|
||||
- **Response:** Cadrage committé sur `feature/agent-session-limits` — premier commit de la branche, isolé du code. **Commit : `fa5b826`** `docs(session-limits): cadrage Architect — gestion des limites de session des agents` **Contenu (cadrage uniquement, 3 fichiers / +212 lignes) :** - `ARCHITECTURE.md` (+185, nouvelle §21) - `.ideai/memory/session-limit-handling-design.md` (nouveau, design validé) - `.ideai/memory/MEMORY.md` (+1, pointeur) Le runtime `.ideai/` non pertinent (conversations, layouts, agents.json…) a été **exclu** du diff comme demandé. **État git après commit (LOCAL, aucun push) :** - `feature/agent-session-limits` @ `fa5b826` ← `develop` @ `401c18a` ← `main` @ `9a8a89f` (tag `v0.1.0`). - Working tree : reste uniquement du runtime `.ideai/conversations/…` non commité (bruit, ignoré pour le cadrage). La branche est prête : le dev peut démarrer **LS1 (domaine)**. Je reviendrai dans la boucle pour committer chaque lot vert.
|
||||
- **Prompt:** LS1 (couche domaine) de la feature « limites de session » est VERT : DevBackend a livré le code pur, QA a écrit 24 tests, `cargo test -p domain` = 165 passed / 0 failed, zéro régression. Commite LS1 sur feature/agent-session-limits (commit suivant après le cadrage fa5b826). Périmètre LOCAL uniquement, aucun push. Fichiers de prod (domaine) modifiés/créés par DevBackend : - crates/domain/src/ports.rs (variante ReplyEvent::RateLimited) - crates/domain/src/readiness.rs (variante ReadinessSignal::RateLimited + classify) - crates/domain/src/session_limit.rs (NOUVEAU : SessionLimit, ResumePlan, RateLimitSource, plan_resume) - crates/domain/src/profile.rs (RateLimitPattern + champ + builder) - crates/domain/src/events.rs (5 variantes DomainEvent) - crates/domain/src/lib.rs (mod + re-exports) Plus les tests QA dans ces mêmes fichiers (#[cfg(test)]). N'inclus PAS le runtime .ideai/ non pertinent. Message conventionnel en français. IMPORTANT — décision de merge : NE merge PAS vers develop maintenant. Le workspace complet (`cargo build --workspace`) NE COMPILE PAS tant que LS2 n'a pas ajouté l'arm `ReplyEvent::RateLimited` côté infra (match exhaustifs). On ne merge feature/* → develop que sur une tranche qui compile de bout en bout (cible : LS1+LS2+LS4 = niveau 1 Claude complet). Confirme juste le hash du commit LS1 et l'état git.
|
||||
- **Response:** LS1 (couche domaine) committé sur `feature/agent-session-limits`, après le cadrage `fa5b826`. **Commit : `0bf1eb3`** `feat(session-limits): LS1 — couche domaine (détection + plan de reprise)` **Contenu (6 fichiers domaine, prod + tests inline / +647 lignes) :** - `crates/domain/src/session_limit.rs` (nouveau) - `crates/domain/src/ports.rs` (ReplyEvent::RateLimited) - `crates/domain/src/readiness.rs` (ReadinessSignal::RateLimited + classify) - `crates/domain/src/profile.rs` (RateLimitPattern + champ + builder) - `crates/domain/src/events.rs` (5 variantes DomainEvent) - `crates/domain/src/lib.rs` (mod + re-exports) Runtime `.ideai/` non pertinent **exclu**. QA : `cargo test -p domain` = 165 passed / 0 failed, zéro régression. **Décision de merge : PAS de merge vers `develop`** — conforme à ta consigne. Le workspace ne compile pas tant que LS2 n'a pas ajouté l'arm `ReplyEvent::RateLimited` côté infra (match exhaustifs). On n'intègre `feature/* → develop` que sur une tranche qui compile de bout en bout (cible : LS1+LS2+LS4 = niveau 1 Claude complet). Je reste sur la branche feature. **État git (LOCAL, aucun push) :** ``` feature/agent-session-limits @ 0bf1eb3 (HEAD) └ fa5b826 docs cadrage └ 401c18a (develop) ← main @ 9a8a89f (tag v0.1.0) ``` Working tree : seul du runtime `.ideai/conversations/…` non commité (ignoré). Dev peut enchaîner sur LS2.
|
||||
- **Prompt:** LS2 (adapter Claude niveau 1, infra) est VERT : `cargo test -p infrastructure` = 188 passed / 0 failed, zéro régression. Commite LS2 sur feature/agent-session-limits (après LS1 0bf1eb3). LOCAL uniquement, aucun push. Fichier de prod modifié : crates/infrastructure/src/session/claude.rs (parse_event émet ReplyEvent::RateLimited ; nouvelle fonction pure parse_reset_ms + helpers ; parseur ISO maison ; doccomments T4). Tests QA : dans crates/infrastructure/src/session/mod.rs (#[cfg(test)]) — 26 nouveaux tests + 2 tests existants mis à jour vers le nouveau contrat ; et crates/infrastructure/src/session/conformance.rs (#[cfg(test)] : RateLimited ajouté aux événements non terminaux autorisés). Exclu le runtime .ideai/. Message conventionnel français. Décision merge : TOUJOURS PAS de merge vers develop (le workspace ne compile pas encore — 2 erreurs résiduelles attendues dans app-tauri, relèvent de LS7/LS8 ; et la réconciliation T4 reste à faire en LS4). On vise la tranche LS1+LS2+LS3+LS4 = niveau 1 Claude de bout en bout qui compile, pour le premier merge. Confirme le hash LS2 et l'état git.
|
||||
- **Response:** LS2 (adapter Claude niveau 1, infra) committé sur `feature/agent-session-limits`, après LS1 `0bf1eb3`. **Commit : `a1755e5`** `feat(session-limits): LS2 — adapter Claude niveau 1 (infra)` **Contenu (3 fichiers infra, prod + tests inline / +545 lignes) :** - `crates/infrastructure/src/session/claude.rs` (prod : parse_event → ReplyEvent::RateLimited, parse_reset_ms + helpers, parseur ISO maison, doccomments T4) - `crates/infrastructure/src/session/mod.rs` (26 nouveaux tests QA + 2 alignés) - `crates/infrastructure/src/session/conformance.rs` (RateLimited en non-terminal autorisé) Runtime `.ideai/` **exclu**. QA : `cargo test -p infrastructure` = 188 passed / 0 failed, zéro régression. **Décision de merge : TOUJOURS PAS de merge vers `develop`** — conforme. Le workspace ne compile pas encore (2 erreurs résiduelles attendues dans app-tauri → LS7/LS8 ; réconciliation T4 → LS4). Premier merge visé sur la tranche **LS1+LS2+LS3+LS4 = niveau 1 Claude de bout en bout qui compile**. **État git (LOCAL, aucun push) :** ``` feature/agent-session-limits @ a1755e5 (HEAD) └ 0bf1eb3 LS1 domaine └ fa5b826 cadrage └ 401c18a (develop) ← main @ 9a8a89f (tag v0.1.0) ``` Working tree : seul du runtime `.ideai/` non commité (ignoré). Dev peut enchaîner sur LS3.
|
||||
- **Prompt:** LS3 (port Scheduler + adapter TokioScheduler) est VERT : `cargo test -p infrastructure` = 195 passed / 0 failed (7 nouveaux tests scheduler, relancés 3× sans flaky), LS1/LS2 toujours verts. Builds domaine + infra OK 0 warning. Le workspace complet ne compile toujours pas (2 erreurs résiduelles inchangées dans app-tauri : chat.rs:185 → LS8, events.rs:261 → LS7 ; la réconciliation T4 reste en LS4). C'est TOI qui décides quoi et quand committer/merger. Je te livre l'état, tu tranches. Fichiers de prod LS3 : - crates/domain/src/ids.rs (ScheduleId via typed_id!) - crates/domain/src/ports.rs (trait Scheduler + enum ScheduledTask) - crates/domain/src/lib.rs (re-exports) - crates/infrastructure/src/scheduler/mod.rs (NOUVEAU : TokioScheduler) - crates/infrastructure/src/lib.rs (pub mod scheduler + re-export) Tests QA : dans crates/infrastructure/src/scheduler/mod.rs (#[cfg(test)]). Runtime .ideai/ à exclure du diff. Décide : faut-il committer LS3 maintenant (et avec quel message) ? Et concernant le merge vers develop — rappel du contexte : le premier merge envisagé portait sur la tranche LS1+LS2+LS3+LS4 (niveau 1 Claude de bout en bout qui compile), or le workspace ne compile pas encore (LS4 + app-tauri manquants). Dis-moi ta décision (commit LS3 sur la feature ? attendre la tranche compilable pour merger ? autre ?) et exécute-la. Confirme hash + état git.
|
||||
- **Response:** Décision (agent Git) : **commit LS3 sur la feature, PAS de merge vers `develop`.** **Pourquoi :** chaque lot vert mérite son commit atomique sur la branche feature (traçabilité, bissection facile), mais le merge `feature/* → develop` exige une tranche qui **compile de bout en bout** — règle d'or. Or le workspace ne compile toujours pas (réconciliation T4 en LS4 + 2 erreurs résiduelles app-tauri : `chat.rs:185`→LS8, `events.rs:261`→LS7). Donc on continue d'accumuler sur la feature ; premier merge maintenu sur la tranche compilable (LS1+LS2+LS3+LS4 niveau 1 Claude, voire jusqu'à LS7/LS8 si la compilation workspace n'est verte qu'à ce moment-là). **Commit : `253310b`** `feat(session-limits): LS3 — port Scheduler + adapter TokioScheduler` **Contenu (5 fichiers prod + tests inline / +389 lignes) :** - domaine : `ports.rs` (trait Scheduler + enum ScheduledTask), `ids.rs` (ScheduleId), `lib.rs` (re-exports) - infra : `scheduler/mod.rs` (nouveau, TokioScheduler), `lib.rs` (pub mod + re-export) Runtime `.ideai/` **exclu**. QA : `cargo test -p infrastructure` = 195 passed / 0 failed (7 tests scheduler, 3× sans flaky) ; LS1/LS2 verts ; builds domaine+infra 0 warning. **État git (LOCAL, aucun push) :** ``` feature/agent-session-limits @ 253310b (HEAD) └ a1755e5 LS2 adapter Claude └ 0bf1eb3 LS1 domaine └ fa5b826 cadrage └ 401c18a (develop) ← main @ 9a8a89f (tag v0.1.0) ``` Dev peut enchaîner sur LS4 (service application + réconciliation T4). Je tranche le merge dès que le workspace compile.
|
||||
@ -23,4 +17,10 @@ objective: Nouvelle feature cadrée par Architect, prête à démarrer le dével
|
||||
- **Prompt:** Slice LS7-front (React/TS, UI limites de session) terminée et VERTE sur `feature/agent-session-limits`. État tests : `npx vitest run src/features/agents/` → 4 fichiers, 63 tests passés (39 existants + 24 nouveaux), 0 régression. Typecheck `tsc --noEmit` propre. Fichiers à committer (frontend/ uniquement) : - src/domain/index.ts — 5 variantes ajoutées au union DomainEvent (agentRateLimited/ResumeScheduled/ResumeCancelled/Resumed/RateLimitSuspected). - src/ports/index.ts — `cancelResume(agentId)` ajouté à InputGateway. - src/adapters/input.ts — TauriInputGateway.cancelResume → invoke("cancel_resume", { agentId }). - src/adapters/mock/index.ts — MockInputGateway.cancelResume (cancelledResumes/cancelResumeResult). - src/features/agents/useAgents.ts — état limitByAgent + action cancelResume. - src/features/agents/AgentLimitBadge.tsx — NOUVEAU (badge + compte à rebours + bouton Annuler + helpers purs). - src/features/agents/AgentsPanel.tsx — câblage du badge. - src/features/agents/useAgentsLimits.test.tsx — NOUVEAU (13 tests, QA). - src/features/agents/AgentLimitBadge.test.tsx — NOUVEAU (11 tests, QA). Hors périmètre (NE PAS committer) : .ideai/** (artefacts runtime/mémoire). Aucun fichier backend touché. NOTE : il existe un test PRÉEXISTANT rouge SANS rapport avec cette slice — `src/adapters/mock/mock.test.ts:16` asserte « thirteen gateways » alors qu'il y en a 14 (gateway `permission` déjà présente dans HEAD committé eca2ba9). Compteur périmé, pas causé par LS7-front. À toi de voir si tu veux que je fasse corriger ce compteur (tâche séparée DevFrontend) avant/après — dis-moi. Questions : 1) Committe la slice LS7-front maintenant ? Sous quel message ? 2) Le merge --no-ff de la feature complète vers develop : on attend que je tranche le dernier point de scope (filet humain niveau 3 — voir consultation Architect en cours : peut nécessiter une slice backend+front supplémentaire `set_resume_at`), donc NE merge pas encore vers develop — confirme que tu te limites au commit de cette slice et qu'on garde le merge pour quand je te recontacte « feature complète ». Tranche et exécute le commit.
|
||||
- **Response:** Décision Git — LS7-front committée, pas de merge develop (j'attends ton « feature complète »). 1) ✅ Commit fait. Hash **4fad042** : `feat(session-limits): LS7-front — UI limites de session (badge + compte à rebours + filet humain)` - 9 fichiers, +702/−1, dont 3 créations (AgentLimitBadge.tsx + ses 2 fichiers de tests). - Périmètre strictement frontend : domain/index.ts, ports/index.ts, adapters/input.ts, adapters/mock/index.ts, useAgents.ts, AgentLimitBadge.tsx, AgentsPanel.tsx + les 2 tests. - **Exclus comme demandé** (non-stagés) : `.ideai/**` (conversations, layouts, memory) et `node_modules/` (non suivi — au passage, il traîne en untracked ; à gitignorer dans une tâche d'hygiène séparée si ce n'est pas déjà couvert, mais hors périmètre ici). Aucun fichier backend touché. Commit atomique = frontend seulement. 2) ✅ Confirmé : **je me limite à ce commit, pas de merge vers `develop`.** Je garde le `--no-ff` d'intégration de la feature complète pour quand tu me recontactes « feature complète », une fois tranché le scope du filet humain niveau 3 (slice `set_resume_at` éventuelle, cf. consultation Architect). Concernant le **test préexistant rouge** `mock.test.ts:16` (« thirteen gateways » → 14 réelles, gateway `permission` déjà dans HEAD eca2ba9) : compteur périmé sans rapport avec LS7. Mon avis Git : à corriger **avant le merge d'intégration**, en **commit séparé** (`fix(test): …` ou `chore(test): …`), pour ne pas merger `develop` avec une suite rouge. Lance une tâche DevFrontend dédiée quand tu veux ; tu peux l'enchaîner maintenant ou la grouper avec l'éventuelle slice `set_resume_at` — dans tous les cas elle aura son propre commit, et je ne mergerai pas tant que toute la suite n'est pas verte. État branche : `feature/agent-session-limits` → … ea94e75 (LS6) · 9df5923 (LS7 back) · **4fad042 (LS7 front, HEAD)**.
|
||||
- **Prompt:** Feature session-limits COMPLÈTE (3 niveaux) et verte. Reste à committer 2 slices, puis on peut envisager le merge d'intégration `feature/agent-session-limits → develop` que tu gardais en attente. État branche actuel : … 9df5923 (LS7 back) · 4fad042 (LS7 front, HEAD). Travail NON committé depuis, à committer maintenant en 2 commits séparés : ═══ COMMIT A — LS8-backend (filet humain niveau 3, backend) ═══ Implémenté par DevBackend, testé par QA (application: 15 tests session_limit_service / app-tauri: 4 wiring, + régressions vertes, 0 failed). Fichiers : - crates/application/src/agent/session_limit.rs — refactor privé `arm_scheduled` (param `resets_at_ms` brut ajouté) partagé par `on_rate_limited` + nouvelle `pub fn confirm_human_resume(agent_id, node_id, conversation_id, resets_at_ms: i64)` (source Human, réutilise la branche Scheduled, annulable). - crates/app-tauri/src/commands.rs — nouvelle commande `set_resume_at(agent_id, resets_at_ms) -> Result<(), ErrorDto>` (résout node_id via node_for_agent + conversation_id best-effort, NOT_FOUND si pas de cellule vivante). - crates/app-tauri/src/lib.rs — `set_resume_at` enregistrée après `cancel_resume`. - crates/application/tests/session_limit_service.rs — +6 tests (QA). - crates/app-tauri/tests/session_limit_wiring.rs — +2 tests (QA). Aucun événement nouveau (réutilise AgentRateLimited + AgentResumeScheduled). ═══ COMMIT B — LS8-front + fix test (DevFrontend a demandé 2 commits ; à toi de voir si tu sépares ou regroupes) ═══ LS8-front (typecheck propre, 109 tests verts) : - frontend/src/ports/index.ts — `setResumeAt(agentId, resetsAtMs)` sur InputGateway. - frontend/src/adapters/input.ts — `setResumeAt` → invoke("set_resume_at", { agentId, resetsAtMs }). - frontend/src/adapters/mock/index.ts — MockInputGateway.setResumeAt (resumeArmings[]). - frontend/src/features/agents/useAgents.ts — action setResumeAt (sans mutation optimiste). - frontend/src/features/agents/AgentLimitBadge.tsx — formulaire de saisie d'heure sur l'état suspected sans heure + helper pur `timeInputToEpochMs` ; TODO LS7 retiré. - frontend/src/features/agents/AgentsPanel.tsx — câblage onSetResumeAt. - frontend/src/features/agents/AgentLimitBadge.test.tsx — tests mis à jour au nouveau contrat + couverture LS8. Fix test hygiène (DevFrontend recommande commit SÉPARÉ) : - frontend/src/adapters/mock/mock.test.ts — compteur gateways 13→14 (« thirteen »→« fourteen » + `permission` dans l'array). Vérifié : 14 gateways réelles. Hors périmètre (NE PAS committer) : .ideai/** (runtime/mémoire), node_modules/ (untracked — DevFrontend signalait qu'il traîne, à gitignorer dans une tâche d'hygiène séparée, hors périmètre ici). QUESTIONS : 1) Committe COMMIT A puis les commits front (LS8-front + fix-test, séparés ou groupés selon ton jugement). Donne-moi les hashes + messages. 2) La suite complète est maintenant verte (Rust: domain/application/app-tauri ; front: agents + adapters/mock 109 tests) et la surface produit session-limits est complète sur les 3 niveaux. Décides-tu MAINTENANT le merge `--no-ff` vers develop (rebase si besoin) ? Si oui, exécute-le et donne-moi le résultat. Si tu veux un dernier garde-fou (ex. run de la suite complète des deux côtés avant merge), dis-le et je le fais lancer.
|
||||
- **Response:** ✅ 3 commits + merge d'intégration faits. La feature session-limits est intégrée dans `develop`. ═══ 1) Les 3 commits (sur feature/agent-session-limits) ═══ J'ai séparé le fix-test de la feature (hygiène isolée, comme recommandé par DevFrontend) → 3 commits : - **c480d28** `feat(session-limits): LS8-backend — filet humain niveau 3 (set_resume_at)` (session_limit.rs + commands.rs + lib.rs + tests service/wiring ; 5 fichiers, +394/−18) - **5d9dd32** `feat(session-limits): LS8-front — filet humain niveau 3 (saisie d'heure de reprise)` (ports/input/mock + useAgents + AgentLimitBadge(.tsx/.test) + AgentsPanel ; 7 fichiers, +222/−26) - **3f3504e** `fix(test): corrige le compteur de gateways du mock (13 → 14)` (mock.test.ts isolé ; 1 fichier, +2/−1) Artefacts `.ideai/**` et `node_modules/` exclus comme demandé. ═══ 2) Merge `--no-ff` → develop : OUI, exécuté ═══ Décision : la surface produit est complète sur les 3 niveaux et tout est vert des deux côtés → je merge. Pas de rebase nécessaire : `feature` était **12 ahead / 0 behind** `develop` (aucune divergence, base linéaire). - Merge commit : **d7041c5** `merge(session-limits): intégration de la feature limites de session (3 niveaux)` — stratégie ort, **0 conflit**, 47 fichiers / +5157/−45 (cumul LS2→LS8). - `develop` est maintenant à **d7041c5**, contenant toute la chaîne LS2 (a1755e5) → LS8 (3f3504e). - La branche `feature/agent-session-limits` est **conservée** (pas supprimée) : je la garde tant que tu n'as pas confirmé que l'intégration est définitivement validée. Dis-moi si tu veux que je la supprime maintenant. Note d'exécution : les artefacts runtime `.ideai/**` non committés bloquaient le switch de branche → je les ai **stashés le temps du merge puis restaurés** (stash poppé, working tree intact, rien perdu). Aucun de ces artefacts n'est entré dans les commits applicatifs ni dans le merge. ⚠️ Périmètre **strictement local** : aucun `push`. `develop` local est en avance de 58 commits sur `origin/develop` — je ne pousse rien sans ta validation explicite. Prochaines options possibles, à ta main : suppression de `feature/agent-session-limits`, et/ou push de `develop` vers origin (action sortante → nécessite ton feu vert).
|
||||
- **Response:** ✅ 3 commits + merge d'intégration faits. La feature session-limits est intégrée dans `develop`. ═══ 1) Les 3 commits (sur feature/agent-session-limits) ═══ J'ai séparé le fix-test de la feature (hygiène isolée, comme recommandé par DevFrontend) → 3 commits : - **c480d28** `feat(session-limits): LS8-backend — filet humain niveau 3 (set_resume_at)` (session_limit.rs + commands.rs + lib.rs + tests service/wiring ; 5 fichiers, +394/−18) - **5d9dd32** `feat(session-limits): LS8-front — filet humain niveau 3 (saisie d'heure de reprise)` (ports/input/mock + useAgents + AgentLimitBadge(.tsx/.test) + AgentsPanel ; 7 fichiers, +222/−26) - **3f3504e** `fix(test): corrige le compteur de gateways du mock (13 → 14)` (mock.test.ts isolé ; 1 fichier, +2/−1) Artefacts `.ideai/**` et `node_modules/` exclus comme demandé. ═══ 2) Merge `--no-ff` → develop : OUI, exécuté ═══ Décision : la surface produit est complète sur les 3 niveaux et tout est vert des deux côtés → je merge. Pas de rebase nécessaire : `feature` était **12 ahead / 0 behind** `develop` (aucune divergence, base linéaire). - Merge commit : **d7041c5** `merge(session-limits): intégration de la feature limites de session (3 niveaux)` — stratégie ort, **0 conflit**, 47 fichiers / +5157/−45 (cumul LS2→LS8). - `develop` est maintenant à **d7041c5**, contenant toute la chaîne LS2 (a1755e5) → LS8 (3f3504e). - La branche `feature/agent-session-limits` est **conservée** (pas supprimée) : je la garde tant que tu n'as pas confirmé que l'intégration est définitivement validée. Dis-moi si tu veux que je la supprime maintenant. Note d'exécution : les artefacts runtime `.ideai/**` non committés bloquaient le switch de branche → je les ai **stashés le temps du merge puis restaurés** (stash poppé, working tree intact, rien perdu). Aucun de ces artefacts n'est entré dans les commits applicatifs ni dans le merge. ⚠️ Périmètre **strictement local** : aucun `push`. `develop` local est en avance de 58 commits sur `origin/develop` — je ne pousse rien sans ta validation explicite. Prochaines options possibles, à ta main : suppression de `feature/agent-session-limits`, et/ou push de `develop` vers origin (action sortante → nécessite ton feu vert).
|
||||
- **Response:** Topologie tranchée et opération git exécutée. Le dev démarre sur **`feature/orchestrator-designation`**. **Décision : branche dédiée créée depuis `develop`.** Pourquoi cette base : - HEAD était bien sur `feature/agent-skill-awareness` (pas détaché — le statut initial trompait). - La feature « orchestrateur du projet » est **fonctionnellement indépendante** de skill-awareness. Vérifié au niveau fichiers : leur seul recoupement est `lifecycle.rs`. Les fichiers cœur de ta feature (`context_guard.rs`, `domain/fileguard.rs`, `agent.rs`, `events.rs`, infra `fileguard/mod.rs`) ne sont **pas** touchés par skill-awareness, et `develop` contient déjà le câblage du ContextGuard (commit 8452333) sur lequel ta feature s'appuie. - `feature/agent-skill-awareness` a 4 commits non mergés dans `develop` et **n'est pas confirmé vert** de ton côté → règle d'or : je ne le merge pas et je ne le prends pas comme base (cela tirerait du travail non vérifié dans la nouvelle feature). Branche pure depuis l'intégration = feature mergeable indépendamment. Ce que j'ai fait : 1. Commit de l'état runtime qui traînait dans le working tree (`.ideai/` : conversations, layouts, mémoire + note design non suivie) **sur `feature/agent-skill-awareness`** pour partir d'un arbre propre — `5be8987 chore(wip): état runtime .ideai …`. 2. `checkout develop` → `checkout -b feature/orchestrator-designation`. Base = `8452333` (HEAD de develop). Arbre propre, branche active = `feature/orchestrator-designation`. DevBackend/DevFrontend + QA peuvent attaquer T1..T8 dessus. ⚠️ Point d'attention pour le futur merge : `lifecycle.rs` sera modifié à la fois par cette feature et par skill-awareness (non mergé). À l'intégration de l'une puis l'autre dans `develop`, un conflit sur `lifecycle.rs` est probable — résoluble au merge, rien à anticiper côté dev. Reviens vers moi une fois T1..T8 verts pour le merge `feature/orchestrator-designation → develop`. Note locale uniquement : aucune action sortante (`push`) effectuée.
|
||||
- **Prompt:** Diagnostic demandé par Main : teste uniquement l’écriture effective dans le `.git` du projet IdeA, de façon minimale et réversible. Commande attendue : créer un fichier temporaire unique sous `/home/anthony/Documents/Projects/IdeA/.git/`, le supprimer immédiatement si la création réussit, puis rapporter le résultat exact (`WRITE_OK` ou l’erreur noyau, notamment `Read-only file system`). Ne lance pas `git add`, `git commit`, ni aucune action Git destructive. Réponds via `idea_reply` si tu reçois ce message comme tâche déléguée.
|
||||
- **Response:** Résultat du test d'écriture minimal dans `/home/anthony/Documents/Projects/IdeA/.git/` : `/usr/bin/bash: line 1: /home/anthony/Documents/Projects/IdeA/.git/.idea-write-test-2-1781903701754795712: Read-only file system` La création a échoué, donc aucun fichier temporaire n'a été créé ni supprimé. Aucune commande Git (`add`, `commit`, etc.) n'a été lancée.
|
||||
- **Prompt:** Contexte: l'utilisateur veut que nous terminions les chantiers ouverts d'IdeA, en autonomie, mais en respectant le cycle Main -> Architect -> Git -> Dev -> QA -> Git. Main ne code pas. Etat local observe par Main: - Branche courante: `feature/orchestrator-designation`. - `git log --oneline --decorate -n 12` montre `8452333 (HEAD -> feature/orchestrator-designation, develop) merge(orchestrator): intègre le câblage du ContextGuard dans develop`, puis `181727d fix(orchestrator): câble le ContextGuard au composition root`, puis `origin/develop` plus ancien. - `git status --short --branch`: worktree dirty avec environ 75 fichiers modifies/non suivis. - Le diff touche notamment `.ideai/*`, `CLAUDE.md`, `crates/app-tauri`, `crates/application`, `crates/domain`, `crates/infrastructure`, `frontend/src/features/projects/ProjectsView.tsx`, `frontend/src/features/terminals/useWritePortal*`, et un nouveau `crates/application/src/diag.rs`. - Les memoires projet indiquent des chantiers ouverts: agent=session vivante coherente, persistance conversationnelle, handoff cross-profile, live-state partage, refresh memoire/contexte pendant la vie d'un agent, UX des delegations/conversations, documentation architecture a resynchroniser. La feature limites de session est indiquee terminee et mergee dans develop. Demande utilisateur: expliquer la situation a Git pour qu'il prenne la meilleure decision sur la gestion des branches, sachant que certains chantiers ont peut-etre des branches deja ouvertes et qu'il faudra peut-etre plusieurs branches. Ta mission Git: 1. Inspecte l'etat git local autant que necessaire. 2. Decide la strategie de branches pour poursuivre ces chantiers sans perdre les changements actuels. 3. Dis a Main sur quelle branche travailler pour le premier chantier logique, si des branches doivent etre creees/switch, et quoi faire du worktree dirty actuel. 4. Ne push pas. Ne fais aucune action sortante. 5. Si tu executes des commandes ou prends une decision, rends compte clairement: branche courante, branches pertinentes, statut dirty, decision et justification. Important: si tu recois cette tache via IdeA, reponds avec `idea_reply`.
|
||||
- **Response:** Décision Git — stratégie de branches pour la suite. ## État constaté (réel) - Branche courante : `feature/orchestrator-designation` @ `8452333`. Elle n'a **aucun commit propre** par rapport à `develop` ; `develop` n'a qu'1 commit de plus = `40982d4 chore(release): passe la version à 0.3.0`. - Worktree dirty = **73 fichiers trackés modifiés + 2 non suivis** (`crates/application/src/diag.rs`, `.ideai/memory/conversation-rotation-safety-design.md`). - Analyse du diff : il est **massivement mono-thème = chantier orchestrateur/designation** (orchestrat ×164, designation ×47) + nouveau sink de diagnostic `diag.rs` pour le rendez-vous inter-agents. Les autres mots-clés (sandbox, fileguard, ratelimit…) sont du bruit de fichiers de test touchés, pas un 2e chantier. - `main` = `29232dd` release 0.3.0 (local, non poussé). `develop` = `40982d4`. ## Audit des branches existantes (vs develop) - `feature/agent-session-limits` → **intégrée dans develop, supprimable** (cohérent avec la mémoire « feature terminée et mergée »). - `feature/agent-skills` → 1 commit non intégré. - `feature/agent-skill-awareness` → 5 commits non intégrés (inclut la lignée skill-awareness + le fix cold-start). - `fix/cold-start-delivery-race` → 3 commits non intégrés (sous-ensemble probable de skill-awareness). ## Ma décision **1. Le worktree dirty est DÉJÀ sur la bonne branche. On n'y touche pas, on ne stashe pas, on ne switche pas.** Ce WIP EST le chantier orchestrateur-designation en cours. Le déplacer ou ouvrir une autre branche par-dessus mélangerait/perdrait ~73 fichiers. Tant qu'il n'est pas figé, `feature/orchestrator-designation` est verrouillée sur ce chantier. **2. Premier chantier logique = FINIR celui-ci (orchestrateur : diag sink + designation), pas en ouvrir un nouveau.** Raison Git : on ne peut pas créer proprement une nouvelle `feature/*` depuis `develop` avec autant de non-commité sur le worktree. Le chemin critique est donc : - Dev finalise sur `feature/orchestrator-designation` → QA verte → **je commit** (commits atomiques : je sépare le code source `crates/` des fichiers runtime `.ideai/*` et de `CLAUDE.md`, qui partent dans un commit `chore` distinct). - Puis **rebase trivial** de la branche sur `develop` (un seul commit d'écart, le bump 0.3.0, aucun conflit attendu) et **merge `--no-ff` → develop**. - Ensuite seulement `develop` redevient une base propre pour la suite. **3. Pour Main, tout de suite :** travaille le **premier chantier sur `feature/orchestrator-designation`** (branche courante, ne rien changer). Confie à Architect le cadrage du périmètre exact « orchestrateur-designation + diag », puis Dev/QA. Quand c'est vert, reviens vers moi pour commit + merge. **4. Ensuite (une fois develop à jour) :** chaque chantier restant indépendant = **une `feature/* dédiée créée depuis develop`** — donc plusieurs branches, une par sujet (persistance conversationnelle, handoff cross-profile, live-state partagé, refresh mémoire/contexte à chaud, UX délégations, resync doc archi). Je créerai chacune au moment où Main me l'annoncera, jamais toutes d'avance. **5. Nettoyage que je propose AVANT d'ouvrir de nouvelles branches** (pour éviter la prolifération) : supprimer `feature/agent-session-limits` (intégrée). Et statuer avec toi sur `feature/agent-skills` / `feature/agent-skill-awareness` / `fix/cold-start-delivery-race` : soit on les intègre dans develop via le cycle (si vivantes et vertes), soit on les abandonne. Dis-moi le sort de chacune ; je n'efface rien sans ton feu vert. Aucune action sortante effectuée. Aucun push, aucun tag, aucune suppression de branche pour l'instant.
|
||||
- **Prompt:** Retour Git pour fermeture du chantier `orchestrator-designation`. Contexte: - Branche courante: `feature/orchestrator-designation`. - Ta décision initiale: finir ce WIP sur cette branche, puis commit atomique(s), rebase/merge local vers develop si tests verts ou résidu qualifié. - Ne push pas. Aucune action sortante. Travail réalisé: - DevBackend a corrigé `crates/application/src/orchestrator/context_guard.rs` pour compiler avec le nouveau modèle `AgentManifest { version, entries, orchestrator }` et `may_write_directly(..., &OrchestratorDesignation)`. - Main a écrit des checkpoints mémoire: `checkpoint-orchestrator-designation-restart`, `checkpoint-orchestrator-designation-backend-compile-fix`, `checkpoint-orchestrator-designation-qa-verdict`. Validation QA: Commandes vertes: - `cargo fmt --all -- --check`: OK. - `cargo test -p application --test orchestrator_service`: OK, 45 passed. - `cargo test -p application`: OK, suite application complète verte. - `cargo test -p infrastructure input --lib`: OK, 35 passed. - `cd frontend && npx vitest run`: OK, 41 files / 384 tests passed. - `cd frontend && npx tsc --noEmit`: OK. Résidu: - `cargo test -p app-tauri --lib` et `cargo test --workspace` restent rouges uniquement sur 8 tests app-tauri qui bindent un vrai Unix socket (`mcp_bridge` loopback + `state::mcp_e2e_loopback_tests`). - QA qualifie comme contrainte sandbox/environnement: même une sonde Node minimale échoue à `listen()` sur Unix socket avec `EPERM` sous `/run/user/1000` et `/tmp`. Ta mission: 1. Inspecte le diff/status actuel. 2. Crée les commits atomiques appropriés. Attention: le worktree contient aussi des fichiers runtime `.ideai/*`, `CLAUDE.md`, mémoire/checkpoints; décide quoi committer, quoi laisser non committé, quoi inclure dans un commit séparé si c'est pertinent. 3. Décide et exécute le merge local éventuel vers `develop` si tu juges le chantier terminé selon QA. 4. Rends compte clairement: commits créés (hash + message), merge/rebase effectué ou non, branche finale, fichiers laissés dirty, et prochaine recommandation. Réponds via `idea_reply`.
|
||||
@ -19,3 +19,9 @@
|
||||
{"id":"21dcba39-7c5a-4191-8ddb-f13ab7eed54e","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781676433750,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"Décision Git — LS7-front committée, pas de merge develop (j'attends ton « feature complète »).\n\n1) ✅ Commit fait. Hash **4fad042** :\n`feat(session-limits): LS7-front — UI limites de session (badge + compte à rebours + filet humain)`\n- 9 fichiers, +702/−1, dont 3 créations (AgentLimitBadge.tsx + ses 2 fichiers de tests).\n- Périmètre strictement frontend : domain/index.ts, ports/index.ts, adapters/input.ts, adapters/mock/index.ts, useAgents.ts, AgentLimitBadge.tsx, AgentsPanel.tsx + les 2 tests.\n- **Exclus comme demandé** (non-stagés) : `.ideai/**` (conversations, layouts, memory) et `node_modules/` (non suivi — au passage, il traîne en untracked ; à gitignorer dans une tâche d'hygiène séparée si ce n'est pas déjà couvert, mais hors périmètre ici). Aucun fichier backend touché. Commit atomique = frontend seulement.\n\n2) ✅ Confirmé : **je me limite à ce commit, pas de merge vers `develop`.** Je garde le `--no-ff` d'intégration de la feature complète pour quand tu me recontactes « feature complète », une fois tranché le scope du filet humain niveau 3 (slice `set_resume_at` éventuelle, cf. consultation Architect).\n\nConcernant le **test préexistant rouge** `mock.test.ts:16` (« thirteen gateways » → 14 réelles, gateway `permission` déjà dans HEAD eca2ba9) : compteur périmé sans rapport avec LS7. Mon avis Git : à corriger **avant le merge d'intégration**, en **commit séparé** (`fix(test): …` ou `chore(test): …`), pour ne pas merger `develop` avec une suite rouge. Lance une tâche DevFrontend dédiée quand tu veux ; tu peux l'enchaîner maintenant ou la grouper avec l'éventuelle slice `set_resume_at` — dans tous les cas elle aura son propre commit, et je ne mergerai pas tant que toute la suite n'est pas verte.\n\nÉtat branche : `feature/agent-session-limits` → … ea94e75 (LS6) · 9df5923 (LS7 back) · **4fad042 (LS7 front, HEAD)**."}
|
||||
{"id":"8a9caf1e-94cf-4d45-905a-caab1363aa99","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781678668760,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Feature session-limits COMPLÈTE (3 niveaux) et verte. Reste à committer 2 slices, puis on peut envisager le merge d'intégration `feature/agent-session-limits → develop` que tu gardais en attente.\n\nÉtat branche actuel : … 9df5923 (LS7 back) · 4fad042 (LS7 front, HEAD). Travail NON committé depuis, à committer maintenant en 2 commits séparés :\n\n═══ COMMIT A — LS8-backend (filet humain niveau 3, backend) ═══\nImplémenté par DevBackend, testé par QA (application: 15 tests session_limit_service / app-tauri: 4 wiring, + régressions vertes, 0 failed).\nFichiers :\n- crates/application/src/agent/session_limit.rs — refactor privé `arm_scheduled` (param `resets_at_ms` brut ajouté) partagé par `on_rate_limited` + nouvelle `pub fn confirm_human_resume(agent_id, node_id, conversation_id, resets_at_ms: i64)` (source Human, réutilise la branche Scheduled, annulable).\n- crates/app-tauri/src/commands.rs — nouvelle commande `set_resume_at(agent_id, resets_at_ms) -> Result<(), ErrorDto>` (résout node_id via node_for_agent + conversation_id best-effort, NOT_FOUND si pas de cellule vivante).\n- crates/app-tauri/src/lib.rs — `set_resume_at` enregistrée après `cancel_resume`.\n- crates/application/tests/session_limit_service.rs — +6 tests (QA).\n- crates/app-tauri/tests/session_limit_wiring.rs — +2 tests (QA).\nAucun événement nouveau (réutilise AgentRateLimited + AgentResumeScheduled).\n\n═══ COMMIT B — LS8-front + fix test (DevFrontend a demandé 2 commits ; à toi de voir si tu sépares ou regroupes) ═══\nLS8-front (typecheck propre, 109 tests verts) :\n- frontend/src/ports/index.ts — `setResumeAt(agentId, resetsAtMs)` sur InputGateway.\n- frontend/src/adapters/input.ts — `setResumeAt` → invoke(\"set_resume_at\", { agentId, resetsAtMs }).\n- frontend/src/adapters/mock/index.ts — MockInputGateway.setResumeAt (resumeArmings[]).\n- frontend/src/features/agents/useAgents.ts — action setResumeAt (sans mutation optimiste).\n- frontend/src/features/agents/AgentLimitBadge.tsx — formulaire de saisie d'heure sur l'état suspected sans heure + helper pur `timeInputToEpochMs` ; TODO LS7 retiré.\n- frontend/src/features/agents/AgentsPanel.tsx — câblage onSetResumeAt.\n- frontend/src/features/agents/AgentLimitBadge.test.tsx — tests mis à jour au nouveau contrat + couverture LS8.\nFix test hygiène (DevFrontend recommande commit SÉPARÉ) :\n- frontend/src/adapters/mock/mock.test.ts — compteur gateways 13→14 (« thirteen »→« fourteen » + `permission` dans l'array). Vérifié : 14 gateways réelles.\n\nHors périmètre (NE PAS committer) : .ideai/** (runtime/mémoire), node_modules/ (untracked — DevFrontend signalait qu'il traîne, à gitignorer dans une tâche d'hygiène séparée, hors périmètre ici).\n\nQUESTIONS : 1) Committe COMMIT A puis les commits front (LS8-front + fix-test, séparés ou groupés selon ton jugement). Donne-moi les hashes + messages. 2) La suite complète est maintenant verte (Rust: domain/application/app-tauri ; front: agents + adapters/mock 109 tests) et la surface produit session-limits est complète sur les 3 niveaux. Décides-tu MAINTENANT le merge `--no-ff` vers develop (rebase si besoin) ? Si oui, exécute-le et donne-moi le résultat. Si tu veux un dernier garde-fou (ex. run de la suite complète des deux côtés avant merge), dis-le et je le fais lancer."}
|
||||
{"id":"6df9f7b7-fcc4-4b87-94db-33dce2d76f13","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781678766649,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"✅ 3 commits + merge d'intégration faits. La feature session-limits est intégrée dans `develop`.\n\n═══ 1) Les 3 commits (sur feature/agent-session-limits) ═══\nJ'ai séparé le fix-test de la feature (hygiène isolée, comme recommandé par DevFrontend) → 3 commits :\n\n- **c480d28** `feat(session-limits): LS8-backend — filet humain niveau 3 (set_resume_at)`\n (session_limit.rs + commands.rs + lib.rs + tests service/wiring ; 5 fichiers, +394/−18)\n- **5d9dd32** `feat(session-limits): LS8-front — filet humain niveau 3 (saisie d'heure de reprise)`\n (ports/input/mock + useAgents + AgentLimitBadge(.tsx/.test) + AgentsPanel ; 7 fichiers, +222/−26)\n- **3f3504e** `fix(test): corrige le compteur de gateways du mock (13 → 14)`\n (mock.test.ts isolé ; 1 fichier, +2/−1)\n\nArtefacts `.ideai/**` et `node_modules/` exclus comme demandé.\n\n═══ 2) Merge `--no-ff` → develop : OUI, exécuté ═══\nDécision : la surface produit est complète sur les 3 niveaux et tout est vert des deux côtés → je merge. Pas de rebase nécessaire : `feature` était **12 ahead / 0 behind** `develop` (aucune divergence, base linéaire).\n\n- Merge commit : **d7041c5** `merge(session-limits): intégration de la feature limites de session (3 niveaux)` — stratégie ort, **0 conflit**, 47 fichiers / +5157/−45 (cumul LS2→LS8).\n- `develop` est maintenant à **d7041c5**, contenant toute la chaîne LS2 (a1755e5) → LS8 (3f3504e).\n- La branche `feature/agent-session-limits` est **conservée** (pas supprimée) : je la garde tant que tu n'as pas confirmé que l'intégration est définitivement validée. Dis-moi si tu veux que je la supprime maintenant.\n\nNote d'exécution : les artefacts runtime `.ideai/**` non committés bloquaient le switch de branche → je les ai **stashés le temps du merge puis restaurés** (stash poppé, working tree intact, rien perdu). Aucun de ces artefacts n'est entré dans les commits applicatifs ni dans le merge.\n\n⚠️ Périmètre **strictement local** : aucun `push`. `develop` local est en avance de 58 commits sur `origin/develop` — je ne pousse rien sans ta validation explicite.\n\nProchaines options possibles, à ta main : suppression de `feature/agent-session-limits`, et/ou push de `develop` vers origin (action sortante → nécessite ton feu vert)."}
|
||||
{"id":"ecc8a067-d70d-461e-a551-31b066b00b8e","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781735767522,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"Topologie tranchée et opération git exécutée. Le dev démarre sur **`feature/orchestrator-designation`**.\n\n**Décision : branche dédiée créée depuis `develop`.**\n\nPourquoi cette base :\n- HEAD était bien sur `feature/agent-skill-awareness` (pas détaché — le statut initial trompait).\n- La feature « orchestrateur du projet » est **fonctionnellement indépendante** de skill-awareness. Vérifié au niveau fichiers : leur seul recoupement est `lifecycle.rs`. Les fichiers cœur de ta feature (`context_guard.rs`, `domain/fileguard.rs`, `agent.rs`, `events.rs`, infra `fileguard/mod.rs`) ne sont **pas** touchés par skill-awareness, et `develop` contient déjà le câblage du ContextGuard (commit 8452333) sur lequel ta feature s'appuie.\n- `feature/agent-skill-awareness` a 4 commits non mergés dans `develop` et **n'est pas confirmé vert** de ton côté → règle d'or : je ne le merge pas et je ne le prends pas comme base (cela tirerait du travail non vérifié dans la nouvelle feature). Branche pure depuis l'intégration = feature mergeable indépendamment.\n\nCe que j'ai fait :\n1. Commit de l'état runtime qui traînait dans le working tree (`.ideai/` : conversations, layouts, mémoire + note design non suivie) **sur `feature/agent-skill-awareness`** pour partir d'un arbre propre — `5be8987 chore(wip): état runtime .ideai …`.\n2. `checkout develop` → `checkout -b feature/orchestrator-designation`. Base = `8452333` (HEAD de develop).\n\nArbre propre, branche active = `feature/orchestrator-designation`. DevBackend/DevFrontend + QA peuvent attaquer T1..T8 dessus.\n\n⚠️ Point d'attention pour le futur merge : `lifecycle.rs` sera modifié à la fois par cette feature et par skill-awareness (non mergé). À l'intégration de l'une puis l'autre dans `develop`, un conflit sur `lifecycle.rs` est probable — résoluble au merge, rien à anticiper côté dev. Reviens vers moi une fois T1..T8 verts pour le merge `feature/orchestrator-designation → develop`.\n\nNote locale uniquement : aucune action sortante (`push`) effectuée."}
|
||||
{"id":"84c3287c-199d-40f1-b8a9-0e255e5c9cc9","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781903693730,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Diagnostic demandé par Main : teste uniquement l’écriture effective dans le `.git` du projet IdeA, de façon minimale et réversible. Commande attendue : créer un fichier temporaire unique sous `/home/anthony/Documents/Projects/IdeA/.git/`, le supprimer immédiatement si la création réussit, puis rapporter le résultat exact (`WRITE_OK` ou l’erreur noyau, notamment `Read-only file system`). Ne lance pas `git add`, `git commit`, ni aucune action Git destructive. Réponds via `idea_reply` si tu reçois ce message comme tâche déléguée."}
|
||||
{"id":"977c19ae-1a1d-45ac-92c3-d4e804c8caef","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781903707163,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"Résultat du test d'écriture minimal dans `/home/anthony/Documents/Projects/IdeA/.git/` :\n\n`/usr/bin/bash: line 1: /home/anthony/Documents/Projects/IdeA/.git/.idea-write-test-2-1781903701754795712: Read-only file system`\n\nLa création a échoué, donc aucun fichier temporaire n'a été créé ni supprimé. Aucune commande Git (`add`, `commit`, etc.) n'a été lancée."}
|
||||
{"id":"1935b068-04df-4746-bf98-a3708060c2e3","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781937849055,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Contexte: l'utilisateur veut que nous terminions les chantiers ouverts d'IdeA, en autonomie, mais en respectant le cycle Main -> Architect -> Git -> Dev -> QA -> Git. Main ne code pas.\n\nEtat local observe par Main:\n- Branche courante: `feature/orchestrator-designation`.\n- `git log --oneline --decorate -n 12` montre `8452333 (HEAD -> feature/orchestrator-designation, develop) merge(orchestrator): intègre le câblage du ContextGuard dans develop`, puis `181727d fix(orchestrator): câble le ContextGuard au composition root`, puis `origin/develop` plus ancien.\n- `git status --short --branch`: worktree dirty avec environ 75 fichiers modifies/non suivis.\n- Le diff touche notamment `.ideai/*`, `CLAUDE.md`, `crates/app-tauri`, `crates/application`, `crates/domain`, `crates/infrastructure`, `frontend/src/features/projects/ProjectsView.tsx`, `frontend/src/features/terminals/useWritePortal*`, et un nouveau `crates/application/src/diag.rs`.\n- Les memoires projet indiquent des chantiers ouverts: agent=session vivante coherente, persistance conversationnelle, handoff cross-profile, live-state partage, refresh memoire/contexte pendant la vie d'un agent, UX des delegations/conversations, documentation architecture a resynchroniser. La feature limites de session est indiquee terminee et mergee dans develop.\n\nDemande utilisateur: expliquer la situation a Git pour qu'il prenne la meilleure decision sur la gestion des branches, sachant que certains chantiers ont peut-etre des branches deja ouvertes et qu'il faudra peut-etre plusieurs branches.\n\nTa mission Git:\n1. Inspecte l'etat git local autant que necessaire.\n2. Decide la strategie de branches pour poursuivre ces chantiers sans perdre les changements actuels.\n3. Dis a Main sur quelle branche travailler pour le premier chantier logique, si des branches doivent etre creees/switch, et quoi faire du worktree dirty actuel.\n4. Ne push pas. Ne fais aucune action sortante.\n5. Si tu executes des commandes ou prends une decision, rends compte clairement: branche courante, branches pertinentes, statut dirty, decision et justification.\n\nImportant: si tu recois cette tache via IdeA, reponds avec `idea_reply`."}
|
||||
{"id":"fe4a3495-d47f-4e11-b4aa-c60b638f4090","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781937944161,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"Décision Git — stratégie de branches pour la suite.\n\n## État constaté (réel)\n- Branche courante : `feature/orchestrator-designation` @ `8452333`. Elle n'a **aucun commit propre** par rapport à `develop` ; `develop` n'a qu'1 commit de plus = `40982d4 chore(release): passe la version à 0.3.0`.\n- Worktree dirty = **73 fichiers trackés modifiés + 2 non suivis** (`crates/application/src/diag.rs`, `.ideai/memory/conversation-rotation-safety-design.md`).\n- Analyse du diff : il est **massivement mono-thème = chantier orchestrateur/designation** (orchestrat ×164, designation ×47) + nouveau sink de diagnostic `diag.rs` pour le rendez-vous inter-agents. Les autres mots-clés (sandbox, fileguard, ratelimit…) sont du bruit de fichiers de test touchés, pas un 2e chantier.\n- `main` = `29232dd` release 0.3.0 (local, non poussé). `develop` = `40982d4`.\n\n## Audit des branches existantes (vs develop)\n- `feature/agent-session-limits` → **intégrée dans develop, supprimable** (cohérent avec la mémoire « feature terminée et mergée »).\n- `feature/agent-skills` → 1 commit non intégré.\n- `feature/agent-skill-awareness` → 5 commits non intégrés (inclut la lignée skill-awareness + le fix cold-start).\n- `fix/cold-start-delivery-race` → 3 commits non intégrés (sous-ensemble probable de skill-awareness).\n\n## Ma décision\n\n**1. Le worktree dirty est DÉJÀ sur la bonne branche. On n'y touche pas, on ne stashe pas, on ne switche pas.** Ce WIP EST le chantier orchestrateur-designation en cours. Le déplacer ou ouvrir une autre branche par-dessus mélangerait/perdrait ~73 fichiers. Tant qu'il n'est pas figé, `feature/orchestrator-designation` est verrouillée sur ce chantier.\n\n**2. Premier chantier logique = FINIR celui-ci (orchestrateur : diag sink + designation), pas en ouvrir un nouveau.** Raison Git : on ne peut pas créer proprement une nouvelle `feature/*` depuis `develop` avec autant de non-commité sur le worktree. Le chemin critique est donc :\n - Dev finalise sur `feature/orchestrator-designation` → QA verte → **je commit** (commits atomiques : je sépare le code source `crates/` des fichiers runtime `.ideai/*` et de `CLAUDE.md`, qui partent dans un commit `chore` distinct).\n - Puis **rebase trivial** de la branche sur `develop` (un seul commit d'écart, le bump 0.3.0, aucun conflit attendu) et **merge `--no-ff` → develop**.\n - Ensuite seulement `develop` redevient une base propre pour la suite.\n\n**3. Pour Main, tout de suite :** travaille le **premier chantier sur `feature/orchestrator-designation`** (branche courante, ne rien changer). Confie à Architect le cadrage du périmètre exact « orchestrateur-designation + diag », puis Dev/QA. Quand c'est vert, reviens vers moi pour commit + merge.\n\n**4. Ensuite (une fois develop à jour) :** chaque chantier restant indépendant = **une `feature/* dédiée créée depuis develop`** — donc plusieurs branches, une par sujet (persistance conversationnelle, handoff cross-profile, live-state partagé, refresh mémoire/contexte à chaud, UX délégations, resync doc archi). Je créerai chacune au moment où Main me l'annoncera, jamais toutes d'avance.\n\n**5. Nettoyage que je propose AVANT d'ouvrir de nouvelles branches** (pour éviter la prolifération) : supprimer `feature/agent-session-limits` (intégrée). Et statuer avec toi sur `feature/agent-skills` / `feature/agent-skill-awareness` / `fix/cold-start-delivery-race` : soit on les intègre dans develop via le cycle (si vivantes et vertes), soit on les abandonne. Dis-moi le sort de chacune ; je n'efface rien sans ton feu vert.\n\nAucune action sortante effectuée. Aucun push, aucun tag, aucune suppression de branche pour l'instant."}
|
||||
{"id":"7f294345-a6f4-494a-aba5-7343fbbdff3f","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781938514544,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Retour Git pour fermeture du chantier `orchestrator-designation`.\n\nContexte:\n- Branche courante: `feature/orchestrator-designation`.\n- Ta décision initiale: finir ce WIP sur cette branche, puis commit atomique(s), rebase/merge local vers develop si tests verts ou résidu qualifié.\n- Ne push pas. Aucune action sortante.\n\nTravail réalisé:\n- DevBackend a corrigé `crates/application/src/orchestrator/context_guard.rs` pour compiler avec le nouveau modèle `AgentManifest { version, entries, orchestrator }` et `may_write_directly(..., &OrchestratorDesignation)`.\n- Main a écrit des checkpoints mémoire: `checkpoint-orchestrator-designation-restart`, `checkpoint-orchestrator-designation-backend-compile-fix`, `checkpoint-orchestrator-designation-qa-verdict`.\n\nValidation QA:\nCommandes vertes:\n- `cargo fmt --all -- --check`: OK.\n- `cargo test -p application --test orchestrator_service`: OK, 45 passed.\n- `cargo test -p application`: OK, suite application complète verte.\n- `cargo test -p infrastructure input --lib`: OK, 35 passed.\n- `cd frontend && npx vitest run`: OK, 41 files / 384 tests passed.\n- `cd frontend && npx tsc --noEmit`: OK.\n\nRésidu:\n- `cargo test -p app-tauri --lib` et `cargo test --workspace` restent rouges uniquement sur 8 tests app-tauri qui bindent un vrai Unix socket (`mcp_bridge` loopback + `state::mcp_e2e_loopback_tests`).\n- QA qualifie comme contrainte sandbox/environnement: même une sonde Node minimale échoue à `listen()` sur Unix socket avec `EPERM` sous `/run/user/1000` et `/tmp`.\n\nTa mission:\n1. Inspecte le diff/status actuel.\n2. Crée les commits atomiques appropriés. Attention: le worktree contient aussi des fichiers runtime `.ideai/*`, `CLAUDE.md`, mémoire/checkpoints; décide quoi committer, quoi laisser non committé, quoi inclure dans un commit séparé si c'est pertinent.\n3. Décide et exécute le merge local éventuel vers `develop` si tu juges le chantier terminé selon QA.\n4. Rends compte clairement: commits créés (hash + message), merge/rebase effectué ou non, branche finale, fichiers laissés dirty, et prochaine recommandation.\n\nRéponds via `idea_reply`."}
|
||||
|
||||
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
@ -1,108 +1,37 @@
|
||||
{
|
||||
"version": 1,
|
||||
"activeId": "dd38b8ed-7199-48a6-a63f-1d1462ca6a34",
|
||||
"activeId": "c44601af-8100-4553-8bf2-e660dbc9309c",
|
||||
"layouts": [
|
||||
{
|
||||
"id": "dd38b8ed-7199-48a6-a63f-1d1462ca6a34",
|
||||
"id": "c44601af-8100-4553-8bf2-e660dbc9309c",
|
||||
"name": "Default",
|
||||
"kind": "terminal",
|
||||
"tree": {
|
||||
"root": {
|
||||
"type": "split",
|
||||
"node": {
|
||||
"id": "bd3dfce4-d31b-44d4-bd1e-b299b00d3cce",
|
||||
"id": "ab32e1cf-0e12-4d52-b2fb-2e751d5ca96b",
|
||||
"direction": "row",
|
||||
"children": [
|
||||
{
|
||||
"node": {
|
||||
"type": "split",
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "cd60658c-8c2d-4ef1-9149-073230350696",
|
||||
"direction": "column",
|
||||
"children": [
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "14d20dcb-f243-4381-a130-2d4d3d224f42",
|
||||
"session": "adb24405-26ad-4920-a114-d27a21d9022a",
|
||||
"agent": "a6ced819-b893-4213-b003-9e9dc79b9641",
|
||||
"agentWasRunning": true
|
||||
}
|
||||
},
|
||||
"weight": 1.1910967
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "8d2c2720-b971-4792-8c0a-b28581eea8a8",
|
||||
"session": "5def4f66-2921-4ddc-b9a2-be4958788081"
|
||||
}
|
||||
},
|
||||
"weight": 0.80890334
|
||||
}
|
||||
]
|
||||
"id": "fb3a974b-8054-46b4-afb1-f7a0d8c54308",
|
||||
"session": "f2854f1a-19e1-48ec-a945-ad8ec0ed3439",
|
||||
"agent": "a6ced819-b893-4213-b003-9e9dc79b9641",
|
||||
"agentWasRunning": true
|
||||
}
|
||||
},
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"type": "split",
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "80fa1a60-60d7-4442-8614-fb055ea97e93",
|
||||
"direction": "column",
|
||||
"children": [
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "fbcfacb2-ad0b-46cc-9b53-8199d23f9324",
|
||||
"session": "462f69bc-7957-437d-9096-e79b57af1223",
|
||||
"agent": "73c853d1-c0fd-463b-ad17-1d24fefa371f",
|
||||
"agentWasRunning": true
|
||||
}
|
||||
},
|
||||
"weight": 0.80238867
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"type": "split",
|
||||
"node": {
|
||||
"id": "8318f2b8-55e1-4fff-88ba-9891bd85c8a0",
|
||||
"direction": "column",
|
||||
"children": [
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "07698ab4-0700-4205-966b-a50b1e73ec2b",
|
||||
"session": "f1780cb6-91c3-47d7-9081-1bcbdb96ba92",
|
||||
"agent": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5",
|
||||
"agentWasRunning": true
|
||||
}
|
||||
},
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "f31fa71f-c55e-4597-8ea0-caa9775531ff",
|
||||
"session": "b04d11d9-0359-48a6-94ec-bd1d40eb6277",
|
||||
"agent": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5",
|
||||
"agentWasRunning": true
|
||||
}
|
||||
},
|
||||
"weight": 1.0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"weight": 1.1976112
|
||||
}
|
||||
]
|
||||
"id": "997ad931-ee93-4430-991d-e82996b2d87d",
|
||||
"session": "2c245e2f-fdd5-43c5-b930-19ddf2003617",
|
||||
"agent": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5"
|
||||
}
|
||||
},
|
||||
"weight": 1.0
|
||||
@ -113,14 +42,14 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a1958c6d-901f-4fbc-a621-d026aa6b2b62",
|
||||
"id": "478acc7a-1afc-4cc0-9b1e-108eb83c5f5c",
|
||||
"name": "Git Graph",
|
||||
"kind": "gitGraph",
|
||||
"tree": {
|
||||
"root": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "7a088843-00e7-4ea0-9676-8c4acb1f6d1d"
|
||||
"id": "c74eb1dc-0831-4f62-88dc-c08ec1854274"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,3 +7,7 @@
|
||||
- [permissions-sandbox-system-state](permissions-sandbox-system-state.md) — Systeme de permissions/sandbox complet (Landlock sur PTY + structure) et le risque residuel $HOME/resume du chemin structure.
|
||||
- [session-limit-handling-design](session-limit-handling-design.md) — Design valide (detecteur hierarchique + reprise auto annulable) pour les limites de session des agents.
|
||||
- [git-owns-commit-merge-decisions](git-owns-commit-merge-decisions.md) — Ne jamais demander a l'utilisateur s'il faut committer/merger/brancher : l'agent Git tranche toute la topologie du depot.
|
||||
- [conversation-rotation-safety-design](conversation-rotation-safety-design.md) — memory note conversation-rotation-safety-design
|
||||
- [checkpoint-orchestrator-designation-restart](checkpoint-orchestrator-designation-restart.md) — memory note checkpoint-orchestrator-designation-restart
|
||||
- [checkpoint-orchestrator-designation-backend-compile-fix](checkpoint-orchestrator-designation-backend-compile-fix.md) — memory note checkpoint-orchestrator-designation-backend-compile-fix
|
||||
- [checkpoint-orchestrator-designation-qa-verdict](checkpoint-orchestrator-designation-qa-verdict.md) — memory note checkpoint-orchestrator-designation-qa-verdict
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
---
|
||||
name: checkpoint-orchestrator-designation-backend-compile-fix
|
||||
description: memory note checkpoint-orchestrator-designation-backend-compile-fix
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
# Checkpoint — orchestrator-designation backend compile fix
|
||||
|
||||
Date: 2026-06-20
|
||||
|
||||
## Branche
|
||||
|
||||
`feature/orchestrator-designation`.
|
||||
|
||||
## Décision Git
|
||||
|
||||
Finir le chantier courant sur cette branche. Pas de stash, pas de switch. Après tests verts ou résidu qualifié: retour Git pour commits atomiques puis merge local vers `develop`.
|
||||
|
||||
## Incident live
|
||||
|
||||
La délégation vers Architect a été affichée dans son chat mais non soumise. Architect a été stoppé. Symptôme cohérent avec l'AppImage live qui n'intègre pas encore les correctifs WIP write-portal/headless delivery.
|
||||
|
||||
## Vérifications Main avant correction DevBackend
|
||||
|
||||
Verts:
|
||||
- `cargo test -p infrastructure input --lib`: 35 passed.
|
||||
- `cargo test -p application --test orchestrator_service`: 45 passed.
|
||||
- `cd frontend && npx vitest run`: 41 files, 384 tests passed.
|
||||
- `cd frontend && npx tsc --noEmit`: OK.
|
||||
|
||||
Rouge initial:
|
||||
- `cargo test --workspace` ne compilait pas `crates/application/src/orchestrator/context_guard.rs`:
|
||||
- `may_write_directly` appelé avec 2 args au lieu de 3 (`OrchestratorDesignation` manquant).
|
||||
- mauvais champ `orchestrator` mis sur `ManifestEntry` au lieu de `AgentManifest`.
|
||||
- init `AgentManifest` sans `orchestrator`.
|
||||
|
||||
## Correction DevBackend
|
||||
|
||||
DevBackend a modifié `crates/application/src/orchestrator/context_guard.rs`:
|
||||
- `ProposeContext` charge `AgentManifest`, récupère `manifest.orchestrator_designation()`, puis appelle `may_write_directly(requester, &GuardedResource::ProjectContext, &designation)`.
|
||||
- Le `FileGuard` reste un verrou, pas le propriétaire de l'autorisation.
|
||||
- Tests locaux adaptés à `AgentManifest { version, entries, orchestrator }`.
|
||||
|
||||
Validations DevBackend:
|
||||
- `cargo fmt --all && cargo test -p application --test orchestrator_service`: OK, 45 passed.
|
||||
- `cargo test -p application`: OK.
|
||||
- `cargo test --workspace`: compile maintenant plus loin mais échoue dans `app-tauri` sur tests loopback Unix socket sous sandbox (`PermissionDenied`/`Operation not permitted` lors du bind `/run/user/1000/idea-mcp/*.sock`).
|
||||
|
||||
## Prochaine étape
|
||||
|
||||
QA doit qualifier le résidu app-tauri:
|
||||
- confirmer que les suites métier/orchestration sont vertes,
|
||||
- confirmer si les échecs app-tauri sont environnement/sandbox et non régression,
|
||||
- proposer la commande de validation acceptable ou le besoin de correction test/env.
|
||||
@ -0,0 +1,42 @@
|
||||
---
|
||||
name: checkpoint-orchestrator-designation-qa-verdict
|
||||
description: memory note checkpoint-orchestrator-designation-qa-verdict
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
# Checkpoint — QA verdict orchestrator-designation
|
||||
|
||||
Date: 2026-06-20
|
||||
|
||||
## Branche
|
||||
|
||||
`feature/orchestrator-designation`.
|
||||
|
||||
## Verdict QA
|
||||
|
||||
Chantier validable sous contrainte d'environnement. Pas de régression fonctionnelle liée à `context_guard.rs`.
|
||||
|
||||
## Commandes vertes qui font foi
|
||||
|
||||
- `cargo fmt --all -- --check`: OK.
|
||||
- `cargo test -p application --test orchestrator_service`: OK, 45 passed.
|
||||
- `cargo test -p application`: OK, suite application complète verte, incluant les tests context guard orchestrateur/proposition.
|
||||
- `cargo test -p infrastructure input --lib`: OK, 35 passed.
|
||||
- `cd frontend && npx vitest run`: OK, 41 files / 384 tests passed.
|
||||
- `cd frontend && npx tsc --noEmit`: OK.
|
||||
|
||||
## Résidu app-tauri
|
||||
|
||||
- `cargo test -p app-tauri --lib`: rouge, 39 passed / 8 failed.
|
||||
- `cargo test --workspace`: rouge sur le même bloc app-tauri.
|
||||
|
||||
Échecs exacts: tests loopback Unix socket:
|
||||
- `mcp_bridge::tests::end_to_end_over_real_loopback`
|
||||
- `state::bind_endpoint_d1_tests::rebind_after_corpse_socket_succeeds`
|
||||
- `state::mcp_e2e_loopback_tests::*`
|
||||
|
||||
Cause qualifiée par QA: contrainte environnement/sandbox. Une sonde Node minimale échoue aussi à `listen()` sur Unix socket avec `EPERM` sous `/run/user/1000` et sous `/tmp`. Donc les tests qui exigent un vrai socket Unix ne sont pas exécutables dans ce sandbox.
|
||||
|
||||
## Prochaine étape
|
||||
|
||||
Retour à Git pour commits atomiques sur `feature/orchestrator-designation`, puis décision de merge local vers `develop` selon sa stratégie. Aucun push.
|
||||
38
.ideai/memory/checkpoint-orchestrator-designation-restart.md
Normal file
38
.ideai/memory/checkpoint-orchestrator-designation-restart.md
Normal file
@ -0,0 +1,38 @@
|
||||
---
|
||||
name: checkpoint-orchestrator-designation-restart
|
||||
description: memory note checkpoint-orchestrator-designation-restart
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
# Checkpoint — reprise chantier orchestrator-designation
|
||||
|
||||
Date: 2026-06-20
|
||||
|
||||
## Situation
|
||||
|
||||
L'utilisateur a demandé de terminer les chantiers ouverts. Main a repris le cycle proprement.
|
||||
|
||||
## Décision Git obtenue
|
||||
|
||||
Git a inspecté l'état local et décidé:
|
||||
|
||||
- Branche courante: `feature/orchestrator-designation`.
|
||||
- Worktree dirty important mais analysé comme mono-thème: chantier orchestrateur/designation + diagnostic inter-agents.
|
||||
- Ne pas stash, ne pas switch, ne pas ouvrir une nouvelle branche maintenant.
|
||||
- Premier chantier à fermer: `orchestrator-designation` sur la branche actuelle.
|
||||
- Après stabilisation + tests verts: retour à Git pour commits atomiques puis merge local vers `develop`.
|
||||
- Ensuite seulement ouvrir des branches dédiées pour les autres chantiers.
|
||||
|
||||
## Incident de délégation
|
||||
|
||||
Main a tenté de demander à Architect de cadrer `orchestrator-designation` via `idea_ask_agent`.
|
||||
L'utilisateur a interrompu et signalé qu'Architect était bloqué avec le texte de la tâche affiché dans son chat mais non envoyé.
|
||||
|
||||
Interprétation: symptôme cohérent avec les problèmes actuels de write portal / soumission physique PTY / délégation visible-background. Ne pas empiler de nouvelles délégations tant que cette zone n'est pas stabilisée.
|
||||
|
||||
## Prochaine reprise recommandée
|
||||
|
||||
1. Nettoyer/arrêter l'agent Architect bloqué si nécessaire.
|
||||
2. Reprendre le chantier courant localement en lecture seule pour identifier exactement les changements WIP liés à la soumission de délégations.
|
||||
3. Comme Main ne code pas, utiliser la délégation seulement vers un agent dont la soumission est confirmée fonctionnelle, ou lancer les agents en visible et vérifier qu'ils reçoivent réellement la tâche.
|
||||
4. Priorité technique immédiate: fermer `orchestrator-designation`, car la délégation inter-agents fiable conditionne tous les autres chantiers.
|
||||
49
.ideai/memory/conversation-rotation-safety-design.md
Normal file
49
.ideai/memory/conversation-rotation-safety-design.md
Normal file
@ -0,0 +1,49 @@
|
||||
---
|
||||
name: conversation-rotation-safety-design
|
||||
description: memory note conversation-rotation-safety-design
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
---
|
||||
name: conversation-rotation-safety-design
|
||||
description: Cadrage initial pour la compression/rotation automatique non interruptive des conversations d'agents.
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
# Conversation Rotation Safety Design
|
||||
|
||||
Objectif : reduire le cout token des conversations longues sans degrader la stabilite des agents ni les contacts inter-agents.
|
||||
|
||||
Socle existant a reutiliser :
|
||||
- log canonique `.ideai/conversations/<conversationId>/log.jsonl` ;
|
||||
- `handoff.md` incremental ;
|
||||
- injection du handoff au lancement/relaunch ;
|
||||
- `LeafCell.conversation_id` = id logique IdeA de paire, distinct du resumable provider ;
|
||||
- `providers.json` pour `(pair_conversation_id, provider_key) -> engine_session_id` ;
|
||||
- `InputMediator.busy_state(agent)` et FIFO par agent ;
|
||||
- `ask_agent` avec lock de tour par agent + wait-for graph anti-deadlock ;
|
||||
- attach/detach cellule deja modelise : la cellule est une vue, `TerminalSessions::rebind_agent_node` existe ;
|
||||
- frontend write-portal sait deja compter la saisie humaine et declarer `set_front_attached`.
|
||||
|
||||
Regle produit centrale : rotation automatique opportuniste, jamais interruptive. IdeA ne compacte/rote une session qu'a un point sur.
|
||||
|
||||
Conditions minimales avant rotation :
|
||||
- agent `Idle` (`InputMediator.busy_state == Idle`) ;
|
||||
- aucune delegation/ticket/reply en cours ;
|
||||
- aucun contact entrant en cours, sinon queue ou ancienne session jusqu'a bascule ;
|
||||
- checkpoint durable OK : prompt/response appendes au log + `handoff.md` a jour ;
|
||||
- cellule stable : pas d'attach/detach ou changement de layout en cours ;
|
||||
- utilisateur non en train d'ecrire : focus/buffer non vide/frappe recente/IME composition/prompt interactif detectable ;
|
||||
- nouvelle session demarree detached/background et prete avant bascule ;
|
||||
- bascule atomique routing logique + attachement cellule ; ancienne session retiree ensuite.
|
||||
|
||||
Etat cible conceptuel :
|
||||
`RotationRequested -> WaitForAgentIdle -> WaitForNoDelegation -> WaitForNoIncomingRoute -> WaitForCellStable -> WaitForUserInputClear -> Checkpointing -> StartReplacementDetached -> AttachReplacementToCell -> SwitchRouting -> RetireOldSession`.
|
||||
|
||||
Premier lot recommande : fondation non destructive.
|
||||
- Ajouter modeles/policy de `ConversationRotation` et raisons de blocage.
|
||||
- Ajouter query/use case d'evaluation qui retourne `Ready` ou `Blocked(reasons)` sans tuer ni relancer.
|
||||
- Exposer les signaux manquants de cellule/saisie depuis le frontend vers backend, d'abord comme etat consultable.
|
||||
- Publier des events de statut de rotation uniquement informatifs.
|
||||
- Ne pas implementer la bascule physique avant que les tests de surete soient verts.
|
||||
@ -34,5 +34,121 @@
|
||||
}
|
||||
],
|
||||
"fallback": "allow"
|
||||
}
|
||||
},
|
||||
"agents": [
|
||||
{
|
||||
"agentId": "a6ced819-b893-4213-b003-9e9dc79b9641",
|
||||
"permissions": {
|
||||
"rules": [
|
||||
{
|
||||
"capability": "read",
|
||||
"effect": "allow",
|
||||
"paths": [
|
||||
"**"
|
||||
],
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"capability": "write",
|
||||
"effect": "deny",
|
||||
"paths": [
|
||||
"**"
|
||||
],
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"capability": "delete",
|
||||
"effect": "allow",
|
||||
"paths": [
|
||||
"**"
|
||||
],
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"capability": "executeBash",
|
||||
"effect": "allow",
|
||||
"paths": [],
|
||||
"commands": []
|
||||
}
|
||||
],
|
||||
"fallback": "allow"
|
||||
}
|
||||
},
|
||||
{
|
||||
"agentId": "dce19c75-9669-4e45-b8de-9950025157da",
|
||||
"permissions": {
|
||||
"rules": [
|
||||
{
|
||||
"capability": "read",
|
||||
"effect": "allow",
|
||||
"paths": [
|
||||
"**"
|
||||
],
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"capability": "write",
|
||||
"effect": "deny",
|
||||
"paths": [
|
||||
"**"
|
||||
],
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"capability": "delete",
|
||||
"effect": "deny",
|
||||
"paths": [
|
||||
"**"
|
||||
],
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"capability": "executeBash",
|
||||
"effect": "allow",
|
||||
"paths": [],
|
||||
"commands": []
|
||||
}
|
||||
],
|
||||
"fallback": "allow"
|
||||
}
|
||||
},
|
||||
{
|
||||
"agentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5",
|
||||
"permissions": {
|
||||
"rules": [
|
||||
{
|
||||
"capability": "read",
|
||||
"effect": "allow",
|
||||
"paths": [
|
||||
"**"
|
||||
],
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"capability": "write",
|
||||
"effect": "deny",
|
||||
"paths": [
|
||||
"**"
|
||||
],
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"capability": "delete",
|
||||
"effect": "deny",
|
||||
"paths": [
|
||||
"**"
|
||||
],
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"capability": "executeBash",
|
||||
"effect": "allow",
|
||||
"paths": [],
|
||||
"commands": []
|
||||
}
|
||||
],
|
||||
"fallback": "allow"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
246
CLAUDE.md
246
CLAUDE.md
@ -1,205 +1,93 @@
|
||||
# IdeA — Contexte & Méthode de travail
|
||||
# IdeA — Contexte projet global
|
||||
|
||||
> Ce document définit **mon rôle**, **la méthode de développement** et **la vision produit** du projet IdeA.
|
||||
> Il fait autorité sur la façon dont le projet est piloté. Toute évolution de méthode doit être répercutée ici.
|
||||
> Contexte commun minimal du projet IdeA. Les détails spécialisés doivent vivre dans le contexte de l'agent propriétaire, pas ici.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mon rôle : chef d'orchestre, pas développeur
|
||||
## 1. Project root
|
||||
|
||||
Je **n'écris pas de code moi-même**. Mon rôle est de **piloter des agents** qui réalisent le travail.
|
||||
Je suis responsable de :
|
||||
Le project root est :
|
||||
|
||||
- Découper le travail en tâches claires et autonomes.
|
||||
- Attribuer chaque tâche aux bons agents.
|
||||
- Garantir que le cycle de développement/test est respecté.
|
||||
- Faire respecter les principes d'architecture (SOLID, Hexagonal).
|
||||
- Maintenir la cohérence globale du projet et de ce document.
|
||||
- Arbitrer et valider avant toute action irréversible ou sortante.
|
||||
|
||||
---
|
||||
|
||||
## 2. Les agents
|
||||
|
||||
### 2.1 Agent Architecture (1 pour tout le projet)
|
||||
- Garant de l'architecture globale : **Hexagonale (Ports & Adapters)** et principes **SOLID**.
|
||||
- Définit les frontières (domaine / application / infrastructure), les ports, les contrats.
|
||||
- Valide que chaque nouvelle feature respecte la structure avant son développement.
|
||||
- Tient à jour la cartographie d'architecture et les conventions.
|
||||
|
||||
### 2.2 Agents de Développement
|
||||
- Écrivent le code des features.
|
||||
- Respectent strictement l'architecture définie par l'agent Architecture.
|
||||
- Code **propre, structuré, stable**.
|
||||
- Reçoivent les rapports d'erreurs des agents de test et corrigent.
|
||||
|
||||
### 2.3 Agents de Test
|
||||
- **Chaque agent de développement est appairé avec un agent de test dédié.**
|
||||
- Écrivent et exécutent les **tests unitaires** des features implémentées ou modifiées.
|
||||
- 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)
|
||||
|
||||
Pour **chaque** feature implémentée ou modifiée :
|
||||
|
||||
```
|
||||
1. Agent Architecture → valide le découpage et les contrats (ports/interfaces)
|
||||
2. Agent Git → décide de la branche (créer feature/*, switch, ou rester)
|
||||
3. Agent Développement → écrit le code
|
||||
4. Agent Test → écrit les tests unitaires + les exécute
|
||||
5a. Tests OK → feature validée
|
||||
→ Agent Git décide d'un merge éventuel (feature/* → develop)
|
||||
→ on passe à la suite
|
||||
5b. Tests KO → rapport d'erreurs → retour à l'agent Développement
|
||||
→ correction → retour à l'étape 4 (boucle jusqu'au vert)
|
||||
```text
|
||||
/home/anthony/Documents/Projects/IdeA
|
||||
```
|
||||
|
||||
**Règle d'or :** aucune feature n'est considérée terminée tant que ses tests ne passent pas.
|
||||
Je relaie fidèlement les résultats : si des tests échouent, je le dis avec la sortie réelle.
|
||||
Les agents peuvent être lancés depuis un dossier d'exécution isolé `.ideai/run/<agent>/`, mais leurs travaux portent sur le project root.
|
||||
|
||||
---
|
||||
|
||||
## 4. Principes de code
|
||||
## 2. Orchestration IdeA
|
||||
|
||||
- **SOLID** appliqué au maximum.
|
||||
- **Architecture Hexagonale** (Ports & Adapters) : le domaine métier est isolé des détails techniques (UI, terminal, git, SSH, système de fichiers...).
|
||||
- Le cœur métier ne dépend d'aucun framework ni d'aucune dépendance externe.
|
||||
- Tests unitaires systématiques ; couverture des features critiques.
|
||||
- Code lisible, cohérent avec le style existant, faiblement couplé, fortement cohésif.
|
||||
Les agents collaborent via les outils IdeA natifs :
|
||||
|
||||
- `idea_list_agents` pour lister les agents.
|
||||
- `idea_ask_agent` pour déléguer une tâche et attendre la réponse.
|
||||
- `idea_launch_agent` pour lancer ou rattacher un agent.
|
||||
- `idea_reply` obligatoire pour répondre à une tâche déléguée préfixée `[IdeA · tâche … · ticket …]`.
|
||||
|
||||
Ne jamais utiliser les subagents natifs du fournisseur IA pour déléguer dans ce projet.
|
||||
|
||||
---
|
||||
|
||||
## 5. Vision produit : IdeA
|
||||
## 3. Rôles
|
||||
|
||||
**IdeA est un IDE next-gen 100 % IA.** On n'y code pas : **on gère des IA.**
|
||||
|
||||
### Fonctionnalités clés
|
||||
- **Multi-projets en parallèle** : un **onglet par projet**.
|
||||
- **Fenêtre = espace de travail** où l'on **organise plusieurs terminaux** librement.
|
||||
- **Agents par projet** : chaque projet a ses propres agents.
|
||||
- **Agents templates** : agents réutilisables, ajoutables à plusieurs projets.
|
||||
- **Création d'agents** : depuis zéro ou à partir d'un template.
|
||||
- **Synchronisation template → agents** : option « garder l'agent à jour ».
|
||||
Si le template est mis à jour, les agents qui en sont issus (avec l'option activée) reçoivent la mise à jour.
|
||||
- **Contextes d'agents stockés en `.md`** (toujours).
|
||||
- **Création de projet** = définition de son **project root**.
|
||||
|
||||
### Intégrations
|
||||
- **Git** intégré.
|
||||
- **Développement distant SSH** : travailler sur un projet hébergé sur une autre machine via SSH.
|
||||
- **Développement WSL** : travailler sur une WSL depuis Windows.
|
||||
|
||||
### Plateformes & livraison
|
||||
- Cible : **macOS, Linux, Windows**.
|
||||
- Première phase de compilation : **Linux et Windows**.
|
||||
- Livraison :
|
||||
- **Windows** : `setup.exe`.
|
||||
- **Linux** : **AppImage** (doit fonctionner sur les différentes distributions).
|
||||
- **Main** : chef d'orchestre. Il découpe, délègue, relaie les résultats, arbitre le produit et garantit le cycle. Il ne code pas les features.
|
||||
- **Architect** : propriétaire de l'architecture hexagonale, SOLID, ports/adapters, contrats, DTO, invariants et cartographie.
|
||||
- **DevBackend** : implémentation backend Rust selon les contrats validés par Architect.
|
||||
- **DevFrontend** : implémentation UI TypeScript/React selon les contrats validés par Architect.
|
||||
- **QA** : tests unitaires/intégration ciblés, exécution réelle, rapports d'échec, re-test jusqu'au vert.
|
||||
- **Git** : propriétaire des branches, commits, merges/rebases locaux. Aucune action sortante sans validation explicite.
|
||||
|
||||
---
|
||||
|
||||
## 6. Stack technique (validée)
|
||||
## 4. Cycle obligatoire
|
||||
|
||||
- **Shell applicatif** : **Tauri v2** (binaires légers, performants, multi-OS, AppImage + installeur `setup.exe`/NSIS Windows natifs).
|
||||
- **Cœur / backend** : **Rust** — stabilité, performance, et expression idiomatique du domaine hexagonal (ports = traits, adapters = implémentations).
|
||||
- **Frontend / UI** : **TypeScript + React**.
|
||||
- **Terminaux** : **xterm.js** (rendu) + **portable-pty** (PTY côté Rust).
|
||||
- **Git** : **libgit2** via `git2` (Rust).
|
||||
- **SSH** : `russh` / `ssh2` (Rust).
|
||||
- **WSL** : invocation de `wsl.exe` depuis le backend.
|
||||
Pour toute feature ou correction applicative :
|
||||
|
||||
## 7. Layout des terminaux (exigence produit)
|
||||
|
||||
Disposition en **grille redimensionnable de type tableur (Excel)** :
|
||||
|
||||
- Splits redimensionnables horizontaux **et** verticaux.
|
||||
- L'utilisateur peut **définir le nombre de colonnes dans une ligne** et **le nombre de lignes dans une colonne**, indépendamment par zone.
|
||||
- Possibilité de **fusionner des cellules** (ex. fusionner deux colonnes sur une ligne), à la manière des cellules fusionnées d'un tableur.
|
||||
- Chaque cellule de la grille héberge un terminal.
|
||||
- → Modèle de layout récursif/imbriqué (pas une grille rigide uniforme) à concevoir par l'agent Architecture.
|
||||
|
||||
## 8. Stockage des contextes & liaison aux templates
|
||||
|
||||
- **Templates d'agents** : stockés dans l'**IDE** (dossier de données utilisateur global de l'app, hors projet).
|
||||
- **Agents de projet** : leurs `.md` sont stockés dans un dossier **`.ideai/`** à la racine du project root.
|
||||
*(Nom choisi pour éviter toute collision avec le `.idea` de JetBrains.)*
|
||||
- **Manifeste de liaison** dans `.ideai/` (ex. `.ideai/agents.json`) qui mappe pour chaque agent de projet :
|
||||
- le `.md` de l'agent,
|
||||
- le template d'origine (le cas échéant),
|
||||
- `synchronized: true/false`,
|
||||
- la **version du template** au dernier sync (pour détecter qu'une mise à jour est disponible).
|
||||
- **Synchro template → agents** : quand un template est mis à jour, les agents liés avec `synchronized: true` reçoivent la MAJ.
|
||||
|
||||
## 9. Moteur IA : adaptateur de CLI flexible (Port `AgentRuntime`)
|
||||
|
||||
Chaque IA est décrite par un **profil déclaratif** (config éditable, pas du code), implémentation d'un **Port** `AgentRuntime` côté domaine. Deux variables clés par IA :
|
||||
|
||||
1. **Commande de lancement** + arguments (ex. `claude`, `codex`, `gemini`, `aider`).
|
||||
2. **Stratégie d'injection du contexte `.md`** :
|
||||
- `conventionFile` : écrire/symlink le `.md` vers le fichier attendu par la CLI (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`…).
|
||||
- `flag` : passer le chemin via un argument.
|
||||
- `stdin` : piper le contenu.
|
||||
- `env` : passer via variable d'environnement.
|
||||
|
||||
Exemple de profil :
|
||||
```json
|
||||
{
|
||||
"id": "claude-code",
|
||||
"name": "Claude Code",
|
||||
"command": "claude",
|
||||
"args": [],
|
||||
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
|
||||
"detect": "claude --version",
|
||||
"cwd": "{projectRoot}"
|
||||
}
|
||||
```text
|
||||
1. Architect cadre ou valide l'architecture et les contrats.
|
||||
2. Git décide de la branche locale.
|
||||
3. DevBackend et/ou DevFrontend implémente.
|
||||
4. QA écrit/exécute les tests.
|
||||
5. Si KO : Main relaie le rapport réel au dev, puis retour QA.
|
||||
6. Si OK : Git committe et décide du merge local éventuel.
|
||||
```
|
||||
|
||||
**Profils intégrés (références) :** Claude Code (`claude` → `CLAUDE.md`), OpenAI Codex CLI (`codex` → `AGENTS.md`), Gemini CLI (`gemini` → `GEMINI.md`), Aider (`aider` → args/message).
|
||||
|
||||
**Règles produit :**
|
||||
- **Premier lancement de l'IDE** : un assistant (first-run) **demande à l'utilisateur** quels profils d'IA configurer. On ne présume rien par défaut.
|
||||
- Les commandes des profils sont **pré-remplies mais éditables**.
|
||||
- L'utilisateur peut **ajouter sa propre commande CLI** (profil custom) pour n'importe quelle IA.
|
||||
|
||||
**Lancement d'un agent :** à l'**activation de l'agent**, on ouvre une cellule terminal (PTY) avec le bon `cwd`, on injecte le contexte `.md`, et on **auto-lance** la CLI du profil.
|
||||
|
||||
## 10. Fenêtres & onglets
|
||||
|
||||
- **Par défaut : un onglet par projet** (comme les IDE classiques).
|
||||
- **Drag & drop d'un onglet** hors de la fenêtre → **crée une nouvelle fenêtre OS** portant ce projet.
|
||||
- **Multi-fenêtres OS supporté** ; chaque fenêtre possède un ou plusieurs onglets/projets.
|
||||
|
||||
## 11. Feuille de route
|
||||
|
||||
1. **Cadrage architecture complet d'abord** (jalon en cours) : l'agent Architecture produit la cartographie complète — domaine, ports, adapters, modules, arborescence — **avant tout code**.
|
||||
2. Puis MVP incrémental selon le cycle dev/test de la section 3.
|
||||
|
||||
## 12. Autonomie d'exécution dans le projet
|
||||
|
||||
L'utilisateur m'accorde un **accès large et autonome** sur le dossier du projet : je peux lire, créer, modifier des fichiers et exécuter les commandes de développement (cargo, npm, npx, git, etc.) **sans demander confirmation à chaque fois**.
|
||||
|
||||
- Concrètement, ces autorisations sont matérialisées dans `.claude/settings.local.json` (mode `acceptEdits` + `Bash`/`Read`/`Edit`/`Write` autorisés), pas dans ce document — CONTEXT.md ne fait que **documenter l'intention**.
|
||||
- **Garde-fous conservés** : les actions destructrices ou hors-projet restent bloquées (`sudo`, `rm -rf` sur `/`/`~`/`$HOME`, `mkfs`, `dd`, `shutdown`/`reboot`…).
|
||||
- L'esprit du rôle (§1) ne change pas : je reste **chef d'orchestre**. L'autonomie porte sur l'exécution mécanique, pas sur l'arbitrage des décisions produit/archi, ni sur les **actions sortantes** (push, publication) qui restent soumises à validation explicite.
|
||||
Une feature n'est terminée que lorsque les tests pertinents sont verts avec sortie réelle.
|
||||
|
||||
---
|
||||
|
||||
*Dernière mise à jour : 2026-06-05*
|
||||
## 5. Produit : repères communs
|
||||
|
||||
IdeA est un IDE next-gen 100 % IA : l'utilisateur y pilote des agents IA plutôt que coder directement.
|
||||
|
||||
Repères stables :
|
||||
|
||||
- Un onglet par projet, multi-fenêtres OS à terme.
|
||||
- Workspace organisé en terminaux/cellules redimensionnables.
|
||||
- Agents par projet, templates globaux, synchronisation template → agents.
|
||||
- Contextes d'agents en Markdown dans `.ideai/`.
|
||||
- Profils IA déclaratifs et éditables, configurés au premier lancement.
|
||||
- Git, SSH et WSL intégrés.
|
||||
- Stack validée : Tauri v2, Rust, TypeScript + React, xterm.js, portable-pty, git2/libgit2, russh/ssh2, `wsl.exe`.
|
||||
|
||||
Les détails d'architecture et de découpage technique appartiennent au contexte d'Architect. Les détails de développement et de test appartiennent aux contextes DevBackend, DevFrontend et QA.
|
||||
|
||||
---
|
||||
|
||||
## 6. Mémoire projet
|
||||
|
||||
Consulter la mémoire projet selon le besoin au lieu de recopier tous les détails ici :
|
||||
|
||||
- `agent-context-memory-and-profile-handoff`
|
||||
- `idea-product-directives-main-handoff`
|
||||
- `remaining-work-idea-agent-control-ide`
|
||||
- `mcp-bridge-and-delegation-runtime-notes`
|
||||
- `permissions-sandbox-system-state`
|
||||
- `session-limit-handling-design`
|
||||
- `git-owns-commit-merge-decisions`
|
||||
- `conversation-rotation-safety-design`
|
||||
|
||||
---
|
||||
|
||||
*Dernière mise à jour : 2026-06-20*
|
||||
@ -37,8 +37,7 @@ use crate::dto::{
|
||||
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
||||
EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
|
||||
ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto,
|
||||
GitCommitDto,
|
||||
GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
|
||||
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
||||
@ -1217,8 +1216,8 @@ pub async fn launch_agent(
|
||||
// est un raté best-effort connu pour LS7.
|
||||
if let Some(parser) = &rate_limit_parser {
|
||||
let text = String::from_utf8_lossy(&chunk);
|
||||
if let Some(limit) =
|
||||
parser.detect(&text, domain::ports::Clock::now_millis(&*detect_clock))
|
||||
if let Some(limit) = parser
|
||||
.detect(&text, domain::ports::Clock::now_millis(&*detect_clock))
|
||||
{
|
||||
service.on_rate_limited(
|
||||
agent_id,
|
||||
@ -1378,10 +1377,7 @@ pub async fn agent_send(
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] (`INVALID` for a malformed agent id).
|
||||
#[tauri::command]
|
||||
pub async fn cancel_resume(
|
||||
agent_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<bool, ErrorDto> {
|
||||
pub async fn cancel_resume(agent_id: String, state: State<'_, AppState>) -> Result<bool, ErrorDto> {
|
||||
let id = parse_agent_id(&agent_id)?;
|
||||
Ok(state.session_limit_service.cancel_resume(id))
|
||||
}
|
||||
|
||||
@ -172,6 +172,16 @@ pub enum DomainEventDto {
|
||||
/// badge the source.
|
||||
source: OrchestrationSourceDto,
|
||||
},
|
||||
/// The project's orchestrator designation changed (T1).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
OrchestratorChanged {
|
||||
/// The project whose designation changed.
|
||||
project_id: String,
|
||||
/// The newly designated orchestrator agent, or `None` for the default
|
||||
/// (the oldest agent orchestrates).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
orchestrator: Option<String>,
|
||||
},
|
||||
/// A memory note was created or updated.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
MemorySaved {
|
||||
@ -401,6 +411,13 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
ok: *ok,
|
||||
source: (*source).into(),
|
||||
},
|
||||
DomainEvent::OrchestratorChanged {
|
||||
project_id,
|
||||
orchestrator,
|
||||
} => Self::OrchestratorChanged {
|
||||
project_id: project_id.to_string(),
|
||||
orchestrator: orchestrator.as_ref().map(|a| a.to_string()),
|
||||
},
|
||||
DomainEvent::MemorySaved { slug } => Self::MemorySaved {
|
||||
slug: slug.as_str().to_string(),
|
||||
},
|
||||
|
||||
@ -75,6 +75,12 @@ pub fn run() {
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.expect("failed to resolve the app data directory");
|
||||
// Point the orchestrator's best-effort diagnostics at a persistent file
|
||||
// (`<app-data>/logs/idea.log`) so inter-agent rendezvous beacons survive a
|
||||
// click-launched AppImage (whose stderr is otherwise discarded). Best-effort:
|
||||
// if the file can't be opened the beacons simply stay on stderr.
|
||||
application::diag::set_log_path(app_data_dir.join("logs").join("idea.log"));
|
||||
application::diag!("[startup] IdeA launched; diagnostics log armed");
|
||||
let app_state = AppState::build(app_data_dir);
|
||||
|
||||
// Wire the domain event bus → Tauri events relay.
|
||||
|
||||
@ -508,8 +508,14 @@ mod tests {
|
||||
|
||||
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}");
|
||||
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
|
||||
|
||||
@ -13,26 +13,25 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::{
|
||||
AgentResumer, AppError, AssignSkillToAgent, ChangeAgentProfile, CheckEmbedderSuggestion,
|
||||
CloseProject, CloseTab,
|
||||
CloseTerminal, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
|
||||
CreateAgentFromTemplate,
|
||||
CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent,
|
||||
LaunchAgentInput, ProposeContext, ReadContext, ReadMemory, SessionLimitService, WriteMemory,
|
||||
DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate,
|
||||
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
||||
FirstRunState, GetMemory, GetProjectPermissions, GitBranches, GitCheckout, GitCommit, GitGraph,
|
||||
GitInit, GitLog, GitStage, GitStatus, GitUnstage, HealthUseCase, InspectConversation,
|
||||
LaunchAgent, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories,
|
||||
ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry,
|
||||
LoadLayout, McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
|
||||
PermissionProjectorRegistry,
|
||||
OpenTerminal, OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext,
|
||||
RecallMemory, ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles,
|
||||
RenameLayout, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile,
|
||||
SaveProfile, SetActiveLayout, SnapshotRunningAgents, StructuredSessions, SuggestedThisSession,
|
||||
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext,
|
||||
UpdateAgentPermissions, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions,
|
||||
UpdateSkill, UpdateTemplate, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||
CloseProject, CloseTab, CloseTerminal, ConfigureProfiles, ContextGuardUseCases,
|
||||
CreateAgentFromScratch, CreateAgentFromTemplate, CreateLayout, CreateMemory, CreateProject,
|
||||
CreateSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory,
|
||||
DeleteProfile, DeleteSkill, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift,
|
||||
DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetMemory, GetProjectPermissions,
|
||||
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
|
||||
GitUnstage, HealthUseCase, InspectConversation, LaunchAgent, LaunchAgentInput, ListAgents,
|
||||
ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories, ListProfiles, ListProjects,
|
||||
ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, LoadLayout, McpRuntime,
|
||||
MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
||||
OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext,
|
||||
ReadContext, ReadMemory, ReadMemoryIndex, ReadProjectContext, RecallMemory, ReconcileLayouts,
|
||||
RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout, ResizeTerminal,
|
||||
ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile, SaveProfile,
|
||||
SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StructuredSessions,
|
||||
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent,
|
||||
UpdateAgentContext, UpdateAgentPermissions, UpdateMemory, UpdateProjectContext,
|
||||
UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WriteMemory, WriteToTerminal,
|
||||
AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector,
|
||||
@ -50,16 +49,16 @@ use uuid::Uuid;
|
||||
|
||||
use infrastructure::{
|
||||
embedder_from_profile, AdaptiveMemoryRecall, ClaudePermissionProjector,
|
||||
ClaudeTranscriptInspector, CliAgentRuntime, CodexPermissionProjector,
|
||||
EmbedderEnvProbe, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
||||
FsHandoffStore, FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore,
|
||||
FsProjectStore, FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository,
|
||||
ClaudeTranscriptInspector, CliAgentRuntime, CodexPermissionProjector, EmbedderEnvProbe,
|
||||
FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore,
|
||||
FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
|
||||
FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository,
|
||||
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, SystemClock,
|
||||
SystemMillisClock, TokioBroadcastEventBus, TokioScheduler, UuidGenerator, VectorMemoryRecall,
|
||||
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
|
||||
VECTOR_ONNX_ENABLED,
|
||||
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory,
|
||||
SystemClock, SystemMillisClock, TokioBroadcastEventBus, TokioScheduler, UuidGenerator,
|
||||
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
||||
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
|
||||
use crate::chat::ChatBridge;
|
||||
@ -773,10 +772,8 @@ impl AppState {
|
||||
// l'infrastructure, keyés par leur `ProjectorKey` via `with(...)`.
|
||||
let permission_projectors = Arc::new(
|
||||
PermissionProjectorRegistry::new()
|
||||
.with(Arc::new(ClaudePermissionProjector)
|
||||
as Arc<dyn domain::PermissionProjector>)
|
||||
.with(Arc::new(CodexPermissionProjector)
|
||||
as Arc<dyn domain::PermissionProjector>),
|
||||
.with(Arc::new(ClaudePermissionProjector) as Arc<dyn domain::PermissionProjector>)
|
||||
.with(Arc::new(CodexPermissionProjector) as Arc<dyn domain::PermissionProjector>),
|
||||
);
|
||||
|
||||
let launch_agent = Arc::new(
|
||||
@ -1520,9 +1517,10 @@ fn claude_mcp_config_target(profile: &AgentProfile) -> Option<&str> {
|
||||
/// Pendant Codex de [`migrate_claude_run_dir`] : répare le `config.toml` MCP isolé
|
||||
/// du run dir (table `[mcp_servers.idea]`) pour que Codex, qui lit ses serveurs MCP
|
||||
/// dans `$CODEX_HOME/config.toml`, voie les outils `idea_*` après un redémarrage.
|
||||
/// `CODEX_HOME` est isolé au run dir au lancement (jamais le `~/.codex` global), donc
|
||||
/// ce fichier appartient entièrement à IdeA : il est régénéré (clobber) dès qu'un
|
||||
/// runtime réel est disponible. Best-effort, idempotent.
|
||||
/// `CODEX_HOME` est isolé au run dir au lancement (jamais le `~/.codex` global).
|
||||
/// Le fichier reste co-géré : la migration répare la partie MCP/trust sans effacer
|
||||
/// les clés de permission (`approval_policy`, `sandbox_mode`) écrites par le
|
||||
/// projecteur Codex. Best-effort, idempotent.
|
||||
async fn migrate_codex_run_dir(
|
||||
project: &Project,
|
||||
agent_id: &AgentId,
|
||||
@ -1547,11 +1545,18 @@ async fn migrate_codex_run_dir(
|
||||
project_id: project.id.as_uuid().simple().to_string(),
|
||||
requester: agent_id.to_string(),
|
||||
});
|
||||
migrate_codex_mcp_config(run_dir_path, profile, runtime.as_ref()).await
|
||||
migrate_codex_mcp_config(
|
||||
run_dir_path,
|
||||
project.root.as_str(),
|
||||
profile,
|
||||
runtime.as_ref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn migrate_codex_mcp_config(
|
||||
run_dir: &Path,
|
||||
project_root: &str,
|
||||
profile: &AgentProfile,
|
||||
runtime: Option<&McpRuntime>,
|
||||
) -> Result<(), std::io::Error> {
|
||||
@ -1564,15 +1569,23 @@ async fn migrate_codex_mcp_config(
|
||||
return Ok(());
|
||||
};
|
||||
let toml_path = run_dir.join(target);
|
||||
let desired = mcp_server_entry_toml(profile, Some(runtime));
|
||||
let declaration = mcp_server_entry_toml(profile, Some(runtime));
|
||||
|
||||
// `config.toml` isolé = entièrement géré par IdeA ⇒ régénération (clobber) ;
|
||||
// idempotent (no-op si le contenu est déjà à jour).
|
||||
match tokio::fs::read_to_string(&toml_path).await {
|
||||
Ok(existing) if existing == desired => return Ok(()),
|
||||
Ok(_) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
// Ne jamais clobber le fichier complet ici : il porte aussi la projection de
|
||||
// permissions Codex. On répare seulement la table MCP et les entrées trust.
|
||||
let existing = match tokio::fs::read_to_string(&toml_path).await {
|
||||
Ok(existing) => Some(existing),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let desired = codex_config_toml_for_migration(
|
||||
existing.as_deref(),
|
||||
&declaration,
|
||||
run_dir.to_string_lossy().as_ref(),
|
||||
project_root,
|
||||
);
|
||||
if existing.as_deref() == Some(desired.as_str()) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(parent) = toml_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
@ -1629,6 +1642,98 @@ fn mcp_server_entry_toml(profile: &AgentProfile, runtime: Option<&McpRuntime>) -
|
||||
domain::McpServerWiring::new(command, args, transport).to_config_toml()
|
||||
}
|
||||
|
||||
fn codex_config_toml_for_migration(
|
||||
existing: Option<&str>,
|
||||
mcp_declaration: &str,
|
||||
run_dir: &str,
|
||||
project_root: &str,
|
||||
) -> String {
|
||||
let mut text = replace_toml_table_block(
|
||||
existing.unwrap_or_default(),
|
||||
"mcp_servers.idea",
|
||||
mcp_declaration.trim_end(),
|
||||
);
|
||||
text = ensure_codex_project_trust(&text, run_dir);
|
||||
text = ensure_codex_project_trust(&text, project_root);
|
||||
if !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn replace_toml_table_block(existing: &str, table: &str, replacement: &str) -> String {
|
||||
let header = format!("[{table}]");
|
||||
let mut out = Vec::new();
|
||||
let mut skipping = false;
|
||||
let mut inserted = false;
|
||||
|
||||
for line in existing.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == header {
|
||||
if !inserted {
|
||||
push_toml_block(&mut out, replacement);
|
||||
inserted = true;
|
||||
}
|
||||
skipping = true;
|
||||
continue;
|
||||
}
|
||||
if skipping && trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||
skipping = false;
|
||||
}
|
||||
if !skipping {
|
||||
out.push(line.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
if !inserted {
|
||||
if !out.is_empty() && !out.last().is_some_and(|line| line.is_empty()) {
|
||||
out.push(String::new());
|
||||
}
|
||||
push_toml_block(&mut out, replacement);
|
||||
}
|
||||
|
||||
out.join("\n")
|
||||
}
|
||||
|
||||
fn push_toml_block(out: &mut Vec<String>, block: &str) {
|
||||
out.extend(block.lines().map(ToOwned::to_owned));
|
||||
}
|
||||
|
||||
fn ensure_codex_project_trust(existing: &str, path: &str) -> String {
|
||||
if path.is_empty() {
|
||||
return existing.to_owned();
|
||||
}
|
||||
let header = format!(r#"[projects.{}]"#, toml_quoted(path));
|
||||
if existing.lines().any(|line| line.trim() == header) {
|
||||
return existing.to_owned();
|
||||
}
|
||||
let mut text = existing.trim_end().to_owned();
|
||||
if !text.is_empty() {
|
||||
text.push_str("\n\n");
|
||||
}
|
||||
text.push_str(&header);
|
||||
text.push_str("\ntrust_level = \"trusted\"\n");
|
||||
text
|
||||
}
|
||||
|
||||
fn toml_quoted(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len() + 2);
|
||||
out.push('"');
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'\\' => 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
|
||||
}
|
||||
|
||||
fn merge_claude_settings_json(existing: &str, project_root: &str) -> Option<Value> {
|
||||
let mut doc = match serde_json::from_str::<Value>(existing) {
|
||||
Ok(Value::Object(map)) => Value::Object(map),
|
||||
@ -1846,7 +1951,7 @@ fn write_atomically(path: &Path, content: &str) -> std::io::Result<()> {
|
||||
mod run_dir_migration_tests {
|
||||
use super::{
|
||||
claude_settings_seed_value, is_claude_mcp_profile, mcp_server_entry,
|
||||
merge_claude_settings_json, merge_mcp_json, migrate_claude_run_dir,
|
||||
merge_claude_settings_json, merge_mcp_json, migrate_claude_run_dir, migrate_codex_run_dir,
|
||||
};
|
||||
use application::McpRuntimeProvider;
|
||||
use domain::ids::{AgentId, ProfileId, ProjectId};
|
||||
@ -1876,6 +1981,25 @@ mod run_dir_migration_tests {
|
||||
))
|
||||
}
|
||||
|
||||
fn codex_profile() -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
ProfileId::from_uuid(Uuid::from_u128(10)),
|
||||
"OpenAI Codex CLI",
|
||||
"codex",
|
||||
Vec::new(),
|
||||
ContextInjection::convention_file("AGENTS.md").unwrap(),
|
||||
None,
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME").unwrap(),
|
||||
McpTransport::Stdio,
|
||||
))
|
||||
}
|
||||
|
||||
fn runtime(agent_id: AgentId) -> application::McpRuntime {
|
||||
application::McpRuntime {
|
||||
exe: "/opt/IdeA.AppImage".to_owned(),
|
||||
@ -2012,6 +2136,58 @@ mod run_dir_migration_tests {
|
||||
let _ = std::fs::remove_dir_all(temp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_codex_run_dir_preserves_permissions_and_repairs_mcp_trust() {
|
||||
let agent_id = AgentId::from_uuid(Uuid::from_u128(89));
|
||||
let temp = std::env::temp_dir().join(format!("idea-codex-run-migrate-{}", Uuid::new_v4()));
|
||||
let project_root = temp.join("project");
|
||||
let run_dir = project_root.join(".ideai/run").join(agent_id.to_string());
|
||||
std::fs::create_dir_all(run_dir.join(".codex")).unwrap();
|
||||
std::fs::write(
|
||||
run_dir.join(".codex/config.toml"),
|
||||
r#"approval_policy = "never"
|
||||
sandbox_mode = "workspace-write"
|
||||
|
||||
[mcp_servers.idea]
|
||||
command = "stale"
|
||||
args = ["mcp-server"]
|
||||
transport = "stdio"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let project = domain::Project::new(
|
||||
ProjectId::from_uuid(Uuid::from_u128(1234)),
|
||||
"demo",
|
||||
domain::project::ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(),
|
||||
domain::remote::RemoteRef::local(),
|
||||
1,
|
||||
)
|
||||
.unwrap();
|
||||
let profile = codex_profile();
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(async {
|
||||
migrate_codex_run_dir(&project, &agent_id, &profile)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let config = std::fs::read_to_string(run_dir.join(".codex/config.toml")).unwrap();
|
||||
assert!(config.contains(r#"approval_policy = "never""#));
|
||||
assert!(config.contains(r#"sandbox_mode = "workspace-write""#));
|
||||
assert!(config.contains("[mcp_servers.idea]"));
|
||||
assert!(config.contains(r#"default_tools_approval_mode = "approve""#));
|
||||
assert!(config.contains("tool_timeout_sec = 86400"));
|
||||
assert!(!config.contains(r#"command = "stale""#));
|
||||
assert!(config.contains(&format!(r#"[projects."{}"]"#, run_dir.to_string_lossy())));
|
||||
assert!(config.contains(&format!(r#"[projects."{}"]"#, project.root.as_str())));
|
||||
|
||||
let _ = std::fs::remove_dir_all(temp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_profile_guard_matches_only_claude_mcp_profiles() {
|
||||
assert!(is_claude_mcp_profile(&claude_profile()));
|
||||
@ -2361,6 +2537,7 @@ mod mcp_serve_peer_tests {
|
||||
manifest: AgentManifest {
|
||||
version: 1,
|
||||
entries: Vec::new(),
|
||||
orchestrator: None,
|
||||
},
|
||||
contents: HashMap::new(),
|
||||
})))
|
||||
@ -3123,11 +3300,11 @@ mod mcp_serve_peer_tests {
|
||||
// traite ces commandes sans cette erreur — et le réfute sans le câblage.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use application::{
|
||||
ContextGuardUseCases, ProposeContext, ReadContext, ReadMemory, WriteMemory,
|
||||
};
|
||||
use application::{ContextGuardUseCases, ProposeContext, ReadContext, ReadMemory, WriteMemory};
|
||||
use domain::conversation::ConversationParty;
|
||||
use domain::memory::{Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType};
|
||||
use domain::memory::{
|
||||
Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType,
|
||||
};
|
||||
use domain::ports::{Clock, MemoryError, MemoryStore};
|
||||
use domain::OrchestratorCommand;
|
||||
use infrastructure::RwFileGuard;
|
||||
@ -3204,8 +3381,7 @@ mod mcp_serve_peer_tests {
|
||||
contexts: FakeContexts,
|
||||
) -> (Arc<OrchestratorService>, Arc<FakeMemory>) {
|
||||
let memory = Arc::new(FakeMemory::default());
|
||||
let file_guard =
|
||||
Arc::new(RwFileGuard::new()) as Arc<dyn domain::fileguard::FileGuard>;
|
||||
let file_guard = Arc::new(RwFileGuard::new()) as Arc<dyn domain::fileguard::FileGuard>;
|
||||
let context_guard = Arc::new(ContextGuardUseCases {
|
||||
read_context: Arc::new(ReadContext::new(
|
||||
Arc::clone(&file_guard),
|
||||
@ -3571,6 +3747,7 @@ mod mcp_e2e_loopback_tests {
|
||||
manifest: AgentManifest {
|
||||
version: 1,
|
||||
entries: Vec::new(),
|
||||
orchestrator: None,
|
||||
},
|
||||
contents: HashMap::new(),
|
||||
})))
|
||||
|
||||
@ -16,9 +16,9 @@ use std::time::Duration;
|
||||
use app_tauri_lib::state::AppState;
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ids::NodeId;
|
||||
use domain::ports::IdGenerator;
|
||||
use domain::AgentId;
|
||||
use infrastructure::UuidGenerator;
|
||||
use domain::ports::IdGenerator;
|
||||
|
||||
fn temp_path(tag: &str) -> PathBuf {
|
||||
let ids = UuidGenerator::new();
|
||||
@ -124,7 +124,10 @@ async fn set_resume_at_resolves_no_cell_for_an_agent_without_a_live_session() {
|
||||
.structured_sessions
|
||||
.node_for_agent(&unknown)
|
||||
.or_else(|| state.terminal_sessions.node_for_agent(&unknown));
|
||||
assert!(resolved.is_none(), "set_resume_at ⇒ NOT_FOUND (aucun armement orphelin)");
|
||||
assert!(
|
||||
resolved.is_none(),
|
||||
"set_resume_at ⇒ NOT_FOUND (aucun armement orphelin)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Parité runtime du filet humain : `confirm_human_resume` (la délégation de
|
||||
@ -156,8 +159,14 @@ async fn confirm_human_resume_arms_a_cancellable_resume_over_the_real_bus() {
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
assert!(saw_rate_limited, "AgentRateLimited relayed on the bus (humain)");
|
||||
assert!(saw_scheduled, "AgentResumeScheduled relayed on the bus (humain)");
|
||||
assert!(
|
||||
saw_rate_limited,
|
||||
"AgentRateLimited relayed on the bus (humain)"
|
||||
);
|
||||
assert!(
|
||||
saw_scheduled,
|
||||
"AgentResumeScheduled relayed on the bus (humain)"
|
||||
);
|
||||
|
||||
// Annulable par la même voie que l'auto.
|
||||
assert!(
|
||||
|
||||
@ -24,6 +24,11 @@ use domain::profile::{
|
||||
StructuredAdapter,
|
||||
};
|
||||
|
||||
/// Codex's interactive TUI is sensitive to receiving a large pasted block and the
|
||||
/// submit key too close together. Keep the default conservative so delegated
|
||||
/// prompts are actually submitted instead of remaining in the input editor.
|
||||
pub const CODEX_SUBMIT_DELAY_MS: u32 = 350;
|
||||
|
||||
/// A fixed UUID namespace used to derive stable ids for reference profiles.
|
||||
/// (Random-looking but constant; only its stability matters.)
|
||||
const REFERENCE_NAMESPACE: uuid::Uuid = uuid::uuid!("6f9b1d2a-7c34-4e58-9a1b-2c3d4e5f6a7b");
|
||||
@ -82,6 +87,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
.expect("codex reference profile is valid")
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_projector(ProjectorKey::Codex)
|
||||
.with_submit_delay_ms(CODEX_SUBMIT_DELAY_MS)
|
||||
.with_mcp(McpCapability::new(
|
||||
// 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
|
||||
|
||||
@ -21,14 +21,14 @@ use domain::ports::{
|
||||
StoreError,
|
||||
};
|
||||
use domain::profile::{McpConfigStrategy, StructuredAdapter};
|
||||
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
|
||||
use domain::{
|
||||
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId,
|
||||
ConversationParty, DomainEvent, EffectivePermissions, Handoff, HandoffStore, ManifestEntry,
|
||||
MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, PermissionProjector, ProfileId,
|
||||
ProjectedFile, ProjectionContext, ProjectorKey, Project, ProjectPath, ProviderSessionStore,
|
||||
PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
|
||||
MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, PermissionProjector, ProfileId, Project,
|
||||
ProjectPath, ProjectedFile, ProjectionContext, ProjectorKey, ProviderSessionStore, PtySize,
|
||||
SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
|
||||
};
|
||||
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::layout::{persist_doc, resolve_doc};
|
||||
@ -1446,6 +1446,7 @@ impl LaunchAgent {
|
||||
let prepared = PreparedContext {
|
||||
content: content.clone(),
|
||||
relative_path: agent.context_path.clone(),
|
||||
project_root: input.project.root.as_str().to_owned(),
|
||||
};
|
||||
// 4a. Resolve the session intention (T4). The conversation id is a property
|
||||
// of the *cell*, not the PTY: the caller (which owns the layout) passes
|
||||
|
||||
@ -22,7 +22,9 @@ pub use structured::{
|
||||
drain_with_readiness, drain_with_readiness_outcome, send_blocking, TurnOutcome,
|
||||
};
|
||||
|
||||
pub use catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles};
|
||||
pub use catalogue::{
|
||||
reference_profile_id, reference_profiles, selectable_reference_profiles, CODEX_SUBMIT_DELAY_MS,
|
||||
};
|
||||
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
|
||||
pub use lifecycle::{
|
||||
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
|
||||
|
||||
@ -35,8 +35,7 @@ use crate::error::AppError;
|
||||
/// `--resume` (via [`domain::ports::SessionPlan::Resume`]) porte déjà tout
|
||||
/// l'historique : ce prompt n'a qu'à **réamorcer** le tour, pas reconstruire le
|
||||
/// contexte. Volontairement neutre et model-agnostique.
|
||||
pub const RESUME_PROMPT: &str =
|
||||
"La limite de session est levée. Reprends là où tu t'étais arrêté.";
|
||||
pub const RESUME_PROMPT: &str = "La limite de session est levée. Reprends là où tu t'étais arrêté.";
|
||||
|
||||
/// Port applicatif de **reprise d'un agent** (frontière implémentée au composition
|
||||
/// root, LS7). Calqué sur les autres traits-passerelles de l'application
|
||||
@ -161,7 +160,13 @@ impl SessionLimitService {
|
||||
conversation_id,
|
||||
} = plan_resume(now, &limit, conversation_id)
|
||||
{
|
||||
self.arm_scheduled(agent_id, fire_at_ms, node_id, conversation_id, Some(resets_at_ms));
|
||||
self.arm_scheduled(
|
||||
agent_id,
|
||||
fire_at_ms,
|
||||
node_id,
|
||||
conversation_id,
|
||||
Some(resets_at_ms),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -199,9 +204,14 @@ impl SessionLimitService {
|
||||
conversation_id,
|
||||
},
|
||||
);
|
||||
self.armed.lock().expect("session-limit mutex sain").insert(agent_id, id);
|
||||
self.events
|
||||
.publish(DomainEvent::AgentResumeScheduled { agent_id, fire_at_ms });
|
||||
self.armed
|
||||
.lock()
|
||||
.expect("session-limit mutex sain")
|
||||
.insert(agent_id, id);
|
||||
self.events.publish(DomainEvent::AgentResumeScheduled {
|
||||
agent_id,
|
||||
fire_at_ms,
|
||||
});
|
||||
}
|
||||
|
||||
/// **(b) Exécution de la reprise.** Consomme une [`ScheduledTask::ResumeAgent`]
|
||||
@ -255,7 +265,10 @@ impl SessionLimitService {
|
||||
};
|
||||
|
||||
if self.scheduler.cancel(id) {
|
||||
self.armed.lock().expect("session-limit mutex sain").remove(&agent_id);
|
||||
self.armed
|
||||
.lock()
|
||||
.expect("session-limit mutex sain")
|
||||
.remove(&agent_id);
|
||||
self.events
|
||||
.publish(DomainEvent::AgentResumeCancelled { agent_id });
|
||||
true
|
||||
|
||||
@ -14,8 +14,8 @@
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use domain::input::InputMediator;
|
||||
use domain::ids::AgentId;
|
||||
use domain::input::InputMediator;
|
||||
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent};
|
||||
use domain::readiness::{ReadinessPolicy, ReadinessSignal};
|
||||
|
||||
@ -178,16 +178,15 @@ async fn drain_bounded_events(
|
||||
on_signal: impl FnMut(ReadinessSignal),
|
||||
) -> Result<TurnOutcome, AgentSessionError> {
|
||||
match timeout {
|
||||
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),
|
||||
},
|
||||
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, on_event, on_signal).await,
|
||||
}
|
||||
}
|
||||
@ -317,7 +316,9 @@ mod tests {
|
||||
let session = FakeSession {
|
||||
events: vec![
|
||||
ReplyEvent::TextDelta { text: "a".into() },
|
||||
ReplyEvent::ToolActivity { label: "lit".into() },
|
||||
ReplyEvent::ToolActivity {
|
||||
label: "lit".into(),
|
||||
},
|
||||
ReplyEvent::Heartbeat,
|
||||
ReplyEvent::Final {
|
||||
content: "fini".into(),
|
||||
|
||||
74
crates/application/src/diag.rs
Normal file
74
crates/application/src/diag.rs
Normal file
@ -0,0 +1,74 @@
|
||||
//! Lightweight, dependency-free diagnostics sink for the orchestrator rendezvous.
|
||||
//!
|
||||
//! The orchestrator already emits best-effort traces through `eprintln!` (see
|
||||
//! [`crate::orchestrator`]). When IdeA is launched by **clicking the AppImage**,
|
||||
//! that stderr is discarded — so an inter-agent block (an `ask` that never gets
|
||||
//! its `idea_reply`) leaves no retrievable trace. This module mirrors those
|
||||
//! traces to a **persistent log file** the user can hand back for diagnosis,
|
||||
//! while keeping the exact same "zero new dependency, never breaks the
|
||||
//! rendezvous" discipline: every write is best-effort and infallible.
|
||||
//!
|
||||
//! - [`set_log_path`] is called once by the composition root (`app-tauri`) with
|
||||
//! `<app-data>/logs/idea.log`. Until then (and in tests) writes go to stderr
|
||||
//! only.
|
||||
//! - [`diag`] (and the [`diag!`] macro) timestamps a line with epoch-millis — the
|
||||
//! same clock as the conversation logs' `atMs` — and appends it to the file,
|
||||
//! always also echoing to stderr so a terminal launch and the test suite still
|
||||
//! see it.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// The resolved log file path, set once at startup. `None` ⇒ stderr-only.
|
||||
static LOG_PATH: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// Serialises concurrent appends so interleaved rendezvous beacons stay on their
|
||||
/// own lines (the orchestrator handles many connections in parallel).
|
||||
static WRITE_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Points the diagnostics sink at `path`, creating its parent directory.
|
||||
///
|
||||
/// Idempotent and infallible: a second call (or a failure to create the
|
||||
/// directory) is ignored — diagnostics must never break startup. Called by the
|
||||
/// composition root once the app-data directory is known.
|
||||
pub fn set_log_path(path: PathBuf) {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let _ = LOG_PATH.set(path);
|
||||
}
|
||||
|
||||
/// Current epoch-millis (same clock as the conversation logs' `atMs`).
|
||||
fn now_ms() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Appends one timestamped diagnostic line. Best-effort and infallible: a missing
|
||||
/// path or an I/O error is swallowed (the line still reaches stderr). Never call
|
||||
/// in a hot loop — these are lifecycle beacons, not a metrics stream.
|
||||
pub fn diag(msg: impl std::fmt::Display) {
|
||||
let line = format!("{} {msg}", now_ms());
|
||||
// Always echo to stderr: keeps a terminal launch and the test suite working,
|
||||
// and preserves the pre-existing `eprintln!` behaviour for these beacons.
|
||||
eprintln!("{line}");
|
||||
if let Some(path) = LOG_PATH.get() {
|
||||
let _guard = WRITE_LOCK.lock();
|
||||
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) {
|
||||
let _ = writeln!(f, "{line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `diag!("...", ...)` — formats then forwards to [`diag`]. Mirrors `eprintln!`.
|
||||
#[macro_export]
|
||||
macro_rules! diag {
|
||||
($($arg:tt)*) => {
|
||||
$crate::diag::diag(format!($($arg)*))
|
||||
};
|
||||
}
|
||||
@ -13,6 +13,7 @@
|
||||
|
||||
pub mod agent;
|
||||
pub mod conversation;
|
||||
pub mod diag;
|
||||
pub mod embedder;
|
||||
pub mod error;
|
||||
pub mod git;
|
||||
@ -31,19 +32,19 @@ pub mod window;
|
||||
pub use agent::{
|
||||
drain_with_readiness, drain_with_readiness_outcome, reference_profile_id, reference_profiles,
|
||||
selectable_reference_profiles, send_blocking, AgentResumer, ChangeAgentProfile,
|
||||
ChangeAgentProfileInput, ChangeAgentProfileOutput,
|
||||
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch,
|
||||
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile,
|
||||
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
|
||||
FirstRunStateOutput, HandoffProvider, InspectConversation, InspectConversationInput,
|
||||
InspectConversationOutput, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents,
|
||||
ListAgentsInput, ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
|
||||
ListResumableAgentsInput, ListResumableAgentsOutput, McpRuntime, PermissionProjectorRegistry,
|
||||
ProfileAvailability,
|
||||
ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput,
|
||||
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
||||
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider,
|
||||
InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent,
|
||||
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
||||
ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput,
|
||||
ListResumableAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProfileAvailability,
|
||||
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
|
||||
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput,
|
||||
SaveProfileOutput, SessionLimitService, StructuredSessionDescriptor, TurnOutcome,
|
||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, RESUME_PROMPT,
|
||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
|
||||
RESUME_PROMPT,
|
||||
};
|
||||
pub use conversation::RecordTurn;
|
||||
pub use embedder::{
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::conversation::ConversationParty;
|
||||
use domain::fileguard::{FileGuard, GuardError, GuardedResource};
|
||||
use domain::fileguard::{may_write_directly, FileGuard, GuardError, GuardedResource};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
|
||||
use domain::ports::{AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath};
|
||||
@ -135,9 +135,10 @@ impl ReadContext {
|
||||
/// Proposes new content for an IdeA-owned context under the [`FileGuard`].
|
||||
///
|
||||
/// For an **agent** context: a direct write under an exclusive write-lease. For the
|
||||
/// **global** project context by a non-orchestrator: the guard returns
|
||||
/// [`GuardError::Forbidden`], which this use case turns into a *materialised proposal*
|
||||
/// (a file under `.ideai/proposals/`) — never an overwrite of the live context.
|
||||
/// **global** project context by a non-orchestrator: the use case asks the domain
|
||||
/// policy whether the requester may write directly; otherwise it materialises a
|
||||
/// proposal (a file under `.ideai/proposals/`) — never an overwrite of the live
|
||||
/// context.
|
||||
pub struct ProposeContext {
|
||||
guard: Arc<dyn FileGuard>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
@ -214,24 +215,23 @@ impl ProposeContext {
|
||||
Ok(ProposeOutcome::Written)
|
||||
}
|
||||
None => {
|
||||
// Global project context: single-writer. Try to acquire the write
|
||||
// lease; Forbidden ⇒ materialise a proposal instead of overwriting.
|
||||
match self
|
||||
.guard
|
||||
.acquire_write(requester, GuardedResource::ProjectContext)
|
||||
.await
|
||||
{
|
||||
Ok(_lease) => {
|
||||
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
||||
self.fs.write(&path, content.as_bytes()).await?;
|
||||
Ok(ProposeOutcome::Written)
|
||||
}
|
||||
Err(GuardError::Forbidden) => {
|
||||
let path = self.file_proposal(&project, requester, &content).await?;
|
||||
Ok(ProposeOutcome::Proposed { path })
|
||||
}
|
||||
Err(other) => Err(map_guard_err(other)),
|
||||
// Global project context: single-writer. Authorization stays in the
|
||||
// domain policy; the guard only serialises the eventual direct write.
|
||||
let manifest = self.contexts.load_manifest(&project).await?;
|
||||
let designation = manifest.orchestrator_designation();
|
||||
let resource = GuardedResource::ProjectContext;
|
||||
if !may_write_directly(requester, &resource, &designation) {
|
||||
let path = self.file_proposal(&project, requester, &content).await?;
|
||||
return Ok(ProposeOutcome::Proposed { path });
|
||||
}
|
||||
let _lease = self
|
||||
.guard
|
||||
.acquire_write(requester, resource)
|
||||
.await
|
||||
.map_err(map_guard_err)?;
|
||||
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
||||
self.fs.write(&path, content.as_bytes()).await?;
|
||||
Ok(ProposeOutcome::Written)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -392,7 +392,7 @@ mod tests {
|
||||
use async_trait::async_trait;
|
||||
use domain::agent::{AgentManifest, ManifestEntry};
|
||||
use domain::conversation::ConversationParty;
|
||||
use domain::fileguard::{may_write_directly, ReadLease, WriteLease};
|
||||
use domain::fileguard::{ReadLease, WriteLease};
|
||||
use domain::ports::{FsError, MemoryError, StoreError};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::{ProfileId, ProjectId, RemoteRef};
|
||||
@ -443,12 +443,9 @@ mod tests {
|
||||
}
|
||||
async fn acquire_write(
|
||||
&self,
|
||||
who: ConversationParty,
|
||||
_who: ConversationParty,
|
||||
res: GuardedResource,
|
||||
) -> Result<WriteLease, GuardError> {
|
||||
if !may_write_directly(who, &res) {
|
||||
return Err(GuardError::Forbidden);
|
||||
}
|
||||
let lock = self.lock_for(&res);
|
||||
Ok(WriteLease::new(Box::new(lock.write_owned().await)))
|
||||
}
|
||||
@ -612,6 +609,7 @@ mod tests {
|
||||
Arc::new(FakeContexts {
|
||||
manifest: AgentManifest {
|
||||
version: 1,
|
||||
orchestrator: None,
|
||||
entries: vec![ManifestEntry {
|
||||
agent_id: agent,
|
||||
name: name.to_owned(),
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
@ -26,7 +26,8 @@ use domain::mailbox::{Ticket, TicketId};
|
||||
use domain::ports::{Clock, EventBus, ProfileStore, PtyHandle};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::{
|
||||
AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project,
|
||||
AgentId, AgentProfile, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId,
|
||||
Project,
|
||||
};
|
||||
|
||||
use crate::conversation::RecordTurn;
|
||||
@ -34,7 +35,7 @@ use crate::conversation::RecordTurn;
|
||||
use crate::agent::{
|
||||
drain_with_readiness, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput,
|
||||
ListAgents, ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext,
|
||||
UpdateAgentContextInput,
|
||||
UpdateAgentContextInput, CODEX_SUBMIT_DELAY_MS,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::orchestrator::{
|
||||
@ -51,6 +52,19 @@ const DEFAULT_ROWS: u16 = 24;
|
||||
/// See [`DEFAULT_ROWS`].
|
||||
const DEFAULT_COLS: u16 = 80;
|
||||
|
||||
/// Submit defaults for delegated prompts, after applying profile-specific
|
||||
/// compatibility fallbacks for existing saved profiles.
|
||||
fn submit_config_for_profile(profile: &AgentProfile) -> SubmitConfig {
|
||||
let delay_ms = profile.submit_delay_ms.or_else(|| {
|
||||
matches!(
|
||||
profile.structured_adapter,
|
||||
Some(domain::profile::StructuredAdapter::Codex)
|
||||
)
|
||||
.then_some(CODEX_SUBMIT_DELAY_MS)
|
||||
});
|
||||
SubmitConfig::new(profile.submit_sequence.clone(), delay_ms)
|
||||
}
|
||||
|
||||
/// Bound on the synchronous inter-agent rendezvous (`agent.message` → `AskAgent`).
|
||||
///
|
||||
/// A target agent's turn can be long (reasoning + tool use), so the cap is
|
||||
@ -152,6 +166,15 @@ impl Drop for BusyTurnGuard {
|
||||
// soit le chemin (erreur, timeout, drop).
|
||||
self.mailbox.cancel_head(self.agent, self.ticket);
|
||||
self.input.mark_idle(self.agent);
|
||||
// Rendezvous beacon (diagnostics) : le garde a libéré une cible restée Busy
|
||||
// (chemin erreur / timeout / futur ask abandonné). Si ce beacon apparaît
|
||||
// sans « ask resolved », la cible n'a jamais répondu — c'est le scénario de
|
||||
// blocage à diagnostiquer.
|
||||
crate::diag!(
|
||||
"[rendezvous] busy-guard freed target agent {} (ticket {})",
|
||||
self.agent,
|
||||
self.ticket,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -939,16 +962,25 @@ impl OrchestratorService {
|
||||
// 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);
|
||||
// Rendezvous beacon (diagnostics) : l'ask est désormais en attente du
|
||||
// `idea_reply` (ou prompt-ready) de la cible. Si la cible termine son tour en
|
||||
// texte SANS appeler `idea_reply`, ce beacon « ask started » n'aura pas de
|
||||
// « ask resolved » correspondant avant l'expiration du `turn_timeout` — la
|
||||
// signature exacte du blocage Main→cible.
|
||||
let started = Instant::now();
|
||||
crate::diag!(
|
||||
"[rendezvous] ask started: requester={} -> target={target} (agent {agent_id}) \
|
||||
ticket={ticket_id} cold_launch={cold_launch} gate_cold_start={gate_cold_start} \
|
||||
turn_timeout_ms={}",
|
||||
requester.map_or_else(|| "user".to_owned(), |a| a.to_string()),
|
||||
turn_timeout.as_millis(),
|
||||
);
|
||||
// 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,
|
||||
);
|
||||
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).
|
||||
@ -973,14 +1005,35 @@ impl OrchestratorService {
|
||||
// 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();
|
||||
crate::diag!(
|
||||
"[rendezvous] ask resolved: target={target} (agent {agent_id}) \
|
||||
ticket={ticket_id} after_ms={} reply_len={}",
|
||||
started.elapsed().as_millis(),
|
||||
result.len(),
|
||||
);
|
||||
Ok(self.reply_outcome(agent_id, &target, result))
|
||||
}
|
||||
// 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)),
|
||||
Ok(Err(_cancelled)) => {
|
||||
crate::diag!(
|
||||
"[rendezvous] ask channel-closed: target={target} (agent {agent_id}) \
|
||||
ticket={ticket_id} after_ms={}",
|
||||
started.elapsed().as_millis(),
|
||||
);
|
||||
Err(AppError::Process(format!(
|
||||
"agent {target} : canal de réponse fermé avant un résultat"
|
||||
)))
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
crate::diag!(
|
||||
"[rendezvous] ask TIMEOUT: target={target} (agent {agent_id}) \
|
||||
ticket={ticket_id} after_ms={} (la cible n'a jamais appelé idea_reply \
|
||||
ni atteint son prompt-ready dans le turn_timeout)",
|
||||
started.elapsed().as_millis(),
|
||||
);
|
||||
Err(AppError::from(domain::ports::AgentSessionError::Timeout))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1053,12 +1106,8 @@ impl OrchestratorService {
|
||||
// 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,
|
||||
);
|
||||
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
|
||||
@ -1317,11 +1366,24 @@ impl OrchestratorService {
|
||||
})?;
|
||||
// Corrélation par ticket quand l'agent l'a renvoyé (déterministe, multi-fil) ;
|
||||
// sinon repli sur la tête de file de l'émetteur (compat agents mono-fil).
|
||||
match ticket {
|
||||
let correlation = match ticket {
|
||||
Some(ticket_id) => mailbox.resolve_ticket(from, ticket_id, result),
|
||||
None => mailbox.resolve(from, result),
|
||||
};
|
||||
// Rendezvous beacon (diagnostics) : un `idea_reply` est arrivé. Tracer s'il a
|
||||
// corrélé à un ask en vol — un échec ici (« no matching ask ») signe une
|
||||
// délégation déjà expirée/abandonnée ou un ticket erroné côté cible.
|
||||
match &correlation {
|
||||
Ok(()) => crate::diag!(
|
||||
"[rendezvous] idea_reply correlated: from agent {from} ticket={}",
|
||||
ticket.map_or_else(|| "head".to_owned(), |t| t.to_string()),
|
||||
),
|
||||
Err(e) => crate::diag!(
|
||||
"[rendezvous] idea_reply UNMATCHED: from agent {from} ticket={} err={e}",
|
||||
ticket.map_or_else(|| "head".to_owned(), |t| t.to_string()),
|
||||
),
|
||||
}
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
correlation.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
// Explicit «end-of-turn» signal (cadrage §6, lot C5): an `idea_reply` means the
|
||||
// emitting agent `from` finished its delegated task ⇒ mark it Idle so its FIFO
|
||||
// advances to the next queued ticket. This is the deterministic OR signal that
|
||||
@ -1773,7 +1835,7 @@ impl OrchestratorService {
|
||||
else {
|
||||
return (None, SubmitConfig::default(), false);
|
||||
};
|
||||
let submit = SubmitConfig::new(profile.submit_sequence, profile.submit_delay_ms);
|
||||
let submit = submit_config_for_profile(&profile);
|
||||
// 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`.
|
||||
@ -1901,7 +1963,7 @@ fn normalise(s: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
|
||||
use domain::ProfileId;
|
||||
|
||||
// (c) — timeouts pilotés par profil (lot 2) : `turn_timeout_ms` prime sur le défaut.
|
||||
@ -1949,6 +2011,28 @@ mod tests {
|
||||
assert_eq!(p.id.to_string(), p.id.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_submit_config_gets_conservative_delay_when_profile_omits_it() {
|
||||
let p = profile(2, "OpenAI Codex CLI", "codex")
|
||||
.with_structured_adapter(StructuredAdapter::Codex);
|
||||
|
||||
let submit = submit_config_for_profile(&p);
|
||||
|
||||
assert_eq!(submit.sequence, None);
|
||||
assert_eq!(submit.delay_ms, Some(CODEX_SUBMIT_DELAY_MS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_profile_submit_delay_is_preserved() {
|
||||
let p = profile(3, "OpenAI Codex CLI", "codex")
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_submit_delay_ms(900);
|
||||
|
||||
let submit = submit_config_for_profile(&p);
|
||||
|
||||
assert_eq!(submit.delay_ms, Some(900));
|
||||
}
|
||||
|
||||
// --- BusyTurnGuard (RAII de fin de tour) -------------------------------
|
||||
//
|
||||
// Fakes minimaux pour observer ce que le garde appelle à son Drop : un médiateur
|
||||
@ -2026,7 +2110,11 @@ mod tests {
|
||||
tid(7),
|
||||
);
|
||||
} // Drop ici.
|
||||
assert_eq!(*med.idled.lock().unwrap(), vec![aid(1)], "mark_idle au Drop");
|
||||
assert_eq!(
|
||||
*med.idled.lock().unwrap(),
|
||||
vec![aid(1)],
|
||||
"mark_idle au Drop"
|
||||
);
|
||||
assert_eq!(
|
||||
*mb.cancelled.lock().unwrap(),
|
||||
vec![(aid(1), tid(7))],
|
||||
@ -2047,7 +2135,10 @@ mod tests {
|
||||
tid(7),
|
||||
);
|
||||
g.disarm();
|
||||
assert!(med.idled.lock().unwrap().is_empty(), "pas de mark_idle après 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"
|
||||
|
||||
@ -42,12 +42,14 @@ impl FakeContexts {
|
||||
Self(Arc::new(Mutex::new(AgentManifest {
|
||||
version: 1,
|
||||
entries: vec![ManifestEntry::from_agent(agent)],
|
||||
orchestrator: None,
|
||||
})))
|
||||
}
|
||||
fn empty() -> Self {
|
||||
Self(Arc::new(Mutex::new(AgentManifest {
|
||||
version: 1,
|
||||
entries: Vec::new(),
|
||||
orchestrator: None,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,25 +23,25 @@ use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ids::{AgentId, ProfileId, ProjectId};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::permission::{
|
||||
EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
|
||||
ProjectionContext, ProjectorKey,
|
||||
};
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
||||
ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall,
|
||||
OutputStream, PermissionStore, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort,
|
||||
RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
|
||||
};
|
||||
use domain::permission::{
|
||||
EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
|
||||
ProjectionContext, ProjectorKey,
|
||||
};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
|
||||
SessionStrategy, StructuredAdapter,
|
||||
};
|
||||
use domain::{PermissionSet, ProjectPermissions};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::skill::{Skill, SkillScope};
|
||||
use domain::{MemoryIndexEntry, MemorySlug, MemoryType};
|
||||
use domain::{PermissionSet, ProjectPermissions};
|
||||
use domain::{PtySize, SessionId, SkillId, SkillRef};
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -84,6 +84,7 @@ impl FakeContexts {
|
||||
manifest: AgentManifest {
|
||||
version: 1,
|
||||
entries: Vec::new(),
|
||||
orchestrator: None,
|
||||
},
|
||||
contents: HashMap::new(),
|
||||
})))
|
||||
@ -1351,7 +1352,9 @@ async fn launch_conventionfile_injects_project_memory_in_order() {
|
||||
"first memory line exact format: {doc}"
|
||||
);
|
||||
assert!(
|
||||
doc.contains("- [Permissions](.ideai/memory/perm-archi.md) — sandbox OS + résumé injecté (reference)"),
|
||||
doc.contains(
|
||||
"- [Permissions](.ideai/memory/perm-archi.md) — sandbox OS + résumé injecté (reference)"
|
||||
),
|
||||
"second memory line exact format: {doc}"
|
||||
);
|
||||
// Recalled order preserved; section after the persona.
|
||||
@ -2611,7 +2614,9 @@ impl PermissionProjector for FakeClaudeProjector {
|
||||
|
||||
/// Faithful Codex projector double: emits a co-owned `MergeToml` over the two
|
||||
/// managed keys + the matching `--sandbox`/`--ask-for-approval` args, both derived
|
||||
/// from the posture exactly like the real projector. `eff == None` ⇒ empty.
|
||||
/// from the posture exactly like the real projector. Workspace-write postures also
|
||||
/// add the project root as a writable directory (`--add-dir`). `eff == None` ⇒
|
||||
/// empty.
|
||||
struct FakeCodexProjector;
|
||||
|
||||
impl FakeCodexProjector {
|
||||
@ -2631,14 +2636,23 @@ impl PermissionProjector for FakeCodexProjector {
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
_ctx: &ProjectionContext,
|
||||
ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
let Some(eff) = eff else {
|
||||
return PermissionProjection::empty();
|
||||
};
|
||||
let (sandbox, approval) = Self::modes(eff.fallback());
|
||||
let contents =
|
||||
format!("sandbox_mode = \"{sandbox}\"\napproval_policy = \"{approval}\"\n");
|
||||
let contents = format!("sandbox_mode = \"{sandbox}\"\napproval_policy = \"{approval}\"\n");
|
||||
let mut args = vec![
|
||||
"--sandbox".to_owned(),
|
||||
sandbox.to_owned(),
|
||||
"--ask-for-approval".to_owned(),
|
||||
approval.to_owned(),
|
||||
];
|
||||
if sandbox == "workspace-write" {
|
||||
args.push("--add-dir".to_owned());
|
||||
args.push(ctx.project_root.to_owned());
|
||||
}
|
||||
PermissionProjection {
|
||||
files: vec![ProjectedFile::MergeToml {
|
||||
rel_path: ".codex/config.toml".to_owned(),
|
||||
@ -2646,12 +2660,7 @@ impl PermissionProjector for FakeCodexProjector {
|
||||
managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()],
|
||||
contents,
|
||||
}],
|
||||
args: vec![
|
||||
"--sandbox".to_owned(),
|
||||
sandbox.to_owned(),
|
||||
"--ask-for-approval".to_owned(),
|
||||
approval.to_owned(),
|
||||
],
|
||||
args,
|
||||
env: Vec::new(),
|
||||
}
|
||||
}
|
||||
@ -2741,8 +2750,11 @@ fn convention_file_plan() -> Option<ContextInjectionPlan> {
|
||||
/// would not fire (here the convention file is GEMINI.md): the explicit field wins.
|
||||
#[tokio::test]
|
||||
async fn projection_selects_claude_from_explicit_projector_field() {
|
||||
let profile = profile(pid(9), ContextInjection::convention_file("GEMINI.md").unwrap())
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let profile = profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("GEMINI.md").unwrap(),
|
||||
)
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
@ -2752,7 +2764,10 @@ async fn projection_selects_claude_from_explicit_projector_field() {
|
||||
Some(perm_doc(Posture::Allow)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
let seeds = fs.writes_ending_with(CLAUDE_SEED_REL);
|
||||
assert_eq!(seeds.len(), 1, "the Claude projector ran (explicit key)");
|
||||
@ -2766,8 +2781,14 @@ async fn projection_selects_claude_from_explicit_projector_field() {
|
||||
/// Claude projector is selected.
|
||||
#[tokio::test]
|
||||
async fn projection_falls_back_to_claude_from_convention_file() {
|
||||
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap());
|
||||
assert!(profile.projector.is_none(), "no explicit projector (legacy)");
|
||||
let profile = profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
);
|
||||
assert!(
|
||||
profile.projector.is_none(),
|
||||
"no explicit projector (legacy)"
|
||||
);
|
||||
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
convention_file_plan(),
|
||||
@ -2775,7 +2796,10 @@ async fn projection_falls_back_to_claude_from_convention_file() {
|
||||
Some(perm_doc(Posture::Allow)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
assert_eq!(
|
||||
fs.writes_ending_with(CLAUDE_SEED_REL).len(),
|
||||
@ -2789,7 +2813,10 @@ async fn projection_falls_back_to_claude_from_convention_file() {
|
||||
#[tokio::test]
|
||||
async fn projection_falls_back_to_codex_from_structured_adapter() {
|
||||
let profile = codex_profile().with_structured_adapter(StructuredAdapter::Codex);
|
||||
assert!(profile.projector.is_none(), "no explicit projector (legacy)");
|
||||
assert!(
|
||||
profile.projector.is_none(),
|
||||
"no explicit projector (legacy)"
|
||||
);
|
||||
let (launch, agent, fs, pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
@ -2799,7 +2826,10 @@ async fn projection_falls_back_to_codex_from_structured_adapter() {
|
||||
Some(perm_doc(Posture::Allow)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
assert_eq!(
|
||||
fs.writes_ending_with(CODEX_CONFIG_REL).len(),
|
||||
@ -2821,7 +2851,10 @@ async fn projection_falls_back_to_codex_from_structured_adapter() {
|
||||
/// no projection at all, even with a full registry and a posed policy.
|
||||
#[tokio::test]
|
||||
async fn projection_noop_for_unprojectable_profile() {
|
||||
let profile = profile(pid(9), ContextInjection::convention_file("GEMINI.md").unwrap());
|
||||
let profile = profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("GEMINI.md").unwrap(),
|
||||
);
|
||||
let (launch, agent, fs, pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
@ -2831,7 +2864,10 @@ async fn projection_noop_for_unprojectable_profile() {
|
||||
Some(perm_doc(Posture::Allow)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
assert!(fs.writes_ending_with(CLAUDE_SEED_REL).is_empty());
|
||||
assert!(fs.writes_ending_with(CODEX_CONFIG_REL).is_empty());
|
||||
@ -2849,8 +2885,11 @@ async fn projection_noop_for_unprojectable_profile() {
|
||||
/// inversion vs. the MCP non-clobbering regime (which skips when the file exists).
|
||||
#[tokio::test]
|
||||
async fn claude_replace_seed_is_clobbered_on_relaunch() {
|
||||
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let profile = profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
)
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let (launch, agent, fs, _pty, sessions) = launch_with_projection(
|
||||
profile,
|
||||
convention_file_plan(),
|
||||
@ -2864,9 +2903,15 @@ async fn claude_replace_seed_is_clobbered_on_relaunch() {
|
||||
fs.mark_existing(&seed_path);
|
||||
|
||||
// First launch, then simulate the agent exiting so a fresh relaunch is allowed.
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch 1");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch 1");
|
||||
sessions.remove(&sid(777));
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch 2");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch 2");
|
||||
|
||||
let seeds = fs.writes_ending_with(CLAUDE_SEED_REL);
|
||||
assert_eq!(
|
||||
@ -2906,7 +2951,10 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
|
||||
);
|
||||
|
||||
// First projection.
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch 1");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch 1");
|
||||
let first = String::from_utf8(
|
||||
fs.writes_ending_with(CODEX_CONFIG_REL)
|
||||
.last()
|
||||
@ -2915,8 +2963,14 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
|
||||
.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(first.contains("user_key = \"keep-me\""), "unmanaged key preserved: {first}");
|
||||
assert!(first.contains("[mcp_servers.idea]"), "unmanaged table preserved: {first}");
|
||||
assert!(
|
||||
first.contains("user_key = \"keep-me\""),
|
||||
"unmanaged key preserved: {first}"
|
||||
);
|
||||
assert!(
|
||||
first.contains("[mcp_servers.idea]"),
|
||||
"unmanaged table preserved: {first}"
|
||||
);
|
||||
assert!(
|
||||
first.contains("sandbox_mode = \"workspace-write\""),
|
||||
"managed sandbox_mode upserted: {first}"
|
||||
@ -2928,7 +2982,10 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
|
||||
|
||||
// Second projection (relaunch): managed keys are replaced in place, not dup'd.
|
||||
sessions.remove(&sid(777));
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch 2");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch 2");
|
||||
let second = String::from_utf8(
|
||||
fs.writes_ending_with(CODEX_CONFIG_REL)
|
||||
.last()
|
||||
@ -2947,7 +3004,10 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
|
||||
1,
|
||||
"idempotent: no duplicate approval_policy: {second}"
|
||||
);
|
||||
assert!(second.contains("user_key = \"keep-me\""), "unmanaged key still preserved");
|
||||
assert!(
|
||||
second.contains("user_key = \"keep-me\""),
|
||||
"unmanaged key still preserved"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- (4) args/env fold into the spawned spec --------------------------------
|
||||
@ -2966,18 +3026,34 @@ async fn codex_projection_folds_args_into_spawn_spec() {
|
||||
Some(perm_doc(Posture::Ask)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
let args = &pty.spawns()[0].args;
|
||||
// Ask ⇒ workspace-write / on-request, in CLI order.
|
||||
let pos = args
|
||||
.windows(2)
|
||||
.position(|w| w == ["--sandbox".to_owned(), "workspace-write".to_owned()]);
|
||||
assert!(pos.is_some(), "expected --sandbox workspace-write in {args:?}");
|
||||
assert!(
|
||||
pos.is_some(),
|
||||
"expected --sandbox workspace-write in {args:?}"
|
||||
);
|
||||
let pos2 = args
|
||||
.windows(2)
|
||||
.position(|w| w == ["--ask-for-approval".to_owned(), "on-request".to_owned()]);
|
||||
assert!(pos2.is_some(), "expected --ask-for-approval on-request in {args:?}");
|
||||
assert!(
|
||||
pos2.is_some(),
|
||||
"expected --ask-for-approval on-request in {args:?}"
|
||||
);
|
||||
let add_dir = args
|
||||
.windows(2)
|
||||
.position(|w| w == ["--add-dir".to_owned(), "/home/me/proj".to_owned()]);
|
||||
assert!(
|
||||
add_dir.is_some(),
|
||||
"expected --add-dir project root in {args:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- (5) MCP decoupling — THE key case of the lot ---------------------------
|
||||
@ -2998,13 +3074,19 @@ async fn codex_sandbox_projected_without_any_mcp_capability() {
|
||||
Some(perm_doc(Posture::Deny)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
// The sandbox config WAS written despite the absent MCP capability.
|
||||
let cfg = fs.writes_ending_with(CODEX_CONFIG_REL);
|
||||
assert_eq!(cfg.len(), 1, "sandbox config projected without MCP");
|
||||
let toml = String::from_utf8(cfg[0].1.clone()).unwrap();
|
||||
assert!(toml.contains("sandbox_mode = \"read-only\""), "Deny ⇒ read-only: {toml}");
|
||||
assert!(
|
||||
toml.contains("sandbox_mode = \"read-only\""),
|
||||
"Deny ⇒ read-only: {toml}"
|
||||
);
|
||||
// And the args were folded too.
|
||||
assert!(
|
||||
pty.spawns()[0]
|
||||
@ -3022,8 +3104,11 @@ async fn codex_sandbox_projected_without_any_mcp_capability() {
|
||||
/// even with a posed policy.
|
||||
#[tokio::test]
|
||||
async fn no_registry_means_no_projection() {
|
||||
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let profile = profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
)
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
convention_file_plan(),
|
||||
@ -3031,7 +3116,10 @@ async fn no_registry_means_no_projection() {
|
||||
Some(perm_doc(Posture::Deny)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
assert!(
|
||||
fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(),
|
||||
@ -3043,8 +3131,11 @@ async fn no_registry_means_no_projection() {
|
||||
/// written, even though the registry IS wired.
|
||||
#[tokio::test]
|
||||
async fn no_policy_posed_means_empty_projection() {
|
||||
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let profile = profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
)
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
convention_file_plan(),
|
||||
@ -3052,7 +3143,10 @@ async fn no_policy_posed_means_empty_projection() {
|
||||
Some(ProjectPermissions::default()), // project_defaults = None ⇒ resolve_for == None
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
assert!(
|
||||
fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(),
|
||||
@ -3066,8 +3160,11 @@ async fn no_policy_posed_means_empty_projection() {
|
||||
/// projector and is reflected in the produced Claude settings (`defaultMode=plan`).
|
||||
#[tokio::test]
|
||||
async fn resolved_deny_posture_reflected_as_plan_mode() {
|
||||
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let profile = profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
)
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
convention_file_plan(),
|
||||
@ -3075,7 +3172,10 @@ async fn resolved_deny_posture_reflected_as_plan_mode() {
|
||||
Some(perm_doc(Posture::Deny)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
let seed = String::from_utf8(fs.writes_ending_with(CLAUDE_SEED_REL)[0].1.clone()).unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&seed).expect("valid settings JSON");
|
||||
|
||||
@ -76,6 +76,7 @@ impl FakeContexts {
|
||||
manifest: AgentManifest {
|
||||
version: 1,
|
||||
entries: Vec::new(),
|
||||
orchestrator: None,
|
||||
},
|
||||
contents: HashMap::new(),
|
||||
saves: 0,
|
||||
@ -1058,7 +1059,10 @@ async fn swap_claude_to_codex_removes_claude_seed_and_projects_codex() {
|
||||
"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");
|
||||
assert!(
|
||||
!f.fs.has_file(&seed_path),
|
||||
"the seed is gone from the FS state"
|
||||
);
|
||||
|
||||
// The relaunch projected the Codex sandbox config + args.
|
||||
assert!(
|
||||
@ -1066,7 +1070,10 @@ async fn swap_claude_to_codex_removes_claude_seed_and_projects_codex() {
|
||||
"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!(
|
||||
toml.contains("sandbox_mode = \"workspace-write\""),
|
||||
"{toml}"
|
||||
);
|
||||
assert!(
|
||||
f.pty.last_spawn_args().contains(&"--sandbox".to_owned()),
|
||||
"sandbox args folded into the relaunch spawn: {:?}",
|
||||
@ -1102,7 +1109,10 @@ async fn swap_claude_to_claude_does_not_remove_seed() {
|
||||
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");
|
||||
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
|
||||
@ -1138,13 +1148,19 @@ async fn swap_codex_to_claude_removes_nothing_and_keeps_codex_config() {
|
||||
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.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");
|
||||
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,
|
||||
@ -1153,7 +1169,8 @@ async fn swap_codex_to_claude_removes_nothing_and_keeps_codex_config() {
|
||||
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 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\":{}}");
|
||||
@ -1251,7 +1268,8 @@ async fn swap_with_cleanup_preserves_pair_id_and_handoff() {
|
||||
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"));
|
||||
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));
|
||||
@ -1273,7 +1291,10 @@ async fn swap_with_cleanup_preserves_pair_id_and_handoff() {
|
||||
// …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");
|
||||
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
|
||||
|
||||
@ -258,8 +258,9 @@ async fn stream_without_final_does_not_mark_idle_and_is_io_error() {
|
||||
#[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 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;
|
||||
@ -322,8 +323,14 @@ async fn timeout_returns_timeout_no_mark_idle_session_alive() {
|
||||
};
|
||||
let mediator = RecordingMediator::new();
|
||||
|
||||
let out =
|
||||
drain_with_readiness(&session, "x", Some(Duration::from_millis(20)), &mediator, agent).await;
|
||||
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),
|
||||
|
||||
@ -143,6 +143,7 @@ impl FakeContexts {
|
||||
manifest: Arc::new(Mutex::new(AgentManifest {
|
||||
version: 1,
|
||||
entries,
|
||||
orchestrator: None,
|
||||
})),
|
||||
fail: false,
|
||||
}
|
||||
@ -152,6 +153,7 @@ impl FakeContexts {
|
||||
manifest: Arc::new(Mutex::new(AgentManifest {
|
||||
version: 1,
|
||||
entries: Vec::new(),
|
||||
orchestrator: None,
|
||||
})),
|
||||
fail: true,
|
||||
}
|
||||
|
||||
@ -61,6 +61,7 @@ impl FakeContexts {
|
||||
manifest: AgentManifest {
|
||||
version: 1,
|
||||
entries: Vec::new(),
|
||||
orchestrator: None,
|
||||
},
|
||||
contents: HashMap::new(),
|
||||
})))
|
||||
|
||||
@ -21,7 +21,7 @@ use domain::project::ProjectPath;
|
||||
use application::{
|
||||
reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput,
|
||||
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState,
|
||||
ListProfiles, ReferenceProfiles, SaveProfile, SaveProfileInput,
|
||||
ListProfiles, ReferenceProfiles, SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -366,6 +366,11 @@ fn catalogue_has_expected_commands_and_injection() {
|
||||
target: "AGENTS.md".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
by_command["codex"].submit_delay_ms,
|
||||
Some(CODEX_SUBMIT_DELAY_MS),
|
||||
"Codex TUI needs a conservative text→submit delay for delegated prompts"
|
||||
);
|
||||
assert_eq!(
|
||||
by_command["gemini"].context_injection,
|
||||
ContextInjection::ConventionFile {
|
||||
|
||||
@ -133,10 +133,12 @@ impl AgentResumer for FakeResumer {
|
||||
conversation_id: Option<String>,
|
||||
resume_prompt: &str,
|
||||
) -> Result<(), AppError> {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((agent_id, node_id, conversation_id, resume_prompt.to_owned()));
|
||||
self.calls.lock().unwrap().push((
|
||||
agent_id,
|
||||
node_id,
|
||||
conversation_id,
|
||||
resume_prompt.to_owned(),
|
||||
));
|
||||
if self.fail.load(Ordering::SeqCst) {
|
||||
Err(AppError::Internal("reprise échouée (fake)".to_owned()))
|
||||
} else {
|
||||
@ -252,7 +254,10 @@ fn on_rate_limited_without_reset_is_human_fallback_no_arm() {
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), None);
|
||||
|
||||
assert!(env.scheduler.armed().is_empty(), "aucun arm sans heure de reset");
|
||||
assert!(
|
||||
env.scheduler.armed().is_empty(),
|
||||
"aucun arm sans heure de reset"
|
||||
);
|
||||
assert!(env.scheduler.cancels().is_empty(), "aucun cancel non plus");
|
||||
assert_eq!(
|
||||
env.bus.events(),
|
||||
@ -284,7 +289,11 @@ fn on_rate_limited_twice_same_agent_dedups_cancelling_previous() {
|
||||
|
||||
// Deux arms (un par signal), ids distincts.
|
||||
let issued = env.scheduler.issued();
|
||||
assert_eq!(issued.len(), 2, "deux arms (rafraîchissement, pas empilement)");
|
||||
assert_eq!(
|
||||
issued.len(),
|
||||
2,
|
||||
"deux arms (rafraîchissement, pas empilement)"
|
||||
);
|
||||
// Le premier id émis a été annulé par le dédoublonnage du 2ᵉ signal.
|
||||
assert_eq!(
|
||||
env.scheduler.cancels(),
|
||||
@ -316,8 +325,12 @@ fn on_rate_limited_twice_same_agent_dedups_cancelling_previous() {
|
||||
async fn execute_resume_calls_resumer_with_prompt_and_emits_resumed() {
|
||||
let env = env_at(NOW);
|
||||
// Arme d'abord (pour prouver que l'entrée est ensuite retirée).
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
|
||||
env.service.on_rate_limited(
|
||||
aid(1),
|
||||
nid(2),
|
||||
Some("conv-1".to_owned()),
|
||||
Some(NOW + 60_000),
|
||||
);
|
||||
|
||||
let task = ScheduledTask::ResumeAgent {
|
||||
agent_id: aid(1),
|
||||
@ -329,7 +342,12 @@ async fn execute_resume_calls_resumer_with_prompt_and_emits_resumed() {
|
||||
// Le resumer a vu exactement l'appel attendu, avec le prompt constant.
|
||||
assert_eq!(
|
||||
env.resumer.calls(),
|
||||
vec![(aid(1), nid(2), Some("conv-1".to_owned()), RESUME_PROMPT.to_owned())]
|
||||
vec![(
|
||||
aid(1),
|
||||
nid(2),
|
||||
Some("conv-1".to_owned()),
|
||||
RESUME_PROMPT.to_owned()
|
||||
)]
|
||||
);
|
||||
|
||||
// AgentResumed publié.
|
||||
@ -364,11 +382,13 @@ async fn execute_resume_propagates_error_without_emitting_resumed() {
|
||||
.execute_resume(task)
|
||||
.await
|
||||
.expect_err("la reprise doit échouer");
|
||||
assert!(matches!(err, AppError::Internal(_)), "erreur propagée: {err:?}");
|
||||
assert!(
|
||||
matches!(err, AppError::Internal(_)),
|
||||
"erreur propagée: {err:?}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!env
|
||||
.bus
|
||||
!env.bus
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::AgentResumed { .. })),
|
||||
@ -385,11 +405,18 @@ async fn execute_resume_propagates_error_without_emitting_resumed() {
|
||||
#[test]
|
||||
fn cancel_resume_after_arm_returns_true_and_emits_cancelled() {
|
||||
let env = env_at(NOW);
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
|
||||
env.service.on_rate_limited(
|
||||
aid(1),
|
||||
nid(2),
|
||||
Some("conv-1".to_owned()),
|
||||
Some(NOW + 60_000),
|
||||
);
|
||||
let issued = env.scheduler.issued();
|
||||
|
||||
assert!(env.service.cancel_resume(aid(1)), "cancel d'un réveil armé ⇒ true");
|
||||
assert!(
|
||||
env.service.cancel_resume(aid(1)),
|
||||
"cancel d'un réveil armé ⇒ true"
|
||||
);
|
||||
// Le bon ScheduleId a été passé au Scheduler.
|
||||
assert_eq!(env.scheduler.cancels(), vec![issued[0]]);
|
||||
// AgentResumeCancelled publié.
|
||||
@ -408,7 +435,10 @@ fn cancel_resume_after_arm_returns_true_and_emits_cancelled() {
|
||||
fn cancel_resume_without_arm_is_false_no_event() {
|
||||
let env = env_at(NOW);
|
||||
assert!(!env.service.cancel_resume(aid(1)));
|
||||
assert!(env.scheduler.cancels().is_empty(), "Scheduler non sollicité");
|
||||
assert!(
|
||||
env.scheduler.cancels().is_empty(),
|
||||
"Scheduler non sollicité"
|
||||
);
|
||||
assert!(env.bus.events().is_empty(), "aucun event");
|
||||
}
|
||||
|
||||
@ -417,8 +447,12 @@ fn cancel_resume_without_arm_is_false_no_event() {
|
||||
#[test]
|
||||
fn cancel_resume_when_scheduler_already_fired_is_false_no_event() {
|
||||
let env = env_at(NOW);
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
|
||||
env.service.on_rate_limited(
|
||||
aid(1),
|
||||
nid(2),
|
||||
Some("conv-1".to_owned()),
|
||||
Some(NOW + 60_000),
|
||||
);
|
||||
// Simule un réveil déjà tiré : cancel renvoie false.
|
||||
env.scheduler.set_cancel_result(false);
|
||||
|
||||
@ -430,8 +464,7 @@ fn cancel_resume_when_scheduler_already_fired_is_false_no_event() {
|
||||
assert_eq!(env.scheduler.cancels().len(), 1);
|
||||
// AUCUN AgentResumeCancelled (pas d'event trompeur).
|
||||
assert!(
|
||||
!env
|
||||
.bus
|
||||
!env.bus
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
|
||||
@ -491,8 +524,7 @@ fn confirm_human_resume_future_arms_and_emits_in_order() {
|
||||
fn confirm_human_resume_past_reset_clamps_fire_at_to_now() {
|
||||
let env = env_at(NOW);
|
||||
let past = NOW - 30_000;
|
||||
env.service
|
||||
.confirm_human_resume(aid(1), nid(2), None, past);
|
||||
env.service.confirm_human_resume(aid(1), nid(2), None, past);
|
||||
|
||||
let armed = env.scheduler.armed();
|
||||
assert_eq!(armed.len(), 1);
|
||||
@ -521,13 +553,21 @@ fn confirm_human_resume_past_reset_clamps_fire_at_to_now() {
|
||||
#[test]
|
||||
fn confirm_human_resume_after_auto_dedups_single_active_arm() {
|
||||
let env = env_at(NOW);
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
|
||||
env.service.on_rate_limited(
|
||||
aid(1),
|
||||
nid(2),
|
||||
Some("conv-1".to_owned()),
|
||||
Some(NOW + 60_000),
|
||||
);
|
||||
env.service
|
||||
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 120_000);
|
||||
|
||||
let issued = env.scheduler.issued();
|
||||
assert_eq!(issued.len(), 2, "deux arms (auto puis humain), pas d'empilement");
|
||||
assert_eq!(
|
||||
issued.len(),
|
||||
2,
|
||||
"deux arms (auto puis humain), pas d'empilement"
|
||||
);
|
||||
assert_eq!(
|
||||
env.scheduler.cancels(),
|
||||
vec![issued[0]],
|
||||
@ -536,8 +576,7 @@ fn confirm_human_resume_after_auto_dedups_single_active_arm() {
|
||||
|
||||
// Aucun AgentResumeCancelled (dédoublonnage interne, silencieux).
|
||||
assert!(
|
||||
!env
|
||||
.bus
|
||||
!env.bus
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
|
||||
@ -545,7 +584,10 @@ fn confirm_human_resume_after_auto_dedups_single_active_arm() {
|
||||
);
|
||||
|
||||
// Unicité de l'armement actif : un seul cancel_resume aboutit.
|
||||
assert!(env.service.cancel_resume(aid(1)), "un armement actif unique ⇒ true");
|
||||
assert!(
|
||||
env.service.cancel_resume(aid(1)),
|
||||
"un armement actif unique ⇒ true"
|
||||
);
|
||||
assert!(
|
||||
!env.service.cancel_resume(aid(1)),
|
||||
"plus aucun armement après le premier cancel ⇒ false (une seule entrée)"
|
||||
@ -560,11 +602,19 @@ fn auto_after_confirm_human_resume_dedups_single_active_arm() {
|
||||
let env = env_at(NOW);
|
||||
env.service
|
||||
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 60_000);
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 120_000));
|
||||
env.service.on_rate_limited(
|
||||
aid(1),
|
||||
nid(2),
|
||||
Some("conv-1".to_owned()),
|
||||
Some(NOW + 120_000),
|
||||
);
|
||||
|
||||
let issued = env.scheduler.issued();
|
||||
assert_eq!(issued.len(), 2, "deux arms (humain puis auto), pas d'empilement");
|
||||
assert_eq!(
|
||||
issued.len(),
|
||||
2,
|
||||
"deux arms (humain puis auto), pas d'empilement"
|
||||
);
|
||||
assert_eq!(
|
||||
env.scheduler.cancels(),
|
||||
vec![issued[0]],
|
||||
@ -572,15 +622,17 @@ fn auto_after_confirm_human_resume_dedups_single_active_arm() {
|
||||
);
|
||||
|
||||
assert!(
|
||||
!env
|
||||
.bus
|
||||
!env.bus
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
|
||||
"le dédoublonnage croisé n'émet PAS AgentResumeCancelled"
|
||||
);
|
||||
|
||||
assert!(env.service.cancel_resume(aid(1)), "un armement actif unique ⇒ true");
|
||||
assert!(
|
||||
env.service.cancel_resume(aid(1)),
|
||||
"un armement actif unique ⇒ true"
|
||||
);
|
||||
assert!(
|
||||
!env.service.cancel_resume(aid(1)),
|
||||
"plus aucun armement après le premier cancel ⇒ false (une seule entrée)"
|
||||
|
||||
@ -91,6 +91,7 @@ impl FakeContexts {
|
||||
Self(Arc::new(Mutex::new(AgentManifest {
|
||||
version: 1,
|
||||
entries,
|
||||
orchestrator: None,
|
||||
})))
|
||||
}
|
||||
fn manifest(&self) -> AgentManifest {
|
||||
|
||||
@ -73,6 +73,7 @@ impl FakeContexts {
|
||||
manifest: AgentManifest {
|
||||
version: 1,
|
||||
entries: Vec::new(),
|
||||
orchestrator: None,
|
||||
},
|
||||
contents: HashMap::new(),
|
||||
})));
|
||||
@ -1111,6 +1112,7 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() {
|
||||
let ctx = PreparedContext {
|
||||
content: MarkdownDoc::new("# persona"),
|
||||
relative_path: "agents/backend.md".to_owned(),
|
||||
project_root: ROOT.to_owned(),
|
||||
};
|
||||
let cwd = ProjectPath::new(ROOT).unwrap();
|
||||
let session = f
|
||||
|
||||
@ -72,6 +72,7 @@ impl FakeContexts {
|
||||
AgentManifest {
|
||||
version: 1,
|
||||
entries,
|
||||
orchestrator: None,
|
||||
},
|
||||
HashMap::new(),
|
||||
))))
|
||||
|
||||
@ -277,24 +277,59 @@ impl ManifestEntry {
|
||||
|
||||
/// In-memory image of `.ideai/agents.json`.
|
||||
///
|
||||
/// Invariant enforced here: `md_path` values are unique across entries.
|
||||
/// Invariants enforced here:
|
||||
/// - `md_path` values are unique across entries;
|
||||
/// - `orchestrator == Some(id)` ⇒ `id` is present in `entries` (referential).
|
||||
///
|
||||
/// **Ordering invariant**: `entries` are kept in **creation order**, so
|
||||
/// `entries.first()` is the **oldest** agent. There is no per-entry timestamp; the
|
||||
/// vector position *is* the chronology, which is why the default orchestrator (when
|
||||
/// none is designated) is `entries.first()`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentManifest {
|
||||
/// Schema version of the manifest file.
|
||||
pub version: u32,
|
||||
/// Entries (one per project agent).
|
||||
/// Entries (one per project agent), in **creation order** (oldest first).
|
||||
#[serde(rename = "agents")]
|
||||
pub entries: Vec<ManifestEntry>,
|
||||
/// Explicitly designated orchestrator agent, if any.
|
||||
///
|
||||
/// We persist only the **deviation** from the default: `None` (the common case)
|
||||
/// means « the oldest agent orchestrates » (see [`effective_orchestrator`]);
|
||||
/// `Some(id)` is an explicit designation. Skipped on serialisation when `None`,
|
||||
/// so pre-feature manifests round-trip unchanged (free backward compatibility).
|
||||
///
|
||||
/// [`effective_orchestrator`]: AgentManifest::effective_orchestrator
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub orchestrator: Option<AgentId>,
|
||||
}
|
||||
|
||||
impl AgentManifest {
|
||||
/// Builds a validated manifest.
|
||||
/// Builds a validated manifest with **no** explicit orchestrator (the default:
|
||||
/// the oldest agent orchestrates — see [`effective_orchestrator`]).
|
||||
///
|
||||
/// [`effective_orchestrator`]: AgentManifest::effective_orchestrator
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError::InconsistentManifest`] if two entries share the
|
||||
/// same `md_path`.
|
||||
pub fn new(version: u32, entries: Vec<ManifestEntry>) -> Result<Self, DomainError> {
|
||||
Self::with_orchestrator(version, entries, None)
|
||||
}
|
||||
|
||||
/// Builds a validated manifest carrying an explicit `orchestrator` designation.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`DomainError::InconsistentManifest`] if two entries share the same
|
||||
/// `md_path`;
|
||||
/// - [`DomainError::InconsistentManifest`] if `orchestrator == Some(id)` while
|
||||
/// `id` is **not** present in `entries` (referential integrity).
|
||||
pub fn with_orchestrator(
|
||||
version: u32,
|
||||
entries: Vec<ManifestEntry>,
|
||||
orchestrator: Option<AgentId>,
|
||||
) -> Result<Self, DomainError> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for entry in &entries {
|
||||
if !seen.insert(entry.md_path.as_str()) {
|
||||
@ -303,6 +338,196 @@ impl AgentManifest {
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Self { version, entries })
|
||||
if let Some(id) = orchestrator {
|
||||
if !entries.iter().any(|e| e.agent_id == id) {
|
||||
return Err(DomainError::InconsistentManifest {
|
||||
reason: format!("designated orchestrator `{id}` is not a known agent"),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
version,
|
||||
entries,
|
||||
orchestrator,
|
||||
})
|
||||
}
|
||||
|
||||
/// The **effective** orchestrator agent: the explicit designation if any,
|
||||
/// otherwise the **oldest** agent (`entries.first()`, by the creation-order
|
||||
/// invariant). `None` only when the manifest has no entries at all.
|
||||
#[must_use]
|
||||
pub fn effective_orchestrator(&self) -> Option<AgentId> {
|
||||
self.orchestrator
|
||||
.or_else(|| self.entries.first().map(|e| e.agent_id))
|
||||
}
|
||||
|
||||
/// Folds the [`effective_orchestrator`](Self::effective_orchestrator) into the
|
||||
/// [`OrchestratorDesignation`] value object consumed by the FileGuard policy.
|
||||
#[must_use]
|
||||
pub fn orchestrator_designation(&self) -> crate::fileguard::OrchestratorDesignation {
|
||||
match self.effective_orchestrator() {
|
||||
Some(id) => crate::fileguard::OrchestratorDesignation::of(id),
|
||||
None => crate::fileguard::OrchestratorDesignation::none(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Designates `id` as the project's orchestrator (radio semantics: overwrites any
|
||||
/// previous designation).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`DomainError::InconsistentManifest`] if `id` is not a known agent of this
|
||||
/// manifest.
|
||||
pub fn designate(&mut self, id: AgentId) -> Result<(), DomainError> {
|
||||
if !self.entries.iter().any(|e| e.agent_id == id) {
|
||||
return Err(DomainError::InconsistentManifest {
|
||||
reason: format!("cannot designate unknown agent `{id}` as orchestrator"),
|
||||
});
|
||||
}
|
||||
self.orchestrator = Some(id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reacts to the removal of an agent: if it was the **explicitly designated**
|
||||
/// orchestrator, clears the designation so the manifest falls back to the default
|
||||
/// (lazy succession — the next-oldest agent becomes effective). A no-op when the
|
||||
/// removed agent was not the designated one.
|
||||
///
|
||||
/// Pure bookkeeping: it does **not** remove the entry itself (the caller owns
|
||||
/// `entries`); it only keeps the `orchestrator` deviation consistent.
|
||||
pub fn on_agent_deleted(&mut self, removed: AgentId) {
|
||||
if self.orchestrator == Some(removed) {
|
||||
self.orchestrator = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod orchestrator_tests {
|
||||
use super::*;
|
||||
use crate::conversation::ConversationParty;
|
||||
use crate::fileguard::{may_write_directly, GuardedResource, OrchestratorDesignation};
|
||||
|
||||
fn agent_id(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
/// Builds a manifest entry for `id`, with a `md_path` derived from `n` so entries
|
||||
/// stay unique.
|
||||
fn entry(n: u128) -> ManifestEntry {
|
||||
ManifestEntry::new(
|
||||
agent_id(n),
|
||||
format!("agent-{n}"),
|
||||
format!("agents/agent-{n}.md"),
|
||||
ProfileId::from_uuid(uuid::Uuid::from_u128(1000 + n)),
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_orchestrator_is_oldest_agent_when_none_designated() {
|
||||
// entries are in creation order: first is the oldest → the effective default.
|
||||
let m = AgentManifest::new(1, vec![entry(1), entry(2), entry(3)]).unwrap();
|
||||
assert_eq!(m.orchestrator, None);
|
||||
assert_eq!(m.effective_orchestrator(), Some(agent_id(1)));
|
||||
assert_eq!(
|
||||
m.orchestrator_designation(),
|
||||
OrchestratorDesignation::of(agent_id(1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_manifest_has_no_effective_orchestrator() {
|
||||
let m = AgentManifest::new(1, vec![]).unwrap();
|
||||
assert_eq!(m.effective_orchestrator(), None);
|
||||
assert_eq!(
|
||||
m.orchestrator_designation(),
|
||||
OrchestratorDesignation::none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_designation_overrides_oldest() {
|
||||
let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap();
|
||||
m.designate(agent_id(2)).unwrap();
|
||||
assert_eq!(m.orchestrator, Some(agent_id(2)));
|
||||
assert_eq!(m.effective_orchestrator(), Some(agent_id(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn designate_is_radio_and_rejects_unknown_agent() {
|
||||
let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap();
|
||||
m.designate(agent_id(1)).unwrap();
|
||||
// Radio: a second designation overwrites the first (never accumulates).
|
||||
m.designate(agent_id(2)).unwrap();
|
||||
assert_eq!(m.orchestrator, Some(agent_id(2)));
|
||||
// Referential: designating an agent absent from `entries` is rejected.
|
||||
let err = m.designate(agent_id(99)).unwrap_err();
|
||||
assert!(matches!(err, DomainError::InconsistentManifest { .. }));
|
||||
// …and the previous valid designation is untouched.
|
||||
assert_eq!(m.orchestrator, Some(agent_id(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lazy_succession_falls_back_to_oldest_when_designated_is_deleted() {
|
||||
let mut m = AgentManifest::new(1, vec![entry(1), entry(2), entry(3)]).unwrap();
|
||||
m.designate(agent_id(3)).unwrap();
|
||||
// The designated agent is removed → designation cleared, default takes over.
|
||||
m.entries.retain(|e| e.agent_id != agent_id(3));
|
||||
m.on_agent_deleted(agent_id(3));
|
||||
assert_eq!(m.orchestrator, None);
|
||||
// Falls back to the (now) oldest remaining agent.
|
||||
assert_eq!(m.effective_orchestrator(), Some(agent_id(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_agent_deleted_is_noop_for_non_designated_agent() {
|
||||
let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap();
|
||||
m.designate(agent_id(2)).unwrap();
|
||||
m.on_agent_deleted(agent_id(1));
|
||||
// Deleting a non-designated agent leaves the designation intact.
|
||||
assert_eq!(m.orchestrator, Some(agent_id(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructor_enforces_referential_integrity_of_orchestrator() {
|
||||
// Some(id) with id present in entries → ok.
|
||||
assert!(
|
||||
AgentManifest::with_orchestrator(1, vec![entry(1), entry(2)], Some(agent_id(2)),)
|
||||
.is_ok()
|
||||
);
|
||||
// Some(id) with id absent → rejected.
|
||||
let err =
|
||||
AgentManifest::with_orchestrator(1, vec![entry(1)], Some(agent_id(42))).unwrap_err();
|
||||
assert!(matches!(err, DomainError::InconsistentManifest { .. }));
|
||||
// None is always valid (even with an empty manifest).
|
||||
assert!(AgentManifest::with_orchestrator(1, vec![], None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn designated_agent_may_write_project_context_via_manifest_designation() {
|
||||
let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap();
|
||||
m.designate(agent_id(1)).unwrap();
|
||||
let d = m.orchestrator_designation();
|
||||
// The designated agent writes the project context directly…
|
||||
assert!(may_write_directly(
|
||||
ConversationParty::agent(agent_id(1)),
|
||||
&GuardedResource::ProjectContext,
|
||||
&d,
|
||||
));
|
||||
// …another agent is refused (single-writer preserved)…
|
||||
assert!(!may_write_directly(
|
||||
ConversationParty::agent(agent_id(2)),
|
||||
&GuardedResource::ProjectContext,
|
||||
&d,
|
||||
));
|
||||
// …and the human is always allowed.
|
||||
assert!(may_write_directly(
|
||||
ConversationParty::User,
|
||||
&GuardedResource::ProjectContext,
|
||||
&d,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -258,6 +258,19 @@ pub enum DomainEvent {
|
||||
/// sinon `None` (l'utilisateur fournira l'heure).
|
||||
resets_at_ms: Option<i64>,
|
||||
},
|
||||
/// The project's **orchestrator designation** changed (cadrage « orchestrateur
|
||||
/// du projet », T1). Emitted when an agent is designated (radio selection) or the
|
||||
/// designation is cleared back to the default (e.g. the designated agent was
|
||||
/// deleted — lazy succession to the oldest agent). Relayed to the front so the UI
|
||||
/// can move the radio / refresh the orchestrator badge. Model-agnostic: carries
|
||||
/// only the neutral fact « who orchestrates now ».
|
||||
OrchestratorChanged {
|
||||
/// The project whose designation changed.
|
||||
project_id: ProjectId,
|
||||
/// The newly designated orchestrator agent, or `None` for the default
|
||||
/// (the oldest agent orchestrates).
|
||||
orchestrator: Option<AgentId>,
|
||||
},
|
||||
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
|
||||
PtyOutput {
|
||||
/// The session.
|
||||
@ -358,6 +371,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestrator_changed_constructs_and_compares() {
|
||||
let project = ProjectId::from_uuid(uuid::Uuid::from_u128(100));
|
||||
let ev = DomainEvent::OrchestratorChanged {
|
||||
project_id: project,
|
||||
orchestrator: Some(agent(1)),
|
||||
};
|
||||
assert_eq!(
|
||||
ev,
|
||||
DomainEvent::OrchestratorChanged {
|
||||
project_id: project,
|
||||
orchestrator: Some(agent(1)),
|
||||
}
|
||||
);
|
||||
// Clearing to the default (None) is a distinct event.
|
||||
assert_ne!(
|
||||
ev,
|
||||
DomainEvent::OrchestratorChanged {
|
||||
project_id: project,
|
||||
orchestrator: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_session_limit_variants_are_not_equal() {
|
||||
// Les variantes ne se confondent pas entre elles malgré des champs proches.
|
||||
|
||||
@ -159,30 +159,71 @@ pub trait FileGuard: Send + Sync {
|
||||
) -> Result<WriteLease, GuardError>;
|
||||
}
|
||||
|
||||
/// **Which agent (if any) the project designates as its orchestrator.**
|
||||
///
|
||||
/// We persist only the *deviation* from the default (cadrage T1, « orchestrateur du
|
||||
/// projet ») : the human ([`ConversationParty::User`]) is a **permanent** orchestrator
|
||||
/// and is never represented here.
|
||||
/// - [`none`](Self::none) — no agent designated: the human is the sole orchestrator.
|
||||
/// - [`of`](Self::of) — agent `id` is the explicitly designated orchestrator (radio
|
||||
/// selection, single agent by construction — the illegal "two orchestrators" state
|
||||
/// is unrepresentable).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OrchestratorDesignation(Option<AgentId>);
|
||||
|
||||
impl OrchestratorDesignation {
|
||||
/// No agent designated: only the human ([`ConversationParty::User`]) orchestrates.
|
||||
#[must_use]
|
||||
pub const fn none() -> Self {
|
||||
Self(None)
|
||||
}
|
||||
|
||||
/// Designates `agent` as the project's orchestrator (radio selection).
|
||||
#[must_use]
|
||||
pub const fn of(agent: AgentId) -> Self {
|
||||
Self(Some(agent))
|
||||
}
|
||||
|
||||
/// The designated agent, if any. `None` ⇒ default (human-only orchestrator).
|
||||
#[must_use]
|
||||
pub const fn designated(&self) -> Option<AgentId> {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `who` is allowed to **write** `res` directly (vs. having to propose).
|
||||
///
|
||||
/// Pure policy, shared by the port's documented contract and the adapter: only the
|
||||
/// Pure policy, shared by the port's documented contract and the adapter: only an
|
||||
/// orchestrator may write the global [`GuardedResource::ProjectContext`]; everyone
|
||||
/// may write the per-agent context and memory. The orchestrator is modelled as
|
||||
/// [`ConversationParty::User`] — IdeA's single logical operator identity (the
|
||||
/// human-driven orchestrator), never a project [`AgentId`].
|
||||
/// may write the per-agent context and memory. Orchestrator identity is resolved
|
||||
/// against the project's [`OrchestratorDesignation`] (the human is always one; a
|
||||
/// project agent only when explicitly designated).
|
||||
#[must_use]
|
||||
pub fn may_write_directly(who: ConversationParty, res: &GuardedResource) -> bool {
|
||||
pub fn may_write_directly(
|
||||
who: ConversationParty,
|
||||
res: &GuardedResource,
|
||||
d: &OrchestratorDesignation,
|
||||
) -> bool {
|
||||
if res.is_project_context() {
|
||||
is_orchestrator(who)
|
||||
is_orchestrator(who, d)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `who` is the orchestrator identity for the single-writer rule.
|
||||
/// Whether `who` is an orchestrator identity for the single-writer rule, given the
|
||||
/// project's [`OrchestratorDesignation`] `d`.
|
||||
///
|
||||
/// The orchestrator is the human-driven operator ([`ConversationParty::User`]); a
|
||||
/// project agent ([`ConversationParty::Agent`]) is never the orchestrator and must
|
||||
/// propose changes to the global context.
|
||||
/// The human-driven operator ([`ConversationParty::User`]) is **always** an
|
||||
/// orchestrator. A project agent ([`ConversationParty::Agent`]) is an orchestrator
|
||||
/// **only** when it is the designated one; otherwise it must propose changes to the
|
||||
/// global context.
|
||||
#[must_use]
|
||||
pub const fn is_orchestrator(who: ConversationParty) -> bool {
|
||||
matches!(who, ConversationParty::User)
|
||||
pub fn is_orchestrator(who: ConversationParty, d: &OrchestratorDesignation) -> bool {
|
||||
match who {
|
||||
ConversationParty::User => true,
|
||||
ConversationParty::Agent { agent_id } => d.designated() == Some(agent_id),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -195,13 +236,41 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn project_context_is_single_writer_to_orchestrator_only() {
|
||||
// Single-writer rule preserved with the default (no designated agent):
|
||||
// the human writes, every agent is refused.
|
||||
let default = OrchestratorDesignation::none();
|
||||
assert!(may_write_directly(
|
||||
ConversationParty::User,
|
||||
&GuardedResource::ProjectContext
|
||||
&GuardedResource::ProjectContext,
|
||||
&default,
|
||||
));
|
||||
assert!(!may_write_directly(
|
||||
agent_party(1),
|
||||
&GuardedResource::ProjectContext
|
||||
&GuardedResource::ProjectContext,
|
||||
&default,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn designated_agent_may_write_project_context() {
|
||||
let id = AgentId::from_uuid(uuid::Uuid::from_u128(1));
|
||||
let designation = OrchestratorDesignation::of(id);
|
||||
// The designated agent now writes the global context directly…
|
||||
assert!(may_write_directly(
|
||||
ConversationParty::agent(id),
|
||||
&GuardedResource::ProjectContext,
|
||||
&designation,
|
||||
));
|
||||
// …while another agent stays refused, and the human stays allowed.
|
||||
assert!(!may_write_directly(
|
||||
agent_party(2),
|
||||
&GuardedResource::ProjectContext,
|
||||
&designation,
|
||||
));
|
||||
assert!(may_write_directly(
|
||||
ConversationParty::User,
|
||||
&GuardedResource::ProjectContext,
|
||||
&designation,
|
||||
));
|
||||
}
|
||||
|
||||
@ -209,16 +278,34 @@ mod tests {
|
||||
fn agent_context_and_memory_are_writable_by_anyone() {
|
||||
let mem = GuardedResource::Memory(MemorySlug::new("note").unwrap());
|
||||
let ctx = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(9)));
|
||||
let default = OrchestratorDesignation::none();
|
||||
for who in [ConversationParty::User, agent_party(1)] {
|
||||
assert!(may_write_directly(who, &mem));
|
||||
assert!(may_write_directly(who, &ctx));
|
||||
assert!(may_write_directly(who, &mem, &default));
|
||||
assert!(may_write_directly(who, &ctx, &default));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_orchestrator_only_for_user() {
|
||||
assert!(is_orchestrator(ConversationParty::User));
|
||||
assert!(!is_orchestrator(agent_party(1)));
|
||||
fn is_orchestrator_for_user_and_designated_agent() {
|
||||
let id = AgentId::from_uuid(uuid::Uuid::from_u128(1));
|
||||
// Human is always an orchestrator, designation or not.
|
||||
assert!(is_orchestrator(
|
||||
ConversationParty::User,
|
||||
&OrchestratorDesignation::none()
|
||||
));
|
||||
// An agent is an orchestrator only when it is the designated one.
|
||||
assert!(!is_orchestrator(
|
||||
ConversationParty::agent(id),
|
||||
&OrchestratorDesignation::none()
|
||||
));
|
||||
assert!(is_orchestrator(
|
||||
ConversationParty::agent(id),
|
||||
&OrchestratorDesignation::of(id)
|
||||
));
|
||||
assert!(!is_orchestrator(
|
||||
agent_party(2),
|
||||
&OrchestratorDesignation::of(id)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -49,9 +49,9 @@ pub mod ports;
|
||||
pub mod profile;
|
||||
pub mod project;
|
||||
pub mod readiness;
|
||||
pub mod remote;
|
||||
pub mod sandbox;
|
||||
pub mod session_limit;
|
||||
pub mod remote;
|
||||
pub mod skill;
|
||||
pub mod template;
|
||||
pub mod terminal;
|
||||
@ -93,7 +93,7 @@ pub use input::{AgentBusyState, AgentLiveness, InputMediator, InputSource};
|
||||
|
||||
pub use readiness::{ReadinessPolicy, ReadinessSignal};
|
||||
|
||||
pub use session_limit::{plan_resume, ResumePlan, RateLimitSource, SessionLimit};
|
||||
pub use session_limit::{plan_resume, RateLimitSource, ResumePlan, SessionLimit};
|
||||
|
||||
pub use conversation_log::{
|
||||
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
|
||||
@ -101,8 +101,8 @@ pub use conversation_log::{
|
||||
};
|
||||
|
||||
pub use fileguard::{
|
||||
is_orchestrator, may_write_directly, FileGuard, GuardError, GuardedResource, ReadLease,
|
||||
WriteLease,
|
||||
is_orchestrator, may_write_directly, FileGuard, GuardError, GuardedResource,
|
||||
OrchestratorDesignation, ReadLease, WriteLease,
|
||||
};
|
||||
|
||||
pub use markdown::MarkdownDoc;
|
||||
@ -126,7 +126,7 @@ 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,
|
||||
ProjectPermissions, ProjectedFile, ProjectionContext, ProjectorKey, PERMISSIONS_VERSION,
|
||||
};
|
||||
|
||||
pub use sandbox::{
|
||||
@ -145,6 +145,6 @@ pub use ports::{
|
||||
FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator,
|
||||
MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PermissionStore,
|
||||
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle,
|
||||
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, SpawnSpec,
|
||||
StoreError, TemplateStore,
|
||||
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler,
|
||||
SpawnSpec, StoreError, TemplateStore,
|
||||
};
|
||||
|
||||
@ -894,8 +894,11 @@ pub trait PermissionProjector: Send + Sync {
|
||||
///
|
||||
/// `eff == None` ⇒ the **empty** projection ([`PermissionProjection::empty`]):
|
||||
/// we keep the CLI's native prompting (the product invariant of [`resolve`]).
|
||||
fn project(&self, eff: Option<&EffectivePermissions>, ctx: &ProjectionContext)
|
||||
-> PermissionProjection;
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
ctx: &ProjectionContext,
|
||||
) -> PermissionProjection;
|
||||
|
||||
/// Run-dir-relative paths of the [`ProjectedFile::Replace`] files this
|
||||
/// projector owns, so the swap path can clean them up when an agent moves
|
||||
@ -1448,7 +1451,10 @@ mod tests {
|
||||
let md = render_permission_summary(Some(&eff)).expect("a posed policy renders a summary");
|
||||
|
||||
// Files: OS-enforced / Landlock when supported.
|
||||
assert!(md.contains("OS-enforced"), "files block must say OS-enforced");
|
||||
assert!(
|
||||
md.contains("OS-enforced"),
|
||||
"files block must say OS-enforced"
|
||||
);
|
||||
assert!(md.contains("Landlock"), "files block must name Landlock");
|
||||
// Commands: advisory, NOT OS-locked, and the why (ExecuteBash).
|
||||
assert!(md.contains("advisory"), "commands must be called advisory");
|
||||
@ -1462,7 +1468,10 @@ mod tests {
|
||||
);
|
||||
// The actual rules surface in their respective sections.
|
||||
assert!(md.contains("`src/**`"), "the file scope is shown");
|
||||
assert!(md.contains("`rm *` (prefix)"), "the command matcher is shown");
|
||||
assert!(
|
||||
md.contains("`rm *` (prefix)"),
|
||||
"the command matcher is shown"
|
||||
);
|
||||
// Resolved posture is surfaced.
|
||||
assert!(md.contains("**Default posture:** Ask"));
|
||||
}
|
||||
|
||||
@ -112,6 +112,8 @@ pub struct PreparedContext {
|
||||
pub content: MarkdownDoc,
|
||||
/// Relative path of the `.md` inside the project.
|
||||
pub relative_path: String,
|
||||
/// Absolute root of the project this context belongs to.
|
||||
pub project_root: String,
|
||||
}
|
||||
|
||||
/// Enriched, **best-effort** details about a conversation, specific to a CLI's
|
||||
|
||||
@ -422,6 +422,12 @@ pub struct McpServerWiring {
|
||||
}
|
||||
|
||||
impl McpServerWiring {
|
||||
/// Codex's MCP client defaults tool calls to a short interactive timeout (120s on
|
||||
/// the versions observed in IdeA). `idea_ask_agent` is a synchronous rendezvous
|
||||
/// that can legitimately span a full delegated dev/test turn, so the generated
|
||||
/// server config must widen the per-tool timeout.
|
||||
pub const IDEA_TOOL_TIMEOUT_SEC: u32 = 24 * 60 * 60;
|
||||
|
||||
/// Construit le wiring depuis ses parties.
|
||||
#[must_use]
|
||||
pub const fn new(command: String, args: Vec<String>, transport: McpTransport) -> Self {
|
||||
@ -475,9 +481,9 @@ impl McpServerWiring {
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// `command`, `args` (tableau TOML), `transport`, et l'approbation automatique
|
||||
/// des outils IdeA. 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);
|
||||
@ -488,8 +494,9 @@ impl McpServerWiring {
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let transport = self.transport_label();
|
||||
let tool_timeout_sec = Self::IDEA_TOOL_TIMEOUT_SEC;
|
||||
format!(
|
||||
"[mcp_servers.idea]\ncommand = {command}\nargs = [{args}]\ntransport = \"{transport}\"\n"
|
||||
"[mcp_servers.idea]\ncommand = {command}\nargs = [{args}]\ntransport = \"{transport}\"\ndefault_tools_approval_mode = \"approve\"\ntool_timeout_sec = {tool_timeout_sec}\n"
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -910,11 +917,16 @@ impl AgentProfile {
|
||||
/// ([`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)) {
|
||||
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,
|
||||
(Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => {
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@ -1262,7 +1274,10 @@ mod mcp_tests {
|
||||
!json.contains("stallAfterMs"),
|
||||
"an unset stall threshold must be omitted; got: {json}"
|
||||
);
|
||||
assert!(json.contains("turnTimeoutMs"), "set threshold present: {json}");
|
||||
assert!(
|
||||
json.contains("turnTimeoutMs"),
|
||||
"set threshold present: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1351,8 +1366,7 @@ mod mcp_tests {
|
||||
assert!(codex.materializes_idea_bridge());
|
||||
|
||||
// Codex WITHOUT any MCP capability ⇒ no bridge.
|
||||
let codex_no_mcp =
|
||||
profile_without_mcp().with_structured_adapter(StructuredAdapter::Codex);
|
||||
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.
|
||||
@ -1448,6 +1462,16 @@ mod mcp_tests {
|
||||
toml.contains("transport = \"stdio\""),
|
||||
"transport expected; got: {toml}"
|
||||
);
|
||||
// IdeA-owned MCP tools are pre-approved, matching Claude's non-prompting
|
||||
// `.mcp.json` path.
|
||||
assert!(
|
||||
toml.contains("default_tools_approval_mode = \"approve\""),
|
||||
"IdeA MCP tools should not prompt for approval; got: {toml}"
|
||||
);
|
||||
assert!(
|
||||
toml.contains("tool_timeout_sec = 86400"),
|
||||
"IdeA MCP tools must outlive Codex's short default tool timeout; got: {toml}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- §21 : rate_limit_pattern (détection de limite par motif, niveau 2) ------
|
||||
@ -1519,8 +1543,14 @@ mod mcp_tests {
|
||||
let json = serde_json::to_string(&profile).expect("serialise");
|
||||
assert!(json.contains("rateLimitPattern"), "key present: {json}");
|
||||
// camelCase respecté sur les champs de RateLimitPattern.
|
||||
assert!(json.contains("resetCapture"), "camelCase field resetCapture: {json}");
|
||||
assert!(json.contains("timeFormat"), "camelCase field timeFormat: {json}");
|
||||
assert!(
|
||||
json.contains("resetCapture"),
|
||||
"camelCase field resetCapture: {json}"
|
||||
);
|
||||
assert!(
|
||||
json.contains("timeFormat"),
|
||||
"camelCase field timeFormat: {json}"
|
||||
);
|
||||
|
||||
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
|
||||
assert_eq!(profile, back);
|
||||
@ -1531,7 +1561,10 @@ mod mcp_tests {
|
||||
// reset_capture / time_format à None ⇒ leurs clés sont omises.
|
||||
let pattern = RateLimitPattern::new("rate limited", None, None).expect("valid pattern");
|
||||
let json = serde_json::to_string(&pattern).expect("serialise");
|
||||
assert!(json.contains("\"pattern\""), "pattern field present: {json}");
|
||||
assert!(
|
||||
json.contains("\"pattern\""),
|
||||
"pattern field present: {json}"
|
||||
);
|
||||
assert!(
|
||||
!json.contains("resetCapture"),
|
||||
"an unset resetCapture must be omitted; got: {json}"
|
||||
|
||||
@ -89,11 +89,9 @@ impl ReadinessPolicy {
|
||||
pub const fn classify(event: &ReplyEvent) -> Option<ReadinessSignal> {
|
||||
match event {
|
||||
ReplyEvent::Final { .. } => Some(ReadinessSignal::TurnEnded),
|
||||
ReplyEvent::RateLimited { resets_at_ms } => {
|
||||
Some(ReadinessSignal::RateLimited {
|
||||
resets_at_ms: *resets_at_ms,
|
||||
})
|
||||
}
|
||||
ReplyEvent::RateLimited { resets_at_ms } => Some(ReadinessSignal::RateLimited {
|
||||
resets_at_ms: *resets_at_ms,
|
||||
}),
|
||||
ReplyEvent::TextDelta { .. }
|
||||
| ReplyEvent::ToolActivity { .. }
|
||||
| ReplyEvent::Heartbeat => None,
|
||||
@ -123,7 +121,9 @@ mod tests {
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
ReadinessPolicy::classify(&ReplyEvent::ToolActivity { label: "lit".into() }),
|
||||
ReadinessPolicy::classify(&ReplyEvent::ToolActivity {
|
||||
label: "lit".into()
|
||||
}),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@ -392,7 +392,7 @@ fn join_root(base: &str, rel: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::permission::{PathScope, PermissionRule, PermissionSet, resolve};
|
||||
use crate::permission::{resolve, PathScope, PermissionRule, PermissionSet};
|
||||
|
||||
// ---- helpers ---------------------------------------------------------
|
||||
|
||||
@ -668,7 +668,10 @@ mod tests {
|
||||
PathAccess::RO,
|
||||
"RW class fenced out, RO class preserved on the same root"
|
||||
);
|
||||
assert!(!g.access.contains(PathAccess::RW), "the RW grant was dropped");
|
||||
assert!(
|
||||
!g.access.contains(PathAccess::RW),
|
||||
"the RW grant was dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -163,7 +163,10 @@ mod tests {
|
||||
let plan = plan_resume(NOW, &limit(Some(past)), None);
|
||||
match plan {
|
||||
ResumePlan::Scheduled { fire_at_ms, .. } => {
|
||||
assert_eq!(fire_at_ms, NOW, "un reset déjà passé ⇒ reprise immédiate (now)");
|
||||
assert_eq!(
|
||||
fire_at_ms, NOW,
|
||||
"un reset déjà passé ⇒ reprise immédiate (now)"
|
||||
);
|
||||
}
|
||||
other => panic!("attendu Scheduled, obtenu {other:?}"),
|
||||
}
|
||||
|
||||
@ -392,6 +392,7 @@ async fn fake_factory_supports_only_structured_profiles_and_starts() {
|
||||
let ctx = PreparedContext {
|
||||
content: MarkdownDoc::new("# ctx"),
|
||||
relative_path: "CLAUDE.md".to_owned(),
|
||||
project_root: "/srv/project".to_owned(),
|
||||
};
|
||||
let cwd = ProjectPath::new("/srv/run").unwrap();
|
||||
let session = factory
|
||||
|
||||
@ -5,10 +5,17 @@
|
||||
//! concurrent readers **or** one exclusive writer per resource; different resources
|
||||
//! are independent (their locks are distinct).
|
||||
//!
|
||||
//! The single-writer rule for [`GuardedResource::ProjectContext`] is enforced
|
||||
//! **before** taking any lock: a `who` that is not the orchestrator
|
||||
//! ([`domain::may_write_directly`] returning `false`) is rejected with
|
||||
//! [`GuardError::Forbidden`] — it must *propose* instead.
|
||||
//! ## Pure lock — no authorization here (cadrage « orchestrateur du projet », Alt. A)
|
||||
//!
|
||||
//! This adapter is a **pure serialisation primitive**: `acquire_write` never returns
|
||||
//! [`GuardError::Forbidden`]. The single-writer *authorization* of the global
|
||||
//! [`GuardedResource::ProjectContext`] (« only the orchestrator may write it directly,
|
||||
//! everyone else proposes ») is **not** a concurrency concern and no longer lives in
|
||||
//! the guard. It is decided one layer up, in the `ProposeContext` use case, which
|
||||
//! consults the project's `OrchestratorDesignation` (now that the orchestrator can be
|
||||
//! a *designated agent*, not just the human, the guard — which only sees a
|
||||
//! [`ConversationParty`] — could not make that call coherently anyway). The guard's
|
||||
//! sole job is to make readers/writers of the same resource take turns.
|
||||
//!
|
||||
//! ## Cooperative scope (cadrage §9.5)
|
||||
//!
|
||||
@ -32,9 +39,7 @@ use async_trait::async_trait;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use domain::conversation::ConversationParty;
|
||||
use domain::fileguard::{
|
||||
may_write_directly, FileGuard, GuardError, GuardedResource, ReadLease, WriteLease,
|
||||
};
|
||||
use domain::fileguard::{FileGuard, GuardError, GuardedResource, ReadLease, WriteLease};
|
||||
|
||||
/// The [`FileGuard`] adapter: one reader/writer lock per guarded resource.
|
||||
///
|
||||
@ -85,14 +90,12 @@ impl FileGuard for RwFileGuard {
|
||||
|
||||
async fn acquire_write(
|
||||
&self,
|
||||
who: ConversationParty,
|
||||
_who: ConversationParty,
|
||||
res: GuardedResource,
|
||||
) -> Result<WriteLease, GuardError> {
|
||||
// Single-writer rule for the global project context: refuse *before* taking
|
||||
// any lock so a forbidden writer never blocks readers.
|
||||
if !may_write_directly(who, &res) {
|
||||
return Err(GuardError::Forbidden);
|
||||
}
|
||||
// Pure lock (Alt. A): no authorization here — `ProposeContext` decides who may
|
||||
// write the global context. Serialise behind any in-flight readers/writer of
|
||||
// the same resource, then hand back the exclusive RAII lease.
|
||||
let lock = self.lock_for(&res);
|
||||
let guard = lock.write_owned().await;
|
||||
Ok(WriteLease::new(Box::new(guard)))
|
||||
@ -170,22 +173,20 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_writing_project_context_is_forbidden() {
|
||||
async fn pure_lock_grants_project_context_write_to_any_party() {
|
||||
// Alt. A: the guard is a pure lock — it no longer enforces the single-writer
|
||||
// *authorization* (that moved into `ProposeContext`). Both the human and an
|
||||
// arbitrary agent obtain a write lease on the project context; `Forbidden` is
|
||||
// never produced at this layer.
|
||||
let guard = RwFileGuard::new();
|
||||
let err = guard
|
||||
assert!(guard
|
||||
.acquire_write(ConversationParty::User, GuardedResource::ProjectContext)
|
||||
.await
|
||||
.is_ok());
|
||||
assert!(guard
|
||||
.acquire_write(agent_party(1), GuardedResource::ProjectContext)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err, GuardError::Forbidden);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn orchestrator_may_write_project_context() {
|
||||
let guard = RwFileGuard::new();
|
||||
let lease = guard
|
||||
.acquire_write(ConversationParty::User, GuardedResource::ProjectContext)
|
||||
.await;
|
||||
assert!(lease.is_ok());
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@ -94,6 +94,10 @@ type HeadlessSink =
|
||||
/// la soumission du collage pour esquiver la paste-detection des CLI. Utilisé seulement
|
||||
/// quand le profil de la cible ne fournit pas de `submit_delay_ms`.
|
||||
const DEFAULT_SUBMIT_DELAY_MS: u32 = 60;
|
||||
/// Taille maximale d'un fragment de consigne écrit dans le PTY en mode headless.
|
||||
const DELEGATION_WRITE_CHUNK_BYTES: usize = 512;
|
||||
/// Pause entre deux fragments de consigne en mode headless.
|
||||
const DELEGATION_WRITE_CHUNK_DELAY_MS: u64 = 8;
|
||||
|
||||
/// État de vivacité d'un agent maintenu par le [`BusyTracker`] (lot 2).
|
||||
///
|
||||
@ -486,8 +490,10 @@ impl MediatedInbox {
|
||||
// watcher prompt-ready, tâche tokio du serveur MCP, ou le fil de l'enqueue).
|
||||
// Texte d'abord, puis la séquence de soumission après le délai — comme le
|
||||
// write-portal frontend — pour esquiver la détection de coller des CLI.
|
||||
// La consigne est fragmentée : les TUI (Codex surtout) peuvent traiter un
|
||||
// gros write unique comme un paste et garder/perdre la fin au démarrage.
|
||||
std::thread::spawn(move || {
|
||||
let _ = pty.write(&handle, text.as_bytes());
|
||||
let _ = write_delegation_chunks(pty.as_ref(), &handle, &text);
|
||||
if delay > 0 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(delay));
|
||||
}
|
||||
@ -662,6 +668,30 @@ fn delegation_preamble(requester: &str, ticket: TicketId, task: &str) -> String
|
||||
format!("[IdeA · tâche de {requester} · ticket {ticket}]\n{task}")
|
||||
}
|
||||
|
||||
fn write_delegation_chunks(
|
||||
pty: &dyn PtyPort,
|
||||
handle: &PtyHandle,
|
||||
text: &str,
|
||||
) -> Result<(), domain::ports::PtyError> {
|
||||
let mut start = 0;
|
||||
let mut current_len = 0;
|
||||
|
||||
for (idx, ch) in text.char_indices() {
|
||||
let len = ch.len_utf8();
|
||||
if current_len > 0 && current_len + len > DELEGATION_WRITE_CHUNK_BYTES {
|
||||
pty.write(handle, text[start..idx].as_bytes())?;
|
||||
std::thread::sleep(std::time::Duration::from_millis(
|
||||
DELEGATION_WRITE_CHUNK_DELAY_MS,
|
||||
));
|
||||
start = idx;
|
||||
current_len = 0;
|
||||
}
|
||||
current_len += len;
|
||||
}
|
||||
|
||||
pty.write(handle, text[start..].as_bytes())
|
||||
}
|
||||
|
||||
impl InputMediator for MediatedInbox {
|
||||
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
|
||||
let ticket_id = ticket.id;
|
||||
@ -1115,6 +1145,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_agent_chunks_long_delegation_before_submit() {
|
||||
let pty = Arc::new(FakePty::new());
|
||||
let bus = Arc::new(RecordingBus::default());
|
||||
let inbox = MediatedInbox::with_pty(
|
||||
Arc::new(InMemoryMailbox::new()),
|
||||
Arc::new(FixedClock(1)),
|
||||
Arc::clone(&pty) as Arc<dyn PtyPort>,
|
||||
)
|
||||
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
|
||||
let a = agent(1);
|
||||
let task = format!("début {} fin", "x".repeat(1200));
|
||||
inbox.bind_handle_with_prompt(a, handle(1), None, SubmitConfig::default());
|
||||
|
||||
inbox.enqueue(a, ticket(10, &task));
|
||||
|
||||
assert!(
|
||||
wait_until(|| pty.writes.lock().unwrap().len() >= 4),
|
||||
"long headless delivery writes multiple chunks followed by submit"
|
||||
);
|
||||
let writes = pty.writes.lock().unwrap().clone();
|
||||
assert_eq!(writes.last().map(Vec::as_slice), Some(b"\r".as_slice()));
|
||||
let delivered = writes[..writes.len() - 1]
|
||||
.iter()
|
||||
.flat_map(|chunk| chunk.iter().copied())
|
||||
.collect::<Vec<_>>();
|
||||
let delivered = String::from_utf8(delivered).unwrap();
|
||||
assert!(
|
||||
delivered.contains(&task),
|
||||
"chunked writes reconstruct the full task"
|
||||
);
|
||||
assert!(
|
||||
bus.delegation_ready().is_empty(),
|
||||
"headless delivery does not also publish DelegationReady"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enqueue_returns_pending_reply_resolved_via_mailbox() {
|
||||
let inbox = inbox_at(5);
|
||||
|
||||
@ -57,11 +57,11 @@ pub use orchestrator::{
|
||||
pub use permission::{ClaudePermissionProjector, CodexPermissionProjector};
|
||||
pub use process::LocalProcessSpawner;
|
||||
pub use pty::PortablePtyAdapter;
|
||||
pub use ratelimit::RateLimitParser;
|
||||
pub use remote::{remote_host, LocalHost};
|
||||
pub use runtime::CliAgentRuntime;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use sandbox::LandlockSandbox;
|
||||
pub use ratelimit::RateLimitParser;
|
||||
pub use sandbox::{default_enforcer, NoopSandbox};
|
||||
pub use scheduler::TokioScheduler;
|
||||
pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory};
|
||||
|
||||
@ -292,8 +292,14 @@ mod tests {
|
||||
|
||||
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:?}");
|
||||
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!(
|
||||
@ -330,7 +336,10 @@ mod tests {
|
||||
"Bash(shutdown*)",
|
||||
"Bash(reboot*)",
|
||||
] {
|
||||
assert!(deny.contains(&guard.to_owned()), "missing guardrail {guard}; deny={deny:?}");
|
||||
assert!(
|
||||
deny.contains(&guard.to_owned()),
|
||||
"missing guardrail {guard}; deny={deny:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,9 +2,11 @@
|
||||
//!
|
||||
//! 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`.
|
||||
//! (`--sandbox` / `--ask-for-approval`) and, for workspace-write postures, the
|
||||
//! project root as an additional writable directory (`--add-dir`). 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
|
||||
@ -44,7 +46,7 @@ impl PermissionProjector for CodexPermissionProjector {
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
_ctx: &ProjectionContext,
|
||||
ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
// Product invariant: nothing posed ⇒ nothing projected. Codex keeps its
|
||||
// native sandbox/approval defaults (no args, no managed keys written).
|
||||
@ -63,6 +65,17 @@ impl PermissionProjector for CodexPermissionProjector {
|
||||
toml_string(approval),
|
||||
);
|
||||
|
||||
let mut args = vec![
|
||||
"--sandbox".to_owned(),
|
||||
sandbox.to_owned(),
|
||||
"--ask-for-approval".to_owned(),
|
||||
approval.to_owned(),
|
||||
];
|
||||
if sandbox == "workspace-write" && !ctx.project_root.is_empty() {
|
||||
args.push("--add-dir".to_owned());
|
||||
args.push(ctx.project_root.to_owned());
|
||||
}
|
||||
|
||||
PermissionProjection {
|
||||
files: vec![ProjectedFile::MergeToml {
|
||||
rel_path: CONFIG_REL_PATH.to_owned(),
|
||||
@ -70,12 +83,7 @@ impl PermissionProjector for CodexPermissionProjector {
|
||||
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(),
|
||||
],
|
||||
args,
|
||||
env: Vec::new(),
|
||||
}
|
||||
}
|
||||
@ -135,7 +143,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// ---- (6) posture → sandbox_mode / approval_policy + (7) args↔contents
|
||||
// coherence + MergeToml shape -------------------------------
|
||||
// coherence + add-dir + MergeToml shape ---------------------
|
||||
|
||||
#[test]
|
||||
fn posture_maps_sandbox_and_approval_in_file_and_args() {
|
||||
@ -176,7 +184,7 @@ mod tests {
|
||||
|
||||
// -- (7) Args reflect the SAME values as the TOML, in CLI order.
|
||||
assert_eq!(
|
||||
proj.args,
|
||||
&proj.args[..4],
|
||||
vec![
|
||||
"--sandbox".to_owned(),
|
||||
sandbox.to_owned(),
|
||||
@ -185,6 +193,21 @@ mod tests {
|
||||
],
|
||||
"posture {posture:?}: args must mirror the TOML values"
|
||||
);
|
||||
if sandbox == "workspace-write" {
|
||||
assert!(
|
||||
proj.args
|
||||
.windows(2)
|
||||
.any(|w| w == ["--add-dir".to_owned(), "/proj".to_owned()]),
|
||||
"workspace-write posture must add the project root as writable: {:?}",
|
||||
proj.args
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
!proj.args.contains(&"--add-dir".to_owned()),
|
||||
"read-only posture must not add writable dirs: {:?}",
|
||||
proj.args
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -520,8 +520,10 @@ mod sandbox_e2e_tests {
|
||||
#[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");
|
||||
eprintln!(
|
||||
"skipping pty_spawn_enforces_sandbox_plan_end_to_end: \
|
||||
Landlock not available/enforced on this kernel"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -92,10 +92,7 @@ impl ResetTimeFormat {
|
||||
Self::EpochSeconds => c.parse::<i64>().ok().map(|s| s.saturating_mul(1000)),
|
||||
Self::EpochMillis => c.parse::<i64>().ok(),
|
||||
Self::Iso8601 => timeparse::parse_rfc3339_to_ms(c),
|
||||
Self::RelativeSeconds => c
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.map(|d| now_ms + (d * 1000.0) as i64),
|
||||
Self::RelativeSeconds => c.parse::<f64>().ok().map(|d| now_ms + (d * 1000.0) as i64),
|
||||
Self::RelativeMillis => c.parse::<i64>().ok().map(|d| now_ms + d),
|
||||
Self::WallClock => {
|
||||
let (h, m, s) = timeparse::parse_wall_clock(c)?;
|
||||
@ -153,7 +150,11 @@ impl RateLimitParser {
|
||||
.or_else(|| name.parse::<usize>().ok().and_then(|i| caps.get(i)))?;
|
||||
self.time_format.resolve(raw.as_str(), now_ms)
|
||||
});
|
||||
Some(SessionLimit::new(resets_at_ms, now_ms, RateLimitSource::Pattern))
|
||||
Some(SessionLimit::new(
|
||||
resets_at_ms,
|
||||
now_ms,
|
||||
RateLimitSource::Pattern,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@ -204,7 +205,9 @@ mod tests {
|
||||
#[test]
|
||||
fn detect_match_without_reset_capture_has_no_time() {
|
||||
let parser = RateLimitParser::new(&pattern("rate limit reached", None, None)).unwrap();
|
||||
let limit = parser.detect("oops: rate limit reached!", NOW).expect("détecté");
|
||||
let limit = parser
|
||||
.detect("oops: rate limit reached!", NOW)
|
||||
.expect("détecté");
|
||||
assert_eq!(limit.resets_at_ms, None);
|
||||
assert_eq!(limit.detected_at_ms, NOW);
|
||||
assert_eq!(limit.source, RateLimitSource::Pattern);
|
||||
@ -340,9 +343,13 @@ mod tests {
|
||||
Some("epoch_s"),
|
||||
))
|
||||
.unwrap();
|
||||
let a = parser.detect("resets at 1700000000", NOW).expect("1er détecté");
|
||||
let a = parser
|
||||
.detect("resets at 1700000000", NOW)
|
||||
.expect("1er détecté");
|
||||
assert!(parser.detect("rien ici", NOW).is_none());
|
||||
let b = parser.detect("resets at 1700000000", NOW).expect("2e détecté");
|
||||
let b = parser
|
||||
.detect("resets at 1700000000", NOW)
|
||||
.expect("2e détecté");
|
||||
assert_eq!(a.resets_at_ms, b.resets_at_ms);
|
||||
assert_eq!(a.resets_at_ms, Some(1_700_000_000_000));
|
||||
}
|
||||
@ -368,7 +375,10 @@ mod tests {
|
||||
let profile = base_profile()
|
||||
.with_structured_adapter(StructuredAdapter::Claude)
|
||||
.with_rate_limit_pattern(pattern("rate limit", None, None));
|
||||
assert!(!applies(&profile), "un agent structuré détecte par le niveau 1");
|
||||
assert!(
|
||||
!applies(&profile),
|
||||
"un agent structuré détecte par le niveau 1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -324,8 +324,7 @@ mod tests {
|
||||
.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());
|
||||
let write = std::fs::write(outside_t.join("new.txt"), b"x").map_err(|e| e.kind());
|
||||
(status, read, write)
|
||||
})
|
||||
.join()
|
||||
|
||||
@ -24,7 +24,7 @@ use tokio::sync::mpsc::UnboundedSender;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use domain::ids::ScheduleId;
|
||||
use domain::ports::{Clock, Scheduler, ScheduledTask};
|
||||
use domain::ports::{Clock, ScheduledTask, Scheduler};
|
||||
|
||||
/// Adapter tokio du port [`Scheduler`] (§21.4). Construire avec [`TokioScheduler::new`].
|
||||
///
|
||||
@ -111,7 +111,7 @@ mod tests {
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::ids::{AgentId, NodeId, ScheduleId};
|
||||
use domain::ports::{Clock, Scheduler, ScheduledTask};
|
||||
use domain::ports::{Clock, ScheduledTask, Scheduler};
|
||||
|
||||
use super::TokioScheduler;
|
||||
use crate::SystemClock;
|
||||
@ -126,7 +126,11 @@ mod tests {
|
||||
|
||||
/// Construit un scheduler + le bout récepteur du canal de remise + l'horloge réelle
|
||||
/// (partagée, pour calculer des échéances cohérentes avec celle qu'`arm` lira).
|
||||
fn make() -> (TokioScheduler, UnboundedReceiver<ScheduledTask>, Arc<dyn Clock>) {
|
||||
fn make() -> (
|
||||
TokioScheduler,
|
||||
UnboundedReceiver<ScheduledTask>,
|
||||
Arc<dyn Clock>,
|
||||
) {
|
||||
let (tx, rx) = unbounded_channel();
|
||||
let clock: Arc<dyn Clock> = Arc::new(SystemClock::new());
|
||||
(TokioScheduler::new(tx, clock.clone()), rx, clock)
|
||||
|
||||
@ -122,6 +122,8 @@ pub struct CodexExecSession {
|
||||
command: String,
|
||||
/// Répertoire de travail (run dir isolé §14.1).
|
||||
cwd: String,
|
||||
/// Project/workspace roots that must be writable in Codex's CLI sandbox.
|
||||
writable_roots: Vec<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
|
||||
@ -143,6 +145,7 @@ impl CodexExecSession {
|
||||
command: impl Into<String>,
|
||||
cwd: impl Into<String>,
|
||||
seed_conversation_id: Option<String>,
|
||||
writable_roots: Vec<String>,
|
||||
sandbox: Option<SandboxPlan>,
|
||||
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||
) -> Self {
|
||||
@ -150,6 +153,7 @@ impl CodexExecSession {
|
||||
id,
|
||||
command: command.into(),
|
||||
cwd: cwd.into(),
|
||||
writable_roots,
|
||||
conversation_id: Mutex::new(seed_conversation_id),
|
||||
sandbox,
|
||||
sandbox_enforcer,
|
||||
@ -160,18 +164,18 @@ impl CodexExecSession {
|
||||
///
|
||||
/// Format RÉEL vérifié 2026-06-10 (codex 0.137.0) :
|
||||
/// - Conversation neuve : `codex exec --json --skip-git-repo-check
|
||||
/// --sandbox workspace-write <prompt>`.
|
||||
/// --sandbox workspace-write --add-dir <project-root> <prompt>`.
|
||||
/// - Reprise (id connu) : `codex exec resume <thread_id> --json
|
||||
/// --skip-git-repo-check --sandbox workspace-write <prompt>`.
|
||||
/// --skip-git-repo-check --sandbox workspace-write --add-dir <project-root>
|
||||
/// <prompt>`.
|
||||
///
|
||||
/// **Autonomie d'écriture (D3)** : `--sandbox workspace-write` autorise l'agent à
|
||||
/// écrire dans son workspace. `codex exec` est déjà non-interactif (aucun prompt
|
||||
/// d'approbation possible), donc on ne passe **pas** `--ask-for-approval` : ce flag
|
||||
/// appartient à la commande interactive `codex`, pas à la sous-commande `exec` qui
|
||||
/// sort sur `error: unexpected argument '--ask-for-approval' found`. Défaut
|
||||
/// raisonnable, aligné sur l'autonomie projet (CLAUDE.md §12) ; à terme **piloté par
|
||||
/// les permissions de l'agent** (`.ideai/permissions.json` + sandbox OS) — non
|
||||
/// implémenté ici.
|
||||
/// sort sur `error: unexpected argument '--ask-for-approval' found`. Comme l'agent
|
||||
/// tourne depuis son run dir isolé, `--add-dir` expose explicitement le project root
|
||||
/// à la sandbox Codex pour que les écritures Git touchent le vrai workspace.
|
||||
fn build_spawn_line(&self, prompt: &str) -> SpawnLine {
|
||||
let mut args = vec!["exec".to_owned()];
|
||||
if let Some(id) = self.conversation_id.lock().expect("mutex sain").as_ref() {
|
||||
@ -182,6 +186,10 @@ impl CodexExecSession {
|
||||
args.push("--skip-git-repo-check".to_owned());
|
||||
args.push("--sandbox".to_owned());
|
||||
args.push("workspace-write".to_owned());
|
||||
for root in self.writable_roots.iter().filter(|root| !root.is_empty()) {
|
||||
args.push("--add-dir".to_owned());
|
||||
args.push(root.clone());
|
||||
}
|
||||
args.push(prompt.to_owned());
|
||||
SpawnLine {
|
||||
command: self.command.clone(),
|
||||
|
||||
@ -79,7 +79,7 @@ impl AgentSessionFactory for StructuredSessionFactory {
|
||||
async fn start(
|
||||
&self,
|
||||
profile: &AgentProfile,
|
||||
_ctx: &PreparedContext,
|
||||
ctx: &PreparedContext,
|
||||
cwd: &ProjectPath,
|
||||
session: &SessionPlan,
|
||||
sandbox: Option<&SandboxPlan>,
|
||||
@ -109,12 +109,18 @@ impl AgentSessionFactory for StructuredSessionFactory {
|
||||
// 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, plan, enforcer))
|
||||
}
|
||||
StructuredAdapter::Codex => {
|
||||
Arc::new(CodexExecSession::new(id, command, cwd, seed, plan, enforcer))
|
||||
}
|
||||
StructuredAdapter::Claude => Arc::new(ClaudeSdkSession::new(
|
||||
id, command, cwd, seed, plan, enforcer,
|
||||
)),
|
||||
StructuredAdapter::Codex => Arc::new(CodexExecSession::new(
|
||||
id,
|
||||
command,
|
||||
cwd,
|
||||
seed,
|
||||
vec![ctx.project_root.clone()],
|
||||
plan,
|
||||
enforcer,
|
||||
)),
|
||||
};
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
@ -62,6 +62,7 @@ mod tests {
|
||||
PreparedContext {
|
||||
content: MarkdownDoc::new("# ctx"),
|
||||
relative_path: "CLAUDE.md".to_owned(),
|
||||
project_root: "/project".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -240,8 +241,7 @@ mod tests {
|
||||
// turn.started / turn.completed ⇒ heartbeat (vivacité non terminale, lot 1).
|
||||
let started = codex::parse_event(r#"{"type":"turn.started"}"#).expect("ok");
|
||||
assert_eq!(started.events, vec![ReplyEvent::Heartbeat]);
|
||||
let completed =
|
||||
codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#).expect("ok");
|
||||
let completed = codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#).expect("ok");
|
||||
assert_eq!(completed.events, vec![ReplyEvent::Heartbeat]);
|
||||
|
||||
let msg = codex::parse_event(
|
||||
@ -310,6 +310,7 @@ mod tests {
|
||||
fake.command(),
|
||||
"/",
|
||||
None,
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
));
|
||||
@ -321,7 +322,14 @@ 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, None, 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
|
||||
@ -339,7 +347,14 @@ 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, None, 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:?}"),
|
||||
@ -437,6 +452,42 @@ mod tests {
|
||||
assert_eq!(content_cx, "réponse Codex");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn factory_passes_project_root_to_codex_add_dir() {
|
||||
let factory = StructuredSessionFactory::new();
|
||||
let (cmd, argv) = make_recording_fake(&[
|
||||
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
|
||||
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
||||
]);
|
||||
let codex = structured_profile(StructuredAdapter::Codex, &cmd);
|
||||
let ctx = PreparedContext {
|
||||
content: MarkdownDoc::new("# ctx"),
|
||||
relative_path: "AGENTS.md".to_owned(),
|
||||
project_root: "/project/root".to_owned(),
|
||||
};
|
||||
|
||||
let session = factory
|
||||
.start(&codex, &ctx, &cwd(), &SessionPlan::None, None)
|
||||
.await
|
||||
.expect("start Codex ok");
|
||||
let content = drain_final(session.as_ref()).await;
|
||||
assert_eq!(content, "ok");
|
||||
|
||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||
let args: Vec<&str> = recorded.lines().collect();
|
||||
assert!(
|
||||
args.windows(2).any(|w| w == ["--add-dir", "/project/root"]),
|
||||
"factory must relay PreparedContext.project_root to Codex --add-dir, got: {args:?}"
|
||||
);
|
||||
assert!(
|
||||
!args.contains(&"--ask-for-approval"),
|
||||
"codex exec must not receive unsupported approval flags, got: {args:?}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&cmd);
|
||||
let _ = std::fs::remove_file(&argv);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn factory_resume_seeds_conversation_id() {
|
||||
let factory = StructuredSessionFactory::new();
|
||||
@ -564,10 +615,14 @@ mod tests {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static C: AtomicU64 = AtomicU64::new(0);
|
||||
let n = C.fetch_add(1, Ordering::Relaxed);
|
||||
let mut bin = std::env::temp_dir();
|
||||
bin.push(format!("idea-rec-cli-{}-{n}", std::process::id()));
|
||||
let mut argv = std::env::temp_dir();
|
||||
argv.push(format!("idea-rec-argv-{}-{n}", std::process::id()));
|
||||
let dir = std::env::current_dir()
|
||||
.expect("cwd")
|
||||
.join("target")
|
||||
.join("test-fakes")
|
||||
.join("session");
|
||||
std::fs::create_dir_all(&dir).expect("create rec fake dir");
|
||||
let bin = dir.join(format!("idea-rec-cli-{}-{n}", std::process::id()));
|
||||
let argv = dir.join(format!("idea-rec-argv-{}-{n}", std::process::id()));
|
||||
|
||||
let mut s = String::from("#!/bin/sh\n");
|
||||
// Enregistre chaque argument sur sa propre ligne dans le sidecar.
|
||||
@ -775,7 +830,15 @@ 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, None, None);
|
||||
let s = CodexExecSession::new(
|
||||
SessionId::new_random(),
|
||||
fake.command(),
|
||||
"/",
|
||||
None,
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let events: Vec<_> = s.send("x").await.expect("send").collect();
|
||||
let finals = events
|
||||
.iter()
|
||||
@ -789,7 +852,10 @@ mod tests {
|
||||
.skip(1)
|
||||
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
||||
.count();
|
||||
assert_eq!(after_final_terminals, 0, "aucun second Final après le premier");
|
||||
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
|
||||
@ -803,7 +869,14 @@ 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, None, 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()
|
||||
@ -959,6 +1032,7 @@ mod tests {
|
||||
cmd.clone(),
|
||||
"/",
|
||||
Some("cx-id".to_owned()),
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
@ -983,7 +1057,8 @@ 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, None, 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"));
|
||||
@ -1093,6 +1168,7 @@ mod tests {
|
||||
fake.command(),
|
||||
"/",
|
||||
None,
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
));
|
||||
@ -1123,7 +1199,14 @@ 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, None, 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,
|
||||
@ -1160,7 +1243,8 @@ 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, None, 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();
|
||||
@ -1192,7 +1276,15 @@ 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, None, None);
|
||||
let session = CodexExecSession::new(
|
||||
SessionId::new_random(),
|
||||
cmd.clone(),
|
||||
"/",
|
||||
None,
|
||||
Vec::new(),
|
||||
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();
|
||||
@ -1217,7 +1309,8 @@ mod tests {
|
||||
// =====================================================================
|
||||
// DURCISSEMENT QA (lot D3, §17.9 D3 — fix codex 0.137) — autonomie
|
||||
// d'écriture Codex : la commande générée porte EXACTEMENT
|
||||
// [exec, --json, --skip-git-repo-check, --sandbox, workspace-write, <prompt>]
|
||||
// [exec, --json, --skip-git-repo-check, --sandbox, workspace-write,
|
||||
// --add-dir, <project-root>, <prompt>]
|
||||
// (resume <id> en tête pour une reprise). Le flag `--ask-for-approval never`
|
||||
// a été RETIRÉ : `codex exec` 0.137 ne le connaît pas (`error: unexpected
|
||||
// argument`) et est déjà non-interactif. Ce test verrouille l'argv exact pour
|
||||
@ -1226,15 +1319,23 @@ mod tests {
|
||||
// =====================================================================
|
||||
|
||||
/// Conversation NEUVE : argv EXACT `[exec, --json, --skip-git-repo-check,
|
||||
/// --sandbox, workspace-write, <prompt>]`. Pas de sous-commande `resume`,
|
||||
/// pas de `--ask-for-approval`.
|
||||
/// --sandbox, workspace-write, --add-dir, <project-root>, <prompt>]`.
|
||||
/// Pas de sous-commande `resume`, pas de `--ask-for-approval`.
|
||||
#[tokio::test]
|
||||
async fn codex_new_conversation_command_carries_exact_args() {
|
||||
let (cmd, argv) = make_recording_fake(&[
|
||||
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, None, None);
|
||||
let session = CodexExecSession::new(
|
||||
SessionId::new_random(),
|
||||
cmd.clone(),
|
||||
"/",
|
||||
None,
|
||||
vec!["/project/root".to_owned()],
|
||||
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();
|
||||
@ -1247,6 +1348,8 @@ mod tests {
|
||||
"--skip-git-repo-check",
|
||||
"--sandbox",
|
||||
"workspace-write",
|
||||
"--add-dir",
|
||||
"/project/root",
|
||||
"salut",
|
||||
],
|
||||
"argv neuf doit être exact (sans resume, sans --ask-for-approval), vu: {args:?}"
|
||||
@ -1256,8 +1359,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// REPRISE (seed d'id) : argv EXACT `[exec, resume, <id>, --json,
|
||||
/// --skip-git-repo-check, --sandbox, workspace-write, <prompt>]`. Toujours
|
||||
/// pas de `--ask-for-approval`.
|
||||
/// --skip-git-repo-check, --sandbox, workspace-write, --add-dir, <project-root>,
|
||||
/// <prompt>]`. Toujours pas de `--ask-for-approval`.
|
||||
#[tokio::test]
|
||||
async fn codex_resume_command_carries_exact_args() {
|
||||
let (cmd, argv) = make_recording_fake(&[
|
||||
@ -1268,6 +1371,7 @@ mod tests {
|
||||
cmd.clone(),
|
||||
"/",
|
||||
Some("cx-id".to_owned()),
|
||||
vec!["/project/root".to_owned()],
|
||||
None,
|
||||
None,
|
||||
);
|
||||
@ -1285,6 +1389,8 @@ mod tests {
|
||||
"--skip-git-repo-check",
|
||||
"--sandbox",
|
||||
"workspace-write",
|
||||
"--add-dir",
|
||||
"/project/root",
|
||||
"vas-y",
|
||||
],
|
||||
"argv reprise doit être exact (resume <id> en tête, sans --ask-for-approval), vu: {args:?}"
|
||||
@ -1339,13 +1445,19 @@ mod tests {
|
||||
#[test]
|
||||
fn parse_reset_ms_integer_seconds_are_scaled_to_ms() {
|
||||
// < 10^12 ⇒ secondes ⇒ ×1000.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": 1_700_000_000_i64 })), Some(1_700_000_000_000));
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": 1_700_000_000_i64 })),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_reset_ms_integer_millis_are_kept_as_is() {
|
||||
// ≥ 10^12 ⇒ déjà des millisecondes ⇒ tel quel.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": 1_700_000_000_000_i64 })), Some(1_700_000_000_000));
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": 1_700_000_000_000_i64 })),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
}
|
||||
|
||||
/// Le SEUIL exact (10^12) : juste en-dessous ⇒ secondes (×1000) ; pile/au-dessus
|
||||
@ -1388,12 +1500,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_reset_ms_string_integer_uses_seconds_heuristic() {
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "1700000000" })), Some(1_700_000_000_000));
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "1700000000" })),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_reset_ms_string_float_uses_seconds_heuristic() {
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "1700000000.5" })), Some(1_700_000_000_500));
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "1700000000.5" })),
|
||||
Some(1_700_000_000_500)
|
||||
);
|
||||
}
|
||||
|
||||
// ---- parse_reset_ms : ISO-8601 / RFC3339 (parseur maison) --------------
|
||||
@ -1487,11 +1605,20 @@ mod tests {
|
||||
// Pas de séparateur de date/heure.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14" })), None);
|
||||
// Année non numérique.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "abcd-11-14T00:00:00Z" })), None);
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "abcd-11-14T00:00:00Z" })),
|
||||
None
|
||||
);
|
||||
// Composante de date manquante (pas de jour).
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11T00:00:00Z" })), None);
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "2023-11T00:00:00Z" })),
|
||||
None
|
||||
);
|
||||
// Trop de composantes de date.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14-9T00:00:00Z" })), None);
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "2023-11-14-9T00:00:00Z" })),
|
||||
None
|
||||
);
|
||||
// Minute manquante dans l'heure.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14T22Z" })), None);
|
||||
}
|
||||
@ -1614,7 +1741,14 @@ mod tests {
|
||||
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"ap"}]},"session_id":"rl-1","parent_tool_use_id":null}"#,
|
||||
r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"rl-1","num_turns":1}"#,
|
||||
]);
|
||||
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
|
||||
let 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,
|
||||
|
||||
@ -158,8 +158,7 @@ async fn run_turn_sandboxed(
|
||||
|
||||
// Thread JETABLE : sa restriction Landlock meurt avec lui.
|
||||
std::thread::spawn(move || {
|
||||
let result =
|
||||
drain_sandboxed(command, args, cwd, env, stdin, &enforcer, &plan, killer_tx);
|
||||
let result = drain_sandboxed(command, args, cwd, env, stdin, &enforcer, &plan, killer_tx);
|
||||
// Le récepteur peut avoir abandonné (timeout) : on ignore l'erreur d'envoi.
|
||||
let _ = done_tx.send(result);
|
||||
});
|
||||
|
||||
@ -187,7 +187,10 @@ async fn structured_run_turn_without_plan_does_not_sandbox() {
|
||||
.expect("run_turn natif réussit");
|
||||
|
||||
assert_eq!(lines, vec![RESULT_LINE.to_owned()]);
|
||||
assert!(allowed_marker.exists(), "écriture in-grant réussit (sans plan)");
|
||||
assert!(
|
||||
allowed_marker.exists(),
|
||||
"écriture in-grant réussit (sans plan)"
|
||||
);
|
||||
assert!(
|
||||
denied_marker.exists(),
|
||||
"sans plan, l'écriture hors-grant DOIT réussir: {denied_marker:?} absent — restriction ambiante anormale"
|
||||
@ -278,7 +281,10 @@ async fn structured_run_turn_fail_closed_no_child_on_enforce_err() {
|
||||
async fn structured_run_turn_none_plan_is_native_path() {
|
||||
let dir = fresh_dir("p4");
|
||||
let marker = dir.join("native.txt");
|
||||
let script = format!("printf '%s\\n' '{RESULT_LINE}'; echo ok > '{}'", marker.display());
|
||||
let script = format!(
|
||||
"printf '%s\\n' '{RESULT_LINE}'; echo ok > '{}'",
|
||||
marker.display()
|
||||
);
|
||||
let spec = SpawnLine {
|
||||
command: "sh".to_owned(),
|
||||
args: vec!["-c".to_owned(), script],
|
||||
@ -327,7 +333,10 @@ async fn structured_two_turns_disjoint_grants_are_confined() {
|
||||
run_turn(&spec_a, None, Some(&enforcer))
|
||||
.await
|
||||
.expect("tour A ok");
|
||||
assert!(a_in.exists(), "tour A: écriture dans son grant (A) doit réussir");
|
||||
assert!(
|
||||
a_in.exists(),
|
||||
"tour A: écriture dans son grant (A) doit réussir"
|
||||
);
|
||||
assert!(
|
||||
!a_into_b.exists(),
|
||||
"tour A: écriture dans B (hors grant A) doit être bloquée"
|
||||
@ -419,7 +428,9 @@ async fn structured_sandboxed_turn_preserves_conversation_id() {
|
||||
use super::factory::StructuredSessionFactory;
|
||||
|
||||
if !landlock_is_enforced() {
|
||||
eprintln!("skip structured_sandboxed_turn_preserves_conversation_id: Landlock indisponible");
|
||||
eprintln!(
|
||||
"skip structured_sandboxed_turn_preserves_conversation_id: Landlock indisponible"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -448,6 +459,7 @@ async fn structured_sandboxed_turn_preserves_conversation_id() {
|
||||
let ctx = PreparedContext {
|
||||
content: MarkdownDoc::new("# ctx"),
|
||||
relative_path: "CLAUDE.md".to_owned(),
|
||||
project_root: run_dir.to_string_lossy().into_owned(),
|
||||
};
|
||||
let cwd = ProjectPath::new(run_dir.to_string_lossy().into_owned()).expect("cwd absolu");
|
||||
let plan = rw_plan(&run_dir); // plan write-only ⇒ reads/exec du fake non gênés
|
||||
@ -466,7 +478,10 @@ async fn structured_sandboxed_turn_preserves_conversation_id() {
|
||||
.iter()
|
||||
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
||||
.count();
|
||||
assert_eq!(finals, 1, "un Final attendu malgré le sandbox, vu: {events:?}");
|
||||
assert_eq!(
|
||||
finals, 1,
|
||||
"un Final attendu malgré le sandbox, vu: {events:?}"
|
||||
);
|
||||
|
||||
// LE POINT : l'id de conversation a bien été capté sous enforcement actif.
|
||||
assert_eq!(
|
||||
|
||||
@ -152,6 +152,7 @@ impl AgentContextStore for IdeaiContextStore {
|
||||
Err(FsError::NotFound(_)) => Ok(AgentManifest {
|
||||
version: MANIFEST_VERSION,
|
||||
entries: Vec::new(),
|
||||
orchestrator: None,
|
||||
}),
|
||||
Err(e) => Err(StoreError::Io(e.to_string())),
|
||||
}
|
||||
|
||||
@ -186,7 +186,11 @@ fn split_seconds_frac(s: &str) -> Option<(i64, i64)> {
|
||||
return Some((s.parse::<i64>().ok()?, 0));
|
||||
};
|
||||
let sec = sec.parse::<i64>().ok()?;
|
||||
let mut d3: String = frac.chars().take_while(char::is_ascii_digit).take(3).collect();
|
||||
let mut d3: String = frac
|
||||
.chars()
|
||||
.take_while(char::is_ascii_digit)
|
||||
.take(3)
|
||||
.collect();
|
||||
while d3.len() < 3 {
|
||||
d3.push('0');
|
||||
}
|
||||
@ -257,7 +261,10 @@ mod tests {
|
||||
fn wall_clock_to_ms_same_day_when_future() {
|
||||
let now_10h = DAY_START + 10 * 3_600_000;
|
||||
// 15:00 est dans le futur ⇒ même jour.
|
||||
assert_eq!(wall_clock_to_ms(now_10h, 15, 0, 0), DAY_START + 15 * 3_600_000);
|
||||
assert_eq!(
|
||||
wall_clock_to_ms(now_10h, 15, 0, 0),
|
||||
DAY_START + 15 * 3_600_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -282,7 +289,8 @@ mod tests {
|
||||
#[test]
|
||||
fn parse_absolute_ms_epoch_seconds_and_millis() {
|
||||
assert_eq!(parse_absolute_ms("1700000000"), Some(1_700_000_000_000)); // s ⇒ ×1000
|
||||
assert_eq!(parse_absolute_ms("1700000000000"), Some(1_700_000_000_000)); // ms tel quel
|
||||
assert_eq!(parse_absolute_ms("1700000000000"), Some(1_700_000_000_000));
|
||||
// ms tel quel
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -45,6 +45,7 @@ fn ctx() -> PreparedContext {
|
||||
PreparedContext {
|
||||
content: MarkdownDoc::new("# hi"),
|
||||
relative_path: ".ideai/agent.md".to_owned(),
|
||||
project_root: "/repo".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -151,3 +151,63 @@ async fn manifest_file_is_camelcase_json_under_ideai() {
|
||||
);
|
||||
assert!(entry.get("md_path").is_none(), "no snake_case leak");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_manifest_without_orchestrator_loads_as_default() {
|
||||
// A pre-feature `agents.json` has no `orchestrator` key. It must deserialise with
|
||||
// `orchestrator == None` (serde default), so `effective_orchestrator()` falls back
|
||||
// to the oldest entry (free backward compatibility).
|
||||
let tmp = TempDir::new();
|
||||
let p = project(&tmp.root());
|
||||
let fs = LocalFileSystem::new();
|
||||
fs.create_dir_all(&tmp.child(".ideai")).await.unwrap();
|
||||
let legacy = r#"{
|
||||
"version": 1,
|
||||
"agents": [
|
||||
{ "agentId": "00000000-0000-0000-0000-000000000001", "name": "Oldest",
|
||||
"mdPath": "agents/oldest.md", "profileId": "00000000-0000-0000-0000-0000000000aa",
|
||||
"synchronized": false },
|
||||
{ "agentId": "00000000-0000-0000-0000-000000000002", "name": "Newer",
|
||||
"mdPath": "agents/newer.md", "profileId": "00000000-0000-0000-0000-0000000000aa",
|
||||
"synchronized": false }
|
||||
]
|
||||
}"#;
|
||||
fs.write(&tmp.child(".ideai/agents.json"), legacy.as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let manifest = store().load_manifest(&p).await.unwrap();
|
||||
assert_eq!(manifest.orchestrator, None);
|
||||
assert_eq!(manifest.effective_orchestrator(), Some(aid(1)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manifest_with_orchestrator_roundtrips_and_emits_camelcase_key() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store();
|
||||
let p = project(&tmp.root());
|
||||
|
||||
let a = agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let b = agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
|
||||
let mut manifest = AgentManifest::new(
|
||||
1,
|
||||
vec![ManifestEntry::from_agent(&a), ManifestEntry::from_agent(&b)],
|
||||
)
|
||||
.unwrap();
|
||||
manifest.designate(aid(2)).unwrap();
|
||||
store.save_manifest(&p, &manifest).await.unwrap();
|
||||
|
||||
// Round-trips byte-for-byte through the store…
|
||||
let back = store.load_manifest(&p).await.unwrap();
|
||||
assert_eq!(back, manifest);
|
||||
assert_eq!(back.effective_orchestrator(), Some(aid(2)));
|
||||
|
||||
// …and the on-disk JSON carries the camelCase `orchestrator` key.
|
||||
let fs = LocalFileSystem::new();
|
||||
let bytes = fs.read(&tmp.child(".ideai/agents.json")).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(
|
||||
json.get("orchestrator").and_then(|v| v.as_str()),
|
||||
Some("00000000-0000-0000-0000-000000000002")
|
||||
);
|
||||
}
|
||||
|
||||
@ -78,6 +78,7 @@ impl FakeContexts {
|
||||
manifest: AgentManifest {
|
||||
version: 1,
|
||||
entries: Vec::new(),
|
||||
orchestrator: None,
|
||||
},
|
||||
contents: HashMap::new(),
|
||||
})))
|
||||
@ -1227,21 +1228,18 @@ async fn ask_agent_rendezvous_times_out_with_a_jsonrpc_error() {
|
||||
agent_id,
|
||||
SessionId::from_uuid(Uuid::from_u128(910)),
|
||||
);
|
||||
let server = server(service)
|
||||
.with_ask_rendezvous_timeout(std::time::Duration::from_millis(50));
|
||||
let server = server(service).with_ask_rendezvous_timeout(std::time::Duration::from_millis(50));
|
||||
|
||||
let raw = tools_call(
|
||||
1,
|
||||
"idea_ask_agent",
|
||||
json!({ "target": "architect", "task": "never answered" }),
|
||||
);
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
server.handle_raw(&raw),
|
||||
)
|
||||
.await
|
||||
.expect("must not hang past the injected timeout")
|
||||
.expect("reply owed");
|
||||
let response =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), server.handle_raw(&raw))
|
||||
.await
|
||||
.expect("must not hang past the injected timeout")
|
||||
.expect("reply owed");
|
||||
|
||||
let error = response.error.expect("a JSON-RPC error on timeout");
|
||||
assert_eq!(error.code, error_codes::INTERNAL_ERROR, "got {error:?}");
|
||||
|
||||
@ -74,6 +74,7 @@ impl FakeContexts {
|
||||
manifest: AgentManifest {
|
||||
version: 1,
|
||||
entries: Vec::new(),
|
||||
orchestrator: None,
|
||||
},
|
||||
contents: HashMap::new(),
|
||||
})))
|
||||
|
||||
@ -28,11 +28,11 @@
|
||||
* - getByRole("alert") — error display
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { LayoutInfo } from "@/domain";
|
||||
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
||||
import { AgentsPanel, ResumeProjectPanel } from "@/features/agents";
|
||||
import { AgentsPanel } from "@/features/agents";
|
||||
import { TemplatesPanel } from "@/features/templates";
|
||||
import { SkillsPanel } from "@/features/skills";
|
||||
import { MemoryPanel } from "@/features/memory";
|
||||
@ -79,6 +79,15 @@ export function ProjectsView() {
|
||||
|
||||
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
|
||||
|
||||
// Reset the active layout whenever the active project changes. `activeLayout`
|
||||
// is only repopulated asynchronously by `LayoutTabs` (which re-fetches the new
|
||||
// project's layouts). Without this reset, the stale id of the *previous*
|
||||
// project would be handed to `LayoutGrid`/`GitGraphView` during the gap, and
|
||||
// loading it against the new project's store fails with "not found: layout X".
|
||||
useEffect(() => {
|
||||
setActiveLayout(null);
|
||||
}, [active?.id]);
|
||||
|
||||
const activeLayoutKind = activeLayout?.kind ?? "terminal";
|
||||
|
||||
const canCreate = name.trim().length > 0 && root.trim().length > 0 && !vm.busy;
|
||||
@ -355,17 +364,6 @@ export function ProjectsView() {
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* ── Reopen resume panel (§15.2) ──
|
||||
Mounted per active project (keyed by id) so it re-pulls the resumable
|
||||
inventory on every open/switch; it renders nothing when empty. */}
|
||||
{active && (
|
||||
<ResumeProjectPanel
|
||||
key={`resume-${active.id}`}
|
||||
projectId={active.id}
|
||||
cwd={active.root}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -120,6 +120,25 @@ describe("useWritePortal (§20)", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("chunks long delegated text before submit and acks after the submit", async () => {
|
||||
const { system, input, view } = setup();
|
||||
const { handle, writes } = makeHandle();
|
||||
act(() => view.result.current.portal.bindHandle(handle));
|
||||
const text = `début ${"x".repeat(1200)} fin`;
|
||||
|
||||
await act(async () => {
|
||||
system.emit(PROFILELESS("t1", text));
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(writes.length).toBeGreaterThan(2);
|
||||
expect(writes[writes.length - 1]).toBe("\r");
|
||||
expect(writes.slice(0, -1).join("")).toBe(text);
|
||||
expect(input.delivered).toEqual([
|
||||
{ projectId: "p1", agentId: "ag1", ticket: "t1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("K=2 race → exactly two \\x7f backspaces before the text (never Ctrl-U)", async () => {
|
||||
const { system, view } = setup();
|
||||
const { handle, writes } = makeHandle();
|
||||
@ -255,6 +274,26 @@ describe("useWritePortal (§20)", () => {
|
||||
expect(input.delivered.length).toBe(1);
|
||||
});
|
||||
|
||||
it("reports front attachment only after the delegation subscription is ready", async () => {
|
||||
const { input, view } = setup();
|
||||
const { handle } = makeHandle();
|
||||
|
||||
act(() => view.result.current.portal.bindHandle(handle));
|
||||
expect(input.frontAttached).toEqual([]);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(input.frontAttached).toEqual([{ agentId: "ag1", attached: true }]);
|
||||
|
||||
act(() => view.result.current.portal.unbindHandle());
|
||||
expect(input.frontAttached).toEqual([
|
||||
{ agentId: "ag1", attached: true },
|
||||
{ agentId: "ag1", attached: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("is inert for a plain (agent-less) cell", async () => {
|
||||
const { system, view } = setup(null);
|
||||
const { handle, writes } = makeHandle();
|
||||
|
||||
@ -44,10 +44,19 @@ import { useGateways } from "@/app/di";
|
||||
|
||||
/** Default submit sequence when the profile omits one (paste-detection esquive). */
|
||||
const DEFAULT_SUBMIT_SEQUENCE = "\r";
|
||||
/** Default delay (ms) between text and submit-sequence writes. */
|
||||
const DEFAULT_SUBMIT_DELAY_MS = 60;
|
||||
/**
|
||||
* Default delay (ms) between text and submit-sequence writes.
|
||||
*
|
||||
* Keep this conservative: Codex's interactive TUI can otherwise keep a delegated
|
||||
* prompt in the input editor until the user presses Enter manually.
|
||||
*/
|
||||
const DEFAULT_SUBMIT_DELAY_MS = 350;
|
||||
/** Anti-flash overlay floor (ms) from step (b). */
|
||||
const OVERLAY_FLOOR_MS = 2000;
|
||||
/** Max UTF-8 bytes per delegation write chunk. */
|
||||
const DELEGATION_WRITE_CHUNK_BYTES = 512;
|
||||
/** Delay between delegation chunks, to avoid TUI paste/drop behaviour on cold agents. */
|
||||
const DELEGATION_WRITE_CHUNK_DELAY_MS = 8;
|
||||
|
||||
/** A pending delegation kept in the local FIFO. */
|
||||
interface PendingDelegation {
|
||||
@ -99,6 +108,8 @@ export function useWritePortal(
|
||||
const handleRef = useRef<TerminalHandle | null>(null);
|
||||
const suspendedRef = useRef(false); // relay suspended while injecting
|
||||
const injectingRef = useRef(false); // a handshake is in flight
|
||||
const subscribedAgentRef = useRef<string | null>(null);
|
||||
const frontAttachedAgentRef = useRef<string | null>(null);
|
||||
|
||||
// Stable refs to the gateways used inside the (non-reactive) handshake.
|
||||
const inputRef = useRef(input);
|
||||
@ -110,6 +121,28 @@ export function useWritePortal(
|
||||
const projectIdRef = useRef(projectId);
|
||||
projectIdRef.current = projectId;
|
||||
|
||||
// Backend routing must only switch to the frontend path once this cell can
|
||||
// actually consume DelegationReady events. If we report "front attached"
|
||||
// immediately at bindHandle time, a delegation emitted before the async event
|
||||
// subscription is installed is lost: the backend publishes an event nobody is
|
||||
// listening to, and it also skips the headless PTY write fallback.
|
||||
const reconcileFrontAttachment = useRef<() => void>(() => {});
|
||||
reconcileFrontAttachment.current = () => {
|
||||
const desired = handleRef.current && subscribedAgentRef.current
|
||||
? subscribedAgentRef.current
|
||||
: null;
|
||||
const previous = frontAttachedAgentRef.current;
|
||||
if (previous === desired) return;
|
||||
|
||||
if (previous) {
|
||||
void inputRef.current?.setFrontAttached(previous, false).catch(() => {});
|
||||
}
|
||||
if (desired) {
|
||||
void inputRef.current?.setFrontAttached(desired, true).catch(() => {});
|
||||
}
|
||||
frontAttachedAgentRef.current = desired;
|
||||
};
|
||||
|
||||
// ── (3)+(4): the handshake, attempted whenever a boundary may have opened ──
|
||||
const tryInject = useRef<() => void>(() => {});
|
||||
tryInject.current = () => {
|
||||
@ -147,7 +180,10 @@ export function useWritePortal(
|
||||
|
||||
// (d) write the text WITHOUT a trailing newline, then the submit
|
||||
// sequence after the profile's delay (esquive de la paste-detection).
|
||||
await handle.write(encoder.encode(head.text));
|
||||
// Long delegated prompts are chunked: Codex's TUI can otherwise treat a
|
||||
// large single write like a paste and occasionally keep/drop the tail on
|
||||
// cold-started agents before Enter is received.
|
||||
await writeDelegationText(handle, head.text);
|
||||
const delay = head.submitDelayMs ?? DEFAULT_SUBMIT_DELAY_MS;
|
||||
await sleep(delay);
|
||||
const submit = head.submitSequence ?? DEFAULT_SUBMIT_SEQUENCE;
|
||||
@ -176,14 +212,19 @@ export function useWritePortal(
|
||||
|
||||
// ── (2): subscribe to delegationReady for THIS agent and enqueue ───────────
|
||||
useEffect(() => {
|
||||
if (!system || !agentId) return;
|
||||
if (!system || !agentId) {
|
||||
subscribedAgentRef.current = null;
|
||||
reconcileFrontAttachment.current();
|
||||
return;
|
||||
}
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
const subscribedAgent = agentId;
|
||||
|
||||
void system
|
||||
.onDomainEvent((event: DomainEvent) => {
|
||||
if (event.type !== "delegationReady") return;
|
||||
if (event.agentId !== agentId) return;
|
||||
if (event.agentId !== subscribedAgent) return;
|
||||
queueRef.current.push({
|
||||
ticket: event.ticket,
|
||||
text: event.text,
|
||||
@ -195,12 +236,25 @@ export function useWritePortal(
|
||||
})
|
||||
.then((un) => {
|
||||
if (cancelled) un();
|
||||
else unsubscribe = un;
|
||||
else {
|
||||
unsubscribe = un;
|
||||
subscribedAgentRef.current = subscribedAgent;
|
||||
reconcileFrontAttachment.current();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
if (subscribedAgentRef.current === subscribedAgent) {
|
||||
subscribedAgentRef.current = null;
|
||||
}
|
||||
if (frontAttachedAgentRef.current === subscribedAgent) {
|
||||
void inputRef.current?.setFrontAttached(subscribedAgent, false).catch(() => {});
|
||||
frontAttachedAgentRef.current = null;
|
||||
} else {
|
||||
reconcileFrontAttachment.current();
|
||||
}
|
||||
};
|
||||
}, [system, agentId]);
|
||||
|
||||
@ -225,11 +279,10 @@ export function useWritePortal(
|
||||
},
|
||||
bindHandle(handle: TerminalHandle) {
|
||||
handleRef.current = handle;
|
||||
// Tell the backend a frontend cell is now mounted for this agent, so the
|
||||
// mediator routes its turns through `delegationReady` (this portal writes)
|
||||
// rather than writing the PTY itself (the headless path for cell-less agents).
|
||||
const agent = agentIdRef.current;
|
||||
if (agent) void inputRef.current.setFrontAttached(agent, true).catch(() => {});
|
||||
// Tell the backend a frontend cell is mounted only after the event
|
||||
// subscription is ready. Until then, the backend keeps the headless PTY
|
||||
// write fallback, so a delegation cannot disappear during mount.
|
||||
reconcileFrontAttachment.current();
|
||||
// The PTY just became available — a queued delegation may be injectable.
|
||||
tryInject.current();
|
||||
},
|
||||
@ -237,8 +290,7 @@ export function useWritePortal(
|
||||
handleRef.current = null;
|
||||
// The cell is gone — let the backend fall back to headless delivery so a
|
||||
// delegation arriving while this agent has no live cell is not lost.
|
||||
const agent = agentIdRef.current;
|
||||
if (agent) void inputRef.current.setFrontAttached(agent, false).catch(() => {});
|
||||
reconcileFrontAttachment.current();
|
||||
},
|
||||
}),
|
||||
[],
|
||||
@ -251,3 +303,37 @@ export function useWritePortal(
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function writeDelegationText(
|
||||
handle: TerminalHandle,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
const chunks = splitUtf8Chunks(text, DELEGATION_WRITE_CHUNK_BYTES);
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
await handle.write(encoder.encode(chunks[i]));
|
||||
if (i + 1 < chunks.length) {
|
||||
await sleep(DELEGATION_WRITE_CHUNK_DELAY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function splitUtf8Chunks(text: string, maxBytes: number): string[] {
|
||||
if (text.length === 0) return [""];
|
||||
const chunks: string[] = [];
|
||||
let current = "";
|
||||
let currentBytes = 0;
|
||||
|
||||
for (const ch of text) {
|
||||
const bytes = encoder.encode(ch).byteLength;
|
||||
if (current.length > 0 && currentBytes + bytes > maxBytes) {
|
||||
chunks.push(current);
|
||||
current = "";
|
||||
currentBytes = 0;
|
||||
}
|
||||
current += ch;
|
||||
currentBytes += bytes;
|
||||
}
|
||||
|
||||
if (current.length > 0) chunks.push(current);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user