Files
IdeA/.ideai/briefs/orchestration-v5-transport-bind-cadrage.md
Blomios 6ca519b815 feat(agent): robustesse routage ask — fix registre session + concurrence (R0+A0) — §14.3/§15
Stabilise le routage de `ask` avant le bind transport MCP (v5). Invariant
« 1 agent = 1 employé » durci ; un agent traite un tour à la fois.

- R0a garde LaunchAgent : lève AgentAlreadyRunning pour un lancement neuf
  ciblant un agent déjà vivant sur un autre node (PTY + structuré) ; rebind
  seulement même-node ou réattache explicite (conversation_id). Idem spawn_agent.
- R0b list_live_agents agrège PTY + structuré (LiveSessions) + dédup.
- R0c réconciliation des layouts.json à doublons à l'ouverture (host déterministe,
  idempotent) — corrige « une cellule reset au retour d'onglet ».
- R0d UI : option agent désactivée si vivant ailleurs + « aller à la cellule »,
  mapping AGENT_ALREADY_RUNNING.
- A0 sérialisation FIFO des tours par agent_id dans ask_agent (verrou tokio par
  agent ; agents différents en parallèle ; timeout tour 300s, cap attente 600s).

Cadrage : .ideai/briefs/orchestration-v5-transport-bind-cadrage.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 17:34:57 +02:00

25 KiB

Orchestration v5 — Bind transport S-MCP + fix registre session (cadrage)

Agent Architecture. Ce document tranche le dernier kilomètre de l'orchestration native : (1) le bind transport entre une CLI MCP réellement lancée et le serveur MCP par projet (verrou §S-MCP, resté ouvert depuis M3), et (2) le fix de robustesse du registre de session (mémoire session-registry-agent-ambiguity). Aucun code de production ici : décisions + contrats + découpage en lots testables.

