feat(agent): conversation par paire + entrée médiée + pivot terminal/MCP

Coeur inter-agents consolidé et surface front réalignée sur la décision
"terminal natif PTY, pas d'UI chat" (Option 1).

Domaine
- nouveaux modules conversation, mailbox, input, fileguard (ports + types)
- orchestrator/profile/events étendus (conversation par paire, FIFO)

Application / Infrastructure
- orchestrator/service + context_guard : sérialisation FIFO par agent,
  garde RW mémoire/contexte, dispatch ask/reply
- adapters in-memory conversation / mailbox / input / fileguard
- registry session + lifecycle agent durcis (1 agent = 1 session vivante)
- outils MCP idea_* alignés sur le nouveau dispatch

Frontend
- MediatedInput + useAgentBusy : entrée utilisateur médiée par IdeA,
  terminal = vue sortie inchangée
- suppression de la vue chat structurée (AgentChatView) — abandonnée
- adapter input + ports mis à jour

Divers
- .ideai/ : mémoire projet + briefs de cadrage versionnés ;
  requests/ runtime ignoré ; agents projet réels (DevBackend/DevFrontend/QA)

Tests : Rust (domain/application/infrastructure/app-tauri) + front (346) verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 07:33:04 +02:00
parent 5f45c22941
commit eca2ba95c4
61 changed files with 9491 additions and 2512 deletions

View File

