//! [`OrchestratorService`] — dispatches a validated [`OrchestratorCommand`] to the //! existing agent/terminal use cases (ARCHITECTURE §14.3). //! //! The orchestrator agent never spawns a process itself: IdeA is the single source //! of truth for the agent lifecycle. This service is the application-layer seam //! that turns a request into the *same* calls the UI makes: //! //! - `spawn_agent` → [`CreateAgentFromScratch`] (if unknown) then [`LaunchAgent`], //! - `stop_agent` → resolve the agent's live session, then [`CloseTerminal`], //! - `update_agent_context` → [`UpdateAgentContext`]. //! //! It talks **only** to use cases and ports ([`ProfileStore`], [`TerminalSessions`]): //! no filesystem watching, no JSON, no process spawning here — those are the //! infrastructure adapter's job. That keeps this fully unit-testable with fakes. use std::collections::HashMap; use std::sync::{Arc, Mutex as StdMutex}; use std::time::Duration; use tokio::sync::Mutex as AsyncMutex; use domain::conversation::{ ConversationParty, ConversationRegistry, SessionRef, WaitForGraph, }; use domain::input::InputMediator; use domain::mailbox::{Ticket, TicketId}; use domain::ports::{EventBus, ProfileStore, PtyHandle}; use domain::{AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project}; use crate::agent::{ CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext, UpdateAgentContextInput, }; use crate::error::AppError; use crate::orchestrator::{ ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory, ReadMemoryInput, WriteMemory, WriteMemoryInput, }; use crate::skill::{CreateSkill, CreateSkillInput}; use crate::terminal::{CloseTerminal, CloseTerminalInput, TerminalSessions}; /// Default terminal geometry for an orchestrator-launched agent cell. The UI /// resizes the PTY to the real cell size on attach; these are sane starting rows /// /cols so the spawn never fails on a zero-sized terminal. const DEFAULT_ROWS: u16 = 24; /// See [`DEFAULT_ROWS`]. const DEFAULT_COLS: u16 = 80; /// Bound on the synchronous inter-agent rendezvous (`agent.message` → `AskAgent`). /// /// A target agent's turn can be long (reasoning + tool use), so the cap is /// generous; on expiry [`send_blocking`] returns [`domain::ports::AgentSessionError::Timeout`] /// **without killing the session**, so the requester can retry. Internal and /// intentionally not yet config-exposed (it may become a per-project setting /// without changing the contract). const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(300); /// Borne d'attente **en file** pour acquérir le verrou de tour d'un agent (A0, /// cadrage v5 §4). /// /// La sérialisation FIFO par agent (« 1 agent = 1 employé : un tour à la fois ») /// fait qu'un `ask` concurrent vers la **même** cible patiente derrière le tour en /// cours. Le timeout de tour ([`ASK_AGENT_TIMEOUT`]) borne **le tour lui-même**, /// **pas** cette attente : on lui donne donc son propre plafond, généreux mais fini, /// pour qu'un `ask` ne reste jamais bloqué indéfiniment si la file est longue /// (inanition). À l'expiration, l'`ask` renvoie un timeout typé (réutilise le /// **même** type que le timeout de tour, cf. [`AppError::Process`] via /// [`domain::ports::AgentSessionError::Timeout`]) ; le tour en cours n'est pas /// affecté. Largeur = un tour complet + sa propre file ⇒ on autorise deux tours /// pleins d'attente. const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(600); /// Fournit les faits OS/runtime (exe + endpoint projet) pour écrire la déclaration MCP /// réelle quand l'orchestrateur (re)lance une cible sur le chemin `ask`. Implémenté dans /// app-tauri (seul détenteur de current_exe/$APPIMAGE/mcp_endpoint). /// /// La couche `application` ne connaît que ce **port** : elle ne calcule jamais le chemin /// de l'exécutable ni l'endpoint loopback (ces faits vivent dans `app-tauri`, cadrage v5 /// §0.3 / §7). Seules les **chaînes** d'un [`McpRuntime`] traversent la frontière. pub trait McpRuntimeProvider: Send + Sync { /// `agent_id` = la cible relancée = le `--requester` (c'est elle qui appellera idea_reply). /// `None` ⇒ dégrade vers la déclaration minimale (jamais d'échec de lancement). fn runtime_for(&self, project: &Project, agent_id: AgentId) -> Option; } /// Dispatches validated orchestrator commands to the agent/terminal use cases. pub struct OrchestratorService { create_agent: Arc, launch_agent: Arc, list_agents: Arc, close_terminal: Arc, update_context: Arc, create_skill: Arc, profiles: Arc, sessions: Arc, /// Médiateur d'entrée (cadrage C3 §5.2) — point de convergence unique de l'entrée /// d'un agent. La cible d'un `agent.message`/`idea_ask_agent` y reçoit un ticket /// (`enqueue`) dont on **attend** la résolution (`idea_reply` ⇒ /// [`OrchestratorCommand::Reply`]) ; son impl écrit aussi le tour dans le PTY de la /// cible (livraison sérialisée, plus d'écriture ad hoc ici). Injecté via /// [`Self::with_input_mediator`] ; `None` ⇒ `AskAgent`/`Reply` non servis (call /// sites/tests legacy restent verts). input: Option>, /// Mailbox sous-jacent du médiateur, pour `resolve`/`resolve_ticket`/`cancel_head` /// (corrélation par ticket). C'est le **même** moteur de corrélation que celui que /// `input` enveloppe ; injecté ensemble via [`Self::with_input_mediator`]. mailbox: Option>, /// Registre des conversations par paire (cadrage C3 §5.2) — résout paresseusement /// le fil `A↔B` (ou `User↔B`) d'un `ask`, sépare strictement les contextes. /// Injecté via [`Self::with_conversations`] ; `None` ⇒ on retombe sur un routage /// par agent sans matérialisation de fil (legacy). conversations: Option>, /// Graphe d'attente inter-agents (cadrage C3 §6) — arête posée à l'`enqueue` d'un /// `ask` A→B, retirée au reply/timeout (RAII via le garde de tour). Sert à /// **refuser** une délégation ré-entrante (A→B→…→A) avant deadlock. wait_for: StdMutex, /// Bus d'événements pour publier [`DomainEvent::AgentReplied`] à l'issue d'un /// `ask` réussi (§17.4). Injecté via [`Self::with_events`] ; `None` ⇒ pas de /// publication (l'`ask` fonctionne quand même). events: Option>, /// Verrous de **tour par agent** (A0, cadrage v5 §4) — sérialisation FIFO des /// `ask` vers une **même** cible : « 1 agent = 1 employé, un tour à la fois ». /// /// Clé = `AgentId` de la **cible** ; valeur = un `tokio::Mutex` d'unité dont le /// garde est tenu **à travers** le `.await` de `send_blocking` (d'où le mutex /// **async**, pas `std`). La `HashMap` elle-même n'est tenue que le temps du /// get-or-create (jamais à travers un `.await`), donc protégée par un mutex /// **synchrone** `std` — pas de garde gardé en travers d'un point de suspension. /// /// Le registre vit **ici** (règle applicative d'orchestration, frontière /// hexagonale, cadrage v5 §5) et **non** dans [`StructuredSessions`] : sérialiser /// les tours est une responsabilité de l'orchestrateur, pas du stockage de /// sessions (SRP) ; le couplage reste minimal. Verrou **par agent** ⇒ deux `ask` /// vers des agents **différents** n'entrent jamais en contention (un map d'`Arc` /// distincts). /// /// Croissance bornée en pratique au nombre d'agents du projet ; une entrée /// morte ne coûte qu'un `Arc>` vide (pas de session, pas de process). ask_locks: StdMutex>>>, /// Fournisseur des faits OS/runtime (exe + endpoint) pour écrire la déclaration /// MCP **réelle** quand `ensure_live_pty` (re)lance une cible sur le chemin `ask` /// (B-3). Injecté au câblage via [`Self::with_mcp_runtime_provider`] depuis /// app-tauri ; `None` ⇒ on conserve la déclaration minimale (`mcp_runtime: None`), /// donc zéro régression pour les call sites/tests qui ne le branchent pas. mcp_runtime_provider: Option>, /// FileGuard-mediated context/memory use cases (cadrage C7). Injected via /// [`Self::with_context_guard`] ; `None` ⇒ les commandes `context.*`/`memory.*` /// renvoient une erreur typée (call sites/tests legacy restent verts). context_guard: Option>, } /// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés /// ensemble dans l'[`OrchestratorService`] (cadrage C7). Regroupés pour garder la /// signature de [`OrchestratorService::with_context_guard`] simple (un seul `Arc`). pub struct ContextGuardUseCases { /// Lecture d'un contexte `.md` IdeA sous read-lease. pub read_context: Arc, /// Proposition/écriture d'un contexte `.md` IdeA sous le garde. pub propose_context: Arc, /// Lecture mémoire sous read-lease. pub read_memory: Arc, /// Écriture mémoire sous write-lease. pub write_memory: Arc, } /// Outcome of dispatching a command — a short, human-readable success summary the /// infrastructure adapter folds into the JSON response file. #[derive(Debug, Clone, PartialEq, Eq)] pub struct OrchestratorOutcome { /// One-line description of what IdeA did (e.g. `"launched agent dev-backend"`). pub detail: String, /// The target agent's **reply content** for a synchronous `agent.message` /// (`AskAgent`, §17.4). `Some(content)` carries the turn's `Final`; every other /// command leaves it `None`. Additive field ⇒ existing call sites/tests are /// untouched (they only read [`Self::detail`]). pub reply: Option, } impl OrchestratorService { /// Builds the service from the use cases and ports it dispatches to. #[must_use] #[allow(clippy::too_many_arguments)] pub fn new( create_agent: Arc, launch_agent: Arc, list_agents: Arc, close_terminal: Arc, update_context: Arc, create_skill: Arc, profiles: Arc, sessions: Arc, ) -> Self { Self { create_agent, launch_agent, list_agents, close_terminal, update_context, create_skill, profiles, sessions, input: None, mailbox: None, conversations: None, wait_for: StdMutex::new(WaitForGraph::new()), events: None, ask_locks: StdMutex::new(HashMap::new()), mcp_runtime_provider: None, context_guard: None, } } /// Branche les use cases C7 (`context.*`/`memory.*`) sous /// [`domain::fileguard::FileGuard`]. Builder additif (signature de [`Self::new`] /// inchangée). #[must_use] pub fn with_context_guard(mut self, guard: Arc) -> Self { self.context_guard = Some(guard); self } /// Returns the per-agent **turn lock**, creating it on first use. /// /// Get-or-create under the synchronous map mutex (held only for this lookup, /// never across an `.await`); the returned `Arc>` is the lock the /// caller acquires (and holds across `send_blocking`) to serialise turns for /// `agent_id`. Two different agents get two distinct `Arc`s ⇒ no contention. fn ask_lock_for(&self, agent_id: &AgentId) -> Arc> { let mut locks = self .ask_locks .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); Arc::clone(locks.entry(*agent_id).or_default()) } /// Branche le **médiateur d'entrée** (cadrage C3 §5.2) pour servir /// `agent.message`/[`OrchestratorCommand::AskAgent`] et /// `agent.reply`/[`OrchestratorCommand::Reply`]. Le `mailbox` est le moteur de /// corrélation **sous-jacent** au médiateur (le même `InMemoryMailbox` que /// `MediatedInbox` enveloppe) : on l'injecte ensemble pour pouvoir `resolve`/ /// `resolve_ticket`/`cancel_head` un ticket. Builder additif : signature de /// [`Self::new`] **inchangée** (les tests/call sites legacy restent verts). #[must_use] pub fn with_input_mediator( mut self, input: Arc, mailbox: Arc, ) -> Self { self.input = Some(input); self.mailbox = Some(mailbox); self } /// Branche le [`ConversationRegistry`] (cadrage C3 §5.2) pour résoudre /// paresseusement le fil `A↔B` (ou `User↔B`) d'un `ask` et séparer les contextes. /// Builder additif (signature de [`Self::new`] inchangée). #[must_use] pub fn with_conversations(mut self, conversations: Arc) -> Self { self.conversations = Some(conversations); self } /// Branche l'[`EventBus`] pour publier [`DomainEvent::AgentReplied`] après un /// `ask` réussi (§17.4). Builder additif (cf. [`Self::with_structured`]). #[must_use] pub fn with_events(mut self, events: Arc) -> Self { self.events = Some(events); self } /// Branche le [`McpRuntimeProvider`] (app-tauri) pour que les (re)lancements /// issus du chemin `ask` (`ensure_live_pty`) écrivent la déclaration MCP **réelle** /// (endpoint + exe + requester) au lieu de la minimale — sans quoi le pont MCP /// n'est jamais spawné et la cible ne peut pas appeler `idea_reply` (timeout). /// Builder additif : signature de [`Self::new`] **inchangée**. #[must_use] pub fn with_mcp_runtime_provider(mut self, provider: Arc) -> Self { self.mcp_runtime_provider = Some(provider); self } /// Dispatches a validated command against `project`. /// /// # Errors /// Propagates the underlying use-case [`AppError`] (e.g. unknown profile, /// unknown agent, PTY failure). For `spawn_agent` a *known* agent is launched /// directly; an *unknown* one is created from scratch first. pub async fn dispatch( &self, project: &Project, command: OrchestratorCommand, ) -> Result { match command { OrchestratorCommand::SpawnAgent { name, profile, context, visibility, } => { self.spawn_agent(project, name, profile, context, visibility) .await } OrchestratorCommand::AskAgent { target, task, requester, } => self.ask_agent(project, target, task, requester).await, OrchestratorCommand::Reply { from, ticket, result, } => self.reply(from, ticket, result), OrchestratorCommand::ListAgents => self.list_agents(project).await, OrchestratorCommand::StopAgent { name } => self.stop_agent(project, name).await, OrchestratorCommand::UpdateAgentContext { name, context } => { self.update_agent_context(project, name, context).await } OrchestratorCommand::CreateSkill { name, content, scope, } => self.create_skill(project, name, content, scope).await, OrchestratorCommand::ReadContext { target, requester } => { self.read_context(project, target, requester).await } OrchestratorCommand::ProposeContext { target, content, requester, } => self.propose_context(project, target, content, requester).await, OrchestratorCommand::ReadMemory { slug, requester } => { self.read_memory(project, slug, requester).await } OrchestratorCommand::WriteMemory { slug, content, requester, } => self.write_memory(project, slug, content, requester).await, } } /// Returns the injected C7 use cases, or a typed error when unwired. fn require_context_guard(&self) -> Result<&ContextGuardUseCases, AppError> { self.context_guard.as_deref().ok_or_else(|| { AppError::Invalid("FileGuard context/memory tools are not configured".to_owned()) }) } /// `context.read` → reads an IdeA-owned context under a shared read-lease; the /// body is returned inline in the outcome's `reply`. async fn read_context( &self, project: &Project, target: Option, requester: ConversationParty, ) -> Result { let md = self .require_context_guard()? .read_context .execute(ReadContextInput { project: project.clone(), target: target.clone(), requester, }) .await?; Ok(OrchestratorOutcome { detail: format!( "read {} context", target.as_deref().unwrap_or("project") ), reply: Some(md.into_string()), }) } /// `context.propose` → direct write (agent ctx / orchestrator on global) or a /// materialised proposal (non-orchestrator on global). async fn propose_context( &self, project: &Project, target: Option, content: String, requester: ConversationParty, ) -> Result { let outcome = self .require_context_guard()? .propose_context .execute(ProposeContextInput { project: project.clone(), target: target.clone(), content, requester, }) .await?; let detail = match outcome { ProposeOutcome::Written => format!( "wrote {} context", target.as_deref().unwrap_or("project") ), ProposeOutcome::Proposed { path } => { format!("filed proposal for project context at {path}") } }; Ok(OrchestratorOutcome { detail, reply: None }) } /// `memory.read` → reads a note (or the index) under a shared read-lease; the /// content is returned inline in the outcome's `reply`. async fn read_memory( &self, project: &Project, slug: Option, requester: ConversationParty, ) -> Result { let content = self .require_context_guard()? .read_memory .execute(ReadMemoryInput { project: project.clone(), slug: slug.clone(), requester, }) .await?; Ok(OrchestratorOutcome { detail: format!("read memory {}", slug.as_deref().unwrap_or("index")), reply: Some(content), }) } /// `memory.write` → writes a note under an exclusive write-lease. async fn write_memory( &self, project: &Project, slug: String, content: String, requester: ConversationParty, ) -> Result { self.require_context_guard()? .write_memory .execute(WriteMemoryInput { project: project.clone(), slug: slug.clone(), content, requester, }) .await?; Ok(OrchestratorOutcome { detail: format!("wrote memory {slug}"), reply: None, }) } /// `spawn_agent`: create the agent if the manifest doesn't already hold one by /// that name, then launch it (which publishes `AgentLaunched` → the UI opens a /// cell + the Agents tab). async fn spawn_agent( &self, project: &Project, name: String, profile: Option, context: Option, visibility: OrchestratorVisibility, ) -> Result { let existing = self.find_agent_id_by_name(project, &name).await?; let agent_id = match existing { Some(id) => id, None => { let profile = profile.as_deref().ok_or_else(|| { AppError::Invalid("profile is required to create an agent".to_owned()) })?; let profile_id = self.resolve_profile(profile).await?; let created = self .create_agent .execute(CreateAgentInput { project: project.clone(), name: name.clone(), profile_id, initial_content: context, }) .await?; created.agent.id } }; if let Some(session_id) = self.sessions.session_for_agent(&agent_id) { match visibility { OrchestratorVisibility::Background => { return Ok(OrchestratorOutcome { detail: format!("agent {name} already running in background"), reply: None, }); } OrchestratorVisibility::Visible { node_id } => { // R0a — même règle que le garde de `LaunchAgent` (cadrage v5 §3.2, // Trou A) : un `spawn` est un **lancement neuf** (pas de // conversation portée), donc ré-attacher à la **même** cellule // hôte est légitime (rebind de vue), mais viser un **autre** node // pour un agent singleton déjà vivant est un second lancement ⇒ // refus `AgentAlreadyRunning`. let host_node = self.sessions.node_for_agent(&agent_id); match ReattachDecision::resolve(Some(node_id), host_node, None) { ReattachDecision::Rebind { node_id } => { let session = self .sessions .rebind_agent_node(&agent_id, node_id) .ok_or_else(|| { AppError::NotFound(format!( "running session {session_id} for agent {name}" )) })?; return Ok(OrchestratorOutcome { detail: format!( "attached agent {name} to cell {}", session.node_id ), reply: None, }); } ReattachDecision::Refuse { node_id } => { return Err(AppError::AgentAlreadyRunning { agent_id, node_id }); } // A `Visible` spawn always carries a node, so the decision is // never `Idempotent`; treat it as a same-node rebind for safety. ReattachDecision::Idempotent => { return Ok(OrchestratorOutcome { detail: format!("agent {name} already running"), reply: None, }); } } } } } let node_id = match visibility { OrchestratorVisibility::Background => None, OrchestratorVisibility::Visible { node_id } => Some(node_id), }; self.launch_agent .execute(LaunchAgentInput { project: project.clone(), agent_id, rows: DEFAULT_ROWS, cols: DEFAULT_COLS, node_id, conversation_id: None, // Orchestrator-driven launch (inside `application`): no OS/runtime // facts to inject here; the real MCP declaration is written when the // agent is (re)launched through the app-tauri composition root. mcp_runtime: None, }) .await?; Ok(OrchestratorOutcome { detail: match visibility { OrchestratorVisibility::Background => { format!("launched agent {name} in background") } OrchestratorVisibility::Visible { node_id } => { format!("launched agent {name} in cell {node_id}") } }, reply: None, }) } /// `agent.message` / `idea_ask_agent`: the **inter-agent delegation rendezvous** /// (Option 1 « Terminal + MCP », lot B-3). /// /// The target's human-facing view is now a **raw native terminal** (PTY REPL), and /// delegation flows through the terminal's single FIFO input plus the MCP mailbox: /// /// 1. Resolve the target by name and acquire its **per-agent turn lock** so two /// `ask`s for the same target serialise FIFO (1 agent = 1 employee). /// 2. Ensure the target is **live in the PTY registry** — reusing its terminal if /// it is already running, otherwise launching it in the background (a normal /// PTH launch: a live PTY *is* the channel now, not an error as before). /// 3. **Enqueue a ticket** in the [`AgentMailbox`] (registering the reply slot) /// **then write** the task into the target's terminal, prefixed with the asking /// agent + ticket id so the target knows to answer via `idea_reply`. /// 4. **Await** the [`domain::mailbox::PendingReply`] bounded by [`ASK_AGENT_TIMEOUT`]: /// the target's later `idea_reply(result)` lands in [`Self::reply`] → /// `mailbox.resolve`, waking this await. On timeout the ticket is retired from /// the head ([`AgentMailbox::cancel_head`]) — **the target stays alive** — and a /// typed timeout is returned (retry possible). /// 5. Return the reply as [`OrchestratorOutcome::reply`] and publish /// [`DomainEvent::AgentReplied`]. /// /// # Errors /// - [`AppError::NotFound`] if the target agent is unknown; /// - [`AppError::Invalid`] if the mailbox/PTY channel is not wired; /// - [`AppError::Process`] on a launch/PTY-write failure, or on the await timeout /// (turn timeout *or* queue-wait timeout — same typed error). async fn ask_agent( &self, project: &Project, target: String, task: String, requester: Option, ) -> Result { let (input, mailbox) = match (&self.input, &self.mailbox) { (Some(i), Some(m)) => (i, m), _ => { return Err(AppError::Invalid( "la messagerie inter-agents (idea_ask_agent) n'est pas disponible : \ médiateur d'entrée non câblé" .to_owned(), )) } }; let agent = self .find_agent_by_name(project, &target) .await? .ok_or_else(|| AppError::NotFound(format!("agent {target}")))?; let agent_id = agent.id; // F2 — garde profil : refuser **immédiatement** une cible dont le profil ne // sait pas consommer le pont `idea_*` matérialisé via `.mcp.json`, plutôt que // de laisser le round-trip échouer en timeout muet (300s). self.guard_mcp_bridge_supported(&agent.profile_id, &target) .await?; // Détection de cycle (cadrage C3 §6) : si l'ask vient d'un **agent** A vers la // cible B, refuser AVANT tout enqueue si poser l'arête A→B fermerait un cycle // d'attente (B attend déjà …→A). Pur, sans I/O ⇒ jamais de deadlock. if let Some(from) = requester { let cycles = { let g = self .wait_for .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); g.would_cycle(from, agent_id) }; if cycles { return Err(AppError::Invalid(format!( "délégation ré-entrante refusée : demander à l'agent '{target}' créerait \ un cycle d'attente inter-agents (deadlock évité)" ))); } } // Résoudre paresseusement le **fil** de l'ask : A↔B si un agent demande, sinon // User↔B. La session vivante est désormais keyée par conversation (lève // `session-registry-agent-ambiguity`). let conversation_id = self.resolve_conversation(requester, agent_id); // Sérialisation FIFO **par agent** (A0) : verrou de tour de la **cible**, tenu // pour TOUT le tour (enqueue → réponse). RAII : tombe sur chaque early-return. let lock = self.ask_lock_for(&agent_id); let _turn = match tokio::time::timeout(ASK_QUEUE_WAIT_CAP, lock.lock_owned()).await { Ok(guard) => guard, Err(_elapsed) => { return Err(AppError::from(domain::ports::AgentSessionError::Timeout)); } }; // Poser l'arête d'attente A→B (retirée en fin de tour par le RAII `_edge`). let _edge = requester.map(|from| WaitEdgeGuard::new(self, from, agent_id)); // 1. Garantir la cible vivante en PTY pour CE fil ; lier sa session à la // conversation, et brancher son handle d'entrée sur le médiateur (livraison). let handle = self .ensure_live_pty(project, agent_id, conversation_id, &target) .await?; // Arm prompt-ready detection (C5) with the target profile's literal marker, so a // return-to-prompt frees the turn (the other OR signal being `idea_reply`). let prompt_pattern = self.prompt_pattern_for_agent(project, agent_id).await; input.bind_handle_with_prompt(agent_id, handle.clone(), prompt_pattern); // 2. Enregistrer le ticket (slot de réponse) + livrer le tour via le médiateur // (écriture sérialisée dans le PTY — plus d'écriture ad hoc ici). Le ticket // porte la source (Human/Agent) et la conversation cible. let requester_label = self.requester_label(project, requester).await; let ticket_id = TicketId::new_random(); let ticket = match requester { Some(from) => { Ticket::from_agent(ticket_id, from, conversation_id, requester_label, task) } None => Ticket::from_human(ticket_id, conversation_id, requester_label, task), }; let pending = input.enqueue(agent_id, ticket); // Delivery is the mediator's responsibility (`InputMediator::enqueue` writes the // turn into the bound handle). The service no longer writes the PTY directly — // no ad-hoc `[IdeA · tâche …]` line here, no `\r` band-aid (cadrage C3 §5.1). // 3. Attendre la réponse, bornée. Timeout/canal fermé ⇒ retirer le ticket // (cible laissée vivante) et renvoyer une erreur typée. match tokio::time::timeout(ASK_AGENT_TIMEOUT, pending).await { Ok(Ok(result)) => Ok(self.reply_outcome(agent_id, &target, result)), Ok(Err(_cancelled)) => { mailbox.cancel_head(agent_id, ticket_id); Err(AppError::Process(format!( "agent {target} : canal de réponse fermé avant un résultat" ))) } Err(_elapsed) => { mailbox.cancel_head(agent_id, ticket_id); Err(AppError::from(domain::ports::AgentSessionError::Timeout)) } } } /// `SubmitHumanInput` (cadrage C4 §5.3) — the **human** Envoyer path. /// /// The operator types into IdeA's mediated input; this resolves the `User↔Agent` /// thread, ensures the target is live in the PTY registry, binds its handle on the /// mediator, and **enqueues** a `Ticket::from_human` into the **same FIFO** the /// inter-agent delegations use (`InputMediator::enqueue`) — so a human submit and a /// delegation serialise on the same agent («1 agent = 1 employee»). /// /// Unlike [`Self::ask_agent`], it is **fire-and-forget**: the human watches the /// terminal for the answer, so we do **not** await the [`PendingReply`] (the reply /// slot is registered and simply left to resolve/expire on its own). The busy /// event is emitted at the mediator's source (Idle→Busy on the starting enqueue). /// /// # Errors /// - [`AppError::Invalid`] if the input mediator is not wired; /// - [`AppError::NotFound`] if the target agent is unknown; /// - [`AppError::Process`] on a launch/PTY failure while ensuring the live session. pub async fn submit_human_input( &self, project: &Project, agent_id: AgentId, text: String, ) -> Result { let input = self.input.as_ref().ok_or_else(|| { AppError::Invalid( "l'entrée médiée (submit_agent_input) n'est pas disponible : \ médiateur d'entrée non câblé" .to_owned(), ) })?; // Display label for error/launch messages; the agent must exist in the // manifest. A human submit to an unknown id is a NotFound, never a panic. let target = self .find_name_by_agent_id(project, agent_id) .await .ok_or_else(|| AppError::NotFound(format!("agent {agent_id}")))?; let target = target.as_str(); // User↔Agent thread (no requester ⇒ left = User). Same lazy resolution as ask. let conversation_id = self.resolve_conversation(None, agent_id); // Ensure the target is live for this thread and bind its input handle on the // mediator (delivery path). Same call the ask path uses. let handle = self .ensure_live_pty(project, agent_id, conversation_id, target) .await?; let prompt_pattern = self.prompt_pattern_for_agent(project, agent_id).await; input.bind_handle_with_prompt(agent_id, handle, prompt_pattern); // Enqueue a human-sourced ticket in the SAME FIFO as delegations. Fire-and- // forget: we drop the PendingReply (the human reads the terminal). The // mediator emits AgentBusyChanged at the source on a starting turn. let ticket = Ticket::from_human(TicketId::new_random(), conversation_id, "vous", text); let _pending = input.enqueue(agent_id, ticket); Ok(OrchestratorOutcome { detail: format!("submitted human input to agent {target}"), reply: None, }) } /// Interrupt (cadrage C4 §5.3) — the **Interrompre** path (Échap/stop). /// /// Resolves the target by name and calls [`InputMediator::preempt`], which signals /// the running turn to stop (best-effort interrupt byte to the agent's bound PTY /// handle). It is **not** an enqueue and resolves **no** ticket — a pending caller /// is never silently answered. Idempotent: interrupting an idle agent is a no-op. /// /// # Errors /// - [`AppError::Invalid`] if the input mediator is not wired; /// - [`AppError::NotFound`] if the target agent is unknown. pub async fn interrupt_agent( &self, project: &Project, agent_id: AgentId, ) -> Result { let input = self.input.as_ref().ok_or_else(|| { AppError::Invalid( "l'interruption (interrupt_agent) n'est pas disponible : \ médiateur d'entrée non câblé" .to_owned(), ) })?; // Confirm the agent exists (typed NotFound rather than a silent no-op on a // bogus id). The manifest lookup also keeps the contract symmetric with submit. if self .find_name_by_agent_id(project, agent_id) .await .is_none() { return Err(AppError::NotFound(format!("agent {agent_id}"))); } input.preempt(agent_id); Ok(OrchestratorOutcome { detail: format!("interrupted agent {agent_id}"), reply: None, }) } /// Resolves the conversation thread id for an ask: `A↔B` when an agent requests, /// else `User↔B` (cadrage C3 §5.2). Without a wired registry, falls back to a /// stable per-agent id derived from the target (legacy routing — never panics). fn resolve_conversation( &self, requester: Option, target: AgentId, ) -> domain::conversation::ConversationId { let left = match requester { Some(from) => ConversationParty::agent(from), None => ConversationParty::User, }; let right = ConversationParty::agent(target); match &self.conversations { Some(reg) => reg.resolve(left, right).id, None => domain::conversation::ConversationId::from_uuid(target.as_uuid()), } } /// `agent.reply` / `idea_reply`: the target agent renders the result of the task /// it is currently processing (Option 1, lot B-4). /// /// Positional correlation: `from` is the **emitting** agent (its identity comes /// from the MCP handshake, not from a model-managed id), so the result resolves /// the ticket at the **head** of *that agent's* mailbox queue — the task it is /// working on. ACK only: no inline payload, no `AgentReplied` here (that belongs /// to the asking side's `ask_agent`). /// /// # Errors /// - [`AppError::Invalid`] if the mailbox is not wired; /// - [`AppError::Invalid`] if `from` has no in-flight request (a reply with no /// matching ask) — typed, never a panic. fn reply( &self, from: AgentId, ticket: Option, result: String, ) -> Result { let mailbox = self.mailbox.as_ref().ok_or_else(|| { AppError::Invalid( "idea_reply n'est pas disponible : file inter-agents non câblée".to_owned(), ) })?; // Corrélation par ticket quand l'agent l'a renvoyé (déterministe, multi-fil) ; // sinon repli sur la tête de file de l'émetteur (compat agents mono-fil). match ticket { Some(ticket_id) => mailbox.resolve_ticket(from, ticket_id, result), None => mailbox.resolve(from, result), } .map_err(|e| AppError::Invalid(e.to_string()))?; // Explicit «end-of-turn» signal (cadrage §6, lot C5): an `idea_reply` means the // emitting agent `from` finished its delegated task ⇒ mark it Idle so its FIFO // advances to the next queued ticket. This is the deterministic OR signal that // pairs with prompt-ready detection; whichever fires first frees the turn. No-op // (and no spurious event) when the mediator is absent or `from` was already idle. if let Some(input) = self.input.as_ref() { input.mark_idle(from); } Ok(OrchestratorOutcome { detail: format!("reply from agent {from} delivered"), reply: None, }) } /// Ensures the target agent has a **live PTY session**, returning its handle. /// /// Reuses the running terminal when present; otherwise launches the agent in the /// background (a normal PTH launch — a live PTY is the delegation channel). After /// a launch the handle is resolved from the registry; a missing handle is a /// [`AppError::Process`] (the launch did not register a PTY session, e.g. a profile /// IdeA cannot drive as a terminal). async fn ensure_live_pty( &self, project: &Project, agent_id: AgentId, conversation_id: domain::conversation::ConversationId, target: &str, ) -> Result { // «1 session vivante / conversation» (cadrage C3 §5.2) : on cherche d'abord la // session du **fil**, puis on retombe sur la session de l'agent (compat : un // agent mono-fil dont la session n'a pas encore été liée à sa conversation). let existing = self .sessions .session_for(conversation_id) .or_else(|| self.sessions.session_for_agent(&agent_id)); if let Some(session_id) = existing { if let Some(handle) = self.sessions.handle(&session_id) { // (Re)lier le fil à cette session vivante (idempotent). self.bind_conversation_session(conversation_id, session_id); return Ok(handle); } } // Dead target: launch it in the background (PTY). On injecte ici la déclaration // MCP **réelle** via le [`McpRuntimeProvider`] câblé (app-tauri détient l'exe et // l'endpoint) — c'est ce qui permet au pont MCP de la cible de se spawner et donc // à la cible d'appeler `idea_reply`. Provider absent (ou `runtime_for` → `None`) // ⇒ déclaration minimale comme avant (dégradation gracieuse). self.launch_agent .execute(LaunchAgentInput { project: project.clone(), agent_id, rows: DEFAULT_ROWS, cols: DEFAULT_COLS, node_id: None, conversation_id: None, mcp_runtime: self .mcp_runtime_provider .as_ref() .and_then(|p| p.runtime_for(project, agent_id)), }) .await?; let session_id = self .sessions .session_for_agent(&agent_id) .ok_or_else(|| { AppError::Process(format!( "agent {target} n'a pas de session terminal vivante après lancement" )) })?; // Lier la session fraîchement lancée à CE fil (registre terminal + registre de // conversations) ⇒ un prochain ask sur le même fil la réutilise. self.bind_conversation_session(conversation_id, session_id); self.sessions.handle(&session_id).ok_or_else(|| { AppError::Process(format!("handle PTY de l'agent {target} introuvable après lancement")) }) } /// Binds `conversation` to `session` in both the terminal registry (fast /// `session_for`) and the [`ConversationRegistry`] (domain thread state), when the /// latter is wired. Idempotent. fn bind_conversation_session( &self, conversation: domain::conversation::ConversationId, session: domain::SessionId, ) { self.sessions.bind_conversation(conversation, session); if let Some(reg) = &self.conversations { reg.bind_session(conversation, SessionRef::new(session)); } } /// Resolves a human-friendly label for the **requesting** agent to prefix the /// delegated task with. Best-effort: there is no requester id threaded into /// [`OrchestratorCommand::AskAgent`] today, so this falls back to a stable /// `"un autre agent"` label. Kept as a seam so a future requester-aware ask can /// surface the real name without touching the call sites. async fn requester_label(&self, project: &Project, requester: Option) -> String { match requester { None => "un autre agent".to_owned(), Some(from) => self .find_name_by_agent_id(project, from) .await .unwrap_or_else(|| "un autre agent".to_owned()), } } /// Best-effort display name for an agent id (manifest lookup). `None` when the id /// is unknown — the caller falls back to a generic label. async fn find_name_by_agent_id(&self, project: &Project, id: AgentId) -> Option { self.list_agents .execute(ListAgentsInput { project: project.clone(), }) .await .ok()? .agents .into_iter() .find(|a| a.id == id) .map(|a| a.name) } /// Builds the success outcome of an `ask` and publishes [`DomainEvent::AgentReplied`] /// (best-effort: a missing event bus never fails the rendezvous). fn reply_outcome( &self, agent_id: domain::AgentId, target: &str, content: String, ) -> OrchestratorOutcome { if let Some(events) = &self.events { events.publish(DomainEvent::AgentReplied { agent_id, reply_len: content.len(), }); } OrchestratorOutcome { detail: format!("agent {target} replied ({} bytes)", content.len()), reply: Some(content), } } /// `list_agents`: discovery — return the project's agents exactly as the UI /// reads them from the manifest, via the **same** [`ListAgents`] use case. /// /// The list is serialised as a JSON array into [`OrchestratorOutcome::reply`] /// (the existing inline-payload channel, also used by `ask`), with a one-line /// count in [`OrchestratorOutcome::detail`]. Each element carries the agent's /// `id`, `name`, `contextPath`, `profileId`, `origin`, `synchronized` and /// `skills` (camelCase, the [`domain::Agent`] serde shape). /// /// # Errors /// Propagates [`AppError`] from the use case (manifest load / invariant) or a /// serialisation failure ([`AppError::Invalid`]). async fn list_agents(&self, project: &Project) -> Result { let listed = self .list_agents .execute(ListAgentsInput { project: project.clone(), }) .await?; let reply = serde_json::to_string(&listed.agents) .map_err(|e| AppError::Invalid(format!("failed to serialise agent list: {e}")))?; Ok(OrchestratorOutcome { detail: format!("listed {} agent(s)", listed.agents.len()), reply: Some(reply), }) } /// `stop_agent`: translate the agent name → its live session → `CloseTerminal`. async fn stop_agent( &self, project: &Project, name: String, ) -> Result { let agent_id = self .find_agent_id_by_name(project, &name) .await? .ok_or_else(|| AppError::NotFound(format!("agent {name}")))?; let session_id = self .sessions .session_for_agent(&agent_id) .ok_or_else(|| AppError::NotFound(format!("running session for agent {name}")))?; self.close_terminal .execute(CloseTerminalInput { session_id }) .await?; Ok(OrchestratorOutcome { detail: format!("stopped agent {name}"), reply: None, }) } /// `update_agent_context`: overwrite the agent's `.md` body. async fn update_agent_context( &self, project: &Project, name: String, context: String, ) -> Result { let agent_id = self .find_agent_id_by_name(project, &name) .await? .ok_or_else(|| AppError::NotFound(format!("agent {name}")))?; self.update_context .execute(UpdateAgentContextInput { project: project.clone(), agent_id, content: context, }) .await?; Ok(OrchestratorOutcome { detail: format!("updated context for agent {name}"), reply: None, }) } /// `create_skill`: create a reusable skill in the requested scope — the same /// path the UI's "New skill" action takes. For [`SkillScope::Project`] the /// skill lands under `/.ideai/skills/`; for [`SkillScope::Global`] the /// project root is ignored by the store. async fn create_skill( &self, project: &Project, name: String, content: String, scope: domain::SkillScope, ) -> Result { let created = self .create_skill .execute(CreateSkillInput { name: name.clone(), content, scope, project_root: project.root.clone(), }) .await?; Ok(OrchestratorOutcome { detail: format!("created skill {} ({:?})", created.skill.name, scope), reply: None, }) } /// Finds an agent id by display name (case-insensitive) in the project manifest. async fn find_agent_id_by_name( &self, project: &Project, name: &str, ) -> Result, AppError> { let listed = self .list_agents .execute(ListAgentsInput { project: project.clone(), }) .await?; Ok(listed .agents .into_iter() .find(|a| a.name.eq_ignore_ascii_case(name)) .map(|a| a.id)) } /// Finds the full [`domain::Agent`] by display name (case-insensitive) in the /// project manifest. Variante de [`Self::find_agent_id_by_name`] qui conserve /// l'agent entier (notamment son `profile_id`), nécessaire à la garde F2. async fn find_agent_by_name( &self, project: &Project, name: &str, ) -> Result, AppError> { let listed = self .list_agents .execute(ListAgentsInput { project: project.clone(), }) .await?; Ok(listed .agents .into_iter() .find(|a| a.name.eq_ignore_ascii_case(name))) } /// Garde F2 : vérifie que le profil de la cible **sait consommer** le pont /// `idea_*` matérialisé par IdeA, et renvoie sinon une [`AppError::Invalid`] /// **immédiate** (au lieu d'un timeout 300s muet sur le round-trip). /// /// **Critère retenu** (le plus robuste aujourd'hui) : le pont est honoré ssi le /// profil porte une capacité MCP en stratégie `ConfigFile` ciblant `.mcp.json` /// **ET** que son adaptateur structuré est `Claude`. En effet IdeA matérialise le /// serveur MCP sous forme d'un fichier `.mcp.json` dans le run dir, ce que **seul** /// Claude Code lit réellement ; Codex déclare pourtant la même stratégie /// `ConfigFile(.mcp.json)` mais lit en pratique `~/.codex/config.toml` ⇒ le pont /// n'est jamais branché et la cible ne peut pas appeler `idea_reply`. On exige donc /// l'adaptateur `Claude` plutôt qu'une simple présence de capacité MCP, ce qui /// exclut Codex de fait et reste valable pour tout futur profil non-Claude. /// /// Profil introuvable ⇒ on **n'interdit pas** (laisse le flux suivre son cours /// comme avant) : la garde ne fait que transformer un échec connu en erreur typée. async fn guard_mcp_bridge_supported( &self, profile_id: &ProfileId, target: &str, ) -> Result<(), AppError> { use domain::profile::{McpConfigStrategy, StructuredAdapter}; let Some(profile) = self .profiles .list() .await? .into_iter() .find(|p| &p.id == profile_id) else { return Ok(()); }; let honours_mcp_json = matches!( profile.mcp.as_ref().map(|c| &c.config), Some(McpConfigStrategy::ConfigFile { target }) if target == ".mcp.json" ); let is_claude = profile.structured_adapter == Some(StructuredAdapter::Claude); if honours_mcp_json && is_claude { return Ok(()); } Err(AppError::Invalid(format!( "la cible '{target}' (profil '{}', adaptateur {:?}) ne supporte pas encore le \ pont idea_* : la délégation inter-agents passe par un serveur MCP déclaré en \ .mcp.json, que seul un profil Claude consomme aujourd'hui. Cible un agent au \ profil Claude.", profile.name, profile.structured_adapter ))) } /// Resolves the **prompt-ready pattern** (cadrage §6, lot C5) of the agent's /// profile, used to arm the [`InputMediator`]'s prompt detection at `bind_handle` /// time. Returns `None` when the agent, its profile, or the pattern is absent — /// the safe fallback: no pattern ⇒ Idle only via explicit signal/timeout. async fn prompt_pattern_for_agent( &self, project: &Project, agent_id: AgentId, ) -> Option { let agent = self .list_agents .execute(ListAgentsInput { project: project.clone(), }) .await .ok()? .agents .into_iter() .find(|a| a.id == agent_id)?; let profiles = self.profiles.list().await.ok()?; profiles .into_iter() .find(|p| p.id == agent.profile_id) .and_then(|p| p.prompt_ready_pattern) } /// Resolves a human-friendly profile reference (slug like `claude-code`, /// command like `claude`, or display name like `Claude Code`) to a configured /// [`ProfileId`]. Matching is universal — never hard-coded to one AI — by /// scanning the configured profiles' command and name. /// /// # Errors /// [`AppError::NotFound`] when no configured profile matches. async fn resolve_profile(&self, reference: &str) -> Result { let needle = normalise(reference); let profiles = self.profiles.list().await?; profiles .into_iter() .find(|p| { normalise(&p.command) == needle || normalise(&p.name) == needle || p.id.to_string() == reference }) .map(|p| p.id) .ok_or_else(|| AppError::NotFound(format!("profile matching '{reference}'"))) } } /// RAII guard that posts a wait-for edge `from → to` for the duration of an ask and /// removes it on drop (reply, timeout, or any early return) — the same discipline as /// the per-agent turn lock. Holds a raw pointer-free borrow via the shared mutex on /// the service's [`WaitForGraph`]; constructed only inside `ask_agent` where the /// service outlives the guard. struct WaitEdgeGuard<'a> { graph: &'a StdMutex, from: AgentId, to: AgentId, } impl<'a> WaitEdgeGuard<'a> { fn new(service: &'a OrchestratorService, from: AgentId, to: AgentId) -> Self { { let mut g = service .wait_for .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); g.add_edge(from, to); } Self { graph: &service.wait_for, from, to, } } } impl Drop for WaitEdgeGuard<'_> { fn drop(&mut self) { let mut g = self .graph .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); g.remove_edge(self.from, self.to); } } /// Normalises a profile reference for tolerant matching: lowercased, with spaces, /// dashes and underscores stripped (`"Claude Code"`, `"claude-code"`, `"claude"` /// → comparable forms; `claude` ⊂ ... handled by the command match above). fn normalise(s: &str) -> String { s.chars() .filter(|c| c.is_ascii_alphanumeric()) .map(|c| c.to_ascii_lowercase()) .collect() } #[cfg(test)] mod tests { use super::*; use domain::profile::{AgentProfile, ContextInjection}; use domain::ProfileId; fn profile(id: u128, name: &str, command: &str) -> AgentProfile { AgentProfile::new( ProfileId::from_uuid(uuid::Uuid::from_u128(id)), name, command, Vec::new(), ContextInjection::convention_file("CLAUDE.md").unwrap(), None, "{agentRunDir}", None, ) .unwrap() } #[test] fn normalise_makes_slug_command_and_name_comparable() { assert_eq!(normalise("Claude Code"), "claudecode"); assert_eq!(normalise("claude-code"), "claudecode"); assert_eq!(normalise("claude_code"), "claudecode"); } #[test] fn resolve_matches_by_command_name_or_id() { // We exercise the pure matching predicate the same way `resolve_profile` // does, without standing up the whole service/ports. let p = profile(1, "Claude Code", "claude"); let by_command = normalise("claude") == normalise(&p.command); let by_name = normalise("claude-code") == normalise(&p.name); assert!(by_command); assert!(by_name); assert_eq!(p.id.to_string(), p.id.to_string()); } }