État du terrain (lu, pas présumé) :

  • infrastructure/src/orchestrator/mcp/{server,transport,jsonrpc,tools}.rs : McpServer::serve(&mut transport) boucle ligne-à-ligne sur Transport::{recv,send} ; StdioTransport<R,W> (JSON Lines générique), MemoryTransport (tests). serve n'est jamais appelé en prod.
  • app-tauri/src/state.rs : ensure_mcp_server crée un McpServer par projet et le parke sur un signal d'arrêt (McpServerHandle::startlet _server = server; stop_rx.recv().await;). Aucun transport, aucun pair.
  • application/src/agent/lifecycle.rs : apply_mcp_config matérialise la conf MCP (ConfigFile/Flag/Env) dans le run dir isolé, après apply_injection, avant spawn/factory.start. mcp_server_declaration écrit un placeholder {"command":"idea","args":["mcp-server"],"transport":"stdio|socket"}.
  • application/src/terminal/registry.rs : TerminalSessions (PTY) + StructuredSessions (IA) + agrégateur LiveSessions. Invariant « 1 session vivante/agent ».
  • application/src/error.rs : AppError::AgentAlreadyRunning { agent_id, node_id } + code AGENT_ALREADY_RUNNINGdéfini mais jamais levé (le garde de LaunchAgent rebind/idempotent au lieu d'échouer).
  • app-tauri/src/commands.rs::list_live_agents lit seulement terminal_sessions.live_agents()aveugle aux sessions structurées.

0. Synthèse exécutive (décisions tranchées)

  1. Transport S-MCP = stdio-spawn : la CLI spawn elle-même un process serveur MCP fourni par IdeA. Ce sous-process est un client de boucle de retour (loopback) vers le process Tauri, pas un second OrchestratorService. Justification : c'est le seul modèle réellement supporté par Claude Code / Codex (déclaration .mcp.json {command,args}), cross-OS sans port réseau (compatible AppImage / Windows / SSH-remote), et il résout le point dur « comment le process serveur retrouve le bon projet » par injection d'identité à l'args/env au LaunchAgent (le projet est connu à ce moment-là). Le socket est rejeté comme défaut (ports/permissions/cross-OS) mais gardé en TODO derrière le même trait Transport.

  2. Le binaire idea mcp-server est un sous-commande du binaire app-tauri existant (pas un nouveau crate/binaire à distribuer séparément) : main.rs route argv[1] == "mcp-server" vers un mode headless loopback qui lit stdin/stdout (JSON Lines = StdioTransport) et relaye chaque message JSON-RPC au process IdeA principal via un canal de loopback local (Unix socket / Windows named pipe par projet, créé par ensure_mcp_server, chemin passé en --endpoint). Le McpServer (qui tient l'OrchestratorService) vit dans le process Tauri ; le sous-process mcp-server n'est qu'un pont stdio↔loopback ultraléger. Un seul binaire à livrer (AppImage/setup.exe).

  3. Contrat conf↔serveur de bout en bout rendu cohérent : apply_mcp_config (M1) écrit une déclaration qui pointe exactement vers ce que ensure_mcp_server (M3) a mis à l'écoute — command = <exe IdeA>, args = ["mcp-server", "--endpoint", <loopback du projet>, "--project", <id>]. Fin du placeholder.

  4. Fix registre session = lot PRIORITAIRE et INDÉPENDANT du bind (peut/doit partir en premier) : l'invariant correct est « 1 session vivante par agent » (décision produit verrouillée, mémoire session-registry-agent-ambiguity : un agent est un singleton, pas N instances). Le fix n'invente pas d'identité par cellule ; il durcit l'invariant sur les deux registres et réconcilie les layouts.json à doublons. Trois trous concrets à boucher (cf. §3).

  5. Robustesse ask : cible morte ⇒ lancement structuré puis envoi ; cible PTY brut ⇒ Invalid explicite (déjà fait) ; cible occupée par un autre tour ⇒ sérialisation FIFO par agent (nouveau, §4) ; timeout 300 s ⇒ cible vivante, erreur typée (déjà fait). Deux ask simultanés sur la même cible ⇒ file, jamais d'entrelacement de tours.

  6. Frontières : le pilotage de serve vit dans l'adapter infra (McpServer + une boucle par connexion sur le loopback du projet), supervisé par McpServerHandle (app-tauri) qui ne fait qu'accepter les connexions et spawn une tâche serve par pair. Aucune logique applicative ne fuit : le sous-process mcp-server ne connaît que des octets JSON-RPC ; OrchestratorService ignore tout du transport.


1. Décision 1 — Modèle de transport S-MCP

1.1 Le point dur, posé proprement

Une CLI MCP (Claude Code, Codex) attend une déclaration de serveur de forme { "command": "...", "args": [...] } : à l'initialize, elle spawn ce process et parle JSON-RPC sur son stdin/stdout. Donc le « serveur » qu'elle voit est un process enfant à elle, distinct du process Tauri. C'est le cœur du problème : ce process enfant n'a pas l'Arc<OrchestratorService> du projet (use cases, registres de sessions, event bus vivent dans Tauri).

Deux familles de solutions :

stdio-spawn (RETENU) socket (rejeté en défaut)
Ce que la CLI spawn un pont idea mcp-server (stdio↔loopback) rien — elle se connecte à un serveur déjà à l'écoute
Où vit l'OrchestratorService process Tauri (le pont relaie) process Tauri (écoute directe)
Cross-OS / AppImage pipes stdio + loopback local (Unix socket / named pipe) ⚠️ port TCP (firewall/permissions) ou socket — déclaration CLI variable
Retrouver le bon projet --endpoint/--project à l'args, fixés au LaunchAgent (projet connu) l'adresse encode le projet, mais la CLI doit la connaître
Compat Claude/Codex {command,args} natif support socket inégal selon CLI/version
Cycle de vie la CLI possède le pont (meurt avec elle) serveur long-vécu, connexions multiplexées

1.2 Pourquoi stdio-spawn, malgré le sous-process en plus

  • Compatibilité réelle : {command,args} est le dénominateur commun des CLIs MCP. Le socket n'est pas universellement déclarable.
  • Cross-OS sans port réseau : le loopback local entre le pont et Tauri est un Unix domain socket (Linux/macOS) ou un named pipe (Windows) — déjà la techno la plus portable pour de l'IPC local, sans firewall ni permission réseau, donc AppImage-safe et SSH-remote-safe (le pont tourne côté machine de l'agent).
  • Résolution du point dur par injection d'identité : le projet est connu au moment du LaunchAgent (c'est lui qui écrit la conf MCP). On encode --endpoint <chemin loopback du projet> (+ --project <id> en garde-fou) dans les args de la déclaration. Le pont n'a rien à deviner : il se connecte à l'endpoint du bon projet. Le McpServer côté Tauri, lui, est déjà attaché à cet OrchestratorService/Project (créé par ensure_mcp_server).

