Files
IdeA/.ideai/briefs/conversation-pair-cadrage.md
Blomios eca2ba95c4 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>
2026-06-12 07:33:04 +02:00

32 KiB

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)

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é)

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)

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 :
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.
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.
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).
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.readOrchestratorCommand::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.proposeOrchestratorCommand::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.readReadMemory (sous FileGuard).
  • idea_memory_write { slug, content }memory.writeWriteMemory (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 :

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) :
    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 AgentMailboxInputMediator — 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.