@ -0,0 +1,515 @@
# Conversation par paire — cadrage d'architecture (multi-agent solide par construction)
> **Agent Architecture.** Ce document tranche le modèle qui rend le multi-agent
> **solide par construction** : l'utilisateur n'a plus à « faire les choses dans le
> bon ordre ». Aucun code de production ici — décisions, contrats (ports/entités),
> découpage en lots testables, frontière backend/frontend.
>
> **Décisions produit arbitrées (NON négociables, rappel) :** (A) conversation par
> paire = un fil entre deux parties, session propre, matérialisation paresseuse ;
> (B) entrée médiée par IdeA (le terminal xterm reste la vue de sortie brute
> INCHANGÉE, seule l'entrée change de chemin), Envoyer=enqueue / Interrompre=préempte ;
> (C) FileGuard borné aux `.md` de contexte + la mémoire, via outils MCP, verrou
> lecteurs/écrivain ; (D) zéro git, hexagonal+SOLID stricts, corrélation par ticket,
> MCP Claude-only, fix `bind_endpoint`, abandon du band-aid `\n`→`\r`.
>
> **État du terrain (lu, pas présumé).** L'essentiel des briques existe déjà :
> `domain/src/mailbox.rs` (`AgentMailbox`, `Ticket`, `TicketId`, `PendingReply`,
> `MailboxError`) ; `infrastructure/src/mailbox/mod.rs` (`InMemoryMailbox`, FIFO par
> agent + `oneshot`) ; `application/src/orchestrator/service.rs` (`ask_agent`,
> `reply`, `ensure_live_pty`, verrou de tour `ask_locks`) ; surface MCP complète
> (`mcp/tools.rs`, `mcp/server.rs`) ; transport bindé (`app-tauri/src/mcp_endpoint.rs`,
> `state.rs::bind_endpoint`/`ensure_mcp_server`/`serve_peer`). **Ce cadrage
> formalise et complète ; il ne réécrit pas.**
---
## 0. Synthèse exécutive (décisions tranchées)
1. **La conversation devient une entité de premier plan** (`Conversation` + `ConversationId`),
absente aujourd'hui. Le couplage actuel « 1 session vivante / agent »
(`session-registry-agent-ambiguity`) est **remplacé** par « 1 session vivante /
**conversation** ». Un agent peut donc avoir **N sessions** simultanées (une par
fil), mais **une seule tâche traitée à la fois** (l'entrée reste sérialisée, §B).
C'est ce qui supprime la fuite de contexte : la délégation A→B n'emprunte plus la
conversation User↔B.
2. **L'entrée passe par un `InputMediator`** (nouveau port application) : toutes les
entrées (humaine **et** inter-agents) convergent vers **une file FIFO unique par
agent**, `enqueue`/`preempt` distincts. Le terminal xterm n'écrit **plus jamais
en direct dans le PTY** ; il devient une **vue de sortie pure**. La file existante
(`AgentMailbox` + `ask_locks`) est **absorbée** par le `InputMediator` : la
messagerie inter-agents n'est qu'une **source d'entrée parmi deux**.
3. **`FileGuard` (nouveau port domaine)** : un verrou lecteurs/écrivain **borné** aux
fichiers qu'IdeA possède (`.md` de contexte d'agent + mémoire). Les agents perdent
l'accès fs brut à ces chemins et passent par de **nouveaux outils MCP**
`idea_context_read/propose` et `idea_memory_read/write`. Le contexte **global
projet** est **mono-écrivain (l'orchestrateur)** ; les autres *proposent*.
4. **Détection occupé/libre = double signal avec fallback sûr** : (a) **retour-de-prompt**
détecté par motif déclaré dans le profil CLI, (b) **signal explicite** de l'agent
(un `idea_reply`, ou fin de tour MCP). **En cas de doute → forwarder** (on
enqueue ; jamais piéger un message). L'occupé/libre remonte au front via un
`DomainEvent` (Channel Tauri), pas via parsing front.
5. **Fixes durables embarqués** : `bind_endpoint` unlink déjà le socket cadavre
(`reclaim_name(true)`, état OK — on **verrouille ce comportement par un test de
non-régression**) ; le band-aid `\n``\r` et l'« injection PTV » de
`service.rs:459` **disparaissent** (l'entrée passe désormais par le `InputMediator`,
pas par une écriture PTY préfixée d'un orchestrateur).
6. **Garde-fous d'orchestration** : timeout par tour (déjà), **plafond d'attente en
file** (déjà, `ASK_QUEUE_WAIT_CAP`), **détection de cycle** sur un graphe wait-for
(nouveau, dans le domaine — pur, testable) pour refuser une délégation
ré-entrante (A→B→A) avant deadlock.
---
## 1. Modèle de domaine
### 1.1 Nouvelles entités / VO
#### `ConversationId` (VO)
- `newtype(uuid::Uuid)`, calqué sur `TicketId`/`AgentId`. Immuable, non vide.
- **Implémenté** : `crates/domain/src/conversation.rs` (nouveau module, à exporter
dans `lib.rs` à côté de `mailbox`).
#### `ConversationParty` (VO, enum)
```text
ConversationParty =
| User // l'humain (une seule instance logique côté IdeA)
| Agent(AgentId) // un agent du projet
```
- Invariant : une `Conversation` relie **deux parties distinctes** (jamais
`Agent(x)↔Agent(x)`, jamais `User↔User`).
#### `Conversation` (entité)
```text
Conversation {
id: ConversationId,
left: ConversationParty,
right: ConversationParty,
session: ConversationSession, // état d'I/O (voir 1.2)
resumable_id: Option<String>, // session-id reprenable de la CLI (suspend = stocke)
}
```
- **Invariants** : `left != right` ; au plus **une** des deux parties est `User` ;
identité d'une conversation = la **paire non ordonnée** `{left, right}` pour un
agent donné (deux paires identiques ⇒ même conversation — clé de la matérialisation
paresseuse). Pur, I/O-free.
- **Matérialisation paresseuse** : une `Conversation` `Agent↔Agent` n'existe en
registre que s'il y a **au moins une tâche** ; suspendue, elle ne garde que
`resumable_id` (pas de session vivante). C'est une **règle du `ConversationRegistry`**
(application), pas un champ persistant lourd.
#### `ConversationSession` (VO, enum — l'état d'I/O du fil)
```text
ConversationSession =
| Dormant // jamais lancée, ou suspendue (resumable_id seul)
| Live { handle_ref: SessionRef } // un flux d'I/O vivant (PTY ou structuré)
```
- `SessionRef` = abstraction d'un handle de session (référence vers une `TerminalSession`
existante, cf. `domain/src/terminal.rs`). Le domaine ne tient **pas** le PTY (infra).
#### `Task` / `Ticket` (extension de l'existant)
- `Ticket` (`domain/src/mailbox.rs`) est **étendu** pour porter **l'origine** et la
**conversation cible** :
```text
Ticket {
id: TicketId, // existant
source: InputSource, // NOUVEAU : Human | Agent(AgentId)
conversation: ConversationId, // NOUVEAU : le fil dans lequel la tâche entre
requester: String, // existant (label d'affichage du préfixe)
task: String, // existant
}
```
- `InputSource` (VO, enum) : `Human | Agent(AgentId)`. Remplace l'actuel
`requester: String` libre comme **source de vérité** (le `String` reste un label
d'affichage dérivé). Permet de **propager l'identité du demandeur** (D) et
d'alimenter le graphe wait-for (détection de cycle).
- **Compat** : `Ticket::new` garde sa signature ; on ajoute `Ticket::from_human(...)`
et `Ticket::from_agent(source, conversation, ...)` (Open/Closed, pas de breaking).
#### File FIFO + état occupé/libre (VO)
- `AgentInbox` (concept porté par le port `InputMediator`, pas une entité persistée) :
**une file FIFO par `AgentId`**, **une tâche en cours à la fois**.
- `AgentBusyState` (VO, enum) : `Idle | Busy { ticket: TicketId, since_ms: u64 }`.
Dérivé, publié au front. Invariant : un agent passe à `Busy` **à l'enqueue qui
démarre un tour** ; revient `Idle` sur **retour-de-prompt** OU **signal explicite**
(cf. §6) ; **en cas de doute, reste `Busy`** mais la file **continue d'accepter**
(forward, jamais bloquer l'émetteur).
#### `WaitForGraph` (VO pur — détection de cycle)
- `domain/src/conversation.rs` : structure pure `wait_edges: Vec<(AgentId, AgentId)>`
(« A attend B »). Fonction pure `would_cycle(graph, from, to) -> bool`.
- Invariant : une `AskAgent` de `A` vers `B` est **refusée** (`MailboxError`/`AppError`
typé) si elle crée un cycle dans le graphe d'attente (A→B alors que B→…→A).
100 % testable sans I/O.
### 1.2 Invariants transverses
- **1 session vivante / conversation** (remplace « 1 / agent »). `session_for(conversation)`
est déterministe ; `sessions_for_agent(agent)` peut renvoyer N (une par fil actif).
- **1 tâche traitée à la fois / agent** : l'`InputMediator` sérialise l'entrée. Deux
fils d'un même agent partagent **la même file d'entrée** (le process CLI sous-jacent
est unique — « 1 agent = 1 employé »). *Conséquence assumée : un agent occupé par
son fil User retarde une délégation entrante — c'est voulu (un employé, une tâche).*
- **Séparation stricte des contextes** : écrire dans la conversation `A↔B` ne touche
jamais `User↔B`. Garanti par le fait que la session reprise (`resumable_id`) est
**par conversation**, pas par agent.
---
## 2. Ports (traits domaine)
> Signatures **conceptuelles**. « Consommé par » = application ; « Implémenté par » = infra/app-tauri.
### `ConversationRegistry` (NOUVEAU — domaine, `conversation.rs`)
- **Rôle** : résoudre/ouvrir paresseusement une conversation pour une paire, tenir son
`session`/`resumable_id`, suspendre/reprendre.
```rust
trait ConversationRegistry: Send + Sync {
/// Get-or-create paresseux : retourne le fil de la paire {a,b}, en l'ouvrant
/// (Dormant) s'il n'existait pas. Pur registre — n'ouvre AUCUNE session.
fn resolve(&self, a: ConversationParty, b: ConversationParty) -> Conversation;
/// Marque une conversation Live avec la session donnée.
fn bind_session(&self, id: ConversationId, session: SessionRef);
/// Suspend : passe Dormant, conserve le resumable_id rendu par la CLI.
fn suspend(&self, id: ConversationId, resumable_id: Option<String>);
fn get(&self, id: ConversationId) -> Option<Conversation>;
}
```
- **Consommé par** : `OrchestratorService` (au lieu de `session_for_agent` brut),
`LaunchAgent`, la reprise au redémarrage.
- **Implémenté par** : `InMemoryConversationRegistry` (infra) — `HashMap` + mutex sync,
jamais tenu en travers d'un `.await` (cf. `ask_locks` existant).
### `InputMediator` (NOUVEAU — domaine ou application ; **décision : domaine**, `input.rs`)
- **Rôle** : le point de convergence de **toutes** les entrées d'un agent (FIFO unique),
avec `enqueue` (Envoyer) et `preempt` (Interrompre) **distincts**, plus l'état busy.
```rust
trait InputMediator: Send + Sync {
/// Envoyer = enqueue : ajoute la tâche en queue FIFO de l'agent, retourne le
/// PendingReply à attendre (réutilise le type mailbox existant).
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply;
/// Interrompre = préempte : signale au tour en cours de s'arrêter (Échap/stop).
/// N'est PAS un enqueue ; ne corrèle aucun ticket.
fn preempt(&self, agent: AgentId);
/// Marque l'agent libre (retour-de-prompt ou signal explicite) ⇒ avance la file.
fn mark_idle(&self, agent: AgentId);
fn busy_state(&self, agent: AgentId) -> AgentBusyState;
}
```
- **Décision frontière** : `InputMediator` **absorbe** `AgentMailbox`. Le mailbox
existant devient le **moteur de corrélation par ticket** *interne* à
l'implémentation du `InputMediator` (l'`InMemoryMailbox` est réutilisé tel quel, sa
FIFO + `oneshot` sont exactement ce qu'il faut). On **n'a donc pas** deux files
concurrentes : `ask_locks` (verrou de tour) + `InMemoryMailbox` (slots de réponse)
sont unifiés derrière ce port. *(Voir §5 pour le chemin de migration.)*
- **Consommé par** : `OrchestratorService::ask_agent` (source = `Agent`), et le
**nouveau** use case `SubmitHumanInput` (source = `Human`).
- **Implémenté par** : `MediatedInbox` (infra) composant `InMemoryMailbox` + le
registre de verrous de tour + l'état busy.
### `FileGuard` (NOUVEAU — domaine, `fileguard.rs`)
- **Rôle** : verrou **lecteurs/écrivain par fichier** sur le périmètre **borné**
(contexte `.md` + mémoire). N lecteurs OU 1 écrivain ; mono-écrivain pour le
contexte global (l'orchestrateur).
```rust
enum GuardedResource { // VO — le périmètre borné, fermé
AgentContext(AgentId),
ProjectContext, // mono-écrivain : orchestrateur uniquement
Memory(MemorySlug),
}
trait FileGuard: Send + Sync {
async fn acquire_read(&self, who: ConversationParty, res: GuardedResource)
-> Result<ReadLease, GuardError>;
async fn acquire_write(&self, who: ConversationParty, res: GuardedResource)
-> Result<WriteLease, GuardError>;
}
```
- `ReadLease`/`WriteLease` = gardes RAII (libèrent à la fin de portée). `GuardError`
typé : `Busy` (attendre), `Forbidden` (un agent ≠ orchestrateur veut écrire
`ProjectContext` ⇒ refus, doit *proposer*).
- **Invariant clé** : toute lecture/écriture des ressources gardées **transite par ce
port** ; l'accès fs brut à ces chemins est retiré aux agents (cf. §3 outils MCP).
- **Consommé par** : `UpdateAgentContext`, `MemoryStore`-consumers, les nouveaux
use cases `ReadContext`/`ProposeContext`/`ReadMemory`/`WriteMemory`.
- **Implémenté par** : `RwFileGuard` (infra) — `HashMap<GuardedResource, RwLock-like>`
(tokio `RwLock` ou sémaphore), + la règle mono-écrivain pour `ProjectContext`.
### `AgentMailbox` (existant — **conservé**, statut révisé)
- Reste le **contrat de rendez-vous par ticket** (corrélation **par `TicketId`**, voir
§3.3 — on **abandonne** la corrélation purement positionnelle « tête de file » dès
qu'un agent peut avoir plusieurs fils). Devient un **détail d'implémentation** du
`InputMediator` ; n'est plus injecté seul dans `OrchestratorService`.
### Ports inchangés réutilisés
- `PtyPort` (écriture du tour dans le PTY = désormais le **seul** chemin d'écriture,
piloté par le `InputMediator`, plus par `ask_agent` directement).
- `ProfileStore` (porte le **motif de retour-de-prompt** par profil, §6).
- `EventBus` (publie `AgentBusyChanged`, `AgentReplied`).
---
## 3. Adapters (infra) + outils MCP
### 3.1 Adapters
| Port | Adapter | Notes |
|---|---|---|
| `ConversationRegistry` | `InMemoryConversationRegistry` | `HashMap<ConversationId, Conversation>` + index paire→id ; mutex sync. |
| `InputMediator` | `MediatedInbox` | compose `InMemoryMailbox` (existant) + verrous de tour + état busy ; publie `AgentBusyChanged`. |
| `FileGuard` | `RwFileGuard` | `RwLock` par `GuardedResource` ; règle mono-écrivain `ProjectContext`. |
| `AgentMailbox` | `InMemoryMailbox` | **inchangé** (réutilisé sous `MediatedInbox`). |
### 3.2 Nouveaux outils MCP (`infrastructure/src/orchestrator/mcp/tools.rs`)
Ajouts **purement additifs** au `catalogue()` (Open/Closed — le dispatch reste intact) :
- **`idea_context_read { target? }`** → action wire `context.read`
`OrchestratorCommand::ReadContext { target }`. `target` absent = le contexte **global
projet** ; sinon le `.md` d'un agent. Passe par `FileGuard::acquire_read`.
- **`idea_context_propose { target?, content }`** → `context.propose`
`OrchestratorCommand::ProposeContext`. Pour un agent : écriture directe sous verrou
écrivain. Pour le **global** : ce n'est **pas** une écriture, c'est une **proposition**
(déposée pour validation par l'orchestrateur/UI ; `FileGuard` refuse l'écriture
directe avec `Forbidden`).
- **`idea_memory_read { slug? }`** → `memory.read``ReadMemory` (sous `FileGuard`).
- **`idea_memory_write { slug, content }`** → `memory.write``WriteMemory` (verrou
écrivain ; mémoire = partagée projet, cf. `shared-project-memory`).
Chaque outil suit le **patron existant** : `map_tool_call` construit un
`OrchestratorRequest`, `validate()` reste l'**unique autorité** de validation, le
`requester` du handshake porte l'identité (`ConversationParty::Agent`).
### 3.3 Corrélation `idea_reply` **par ticket** (D)
- **Changement** : aujourd'hui `idea_reply` corrèle **positionnellement** (tête de la
file de l'émetteur — `mailbox.resolve(from, result)`). Dès qu'un agent peut avoir
**plusieurs fils**, la tête de « sa » file est ambiguë.
- **Décision** : le préfixe injecté dans le PTY (`[IdeA · tâche de A · ticket <id>]`)
porte **déjà** le `ticket_id`. On expose un champ **optionnel** `ticket` au schéma de
`idea_reply` (`{ result, ticket? }`) ; quand présent, `resolve` corrèle **par
`TicketId`** (déterministe, multi-fil) ; absent, on **retombe** sur la tête de file
(compat agents simples, mono-fil). Le préfixe doit donc **demander à l'agent de
renvoyer le `ticket`** (mise à jour de la description outil + protocole §B-5
existant). `AgentMailbox::resolve` gagne une variante `resolve_ticket(agent,
ticket_id, result)`.
---
## 4. Frontière front : vue de sortie (xterm inchangé) / entrée médiée
### 4.1 État actuel à modifier
`frontend/src/features/terminals/TerminalView.tsx` câble aujourd'hui **directement**
les frappes au PTY :
```ts
const onKey = term.onData((data) => {
if (handle) void handle.write(encoder.encode(data)); // ← chemin à couper
});
```
C'est **exactement** le couplage que le Modèle B retire.
### 4.2 Décision frontend
1. **xterm reste la vue de sortie brute, INCHANGÉE** : `onData (PTY) → term.write`
conservé tel quel. **Interdiction** de ressusciter `AgentChatView` (déjà supprimé
dans le diff courant — ne pas le réintroduire).
2. **`term.onData` (frappes) n'écrit plus dans le PTY** pour une cellule **agent**.
Deux modes :
- **Cellule terminal simple (non-agent)** : comportement actuel conservé (écriture
directe — pas de médiation, c'est un shell brut).
- **Cellule agent** : les frappes vont dans un **champ de saisie géré par IdeA**
(composant `MediatedInput`, rendu **sous** le terminal), pas dans le PTY. xterm
passe en lecture seule pour l'entrée (sortie toujours live).
3. **Nouveau port UI `InputGateway`** (`frontend/src/ports/index.ts`) :
```ts
interface InputGateway {
submit(projectId: string, agentId: string, text: string): Promise<void>; // Envoyer = enqueue
interrupt(projectId: string, agentId: string): Promise<void>; // Interrompre = preempt
}
```
Adapter Tauri : `invoke("submit_agent_input", …)` / `invoke("interrupt_agent", …)`
(nouvelles commands app-tauri → `SubmitHumanInput` / `preempt`). Mock pour tests.
4. **Occupé/libre remonte par event** : un `DomainEvent::AgentBusyChanged { agent_id,
busy }` relayé en event Tauri (pas un Channel haute-fréquence — événement discret).
Le `MediatedInput` désactive « Envoyer » pendant `Busy` mais **autorise toujours
l'enqueue** (le bouton enfile derrière ; jamais bloqué — fallback « forward »), et
active « Interrompre ». Le front **ne parse jamais** la sortie pour deviner l'état.
### 4.3 Composants/state touchés
- `features/terminals/TerminalView.tsx` : brancher le mode agent (entrée détournée).
- `features/terminals/MediatedInput.tsx` (**nouveau**) : champ + boutons Envoyer/Interrompre.
- `features/layout/LayoutGrid.tsx` : déjà route vers `TerminalView` ; ajoute le
`MediatedInput` sous le terminal quand `agent != null`.
- `ports/index.ts` + `adapters/agent.ts` (ou nouvel `adapters/input.ts`) + mock.
- state : un store léger `agentBusy: Record<agentId, boolean>` alimenté par l'event.
---
## 5. Impact sur le code existant
### 5.1 Supprimé / retiré
- **L'écriture PTY préfixée par `ask_agent`** (`service.rs` ~459 :
`pty.write(&handle, "[IdeA · tâche …]\n")`) **n'est plus le chemin d'entrée**. La
tâche déléguée entre désormais par `InputMediator::enqueue` (qui, dans son impl,
écrira la ligne dans le PTY — mais **sérialisée derrière l'entrée humaine** du même
agent, ce qui n'était pas le cas avant). → la logique d'écriture **déménage** de
`ask_agent` vers l'impl `MediatedInbox`.
- **Band-aid `\n`→`\r`** : abandonné (le « mode injection PTV » disparaît). Plus de
réécriture de fin de ligne ad hoc.
- **`AgentChatView`** (front) : déjà supprimé dans le diff courant — **rester** supprimé.
### 5.2 Modifié
- **`OrchestratorService`** : ne reçoit plus `with_mailbox(mailbox, pty)` séparément
mais `with_input_mediator(Arc<dyn InputMediator>)` + `with_conversations(Arc<dyn
ConversationRegistry>)`. `ask_agent` devient : résoudre la **conversation A↔B**
(paresseux), vérifier le **graphe wait-for** (refus si cycle), `enqueue` la tâche
(source = `Agent`), `await PendingReply` borné. `reply` corrèle **par ticket** (§3.3).
`ensure_live_pty` reste, mais branché sur `session_for(conversation)` au lieu de
`session_for_agent`.
- **`session_for_agent`** (registre `terminal/registry.rs`) : devient
`session_for(conversation_id)` ; `sessions_for_agent` (pluriel) ajouté. Lève
l'ambiguïté `session-registry-agent-ambiguity` **par construction** (la clé est la
conversation, pas l'agent).
- **`bind_endpoint`** (`state.rs`) : **déjà** `reclaim_name(true)` ⇒ unlink du cadavre.
**Action = verrouiller par un test** (ouvrir/fermer/SIGKILL simulé/rebind sans
`EADDRINUSE`). Pas de changement de code attendu, sauf si le test révèle un trou.
- **`idea_reply`** (tools.rs / orchestrator.rs / server.rs) : champ `ticket?` ajouté,
`Reply { from, ticket: Option<TicketId>, result }`, `map_tool_call` le propage.
- **`Ticket`** (`mailbox.rs`) : champs `source: InputSource`, `conversation:
ConversationId` ajoutés (constructeurs additifs).
### 5.3 Ajouté
- Domaine : `conversation.rs` (`ConversationId`, `Conversation`, `ConversationParty`,
`ConversationSession`, `WaitForGraph`), `input.rs` (`InputMediator`, `InputSource`,
`AgentBusyState`), `fileguard.rs` (`FileGuard`, `GuardedResource`, leases).
- Application : use cases `SubmitHumanInput`, `ReadContext`/`ProposeContext`,
`ReadMemory`/`WriteMemory` ; détection de cycle câblée dans `ask_agent`.
- Infra : `InMemoryConversationRegistry`, `MediatedInbox`, `RwFileGuard` ; outils MCP
`idea_context_*` / `idea_memory_*`.
- app-tauri : commands `submit_agent_input`, `interrupt_agent` ; relais event
`AgentBusyChanged` ; câblage des nouveaux ports au composition root (`state.rs`).
- Front : `MediatedInput`, `InputGateway` + adapter + mock + store busy.
---
## 6. Détection occupé/libre
**Mécanisme retenu = double signal, OR, avec fallback sûr.**
| Signal | Source | Fiabilité |
|---|---|---|
| **Retour-de-prompt** | motif (regex/literal) déclaré dans le **profil CLI** (`AgentProfile`, nouveau champ `prompt_ready_pattern: Option<String>`), détecté sur le flux PTY par l'impl `MediatedInbox` | bon pour un shell/CLI au prompt stable ; faillible (motif dans la sortie) |
| **Signal explicite** | l'agent appelle `idea_reply` (fin d'une délégation) **ou** un signal de fin-de-tour MCP | déterministe quand l'agent coopère |
- Transition `Busy → Idle` = **premier** des deux signaux qui arrive.
- **Fallback « en cas de doute → forwarder »** : si **aucun** signal n'est sûr (motif
absent du profil, agent muet), l'agent **reste marqué `Busy`** mais la file
**continue d'accepter** les `enqueue` ; un message entrant **n'est jamais rejeté**,
il patiente dans la FIFO. On ne « piège » donc jamais un message ; au pire il attend.
- **Garde-fou anti-blocage** : le timeout par tour (`ASK_AGENT_TIMEOUT`, existant)
retire le ticket de tête et **relâche** le tour même si aucun signal n'est venu ⇒
la file avance. L'agent reste vivant.
- Le motif vit **dans le profil** (donnée, pas code) ⇒ ajouter une CLI = éditer un
profil (Open/Closed, cohérent §9 CLAUDE.md).
---
## 7. Découpage en lots livrables (ordonnés par dépendance)
> Chaque lot = binôme dev/test. **B = DevBackend (Rust)**, **F = DevFrontend (TS/React)**.
> Chemin critique : C1 → C2 → C3 → C4. FileGuard (C6) et front (F1/F2) parallélisables.
### Bloc Conversation (cœur — backend)
| Lot | Côté | Périmètre | Tests |
|---|---|---|---|
| **C1** | B (domaine) | `conversation.rs` : `ConversationId`, `ConversationParty`, `Conversation`, `ConversationSession`, `WaitForGraph::would_cycle`. `input.rs` : `InputSource`, `AgentBusyState`. Extension `Ticket` (source+conversation, ctors additifs). | invariants paire (left≠right, ≤1 User) ; identité = paire non ordonnée ; `would_cycle` (A→B→A refusé, A→B→C ok) ; ticket porte source+conversation. Pur, sans I/O. |
| **C2** | B (domaine+infra) | Ports `ConversationRegistry` + `InputMediator` (domaine) ; adapters `InMemoryConversationRegistry` + `MediatedInbox` (compose `InMemoryMailbox` existant). | resolve paresseux (même paire ⇒ même id) ; enqueue→PendingReply ; preempt distinct d'enqueue ; busy_state transitions ; 2 enqueue même agent sérialisés ; agents ≠ parallèles. |
| **C3** | B (application) | `OrchestratorService` : `with_input_mediator`+`with_conversations` ; `ask_agent` réécrit (résout conversation A↔B, garde wait-for, enqueue source=Agent, await) ; `reply` par ticket. `session_for(conversation)`. Retrait écriture PTY directe + band-aid `\r`. | ask A→B route dans la bonne conversation (pas User↔B) ; cycle A→B→A ⇒ erreur typée avant deadlock ; reply corrèle par ticket (multi-fil) ; reply sans ticket = fallback tête ; timeout libère file, cible vivante. |
| **C4** | B (application+app-tauri) | Use case `SubmitHumanInput` (source=Human) + commands `submit_agent_input`/`interrupt_agent` ; event `AgentBusyChanged` relayé. Câblage composition root (`state.rs`). | submit humain enfile dans la **même** FIFO que les délégations ; interrupt = preempt (pas enqueue) ; busy event émis aux bons moments ; câblage : un ask et un submit concurrents sur A sérialisent. |
### Bloc détection occupé/libre (backend)
| Lot | Côté | Périmètre | Tests |
|---|---|---|---|
| **C5** | B (domaine+infra) | Champ profil `prompt_ready_pattern` ; détection retour-de-prompt dans `MediatedInbox` ; OR avec signal explicite ; fallback « reste Busy mais accepte ». | motif détecté ⇒ Idle ; idea_reply ⇒ Idle ; ni l'un ni l'autre ⇒ Busy mais enqueue accepté ; timeout ⇒ file avance. |
### Bloc FileGuard (backend — parallélisable après C1)
| Lot | Côté | Périmètre | Tests |
|---|---|---|---|
| **C6** | B (domaine+infra) | `fileguard.rs` (port + `GuardedResource` + leases) ; `RwFileGuard` ; règle mono-écrivain `ProjectContext`. | N lecteurs concurrents OK ; 1 écrivain exclusif ; agent≠orchestrateur écrit ProjectContext ⇒ `Forbidden` ; lease RAII libère. |
| **C7** | B (application+infra MCP) | Use cases `ReadContext`/`ProposeContext`/`ReadMemory`/`WriteMemory` sous FileGuard ; outils MCP `idea_context_*`/`idea_memory_*` ; retrait accès fs brut de ces chemins. | map_tool_call → command ; validate exige `content` ; propose global ≠ write direct ; lecture concurrente non bloquante ; écriture sérialisée. |
### Bloc frontend
| Lot | Côté | Périmètre | Tests (Vitest/RTL, gateways mock) |
|---|---|---|---|
| **F1** | F | `InputGateway` (port+adapter+mock) ; `MediatedInput` (Envoyer=submit / Interrompre=interrupt) ; store busy alimenté par event. | submit appelle gateway.submit ; interrupt appelle interrupt ; busy event désactive Envoyer (mais enqueue possible), active Interrompre. |
| **F2** | F | `TerminalView` mode agent : frappes → `MediatedInput` (plus le PTY) ; xterm reste sortie live INCHANGÉE pour le non-agent. `LayoutGrid` monte `MediatedInput` sous le terminal si `agent != null`. | cellule agent ⇒ onData ne write pas le PTY ; cellule simple ⇒ comportement actuel ; sortie PTY toujours peinte ; jamais d'AgentChatView. |
### Bloc durcissement
| Lot | Côté | Périmètre | Tests |
|---|---|---|---|
| **D1** | B (app-tauri) | Test de non-régression `bind_endpoint` : bind → drop (SIGKILL simulé : laisser le fichier socket) → rebind **sans** `EADDRINUSE`. Verrouille `reclaim_name(true)`. | rebind après cadavre OK ; idempotent ; pas de fuite de fichier après close. |
**Ordre recommandé** : **C1 → C2 → C3 → C4** (cœur), **C5** après C2, **C6 → C7**
en parallèle (après C1), **F1 → F2** dès que les commands C4 existent (mock avant),
**D1** isolé n'importe quand.
---
## 8. Stratégie de tests par couche
| Couche | Type | Comment |
|---|---|---|
| **domaine** (`conversation`, `input`, `fileguard`, `mailbox` étendu) | unitaires **purs**, sans I/O ni async là où possible | invariants de paire, `would_cycle`, transitions `AgentBusyState`, ctors `Ticket`. Déterministe. C'est là que vit la garantie « solide par construction ». |
| **application** (`OrchestratorService`, `SubmitHumanInput`, use cases FileGuard) | unitaires avec **ports mockés** (fakes manuels, façon `service.rs` actuel) | ask route la bonne conversation ; cycle refusé ; reply par ticket ; submit+ask sérialisés ; FileGuard mono-écrivain. **Aucun vrai PTY/fs/MCP.** |
| **infra** (`MediatedInbox`, `RwFileGuard`, `InMemoryConversationRegistry`, outils MCP) | intégration **ciblée** | FIFO réelle + `oneshot` ; RwLock concurrence ; `map_tool_call` round-trip ; `bind_endpoint` (D1). Réutilise les tests `InMemoryMailbox` existants. |
| **app-tauri** | commands ↔ use cases | `submit_agent_input`/`interrupt_agent` mappent bien ; event `AgentBusyChanged` émis ; câblage composition root cohérent (endpoint partagé). |
| **frontend** (`MediatedInput`, `TerminalView`) | Vitest + RTL, **gateways mock** | entrée détournée hors PTY ; busy désactive Envoyer sans bloquer enqueue ; xterm sortie inchangée ; **sans backend**. |
---
## 9. Risques / points ouverts
1. **Fiabilité de la détection retour-de-prompt** (C5) — le plus dur. Un motif dans la
sortie d'un agent peut **faussement** signaler Idle (libère trop tôt) ou ne jamais
matcher (reste Busy). *Mitigation* : OR avec le signal explicite `idea_reply` +
fallback « reste Busy mais accepte » + timeout par tour. *Reste ouvert* : faut-il un
« heartbeat » MCP de fin-de-tour côté CLI ? (hors périmètre immédiat, Claude-only).
2. **Suspension/reprise de session par conversation** (`resumable_id`) — un agent à N
fils doit reprendre **le bon** session-id par fil au redémarrage. Dépend du
`session{assignFlag,resumeFlag}` du profil (cf. `conversation-resume-architecture`).
*Ouvert* : capacité réelle des CLI à tenir N conversations resumables simultanées
pour un même process « 1 agent = 1 employé » — possible conflit entre « N fils » et
« 1 process ». **Décision de cadrage** : **1 process/agent**, les fils **partagent
la file d'entrée** (sérialisés) ; le `resumable_id` par conversation sert surtout à
la **reprise au redémarrage**, pas à du vrai parallélisme intra-process.
3. **Deadlock & détection de cycle** (`WaitForGraph`) — couvre A→B→A directs et
transitifs, mais le graphe doit être **alimenté en temps réel** (arête posée à
l'enqueue, retirée au reply/timeout). *Risque* : arête fantôme si un reply se perd
⇒ faux positif de cycle. *Mitigation* : retrait d'arête garanti par le RAII du tour
(comme `_turn` aujourd'hui) + timeout.
4. **Corrélation par ticket vs agents « simples »** — un agent qui ne renvoie pas le
`ticket` dans `idea_reply` retombe sur la corrélation positionnelle (tête de file),
ambiguë en multi-fil. *Mitigation* : protocole §B-5 (description outil) **insiste**
sur le renvoi du ticket ; mono-fil reste correct sans. *Ouvert* : forcer le ticket
requis casserait des agents simples — on garde optionnel.
5. **Périmètre FileGuard contournable** — tant que l'agent garde un shell brut (PTY),
il peut écrire les `.md`/mémoire **par le filesystem** malgré le verrou MCP. Le
verrou n'est étanche que si l'accès fs à ces chemins est **réellement** retiré
(sandbox, cf. `agent-permissions-architecture` / Landlock). *Ouvert* : sans sandbox
OS, le `FileGuard` est **coopératif** (protège des collisions IdeA↔IdeA, pas d'un
agent qui contourne). À acter : FileGuard = correction des collisions **dans le
chemin IdeA** d'abord ; étanchéité réelle = lot sandbox ultérieur.
6. **Migration `AgentMailbox` → `InputMediator`** — risque de double-file transitoire.
*Mitigation* : `MediatedInbox` **enveloppe** `InMemoryMailbox` (pas de réécriture),
`OrchestratorService` bascule d'un `with_mailbox` vers `with_input_mediator` en un
lot (C2→C3), tests existants `InMemoryMailbox` conservés verts.
---
*Document maintenu par l'Agent Architecture — cadrage « conversation par paire »,
base des lots C1→C7 / F1→F2 / D1 avant tout code.*

View File

@ -0,0 +1,48 @@
# Design — Option 1 « Terminal + MCP » (orchestration inter-agents)
> Décision produit arbitrée (2026-06-11). Remplace la vue chat structurée par le
> terminal natif + délégation inter-agents par outils MCP. Source : agent Architecte.
> Statut : **design validé, dev NON commencé** (limite de session atteinte le 2026-06-11,
> reset 3:40am Europe/Paris). Reprendre par les lots backend B-0→B-5 et frontend F-1.
## Objectif
- **Vue humaine = terminal brut natif** (PTY interactif). Réflexion live + Échap = natifs CLI, zéro parsing par modèle. On abandonne `AgentChatView`/stream-json comme vue.
- **Délégation cross-model via MCP** : `idea_ask_agent(target, task)` bloquant → la cible traite quand libre (FIFO) → rend son résultat via NOUVEL outil `idea_reply(result)` → IdeA débloque l'appelant. Fin-de-tour = signal MCP explicite.
- Principes : 1 agent = 1 employé (1 process/session, input FIFO) ; hexagonal + SOLID stricts ; plus aucun `parse_event` requis pour vue ni orchestration.
## Découvertes clés de l'architecte (état réel du code)
1. La **file FIFO existe déjà** : `OrchestratorService` (`crates/application/src/orchestrator/service.rs`) a `ask_locks: Mutex<HashMap<AgentId, Arc<AsyncMutex<()>>>>` + `ask_lock_for()` + `ASK_QUEUE_WAIT_CAP` (600s) + `ASK_AGENT_TIMEOUT` (300s). On la formalise en port `AgentMailbox` (pour porter un `oneshot` de réponse).
2. `idea_ask_agent``agent.message``OrchestratorCommand::AskAgent{target_agent, task}` **déjà câblé** (mcp/tools.rs, domain/orchestrator.rs, service.rs). On réimplémente le **corps** de `ask_agent()`.
3. Aujourd'hui `ask_agent` **exige une session structurée** et renvoie `AppError::Invalid` si la cible est en PTY brut (service.rs ~400-410). **Inverser cette branche** : PTY vivant = canal normal.
4. Routage structuré dans `crates/application/src/agent/lifecycle.rs` (`LaunchAgent` ~1100). Levier de bascule : **ne plus injecter la fabrique structurée au composition root** (`crates/app-tauri/src/state.rs`, `with_structured`).
5. `apply_mcp_config` (lifecycle.rs ~1391) écrit déjà `.mcp.json` + `--mcp-config` AVANT le spawn, **chemin PTY inclus** → la CLI PTY a déjà le serveur MCP IdeA (à vérifier par test B-0). Vigilance : `ensure_mcp_server` doit piloter `McpServer::serve` sur le loopback.
6. `idea_reply` n'existe nulle part : seul vrai ajout de surface.
## Lots BACKEND (Rust — agent dev backend) ; NE PAS faire B-6 (nettoyage) avant coordination
- **B-0** Prérequis transport MCP : garantir CLI PTY reçoit `--mcp-config <path>` (endpoint/project/requester) + `serve` piloté loopback. Test : CLI factice PTY appelle `idea_list_agents`, reçoit réponse.
- **B-1** Port `AgentMailbox` + `InMemoryMailbox`. Domaine pur (`crates/domain/src/mailbox.rs` ou ports.rs) : trait + `Ticket{id,requester,task}`, `TicketId`, `MailboxError`. Infra (`crates/infrastructure/src/mailbox/`) : `HashMap<AgentId, VecDeque<(Ticket, oneshot::Sender<String>)>>` + mutex ; `enqueue` rend `PendingReply` (sur `oneshot::Receiver`). Tests : FIFO ; `resolve` réveille le bon pending ; 2 ask même cible sérialisés ; cibles ≠ non bloquants ; timeout retire ticket de tête.
- **B-2** Bascule routage : tous en PTY. `state.rs` : retirer `with_structured` de `LaunchAgent`/`OrchestratorService`/`ChangeAgentProfile`. Tests : profil Claude → PTY ; DTO renvoie `CellKind::Pty`. Ne pas supprimer `launch_structured` (mort-code, nettoyage ultérieur).
- **B-3** Réimplémenter `ask_agent` : résoudre id → `mailbox.enqueue` → ticket en tête → garantir cible vivante PTY (sinon LaunchAgent PTY bg) → `PtyPort::write` préfixe `[IdeA · tâche de {A} · ticket {id}] {task}\n``await PendingReply` borné `ASK_AGENT_TIMEOUT`. PTY vivant = normal. Timeout : garder agent vivant, retirer ticket de tête. Publier `AgentReplied`. Injecter `Arc<dyn AgentMailbox>` + `Arc<dyn PtyPort>`. Tests : injection bon handle ; agent mort relancé ; timeout libère file ; AgentReplied.
- **B-4** Outil/action `idea_reply` : `ToolDef idea_reply` (schéma `{result:string}` seul, pas de ticket_id exposé), action wire `agent.reply`, `OrchestratorCommand::Reply{from:AgentId, result}`, `validate`, `map_tool_call` (passe `requester` du handshake comme `from`), bras dispatch → `mailbox.resolve(from, result)`. Corrélation implicite : `idea_reply` résout le ticket en tête de la file de l'émetteur (identité via handshake, pas via id géré par le modèle). `tool_returns_reply` : idea_reply = ACK sans inline. Tests : mapping ; validate exige result ; resolve corrèle tête ; reply sans ask = erreur typée (pas de panic).
- **B-5** Protocole délégation dans le contexte : injecter dans convention file (`apply_injection`) + description outil : « reçois `[IdeA · tâche …]` → traite → appelle IMPÉRATIVEMENT `idea_reply(result=…)` ; ne réponds jamais qu'en texte. » Test : convention file contient l'instruction.
## Lots FRONTEND (TS/React — agent dev frontend) ; NE PAS faire F-2 (suppression) avant coordination
- **F-1** Router toute cellule agent vers `TerminalView` (jamais `AgentChatView`) ; ré-attache PTY + scrollback OK. Backend renverra `cellKind:"pty"`. Lire `frontend/src/features/layout/LayoutGrid.tsx`, `features/chat/AgentChatView.tsx`, `TerminalView`, `adapters/agent.ts`, `ports/index.ts`, `domain/index.ts`. Laisser `AgentChatView` inerte (non monté), pas supprimé. Tests Vitest : agent rend `TerminalView`, jamais `AgentChatView` ; re-mount repeint pty.
## Ordre / dépendances
```
B-0 ─┬─ B-2 ─┬─ B-3 ─ B-4 ─ B-5
B-1 ─┘ └─ F-1
Nettoyage (B-6, F-2) en dernier, coordonné.
```
Chemin critique : B-0 → B-2 → B-3 → B-4 → B-5. B-1 ∥ B-0. F-1 dès B-2.
## Cohérence
Domaine sans I/O (port + entités pures) ; oneshot/PTY/MCP = infra ; application via ports. Open/Closed (idea_reply = ajout, dispatch intact) ; Liskov (Claude/Codex identiques derrière PTY+MCP) ; 1 process/agent préservé.
## Fichiers à toucher
- Domaine : `mailbox.rs` (nouveau) / `ports.rs` ; `orchestrator.rs` (variante `Reply` + action `agent.reply`).
- Application : `orchestrator/service.rs` (ask_agent + reply + injection ports) ; `agent/structured.rs` (supprimé au nettoyage) ; `agent/lifecycle.rs` (routage).
- Infra : `mailbox/` (nouveau) ; `orchestrator/mcp/tools.rs` (idea_reply) ; `orchestrator/mcp/server.rs` (passer requester).
- app-tauri : `state.rs` (retrait with_structured + injection mailbox + ensure_mcp_server) ; `commands.rs`/`dto.rs` (nettoyage ultérieur).
- Frontend : `features/layout/LayoutGrid.tsx` (routage TerminalView) ; `features/chat/*` (nettoyage ultérieur).

View File

@ -0,0 +1,35 @@
# Protocole — Validation réelle de la conversation inter-agents via IdeA
> À exécuter depuis la **nouvelle AppImage** (build 2026-06-10 18:23, contenant R0+A0+M5,
> commits `37e7274` / `6ca519b` / `cf89b3b`). L'ancienne image (testée avant) ne contenait
> pas ce code et a renvoyé l'erreur attendue « agent Ask pas pilotable en mode structuré ».
## But
Prouver en conditions réelles qu'un agent (Main/Claude) peut **demander** une tâche à un autre
agent (Ask/Codex) **via IdeA** et **recevoir sa réponse inline** — pas seulement par tests à fakes.
## Pré-requis pour que le `ask` aboutisse
- La cible (**Ask**) doit être pilotée en **mode structuré** (profil Codex avec adapter structuré).
Le menu de sélection d'agent ne propose normalement que des profils structurés (Claude/Codex).
- Ask **ne doit pas** déjà tourner comme **terminal brut** (PTY) dans une cellule : sinon
`ask_agent` refuse (invariant « 1 session/agent », cible PTY brut = pas de canal de réponse).
- Le plus simple : **laisser Ask éteint** et laisser `ask_agent` le **lancer lui-même** en
structuré (sémantique : cible morte ⇒ launch structuré background ⇒ send ⇒ Final).
## Procédure (protocole fichier, identique au test précédent)
1. Déposer `.ideai/requests/main/<nom>.json` :
```json
{ "type": "agent.message", "requestedBy": "Main", "targetAgent": "Ask",
"task": "Petite recherche, pas de code : résume en 3 points ce que fait le module
crates/infrastructure/src/orchestrator/mcp/ et liste les outils idea_*." }
```
2. Attendre l'apparition de `.ideai/requests/main/<nom>.json.response.json`.
## Succès attendu
`{ "ok": true, "action": "agent.message", "reply": "<réponse de Codex>" }` — le champ **`reply`**
porte le contenu produit par Ask. (Échec précédent = `ok:false` + erreur PTY brut.)
## Voie native MCP (bonus)
Le bind transport S-MCP (M5) est livré : un agent lancé avec un profil MCP voit les outils
`idea_*` (dont `idea_ask_agent`) et le résultat revient inline. À valider quand un profil MCP
est branché sur Claude/Codex. Voir `.ideai/briefs/orchestration-v5-transport-bind-cadrage.md`.