1.3 Le pont idea mcp-server (sous-commande du binaire existant)

  • Pas de nouveau binaire distribué : main.rs détecte argv[1] == "mcp-server" avant d'initialiser Tauri/WebKit, et bascule en mode headless pont. Un seul exécutable livré (AppImage / setup.exe).
  • Rôle du pont : StdioTransport(stdin, stdout) côté CLI ; un client de loopback côté Tauri. Boucle : lire une ligne JSON-RPC de la CLI → l'écrire sur le loopback → lire la réponse du loopback → l'écrire sur stdout. Zéro logique métier : c'est un tube transparent. (Optionnellement, le pont peut directement porter le McpServer si l'OrchestratorService était accessible — il ne l'est pas inter-process — d'où le relais.)
  • Côté Tauri : ensure_mcp_server crée l'endpoint loopback du projet (socket/pipe), et McpServerHandle accepte les connexions ; chaque connexion (= un pont = un agent) ⇒ une tâche McpServer::serve(&mut conn_transport)conn_transport enveloppe le flux loopback. McpServer est déjà branché à l'OrchestratorService du projet.

1.4 Identité de l'appelant (lève le requester_id = "mcp" figé)

server.rs::publish_processed tague aujourd'hui requester_id: "mcp" (placeholder). Avec stdio-spawn, le pont connaît l'agent (le LaunchAgent peut injecter --requester <agent-id> dans les args de la déclaration, comme --project). Le pont passe cette identité dans la poignée de connexion (premier message de handshake loopback, hors JSON-RPC MCP), et McpServer::serve la porte dans son contexte de connexion ⇒ OrchestratorRequestProcessed.requester_id devient l'agent réel. Observabilité UI exacte (qui a délégué à qui).

1.5 Socket = TODO derrière le même trait

Le trait Transport (jsonrpc.rs) isole déjà le serveur du transport. Un SocketTransport (TCP/HTTP-stream) reste un ajout sans toucher McpServer si une CLI l'exige. Non requis pour Claude/Codex ⇒ hors périmètre v5, documenté.


2. Décision 2 — Contrat conf injectée (M1) ↔ serveur écouté (M3/v5)

Bout en bout, un seul chemin :

profil.mcp = Some(McpCapability{ config: ConfigFile{".mcp.json"} | Flag{..} | Env{..}, transport })
   │  (LaunchAgent, run dir isolé .ideai/run/<agent>/ ; après apply_injection, avant spawn)
   ▼
