# 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, // 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); fn get(&self, id: ConversationId) -> Option; } ``` - **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; async fn acquire_write(&self, who: ConversationParty, res: GuardedResource) -> Result; } ``` - `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` (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` + 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 ]`) 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; // Envoyer = enqueue interrupt(projectId: string, agentId: string): Promise; // 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` 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)` + `with_conversations(Arc)`. `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, 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`), 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.*