apply_mcp_config écrit la DÉCLARATION RÉELLE (fin du placeholder) :
   {
     "mcpServers": {
       "idea": {
         "command": "<chemin absolu de l'exe IdeA>",      ← std::env::current_exe()
         "args": ["mcp-server",
                  "--endpoint", "<loopback du projet>",     ← fourni par ensure_mcp_server
                  "--project",  "<project id>",
                  "--requester","<agent id>"]                ← identité (§1.4)
       }
     }
   }
   │  (la CLI lit .mcp.json à l'initialize)
   ▼
la CLI SPAWN  <exe IdeA> mcp-server --endpoint … --project … --requester …
   │  (pont stdio↔loopback)
   ▼
le pont se connecte au LOOPBACK DU PROJET (créé par ensure_mcp_server)
   │  (handshake : project id + requester id)
   ▼
McpServerHandle ACCEPTE ⇒ tâche McpServer::serve(conn)  [McpServer tient l'OrchestratorService du projet]
   │  tools/call → map_tool_call → OrchestratorCommand → OrchestratorService::dispatch(&project, cmd)
   ▼
réponse inline (idea_ask_agent ⇒ outcome.reply) renvoyée verbatim à la CLI

Invariant de cohérence à tester : le chemin de l'endpoint et l'exe écrits par apply_mcp_config sont exactement ceux que ensure_mcp_server met à l'écoute pour ce projet. Source de vérité unique : une fonction (app-tauri) calcule l'endpoint d'un ProjectId ; M1 (qui écrit la conf) et M3/v5 (qui écoute) l'appellent tous deux. Pas de chaîne dupliquée.

Le transport du profil reste surfacé dans la déclaration pour la voie socket future, mais en stdio-spawn il est implicite (la CLI spawn = stdio). McpConfigStrategy inchangé : ConfigFile écrit le fichier, Flag/Env passent le chemin de la conf (run dir) — sémantique déjà en place, on ne fait que remplir la déclaration de vrai contenu.


3. Décision 3 — Fix du registre de session (lot PRIORITAIRE, indépendant)

3.1 Invariant correct (verrouillé)

1 session vivante par agent (un agent = singleton : un .md, une conversation, un run dir, une mémoire). On ne modélise pas une identité par cellule. La cellule est une vue rebindable (§17.6). session_for_agent est donc déterministe par construction — à condition que l'invariant soit réellement enforced et que les deux registres soient considérés. Aujourd'hui il y a trois fuites :

3.2 Trou A — le garde de LaunchAgent ne lève jamais AgentAlreadyRunning

LaunchAgent::execute (lifecycle.rs ~877-920) : si l'agent a déjà une session vivante (PTY ou structurée) et qu'un node_id est demandé, il rebind silencieusement ; sans node, il rend la session existante (idempotent). C'est juste pour réattacher une vue, mais cela masque un vrai second lancement (deux cellules distinctes voulant le lancer chacune). AppError::AgentAlreadyRunning est défini mais jamais levé.

Décision : distinguer réattache de vue (légitime, rebind) de second lancement (à refuser). Le signal de discrimination existe déjà dans le flux : un LaunchAgentInput issu d'une réattache (la cellule sait que l'agent tournait : agent_was_running/conversation_id présents) vs un lancement neuf. Le garde lève AgentAlreadyRunning { agent_id, node_id_hôte } quand un lancement neuf vise un agent déjà vivant sur un autre node, et rebind seulement quand le node_id demandé est le node hôte (ou réattache explicite). L'orchestrateur spawn_agent (Visible{node_id}) suit la même règle.

3.3 Trou B — list_live_agents est aveugle aux sessions structurées

commands.rs::list_live_agentsstate.terminal_sessions.live_agents() seulement. Un agent chat (structuré) vivant n'apparaît pas ⇒ l'UI ne le désactive pas dans le dropdown ⇒ on peut tenter de le relancer ailleurs.

Décision : la commande lit l'agrégateur LiveSessions::live_agents() (PTY + structuré), déjà présent dans le registre. Un seul point de vérité de liveness pour l'UI.

3.4 Trou C — layouts.json à doublons (deux feuilles, même agent)

Les layouts persistés contiennent déjà des feuilles en double sur le même agent id (constaté). À l'ouverture, ne pas auto-lancer la 2ᵉ ; réconcilier : une seule feuille reste « hôte vivant », les autres sont des vues mortes (pas de relance, pas de bannière fraîche). C'est précisément ce qui causait le symptôme (« une cellule reset au retour d'onglet »).

Décision : étape de réconciliation à l'ouverture du projet (app-tauri, jumelle de SnapshotRunningAgents) : pour chaque agent apparaissant sur N feuilles, garder une hôte, dé-flagger agent_was_running/conversation_id sur les autres feuilles dupliquées. La reprise (B, §15.2) ne relance alors qu'une session/agent.

3.5 Pourquoi indépendant du bind

Aucun de ces trois trous ne touche MCP/transport : ils vivent dans LaunchAgent, list_live_agents, et l'ouverture de projet. Le fix stabilise le routage de ask (qui s'appuie sur session_for_agent) avant d'ouvrir la vanne MCP ⇒ on évite de débugger un ask mal routé et un transport neuf en même temps. ⇒ Lot R0, livré en premier.


4. Décision 4 — Robustesse ask (sérialisation FIFO par agent)

Sémantique cible (au-dessus de l'existant) :

Situation Comportement
Cible inconnue AppError::NotFound (fait)
Cible morte lancement structuré (background) puis envoi (fait)
Cible vivante en PTY brut AppError::Invalid explicite, jamais d'ACK trompeur (fait)
Cible vivante structurée, libre rendez-vous direct send_blocking (fait)
Cible vivante structurée, déjà en tour (autre ask) file FIFO par agent : le 2ᵉ ask attend son tour, pas d'entrelacement (NOUVEAU)
Timeout 300 s cible vivante, erreur typée Timeout, retry possible (fait)

Le seul vrai manque = la concurrence. Deux ask simultanés sur la même cible appelleraient send_blocking en parallèle sur la même AgentSession ⇒ deux tours entrelacés sur un moteur qui pilote une conversation. Inacceptable (cf. bug accents = writes non sérialisés).

Décision : sérialiser les tours par agent dans OrchestratorService::ask_agent via un verrou par agent_id (un Mutex/sémaphore d'unité, registre HashMap<AgentId, Arc<Mutex<()>>> détenu par le service, ou porté par l'entrée de StructuredSessions). Un ask acquiert le verrou de l'agent avant send_blocking, le relâche après le Final/timeout. Les ask concurrents forment une file naturelle (ordre d'acquisition). Le timeout s'applique au tour (pas à l'attente du verrou) — ou un timeout global borné l'attente totale (décision : timeout par tour ; l'attente en file ne consomme pas le budget, mais un plafond d'attente évite l'inanition). Cohérent avec « 1 conversation déterministe/agent ».

Erreurs typées remontées aux deux portes : MCP ⇒ tool_result_text(.., isError=true) (déjà) ; fichier ⇒ *.response.json avec le champ d'erreur (déjà via OrchestratorOutcome/watcher). Le verrou n'ajoute pas de nouveau type d'erreur ; un timeout d'attente en file ⇒ Timeout (même type).


5. Décision 5 — Frontières hexagonales (où vit serve)

  • McpServer::serve = adapter infra, piloté par connexion : McpServerHandle (app-tauri) accepte sur l'endpoint loopback du projet et, par pair connecté (= un agent), spawn une tâche serve(conn). Le serveur reste sans état de connexion au-delà de l'identité du pair (portée par le contexte de connexion, §1.4).
  • McpServerHandle évolue : aujourd'hui il parke (let _server; stop_rx.recv()). Demain il ouvre l'endpoint + boucle d'accept ; à l'arrêt, ferme l'endpoint (les ponts enfants meurent avec leur CLI). Toujours non-bloquant pour open/close projet (l'accept est async, parqué sur l'absence de pair).
  • Le sous-process mcp-server vit dans app-tauri (route main.rs), mais ne connaît que stdio + loopback + JSON brut : zéro OrchestratorService, zéro use case. Frontière nette.
  • Aucune fuite applicative dans l'infra : OrchestratorService::dispatch est appelé à l'identique par les trois portes (fichier, MCP, UI). Le verrou par agent (§4) est une règle applicative ⇒ il vit dans OrchestratorService (ou le registre StructuredSessions), pas dans l'adapter MCP.

6. Découpage en LOTS testables (méthode §3)

Ordre global : R0 (fix registre) d'abord — indépendant, débloque la robustesse de ask. Puis bind transport M5x. Front (M5-UI) optionnel en fin.

Bloc R — Fix registre session (PRIORITAIRE, indépendant du transport)

Lot Côté Périmètre Critères de test
R0a back (application) Garde LaunchAgent : lever AgentAlreadyRunning{agent_id,node_hôte} pour un lancement neuf ciblant un agent déjà vivant sur un autre node (PTY ou structuré) ; rebind seulement si node demandé = node hôte / réattache. Même règle dans OrchestratorService::spawn_agent. lancement neuf d'un agent vivant ailleurs ⇒ AgentAlreadyRunning (code AGENT_ALREADY_RUNNING) ; réattache même node ⇒ rebind sans respawn ; idempotence background inchangée ; ask d'une cible morte ⇒ lancement OK (pas de faux positif).
R0b back (app-tauri) list_live_agents lit LiveSessions::live_agents() (PTY + structuré). un agent chat vivant apparaît dans la liste ; un agent PTY aussi ; aucun doublon ; sans session ⇒ vide.
R0c back (app-tauri) Réconciliation à l'ouverture projet : pour un agent sur N feuilles, garder une hôte, dé-flagger agent_was_running/conversation_id sur les autres. layout à doublons ⇒ après ouverture, une seule feuille « était en cours » pour l'agent ; layout sans doublon inchangé ; persistance idempotente (2ᵉ ouverture = no-op).
R0d front Dropdown agent du leaf : désactiver/"déjà placé" via la liste R0b (PTY+chat) ; gérer le retour AGENT_ALREADY_RUNNING (aller-à / déplacer). Vitest : agent vivant ailleurs ⇒ option désactivée + action « aller à la cellule » ; erreur backend mappée à un message clair.

Bloc M5 — Bind transport S-MCP (stdio-spawn)

Lot Côté Périmètre Critères de test
M5a back (app-tauri) Endpoint loopback par projet : fonction unique mcp_endpoint(project_id) (Unix socket / Windows named pipe) ; ensure_mcp_server l'ouvre, stop_orchestrator_watch le ferme. endpoint créé à l'open, supprimé au close ; idempotent (1/projet) ; chemin déterministe par ProjectId ; pas de collision inter-projets.
M5b back (app-tauri) Sous-commande mcp-server dans main.rs (avant init Tauri) : pont StdioTransport(stdin,stdout) ↔ loopback (--endpoint), handshake (--project,--requester). argv mcp-server ⇒ mode headless, ne lance pas la webview ; relaie une requête JSON-RPC ligne→loopback→réponse→stdout ; EOF stdin ⇒ sortie propre ; endpoint absent ⇒ erreur non-zéro, jamais de hang.
M5c back (infra/app-tauri) McpServerHandle accepte sur l'endpoint et spawn McpServer::serve(conn) par pair ; identité du pair (requester) portée au contexte de connexion ⇒ OrchestratorRequestProcessed.requester_id = agent réel (fin du "mcp" figé). une connexion ⇒ une tâche serve ; initialize/tools/list/tools/call OK de bout en bout via le loopback (test d'intégration local, hors réseau) ; requester_id = l'agent ; déconnexion d'un pair n'affecte pas les autres ; arrêt ferme l'endpoint + termine les serve.
M5d back (application) apply_mcp_config écrit la déclaration RÉELLE (fin placeholder) : command = current_exe, args = ["mcp-server","--endpoint",mcp_endpoint(project),"--project",id,"--requester",agent]. ConfigFile non-clobbering ; Flag/Env portent le chemin de conf. Source d'endpoint partagée avec M5a. mcp=None ⇒ aucune écriture (inchangé) ; ConfigFile.mcp.json pointe l'exe + endpoint exacts du projet ; endpoint identique à ensure_mcp_server (test de cohérence M1↔M3) ; non-clobbering.
M5e back (intégration) Smoke end-to-end loopback (sans CLI réelle) : un faux pont écrit tools/call idea_list_agents/idea_ask_agent sur le loopback d'un projet et reçoit la réponse inline du dispatch réel. idea_list_agents ⇒ JSON des agents ; idea_ask_agent vers une cible structurée ⇒ reply inline ; cible PTY ⇒ erreur typée ; JSON-RPC malformé ⇒ erreur, jamais panic ; hors réseau.

Bloc A — Robustesse ask (concurrence)

Lot Côté Périmètre Critères de test
A0 back (application) Sérialisation FIFO par agent dans ask_agent : verrou par agent_id autour de send_blocking ; timeout par tour ; plafond d'attente en file. deux ask concurrents sur la même cible ⇒ tours séquentiels (pas d'entrelacement), ordre FIFO ; un ask sur agent A et un sur agent B ⇒ parallèles ; timeout d'un tour laisse la cible vivante et libère la file ; plafond d'attente ⇒ Timeout typé.

Optionnel

Lot Côté Périmètre Critères de test
M5-UI front Badge source (mcp/file) + requester réel sur une délégation (réutilise OrchestratorRequestProcessed). badge source correct ; requester = agent réel (plus « mcp ») ; pas de régression sans event.

Ordre recommandé : R0a→R0b→R0c→R0d (stabilise le routage), puis A0 (concurrence ask, ne dépend pas du transport), puis M5a→M5b→M5c→M5d→M5e (bind), puis M5-UI. R0 et A0 sont livrables sans toucher MCP ; M5 ne doit partir qu'après R0 (sinon on débugge ask mal routé + transport neuf ensemble).


7. Conformité hexagonale & SOLID (rappel)

  • Règle de dépendance : JSON-RPC, stdio, loopback socket/pipe, sous-process mcp-server = infra/app-tauri exclusivement. domain/application ignorent MCP et le transport. Le verrou FIFO par agent est une règle applicative (vit dans OrchestratorService/registre), pas dans l'adapter.
  • S : McpServer = traduire JSON-RPC → dispatch ; le pont = relayer des octets ; McpServerHandle = cycle de vie + accept ; le verrou = sérialiser les tours. Aucune responsabilité fourre-tout.
  • O : un SocketTransport futur = un impl de Transport en plus, zéro modif de McpServer. Une CLI MCP de plus = un profil avec mcp = Some(..), zéro code.
  • L : les trois portes (fichier, MCP, UI) sont substituables derrière OrchestratorService::dispatchun comportement applicatif.
  • I : McpServerHandle ne voit que « accepter + servir » ; OrchestratorService ne voit que dispatch + registres ; l'UI ne voit que LiveSessions::live_agents.