//! [`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::path::{Component, Path, PathBuf}; use std::sync::{Arc, Mutex as StdMutex}; use std::time::{Duration, Instant}; use tokio::sync::Mutex as AsyncMutex; use domain::conversation::{ConversationParty, ConversationRegistry, SessionRef, WaitForGraph}; use domain::conversation_log::{ConversationTurn, TurnId, TurnRole}; use domain::input::{InputMediator, InputSource, SubmitConfig}; use domain::mailbox::{Ticket, TicketId}; use domain::ports::{BackgroundTaskStore, Clock, EventBus, ProfileStore, PtyHandle}; use domain::project::ProjectPath; use domain::{ AgentId, AgentProfile, BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project, TaskId, }; use crate::conversation::RecordTurn; use crate::memory::HarvestMemoryFromTurn; use crate::agent::{ drain_with_readiness_and_announcements, AnnouncementPublisher, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext, UpdateAgentContextInput, CODEX_SUBMIT_DELAY_MS, }; use crate::background::{SpawnBackgroundCommand, SpawnBackgroundCommandInput}; use crate::error::AppError; use crate::orchestrator::rendezvous::{run_inactivity_watchdog, WatchdogOutcome}; use crate::orchestrator::{ ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory, ReadMemoryInput, WriteMemory, WriteMemoryInput, }; use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput}; use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions}; use crate::workstate::{GetLiveStateLean, UpdateLiveState, UpdateLiveStateInput}; /// 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; /// Submit defaults for delegated prompts, after applying profile-specific /// compatibility fallbacks for existing saved profiles. fn submit_config_for_profile(profile: &AgentProfile) -> SubmitConfig { let delay_ms = profile.submit_delay_ms.or_else(|| { matches!( profile.structured_adapter, Some(domain::profile::StructuredAdapter::Codex) ) .then_some(CODEX_SUBMIT_DELAY_MS) }); SubmitConfig::new(profile.submit_sequence.clone(), delay_ms) } fn structured_no_reply_error(err: &domain::ports::AgentSessionError) -> bool { matches!( err, domain::ports::AgentSessionError::Io(message) if message.contains("sans événement Final") || message.contains("without a structured final") ) } fn bound_task_text(value: &str, max_bytes: usize) -> String { if value.len() <= max_bytes { return value.to_owned(); } let mut end = max_bytes; while !value.is_char_boundary(end) { end -= 1; } value[..end].to_owned() } fn resolve_background_cwd( project_root: &ProjectPath, cwd: Option, ) -> Result { let root = Path::new(project_root.as_str()); let raw = cwd.as_deref().map(str::trim).filter(|s| !s.is_empty()); let candidate = match raw { None => root.to_path_buf(), Some(value) => { let path = Path::new(value); if path.is_absolute() { path.to_path_buf() } else { root.join(path) } } }; let normalized = normalize_path_no_parent(&candidate)?; if !normalized.starts_with(root) { return Err(AppError::Invalid( "background task cwd must stay under the project root".to_owned(), )); } let Some(path) = normalized.to_str() else { return Err(AppError::Invalid( "background task cwd is not valid UTF-8".to_owned(), )); }; ProjectPath::new(path).map_err(|_| AppError::Invalid("invalid background task cwd".to_owned())) } fn normalize_path_no_parent(path: &Path) -> Result { let mut out = PathBuf::new(); for component in path.components() { match component { Component::Prefix(prefix) => out.push(prefix.as_os_str()), Component::RootDir => out.push(component.as_os_str()), Component::CurDir => {} Component::Normal(part) => out.push(part), Component::ParentDir => { return Err(AppError::Invalid( "background task cwd must not contain `..`".to_owned(), )) } } } Ok(out) } /// 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). /// /// **Plancher universel FINI (backstop no-reply).** Le vrai signal de fin-de-tour /// existe désormais (`turn_duration` du transcript via [`domain::ports::TurnWatcher`] /// ⇒ [`domain::input::InputMediator::turn_ended`], qui réveille l'appelant en ≈ grâce), /// mais il n'est émis que par Claude. Ce timeout reste donc le **dernier recours** /// pour toute cible silencieuse qui n'émet pas `turn_duration` (Codex & co) ou qui /// hang : il ne doit JAMAIS être (quasi-)infini sous peine de wedger l'appelant. 600 s /// par défaut, **surchargeable par profil** via `turn_timeout_ms` /// ([`resolve_turn_timeout`]). const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(600); /// **Sonde de vivacité** d'une cible de délégation, keyée par son [`AgentId`] : renvoie /// un **jeton d'activité** monotone non-décroissant (en pratique la taille cumulée du /// transcript de la cible). Un jeton strictement plus grand entre deux sondes ⇒ la cible /// **progresse** (vivante et au travail, **même dans un seul long tour** qui n'émet aucun /// événement intermédiaire — cf. [`rendezvous`](crate::orchestrator::rendezvous)). /// `None` ⇒ vivacité non observable (cible/transcript introuvable) ⇒ traité comme /// « pas de progrès ». /// /// **Pourquoi un port et pas le flux d'événements** : le flux de /// [`domain::ports::AgentSession::send`] est **batché en fin de tour** (les `ReplyEvent` /// arrivent tous à la fin, pas en continu), donc inutilisable comme signe de vie *pendant* /// un long tour. La seule preuve de vie disponible est cette sonde externe (transcript). /// Closure abstraite (frontière hexagonale) construite à la composition root — la seule /// couche qui sait dériver le run-dir transcript d'un `AgentId` ; l'application reste pure. pub type AskLivenessProbe = Arc< dyn Fn( domain::project::ProjectPath, AgentId, ) -> std::pin::Pin> + Send>> + Send + Sync, >; /// Plafond **absolu** par défaut du rendez-vous délégué : la fenêtre d'inactivité /// réarmée ne parque jamais un `ask` au-delà, même contre une cible perpétuellement /// active. Généreux (4 h) pour qu'un tour unique lourd mais réel finisse bien avant. /// Surchargeable via `IDEA_ASK_RENDEZVOUS_CEILING_MS` à la composition root. const ASK_AGENT_CEILING: Duration = crate::orchestrator::rendezvous::DEFAULT_RENDEZVOUS_CEILING; /// 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. /// /// FINI (cf. [`ASK_AGENT_TIMEOUT`]) : deux tours pleins d'attente ⇒ 2 × 600 s = 1200 s. /// Comme la borne de tour, ce plafond reste fini (jamais d'attente quasi-infinie en file). const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(1200); /// Borne de tour effective (lot 2, timeouts pilotés par profil) : le /// `turn_timeout_ms` du profil de la cible **quand il existe**, sinon le défaut /// historique [`ASK_AGENT_TIMEOUT`] (zéro régression pour un profil sans `liveness` / /// sans `turn_timeout_ms`). Pure et testable sans wiring de service. #[must_use] fn resolve_turn_timeout(turn_timeout_ms: Option) -> Duration { turn_timeout_ms.map_or(ASK_AGENT_TIMEOUT, |ms| Duration::from_millis(u64::from(ms))) } /// Garde RAII de **fin de tour** : ramène la cible `Idle` quoi qu'il arrive (succès, /// erreur, timeout, **et surtout future abandonné/dropped**) tant qu'elle n'a pas été /// désarmée. /// /// Cause racine du blocage `Busy` à vie : l'agent passe `Idle→Busy` dès l'`enqueue` /// (qui démarre le tour) mais ne revenait `Idle` que sur la **branche succès** du /// `select!`/`match`. Si le futur `ask_agent` est **dropped** (le demandeur a interrompu /// son appel), AUCUNE branche ne s'exécute ⇒ l'agent reste `Busy`, et toutes les /// délégations suivantes s'empilent derrière ce tour fantôme sans jamais être livrées. /// /// Le garde tient les `Arc` nécessaires pour, à son `Drop`, faire à la fois /// `cancel_head(agent, ticket)` (retire le ticket fantôme de la FIFO — idempotent et /// positionnel : no-op si le head a déjà changé) **et** `mark_idle(agent)` (libère la /// FIFO — idempotent). Sur **succès**, on appelle [`BusyTurnGuard::disarm`] AVANT de /// retourner : le `mark_idle` « propre » déjà présent reste en place et on évite tout /// double `cancel_head` d'un ticket déjà résolu. struct BusyTurnGuard { input: Arc, mailbox: Arc, agent: AgentId, ticket: TicketId, armed: bool, } impl BusyTurnGuard { /// Arme le garde juste après l'`enqueue` (qui a démarré le tour ⇒ cible `Busy`). fn new( input: Arc, mailbox: Arc, agent: AgentId, ticket: TicketId, ) -> Self { Self { input, mailbox, agent, ticket, armed: true, } } /// Désarme le garde (branche succès) : le `Drop` devient un no-op. À appeler AVANT /// de retourner le contenu, pour préserver le `mark_idle` propre déjà fait et ne pas /// re-`cancel_head` un ticket déjà résolu. fn disarm(mut self) { self.armed = false; } } impl Drop for BusyTurnGuard { fn drop(&mut self) { if self.armed { // Ordre : retirer le ticket fantôme de la FIFO PUIS libérer le busy state. // `cancel_head` est positionnel/idempotent (no-op si le head n'est plus ce // ticket) et `mark_idle` est idempotent (no-op si déjà Idle) ⇒ sûr quel que // soit le chemin (erreur, timeout, drop). self.mailbox.cancel_head(self.agent, self.ticket); self.input.mark_idle(self.agent); // Rendezvous beacon (diagnostics) : le garde a libéré une cible restée Busy // (chemin erreur / timeout / futur ask abandonné). Si ce beacon apparaît // sans « ask resolved », la cible n'a jamais répondu — c'est le scénario de // blocage à diagnostiquer. crate::diag!( "[rendezvous] busy-guard freed target agent {} (ticket {})", self.agent, self.ticket, ); } } } /// 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; } /// Fournit le use case [`RecordTurn`] **lié au project root** de la délégation en /// cours (lot P6b). /// /// L'[`OrchestratorService`] est **unique et partagé par tous les projets ouverts** /// (un seul `Arc` au composition root), alors que le log/handoff conversationnel est /// **par project root** (`/.ideai/conversations/`, comme la mémoire). Les /// adapters `Fs*` de P6a fixent leur racine à la construction et leur port ne porte /// pas le root par appel ; on ne peut donc pas figer un `RecordTurn` global. Ce /// **port** lève la tension : `ask_agent` connaît le `project.root` du tour et demande /// au provider de matérialiser un `RecordTurn` ciblant le **bon** dossier (les adapters /// `Fs*` ne font que des jointures de chemin, leur construction est triviale). /// /// `None` ⇒ aucune persistance (best-effort absente) : zéro régression pour les call /// sites/tests qui ne le branchent pas. Implémenté dans app-tauri (seul détenteur des /// adapters `Fs*`). pub trait RecordTurnProvider: Send + Sync { /// Construit le [`RecordTurn`] dont le log/handoff ciblent `root`. Appelé une fois /// par checkpoint best-effort ; `None` ⇒ on saute silencieusement la persistance. fn record_turn_for(&self, root: &ProjectPath) -> Option>; } /// Provider **par project root** d'un [`UpdateLiveState`] (lot LS3), calqué sur /// [`RecordTurnProvider`]. /// /// Même tension résolue de la même façon : l'[`OrchestratorService`] est **unique** /// pour tous les projets, mais le live-state est **par project root** /// (`/.ideai/live-state.json`) — et le port [`domain::ports::LiveStateStore`] /// fixe son root à la construction (pas de paramètre `root` par appel, contrairement /// au `MemoryStore` du harvest). `ask_agent` connaît le `project.root` du tour et /// demande au provider de matérialiser un `UpdateLiveState` ciblant le **bon** dossier /// (les adapters `Fs*` ne font que des jointures de chemin, leur construction est /// triviale). /// /// `None` ⇒ aucun auto-update : zéro régression. Implémenté dans app-tauri (seul /// détenteur des adapters `Fs*` et de l'horloge). pub trait LiveStateProvider: Send + Sync { /// Construit l'[`UpdateLiveState`] dont le store cible `root`. Appelé une fois par /// transition best-effort ; `None` ⇒ on saute silencieusement l'auto-update. fn live_state_for(&self, root: &ProjectPath) -> Option>; } /// Provider **par project root** d'un [`GetLiveStateLean`] (lot LS4), pour servir /// l'outil de lecture `idea_workstate_read`. /// /// Même tension/réponse que [`LiveStateProvider`] : l'[`OrchestratorService`] est /// **unique** pour tous les projets, mais le live-state est **par project root** et le /// port [`domain::ports::LiveStateStore`] fixe son root à la construction. `read_workstate` /// connaît le `project.root` et demande au provider un `GetLiveStateLean` ciblant le /// **bon** dossier (prune-on-read + snapshot lean). /// /// `None` ⇒ l'outil renvoie « not configured » (call sites/tests legacy restent verts). /// Implémenté dans app-tauri (seul détenteur des adapters `Fs*` et de l'horloge). pub trait LiveStateReadProvider: Send + Sync { /// Construit le [`GetLiveStateLean`] dont le store cible `root`. `None` ⇒ l'outil /// `idea_workstate_read` n'est pas servi pour ce root. fn live_state_lean_for(&self, root: &ProjectPath) -> 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, /// Snapshot opérationnel des mêmes arêtes d'attente, exposé au chemin d'arrêt /// utilisateur : si A est stoppé pendant qu'il attend B, IdeA stoppe aussi B. /// Séparé de [`WaitForGraph`] qui reste un objet domaine minimal de détection de /// cycle, sans API de traversal. active_waits: 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>, /// Provider du use case [`RecordTurn`] **par project root** (lot P6b), pour /// persister **best-effort** le Prompt et la Response de chaque paire déléguée dans /// le bon `conversationId`. Injecté avec son horloge via [`Self::with_record_turn`] ; /// `None` ⇒ aucune persistance (zéro régression pour les call sites/tests legacy). /// Un échec de persistance ne transforme **jamais** un succès de délégation en erreur. record_turn: Option>, /// Horloge millis (port [`Clock`]) pour estampiller `at_ms` des tours persistés — /// injectée avec [`Self::record_turn`]. La couche `application` reste **pure** : pas /// de `SystemTime::now()` brut ici, le temps vient du port injecté au composition root. clock: Option>, /// Registre des **sessions IA structurées** vivantes (§17.5), pour router un `ask` /// vers une cible à `structured_adapter` directement sur sa session /// ([`drain_with_readiness`](crate::agent::drain_with_readiness)) — le `Final` /// déterministe réveille le `pending` **et** marque l'agent `Idle` (chantier /// readiness/heartbeat, lot 1, fix du blocage `Busy`). Injecté via /// [`Self::with_structured`] ; `None` ⇒ on conserve **uniquement** le chemin /// PTY/mailbox legacy (zéro régression pour les call sites/tests qui ne le branchent /// pas). structured: Option>, /// Harvester d'auto-mémoire (Lot E1), branché **après** le checkpoint Response /// best-effort d'un `ask` réussi : parse les blocs ` ```idea-memory ` de la /// réponse et persiste les notes valides via le `MemoryStore`. Injecté via /// [`Self::with_memory_harvest`] ; `None` ⇒ aucun harvest (zéro régression). Un /// échec de harvest ne transforme **jamais** un succès de délégation en erreur. memory_harvest: Option>, /// Use case de lecture d'un skill **par nom** (`idea_skill_read`, feature /// « skills à la MCP »). Compose le port `SkillStore` existant (aucun nouveau /// port). Injecté via [`Self::with_read_skill`] ; `None` ⇒ la commande /// `skill.read` renvoie une erreur typée (call sites/tests legacy restent verts). read_skill: Option>, /// Auto-update du **live-state** (programme live-state, lot LS3), dérivé des /// transitions de délégation déjà existantes (zéro token agent, zéro appel /// modèle) : la cible passe `Working` quand un `ask` est accepté, puis `Done` /// (+ `last_delegation`) quand elle rend son résultat. Injecté via /// [`Self::with_live_state`] ; `None` (défaut) ⇒ aucun auto-update (zéro /// régression). **Best-effort strict** : un échec de persistance ne transforme /// **jamais** un succès de délégation/reply en erreur. live_state: Option>, /// Provider de lecture du **live-state** (lot LS4) pour l'outil /// `idea_workstate_read`. Distinct de [`Self::live_state`] (écriture) : la lecture /// applique le prune-on-read et renvoie le snapshot lean enrichi du nom d'agent. /// Injecté via [`Self::with_live_state_read`] ; `None` ⇒ l'outil renvoie une erreur /// typée « not configured » (call sites/tests legacy restent verts). live_state_read: Option>, /// **Sonde de vivacité** de la cible (signe de vie) pilotant la fenêtre d'inactivité /// du rendez-vous délégué (cf. [`AskLivenessProbe`] et /// [`rendezvous`](crate::orchestrator::rendezvous)). Injectée via /// [`Self::with_ask_liveness_probe`]. `None` ⇒ la borne de tour reste un **timeout /// plat** (fallback fenêtre plate = comportement historique, zéro régression). ask_liveness_probe: Option, /// Plafond **absolu** du rendez-vous délégué (cf. [`ASK_AGENT_CEILING`]) : la fenêtre /// d'inactivité réarmée ne parque jamais un `ask` au-delà. Surchargeable via /// [`Self::with_ask_ceiling`] (la composition root résout l'override d'env) ; les /// tests le rétrécissent à quelques ms. Défaut = 4 h. ask_ceiling: Duration, /// Store durable des tâches de fond de 1re classe. B6 l'utilise pour tracer un /// `idea_ask_agent` comme [`BackgroundTaskKind::HeadlessRendezvous`]. `None` ⇒ /// comportement historique sans persistance de tâche (call sites non câblés). background_tasks: Option>, /// Use case B8 qui produit une tâche de fond command-backed depuis la surface /// agent MCP `idea_run_in_background`. `None` ⇒ outil non configuré. spawn_background_command: 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()), active_waits: StdMutex::new(Vec::new()), events: None, ask_locks: StdMutex::new(HashMap::new()), mcp_runtime_provider: None, context_guard: None, record_turn: None, clock: None, structured: None, memory_harvest: None, read_skill: None, live_state: None, live_state_read: None, ask_liveness_probe: None, ask_ceiling: ASK_AGENT_CEILING, background_tasks: None, spawn_background_command: None, } } /// Branche le producteur B8 command-backed pour `idea_run_in_background`. #[must_use] pub fn with_spawn_background_command(mut self, spawn: Arc) -> Self { self.spawn_background_command = Some(spawn); self } /// Branche le store des tâches de fond pour tracer les rendez-vous inter-agents /// comme [`BackgroundTaskKind::HeadlessRendezvous`] (B6). /// /// Le `clock` fournit les timestamps métier de la tâche. Il réutilise le champ /// horloge applicatif déjà existant ; si un provider de conversation log l'avait /// déjà posé, la même horloge reste partagée. #[must_use] pub fn with_background_tasks( mut self, store: Arc, clock: Arc, ) -> Self { self.background_tasks = Some(store); self.clock = Some(clock); self } /// Branche la **sonde de vivacité** (signe de vie) du rendez-vous délégué : la borne /// de tour devient une **fenêtre d'inactivité** réarmée à chaque progrès observé, /// sous le plafond [`Self::with_ask_ceiling`]. Builder additif (signature de /// [`Self::new`] inchangée) ; sans sonde, la borne reste un timeout plat (fallback, /// zéro régression). Cf. [`AskLivenessProbe`]. #[must_use] pub fn with_ask_liveness_probe(mut self, probe: AskLivenessProbe) -> Self { self.ask_liveness_probe = Some(probe); self } /// Surcharge le **plafond absolu** du rendez-vous délégué (défaut [`ASK_AGENT_CEILING`] /// = 4 h) au-delà duquel la fenêtre d'inactivité réarmée ne parque jamais un `ask`. /// La composition root y applique l'override d'env ; les tests le rétrécissent. /// Builder additif. #[must_use] pub fn with_ask_ceiling(mut self, ceiling: Duration) -> Self { self.ask_ceiling = ceiling; self } /// Branche le use case [`ReadSkill`] (`skill.read`/`idea_skill_read`). Builder /// additif (signature de [`Self::new`] inchangée) ; `None` ⇒ la commande /// renvoie une erreur typée « not configured ». #[must_use] pub fn with_read_skill(mut self, read_skill: Arc) -> Self { self.read_skill = Some(read_skill); self } /// Branche le registre des [`StructuredSessions`] (§17.5) pour router un `ask` /// vers une cible **structurée** sur sa propre session. Builder additif (signature /// de [`Self::new`] inchangée) ; `None` ⇒ chemin PTY/mailbox legacy uniquement. #[must_use] pub fn with_structured(mut self, structured: Arc) -> Self { self.structured = Some(structured); self } /// 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()) } /// Returns the transitive set of agents currently waited on by `agent`. /// /// Used by user-driven cancellation: stopping A while A waits on B should also /// stop B, and then any agent B itself waits on. The snapshot is best-effort and /// lock-bounded; callers perform the actual interruption/stop outside the mutex. #[must_use] pub fn active_wait_dependencies(&self, agent: AgentId) -> Vec { let edges = self .active_waits .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); Self::active_wait_dependencies_from_edges(&edges, agent) } /// Returns the transitive dependencies of `agent` in an active wait-edge snapshot. #[must_use] fn active_wait_dependencies_from_edges( edges: &[(AgentId, AgentId)], agent: AgentId, ) -> Vec { let mut out = Vec::new(); let mut stack: Vec = edges .iter() .filter_map(|(from, to)| (*from == agent).then_some(*to)) .collect(); while let Some(next) = stack.pop() { if out.contains(&next) { continue; } out.push(next); stack.extend( edges .iter() .filter_map(|(from, to)| (*from == next).then_some(*to)), ); } out } /// 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 } fn now_ms(&self) -> u64 { self.clock .as_ref() .map_or(0, |clock| clock.now_millis().max(0) as u64) } #[allow(clippy::too_many_arguments)] async fn start_rendezvous_task( &self, project: &Project, owner_agent_id: AgentId, requester_agent_id: Option, target_agent_id: AgentId, ticket_id: TicketId, conversation_id: domain::conversation::ConversationId, ) -> Result, AppError> { let Some(store) = &self.background_tasks else { return Ok(None); }; let task_id = TaskId::new_random(); let now = self.now_ms(); let task = BackgroundTask::new( task_id, project.id, owner_agent_id, BackgroundTaskKind::HeadlessRendezvous { requester_agent_id, target_agent_id, ticket_id, conversation_id, }, BackgroundTaskWakePolicy::RecordOnly, now, None, ) .map_err(|err| AppError::Internal(err.to_string()))? .transition(BackgroundTaskState::Running, now) .map_err(|err| AppError::Internal(err.to_string()))?; store .create(&task) .await .map_err(|err| AppError::Store(err.to_string()))?; if let Some(events) = &self.events { events.publish(DomainEvent::BackgroundTaskStarted { project_id: project.id, task_id, owner_agent_id, }); } Ok(Some(task_id)) } async fn complete_rendezvous_task_success( &self, project: &Project, owner_agent_id: AgentId, task_id: Option, reply: &str, ) -> Result<(), AppError> { let result = BackgroundTaskResult::Success { finished_at_ms: self.now_ms(), exit_code: None, summary: "Headless rendezvous completed with Final".to_owned(), stdout_tail: Some(bound_task_text( reply, domain::BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES, )), stderr_tail: None, }; self.finish_rendezvous_task(project, owner_agent_id, task_id, result) .await } async fn complete_rendezvous_task_failure( &self, project: &Project, owner_agent_id: AgentId, task_id: Option, error: impl Into, ) -> Result<(), AppError> { let result = BackgroundTaskResult::Failure { finished_at_ms: self.now_ms(), exit_code: None, error: bound_task_text(&error.into(), domain::BACKGROUND_TASK_TEXT_MAX_BYTES), stdout_tail: None, stderr_tail: None, }; self.finish_rendezvous_task(project, owner_agent_id, task_id, result) .await } async fn complete_rendezvous_task_cancelled( &self, project: &Project, owner_agent_id: AgentId, task_id: Option, reason: impl Into, ) -> Result<(), AppError> { let result = BackgroundTaskResult::Cancelled { finished_at_ms: self.now_ms(), reason: bound_task_text(&reason.into(), domain::BACKGROUND_TASK_TEXT_MAX_BYTES), }; self.finish_rendezvous_task(project, owner_agent_id, task_id, result) .await } async fn finish_rendezvous_task( &self, project: &Project, owner_agent_id: AgentId, task_id: Option, result: BackgroundTaskResult, ) -> Result<(), AppError> { let (Some(store), Some(task_id)) = (&self.background_tasks, task_id) else { return Ok(()); }; let task = store .get(task_id) .await .map_err(|err| AppError::Store(err.to_string()))? .ok_or_else(|| AppError::Store(format!("background task {task_id} not found")))?; if task.is_terminal() { return Ok(()); } let event = match &result { BackgroundTaskResult::Success { .. } => DomainEvent::BackgroundTaskCompleted { project_id: project.id, task_id, owner_agent_id, }, BackgroundTaskResult::Failure { .. } => DomainEvent::BackgroundTaskFailed { project_id: project.id, task_id, owner_agent_id, }, BackgroundTaskResult::Cancelled { .. } | BackgroundTaskResult::Expired { .. } => { DomainEvent::BackgroundTaskCancelled { project_id: project.id, task_id, owner_agent_id, } } }; let completed = task .complete(result) .map_err(|err| AppError::Internal(err.to_string()))?; store .save(&completed) .await .map_err(|err| AppError::Store(err.to_string()))?; if let Some(events) = &self.events { events.publish(event); } Ok(()) } /// 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 } /// Branche le provider de [`RecordTurn`] **par project root** + son horloge (lot /// P6b) pour persister **best-effort** le Prompt et la Response de chaque paire /// déléguée. Builder additif : signature de [`Self::new`] **inchangée** (les /// tests/call sites legacy restent verts ; `None` ⇒ aucune persistance, donc aucune /// régression). Un échec de persistance ne casse jamais la délégation live. #[must_use] pub fn with_record_turn( mut self, record_turn: Arc, clock: Arc, ) -> Self { self.record_turn = Some(record_turn); self.clock = Some(clock); self } /// Branche le harvester d'auto-mémoire (Lot E1), appelé **après** le checkpoint /// `RecordTurn` de la réponse d'un `ask` réussi. `None` (défaut) ⇒ aucun harvest. /// Le `MemoryStore` prend le `root` par appel, donc un seul use case sert tous /// les projets (pas de provider par root nécessaire, contrairement au handoff). #[must_use] pub fn with_memory_harvest(mut self, harvester: Arc) -> Self { self.memory_harvest = Some(harvester); self } /// Branche l'auto-update du live-state (lot LS3). Builder additif (signature de /// [`Self::new`] inchangée) ; `None` (défaut) ⇒ aucun auto-update. Prend un /// **provider par root** (calqué sur `with_record_turn`) car le live-state est par /// project root et le port fixe son root à la construction. #[must_use] pub fn with_live_state(mut self, live_state: Arc) -> Self { self.live_state = Some(live_state); self } /// Branche le provider de **lecture** du live-state (lot LS4) pour l'outil /// `idea_workstate_read`. Builder additif (signature de [`Self::new`] inchangée) ; /// `None` (défaut) ⇒ l'outil renvoie « not configured ». Provider **par root** car /// le live-state est par project root et le port fixe son root à la construction. #[must_use] pub fn with_live_state_read(mut self, provider: Arc) -> Self { self.live_state_read = Some(provider); self } /// Distille l'objectif d'un ticket en un `intent` court pour le live-state : /// whitespace normalisé puis tronqué à [`domain::live_state::FIELD_PREVIEW_MAX_CHARS`] /// (même mécanique que la preview du workstate). Borner **ici** garantit qu'on ne /// dépasse jamais le seuil dur anti-dump du domaine (un `ask` peut porter une tâche /// volumineuse, que `LiveEntry::new` rejetterait sinon — l'auto-update serait alors /// silencieusement perdu). fn distill_intent(task: &str) -> String { task.split_whitespace() .collect::>() .join(" ") .chars() .take(domain::live_state::FIELD_PREVIEW_MAX_CHARS) .collect() } /// Pose **best-effort** la cible d'une délégation acceptée en `Working` sur le /// live-state (lot LS3). No-op silencieux quand l'auto-update n'est pas câblé. /// Tout échec est avalé (un diag, jamais une erreur propagée) : l'auto-update /// dérivé ne doit **jamais** faire échouer une délégation. N'écrit **que** sur /// cette transition d'`ask` (jamais par tour/outil) ⇒ pas de write-storm. async fn mark_target_working_best_effort( &self, root: &ProjectPath, target: AgentId, ticket: TicketId, intent: String, ) { let Some(provider) = &self.live_state else { return; }; let Some(update) = provider.live_state_for(root) else { return; }; let outcome = update .execute(UpdateLiveStateInput { agent_id: target, ticket: Some(ticket), intent, status: domain::live_state::WorkStatus::Working, progress: None, last_delegation: None, }) .await; if let Err(e) = outcome { crate::diag!("[live-state] working auto-update failed: target={target} err={e}"); } } /// Pose **best-effort** la cible en `Done` (+ `last_delegation` = ticket résolu) /// sur le live-state (lot LS3), quand elle vient de rendre son résultat. Mêmes /// garanties best-effort/no-storm que [`Self::mark_target_working_best_effort`]. async fn mark_target_done_best_effort( &self, root: &ProjectPath, target: AgentId, ticket: TicketId, ) { let Some(provider) = &self.live_state else { return; }; let Some(update) = provider.live_state_for(root) else { return; }; let outcome = update .execute(UpdateLiveStateInput { agent_id: target, ticket: Some(ticket), intent: String::new(), status: domain::live_state::WorkStatus::Done, progress: None, last_delegation: Some(ticket), }) .await; if let Err(e) = outcome { crate::diag!("[live-state] done auto-update failed: target={target} err={e}"); } } /// Persiste **best-effort** un tour (`Prompt`/`Response`) dans `conversation`. /// /// No-op silencieux quand le provider/horloge ne sont pas câblés, quand le provider /// ne rend pas de [`RecordTurn`] pour ce root, ou quand l'`append`/`save` échoue : la /// persistance ne doit **jamais** transformer un succès de délégation en erreur, ni /// paniquer (contrat P6b). N'ajoute pas de latence inutile (un seul `await` borné par /// les adapters `Fs*`, déjà sérialisés par le verrou de tour de la cible). async fn record_turn_best_effort( &self, root: &ProjectPath, conversation: domain::conversation::ConversationId, source: InputSource, role: TurnRole, text: String, ) { let (Some(provider), Some(clock)) = (&self.record_turn, &self.clock) else { return; }; let Some(record) = provider.record_turn_for(root) else { return; }; let at_ms = u64::try_from(clock.now_millis()).unwrap_or(0); let turn = ConversationTurn::new( TurnId::new_random(), conversation, at_ms, source, role, text, ); // Best-effort : un échec de persistance est avalé (le contrat P6b interdit qu'il // remonte). Pas de framework de log dans `application` ; on reste cohérent avec // les autres effets best-effort du service (cf. publication `AgentReplied`). let _ = record.record(conversation, turn).await; } /// Récolte **best-effort** les notes d'auto-mémoire (Lot E1) d'une réponse. /// /// No-op silencieux quand le harvester n'est pas câblé. Ne parse **que** le texte /// du tour courant (jamais le log complet) et n'agit que sur un `Response`. Tout /// échec parse/store reste dans l'`outcome` (jamais propagé) : un succès de /// délégation n'est **jamais** transformé en erreur. Branché **après** le /// checkpoint `RecordTurn` de la réponse (append/handoff déjà effectués). async fn harvest_memory_best_effort(&self, root: &ProjectPath, text: &str) { let Some(harvester) = &self.memory_harvest else { return; }; let outcome = harvester.harvest(root, TurnRole::Response, text).await; if !outcome.saved.is_empty() || !outcome.errors.is_empty() { crate::diag!( "[harvest] auto-memory: found={} saved={} skipped={} errors={}", outcome.found, outcome.saved.len(), outcome.skipped, outcome.errors.len(), ); } } /// 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::ReadSkill { name, requester } => { self.read_skill(project, name, requester).await } OrchestratorCommand::WriteMemory { slug, content, requester, } => self.write_memory(project, slug, content, requester).await, OrchestratorCommand::ReadWorkState { requester } => { self.read_workstate(project, requester).await } OrchestratorCommand::SetWorkState { agent, status, intent, progress, ticket, last_delegation, } => { self.set_workstate( project, agent, status, intent, progress, ticket, last_delegation, ) .await } OrchestratorCommand::RunInBackground { owner, label, command, args, cwd, deadline_ms, } => { self.run_in_background(project, owner, label, command, args, cwd, deadline_ms) .await } } } async fn run_in_background( &self, project: &Project, owner: AgentId, label: String, command: String, args: Vec, cwd: Option, deadline_ms: Option, ) -> Result { let spawn = self.spawn_background_command.as_ref().ok_or_else(|| { AppError::Invalid("background command runner is not configured".to_owned()) })?; let cwd = resolve_background_cwd(&project.root, cwd)?; let output = spawn .execute(SpawnBackgroundCommandInput { project_id: project.id, owner_agent_id: owner, label, command: domain::ports::SpawnSpec { command, args, cwd, env: Vec::new(), context_plan: None, sandbox: None, }, wake_policy: BackgroundTaskWakePolicy::WakeOwner, deadline_ms, }) .await?; let state = match output.task.state { BackgroundTaskState::Queued => "Queued", BackgroundTaskState::Running => "Running", BackgroundTaskState::Waiting => "Waiting", BackgroundTaskState::Completed => "Completed", BackgroundTaskState::Failed => "Failed", BackgroundTaskState::Cancelled => "Cancelled", BackgroundTaskState::Expired => "Expired", }; Ok(OrchestratorOutcome { detail: serde_json::json!({ "taskId": output.task.id.to_string(), "state": state, }) .to_string(), reply: None, }) } /// 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), }) } /// `skill.read` → resolves a skill by name (project scope first, then global) /// and returns its Markdown body inline in the outcome's `reply`. Read-only: /// `requester` is accepted for symmetry with the other read tools but skill /// reads take no [`domain::fileguard::FileGuard`] lease. async fn read_skill( &self, project: &Project, name: String, _requester: ConversationParty, ) -> Result { let read_skill = self.read_skill.as_deref().ok_or_else(|| { AppError::Invalid("the idea_skill_read tool is not configured".to_owned()) })?; let md = read_skill .execute(ReadSkillInput { name: name.clone(), project_root: project.root.clone(), }) .await?; Ok(OrchestratorOutcome { detail: format!("read skill {name}"), reply: Some(md.into_string()), }) } /// `workstate.read` / `idea_workstate_read` → the project's agent **live-state** /// (lot LS4): the lean snapshot (prune-on-read) enriched with each agent's display /// name, returned inline as a JSON array. Entries whose agent is no longer in the /// manifest are dropped; `progress` is **never** exposed (the lean DTO has none). /// /// Shape: `[{"agent","status","intent","ticket?","lastDelegation?"}]`. async fn read_workstate( &self, project: &Project, _requester: ConversationParty, ) -> Result { let provider = self .live_state_read .as_deref() .ok_or_else(|| AppError::Invalid("idea_workstate_read not configured".to_owned()))?; let getter = provider .live_state_lean_for(&project.root) .ok_or_else(|| AppError::Invalid("idea_workstate_read not configured".to_owned()))?; let lean = getter.execute().await?; // Cross with the manifest to enrich each row with the agent's display name and // to drop rows whose agent left the manifest (boundary preserved). let listed = self .list_agents .execute(ListAgentsInput { project: project.clone(), }) .await?; let names: HashMap = listed.agents.into_iter().map(|a| (a.id, a.name)).collect(); let rows: Vec = lean .entries .into_iter() .filter_map(|entry| { let name = names.get(&entry.agent_id)?; let mut row = serde_json::json!({ "agent": name, "status": entry.status, "intent": entry.intent, }); if let Some(ticket) = entry.ticket { row["ticket"] = serde_json::json!(ticket); } if let Some(last) = entry.last_delegation { row["lastDelegation"] = serde_json::json!(last); } Some(row) }) .collect(); let reply = serde_json::to_string(&rows) .map_err(|e| AppError::Invalid(format!("failed to serialise work-state: {e}")))?; Ok(OrchestratorOutcome { detail: format!("read work-state ({} agent rows)", rows.len()), reply: Some(reply), }) } /// `workstate.set` / `idea_workstate_set` → publishes (insert/replace) the **current /// agent's** live-state row (lot LS4). The key is the handshake identity (`agent`), /// so an agent only ever writes its own row. Reuses the LS3 write provider /// ([`Self::live_state`]); `updated_at_ms` is stamped by the use case's `Clock`, and /// `intent`/`progress` are domain-bounded ([`UpdateLiveState`] via `LiveEntry::new`). /// **ACK only** (no inline reply). #[allow(clippy::too_many_arguments)] async fn set_workstate( &self, project: &Project, agent: AgentId, status: domain::live_state::WorkStatus, intent: Option, progress: Option, ticket: Option, last_delegation: Option, ) -> Result { let provider = self .live_state .as_deref() .ok_or_else(|| AppError::Invalid("idea_workstate_set not configured".to_owned()))?; let update = provider .live_state_for(&project.root) .ok_or_else(|| AppError::Invalid("idea_workstate_set not configured".to_owned()))?; update .execute(UpdateLiveStateInput { agent_id: agent, ticket, intent: intent.unwrap_or_default(), status, progress, last_delegation, }) .await?; Ok(OrchestratorOutcome { detail: format!("set work-state for agent {agent}"), reply: None, }) } /// `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, allow_structured_alongside_pty: false, }) .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**. /// /// The MCP tool and the file watcher both enter here, but they are only entrypoints /// into the same application orchestration path: /// /// 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. Resolve the requester/target conversation and reject wait-for cycles before /// enqueueing anything. /// 3. Ensure the target has a structured/headless [`domain::ports::AgentSession`], /// launching it through the profile adapter if it is cold. /// 4. Drive the turn directly through [`domain::ports::AgentSession::send`] and /// drain until the structured `Final` is captured. The target does **not** see /// a ticket and does **not** call `idea_reply`. /// 5. Return the captured final answer as [`OrchestratorOutcome::reply`] and publish /// [`DomainEvent::AgentReplied`]. /// /// # Errors /// - [`AppError::NotFound`] if the target agent is unknown; /// - [`AppError::Invalid`] if structured/headless orchestration is not wired, or if /// the target profile cannot be driven as a structured session; /// - [`AppError::Process`] on launch/session 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 agent = self .find_agent_by_name(project, &target) .await? .ok_or_else(|| AppError::NotFound(format!("agent {target}")))?; let agent_id = agent.id; // 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 { crate::diag!( "[rendezvous] cycle refused: requester={from} -> target={target} \ (agent {agent_id}) (deadlock évité, aucun enqueue)", ); 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); // Diagnostics : tracer l'attente du verrou de tour. Un « turn-lock waiting » sans // « turn-lock acquired » qui suit signe un tour précédent jamais relâché (la cible // reste Busy) — le verrou n'est libéré qu'à la fin du tour courant. crate::diag!( "[rendezvous] turn-lock waiting: requester={} -> target={target} (agent {agent_id}) \ wait_cap_ms={}", requester.map_or_else(|| "user".to_owned(), |a| a.to_string()), ASK_QUEUE_WAIT_CAP.as_millis(), ); let lock_wait = Instant::now(); let _turn = match tokio::time::timeout(ASK_QUEUE_WAIT_CAP, lock.lock_owned()).await { Ok(guard) => { crate::diag!( "[rendezvous] turn-lock acquired: target={target} (agent {agent_id}) \ waited_ms={}", lock_wait.elapsed().as_millis(), ); guard } Err(_elapsed) => { crate::diag!( "[rendezvous] turn-lock TIMEOUT: target={target} (agent {agent_id}) \ waited_ms={} (tour précédent jamais relâché ?)", lock_wait.elapsed().as_millis(), ); 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)); // ── Chemin **structuré/headless uniquement** ────────────────────────────── // La conversation inter-agent ne passe plus par le PTY ni par le rendez-vous // `idea_reply` MCP. La cible doit être pilotable via `AgentSession::send`; son // `Final` est la seule réponse normale du tour. Les profils legacy PTY/TUI sont // refusés au lieu de retomber sur l'ancien chemin MCP fragile. let structured = self.structured.as_ref().ok_or_else(|| { AppError::Invalid( "la conversation inter-agent headless n'est pas disponible : registre \ de sessions structurées non câblé" .to_owned(), ) })?; let session = self .ensure_structured_session(project, agent_id, &agent.profile_id, structured) .await? .ok_or_else(|| { AppError::Invalid(format!( "la cible '{target}' ne peut pas recevoir de conversation inter-agent : \ son profil ne déclare pas d'adaptateur structured/headless" )) })?; self.bind_conversation_session(conversation_id, session.id()); self.ask_structured( project, agent_id, &target, conversation_id, requester, task, session.as_ref(), ) .await } /// Chemin `ask` **structuré** (readiness/heartbeat lot 1) : la cible a un /// `structured_adapter` et une session vivante ([`StructuredSessions`]). On pilote /// son tour **directement** sur le port [`domain::ports::AgentSession`] et on le /// draine via [`drain_with_readiness`] : le [`domain::ports::ReplyEvent::Final`] /// déterministe rend le contenu (réveille le `pending`) **et** marque l'agent `Idle` /// (`mark_idle`), sans dépendre d'un `idea_reply` explicite ni d'un PTY (que la cible /// n'a pas). C'est le fix de la cause racine du blocage `Busy`. /// /// On enregistre tout de même un ticket dans la FIFO (`enqueue`) pour la comptabilité /// busy et l'exclusion mutuelle, mais il n'est plus une source de réponse : le ticket /// est retiré quand le [`domain::ports::ReplyEvent::Final`] structured a été reçu. /// Le checkpoint Prompt/Response best-effort est conservé à l'identique du chemin PTY. #[allow(clippy::too_many_arguments)] async fn ask_structured( &self, project: &Project, agent_id: AgentId, target: &str, conversation_id: domain::conversation::ConversationId, requester: Option, task: String, session: &dyn domain::ports::AgentSession, ) -> 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(), )) } }; // Checkpoint Prompt (best-effort), AVANT de déplacer `task` dans le ticket. let prompt_source = match requester { Some(from) => InputSource::agent(from), None => InputSource::Human, }; self.record_turn_best_effort( &project.root, conversation_id, prompt_source, TurnRole::Prompt, task.clone(), ) .await; // Ticket dans la FIFO : comptabilité busy + exclusion mutuelle. La réponse ne // viendra plus de cette mailbox, seulement du `Final` structured/headless. 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.clone(), ), None => Ticket::from_human(ticket_id, conversation_id, requester_label, task.clone()), }; // Timeout de tour piloté par profil (lot 2) + armement du seuil de stall, AVANT // l'enqueue qui démarre le tour (le médiateur arme alors sa fenêtre de vivacité). let turn_timeout = self.turn_timeout_for(project, agent_id).await; let _pending = input.enqueue_silent(agent_id, ticket); let rendezvous_task = self .start_rendezvous_task( project, agent_id, requester, agent_id, ticket_id, conversation_id, ) .await?; // Auto-update live-state (lot LS3), best-effort : la cible passe `Working` sur // cette transition d'`ask` acceptée (chemin structuré). `task` est encore vivant // ici (utilisé par le drain plus bas), on le distille directement. self.mark_target_working_best_effort( &project.root, agent_id, ticket_id, Self::distill_intent(&task), ) .await; // Garde RAII de fin de tour, armé JUSTE après l'enqueue (cible `Busy`). Couvre // toutes les sorties — `return Err` des bras du select, OU **futur abandonné // (drop)** — en ramenant la cible `Idle` au Drop (cf. [`BusyTurnGuard`]). C'est // le fix de la cause racine du blocage `Busy` à vie. let busy_guard = BusyTurnGuard::new(Arc::clone(input), Arc::clone(mailbox), agent_id, ticket_id); // Rendezvous beacon (chemin structuré) : équivalent du « ask started » du chemin // PTY. La cible n'a pas de PTY ; le tour se débloque uniquement sur le `Final` // de sa session structured/headless. let started = Instant::now(); crate::diag!( "[rendezvous] ask started (structured): requester={} -> target={target} \ (agent {agent_id}) conversation={conversation_id} ticket={ticket_id} \ turn_timeout_ms={}", requester.map_or_else(|| "user".to_owned(), |a| a.to_string()), turn_timeout.as_millis(), ); // Drainer le tour structuré (le `Final` ⇒ contenu + `mark_idle`). Le drain lui-même // est **non borné** (`None`) : la borne de tour est désormais portée par la // **fenêtre d'inactivité réarmable** autour de l'attente (signe de vie = sonde de // transcript), pas par un timeout plat interne — un long tour unique qui progresse // n'est plus coupé à `turn_timeout`. Aucun `idea_reply` ne peut résoudre ce tour. let announcement_publisher = self.events.as_ref().map(|bus| AnnouncementPublisher { bus: Arc::clone(bus), project_id: project.id, requester: requester.map_or(ConversationParty::User, ConversationParty::agent), target: agent_id, ticket: ticket_id, }); let drain = drain_with_readiness_and_announcements( session, &task, None, input.as_ref(), agent_id, announcement_publisher, ); // L'attente du rendez-vous structured, rendue comme `Result` // pour être enveloppée par le watchdog. Un flux clos sans `Final` correspond // au no-reply du chemin headless : la cible a rendu la main sans réponse // exploitable, on expose donc l'erreur métier retryable plutôt qu'un PROCESS. let wait = async { drain.await.map_err(|err| { if structured_no_reply_error(&err) { AppError::TargetReturnedNoReply(target.to_owned()) } else { AppError::from(err) } }) }; // Borne par la fenêtre d'inactivité (réarmée sur signe de vie) sous plafond absolu. let result = match self .run_ask_with_watchdog(wait, turn_timeout, &project.root, agent_id, target, started) .await { WatchdogOutcome::Resolved(Ok(content)) => { self.complete_rendezvous_task_success(project, agent_id, rendezvous_task, &content) .await?; crate::diag!( "[rendezvous] ask resolved (structured): target={target} \ (agent {agent_id}) ticket={ticket_id} after_ms={} reply_len={}", started.elapsed().as_millis(), content.len(), ); content } // Erreur de tour (drain-error, no-reply, canal fermé) : le garde fait // `cancel_head` + `mark_idle` au Drop. On réconcilie la live-state à `Done` // pour qu'un abandon ne laisse pas un busy fantôme. WatchdogOutcome::Resolved(Err(err)) => { let task_error = if matches!( err, AppError::TargetReturnedNoReply(_) | AppError::Process(_) ) { format!("NoReply: {err}") } else { err.to_string() }; if task_error.to_ascii_lowercase().contains("cancelled") || task_error.to_ascii_lowercase().contains("annul") { self.complete_rendezvous_task_cancelled( project, agent_id, rendezvous_task, task_error, ) .await?; } else { self.complete_rendezvous_task_failure( project, agent_id, rendezvous_task, task_error, ) .await?; } self.mark_target_done_best_effort(&project.root, agent_id, ticket_id) .await; crate::diag!( "[rendezvous] ask error (structured): target={target} (agent {agent_id}) \ ticket={ticket_id} after_ms={} err={err}", started.elapsed().as_millis(), ); return Err(err); } // Plafond absolu atteint alors que la cible progresse encore : verdict distinct. WatchdogOutcome::CeilingActive => { self.complete_rendezvous_task_failure( project, agent_id, rendezvous_task, format!("Timeout: rendezvous absolute ceiling reached for target {target}"), ) .await?; self.mark_target_done_best_effort(&project.root, agent_id, ticket_id) .await; crate::diag!( "[rendezvous] ask CEILING (structured, still active): target={target} \ (agent {agent_id}) ticket={ticket_id} after_ms={}", started.elapsed().as_millis(), ); return Err(AppError::TargetCeilingActive(target.to_owned())); } // Vrai silence (aucun progrès sur la fenêtre) : timeout typé, identique à // l'ancienne borne plate. Live-state réconciliée à `Done`. WatchdogOutcome::NoReply => { self.complete_rendezvous_task_failure( project, agent_id, rendezvous_task, format!("Timeout: rendezvous inactivity window expired for target {target}"), ) .await?; self.mark_target_done_best_effort(&project.root, agent_id, ticket_id) .await; crate::diag!( "[rendezvous] ask TIMEOUT (structured): target={target} (agent {agent_id}) \ ticket={ticket_id} after_ms={} (aucun progrès observé sur la fenêtre)", started.elapsed().as_millis(), ); return Err(AppError::from(domain::ports::AgentSessionError::Timeout)); } }; // Succès : le `Final` a rendu la réponse. On retire explicitement le ticket de // comptabilité (aucun `idea_reply` ne le fera), puis on désarme le garde RAII. mailbox.cancel_head(agent_id, ticket_id); input.mark_idle(agent_id); busy_guard.disarm(); // Checkpoint Response (best-effort), AVANT de déplacer `result`. self.record_turn_best_effort( &project.root, conversation_id, InputSource::agent(agent_id), TurnRole::Response, result.clone(), ) .await; // Auto-memory harvest (Lot E1), après l'append/handoff de la réponse. // Best-effort strict — un échec n'altère jamais ce succès de délégation. self.harvest_memory_best_effort(&project.root, &result) .await; // Auto-update live-state (lot LS3), best-effort : la cible vient de rendre son // résultat (chemin structuré) ⇒ `Done` + `last_delegation` = ticket résolu. self.mark_target_done_best_effort(&project.root, agent_id, ticket_id) .await; Ok(self.reply_outcome(agent_id, target, result)) } /// `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, cold_launch) = self .ensure_live_pty(project, agent_id, conversation_id, target) .await?; let (submit, has_mcp) = self.submit_and_mcp_for_agent(project, agent_id).await; // Gate cold-launch : un agent froid n'est pas encore prêt. On diffère le 1er tour // s'il existe un signal pour le libérer — la connexion du pont MCP de l'agent // (`InputMediator::release_cold_start`, déclenchée par l'McpServer). Seul signal // de readiness restant (watcher prompt-ready PTY supprimé). Sans pont MCP ⇒ pas // de gate (livraison immédiate, sinon blocage indéfini). let gate_cold_start = cold_launch && has_mcp; if gate_cold_start { input.mark_starting(agent_id); } input.bind_handle_with_submit(agent_id, handle, submit); // 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, }) } /// **Ack de livraison** (ARCHITECTURE §20.3) — best-effort, observabilité only. /// /// The frontend write-portal calls this (via the `delegation_delivered` Tauri /// command) once it has **physically written** a delegation `ticket` into the agent's /// native PTY, to distinguish "queued because the human line was busy" from "written" /// in logs/observability. It **does not** change correlation: the requester's `ask` /// is still woken by `idea_reply`→`resolve_ticket`, and the per-turn timeout/cycle /// guards are untouched. It is a pure log no-op when nothing is wired, so a missing /// mediator/registry never breaks the rendezvous. pub fn note_delegation_delivered( &self, project: &Project, agent_id: AgentId, ticket: TicketId, ) { // Observability beacon only: never mutates the mailbox/busy state nor resolves // the ticket (that stays `idea_reply`-driven). Kept best-effort by construction — // a pure, infallible hook so a missing mediator/registry never breaks the // rendezvous. let _ = project; crate::diag!( "[delivery] front ack received: agent={agent_id} ticket={ticket} \ (write-portal reports text+submit writes completed)" ); } /// Libère le premier tour différé d'un agent **lancé à froid** quand son pont MCP se /// connecte (readiness de démarrage). Pont entre l'McpServer (adapter entrant) et le /// médiateur d'entrée. No-op si aucun médiateur n'est câblé ou si rien n'est différé. pub fn release_agent_cold_start(&self, agent: domain::AgentId) { if let Some(input) = &self.input { input.release_cold_start(agent); } } /// Résout l'`AgentId` d'un agent par son **nom d'affichage** (insensible à la casse) /// dans le manifeste du projet, pour la composition root (qui ne connaît la cible d'un /// `idea_ask_agent` que par son nom). Expose la résolution privée `find_agent_id_by_name` /// afin que le filet serveur MCP puisse dériver le run-dir transcript de la cible (sonde /// d'activité du rendez-vous). `None` si aucun agent ne porte ce nom. /// /// # Errors /// Propage l'[`AppError`] du listing des agents (lecture du manifeste). pub async fn resolve_agent_id_by_name( &self, project: &Project, name: &str, ) -> Result, AppError> { self.find_agent_id_by_name(project, name).await } /// Déclare si une **cellule terminal frontend** est montée pour `agent` (write-portal /// actif). Pont entre le write-portal (adapter UI) et le médiateur : un agent avec /// cellule reçoit ses tours via l'événement `DelegationReady` (le front écrit) ; un /// agent **headless** (délégué en arrière-plan, sans cellule) voit le médiateur écrire /// lui-même la tâche dans son PTY — sinon le tour est perdu. No-op sans médiateur. pub fn set_agent_front_attached(&self, agent: domain::AgentId, attached: bool) { crate::diag!("[delivery] front attachment changed: agent={agent} attached={attached}"); if let Some(input) = &self.input { input.set_front_attached(agent, attached); } else { crate::diag!( "[delivery] front attachment ignored because input mediator is not wired: \ agent={agent} attached={attached}" ); } } /// 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, // Repli pur déterministe partagé avec `LaunchAgent` (ARCHITECTURE §19.7, // lot P8a) : la même paire dérive la même clé de conversation des deux // côtés (sauvegarde du handoff ici, dérivation côté cellule là-bas). None => domain::conversation::ConversationId::for_pair(left, right), } } /// `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 { // Diagnostics : un `idea_reply` est entré, AVANT toute corrélation. Longueur // seulement (jamais le corps). Si ce beacon apparaît mais que `correlated`/ // `UNMATCHED` ne suit pas, la file n'est pas câblée. crate::diag!( "[rendezvous] idea_reply received: from agent {from} ticket={} result_len={}", ticket.map_or_else(|| "head".to_owned(), |t| t.to_string()), result.len(), ); let mailbox = self.mailbox.as_ref().ok_or_else(|| { crate::diag!( "[rendezvous] idea_reply DROPPED: from agent {from} (file inter-agents non câblée)", ); 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). let correlation = match ticket { Some(ticket_id) => mailbox.resolve_ticket(from, ticket_id, result), None => mailbox.resolve(from, result), }; // Rendezvous beacon (diagnostics) : un `idea_reply` est arrivé. Tracer s'il a // corrélé à un ask en vol — un échec ici (« no matching ask ») signe une // délégation déjà expirée/abandonnée ou un ticket erroné côté cible. match &correlation { Ok(()) => crate::diag!( "[rendezvous] idea_reply correlated: from agent {from} ticket={}", ticket.map_or_else(|| "head".to_owned(), |t| t.to_string()), ), Err(e) => crate::diag!( "[rendezvous] idea_reply UNMATCHED: from agent {from} ticket={} err={e}", ticket.map_or_else(|| "head".to_owned(), |t| t.to_string()), ), } correlation.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). /// /// Le booléen renvoyé indique un **lancement à froid** (`true` quand l'agent n'avait /// pas de session vivante et vient d'être démarré) : l'appelant l'utilise pour /// *gater* le premier tour sur le prompt-ready (cf. [`InputMediator::mark_starting`]), /// car un agent froid n'est pas encore à son prompt. `false` ⇒ session réutilisée /// (agent déjà chaud) ⇒ livraison immédiate, comportement inchangé. async fn ensure_live_pty( &self, project: &Project, agent_id: AgentId, conversation_id: domain::conversation::ConversationId, target: &str, ) -> Result<(PtyHandle, bool), AppError> { // «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). Réutilisation // d'une session déjà chaude ⇒ pas de gate de démarrage à froid. self.bind_conversation_session(conversation_id, session_id); return Ok((handle, false)); } } // 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)), allow_structured_alongside_pty: false, }) .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); let handle = self.sessions.handle(&session_id).ok_or_else(|| { AppError::Process(format!( "handle PTY de l'agent {target} introuvable après lancement" )) })?; // Lancement à froid : `true` ⇒ l'appelant gatera le premier tour sur le prompt. Ok((handle, true)) } /// Lot 1b — garantit une **session structurée vivante** pour la cible d'un `ask`. /// /// Retourne : /// - `Ok(Some(session))` si la cible a déjà une session vivante, **ou** si son /// profil porte un `structured_adapter` et qu'on vient de la (re)lancer ; /// - `Ok(None)` si la cible n'est **pas** structurée (profil sans `structured_adapter`, /// ou profil introuvable) ⇒ `AskAgent` refuse la conversation inter-agent. /// /// L'auto-lancement réutilise le **même** [`LaunchAgent`] que les lancements UI : /// pour un profil structuré, le launcher route §17.4 vers `launch_structured`, /// démarre la session via la fabrique et l'insère dans le registre /// [`StructuredSessions`] **partagé**. Le runtime MCP peut encore être matérialisé /// pour les outils non conversationnels, mais il ne participe plus à la résolution /// de la réponse inter-agent. async fn ensure_structured_session( &self, project: &Project, agent_id: AgentId, profile_id: &ProfileId, structured: &Arc, ) -> Result>, AppError> { // Cible déjà chaude : route directe (aucun lancement). if let Some(session) = structured.session_for_agent(&agent_id) { return Ok(Some(session)); } // Cible froide : ne (re)lancer que si le profil sait être piloté en mode // structuré. Sinon (agent PTY/TUI legacy, ou profil introuvable) ⇒ refus par // l'appelant. let is_structured = self .profiles .list() .await? .into_iter() .find(|p| &p.id == profile_id) .is_some_and(|p| p.is_selectable()); if !is_structured { return Ok(None); } // Démarrer la session via le launcher (route §17.4 → `launch_structured`, // insère dans CE registre) sans fermer une éventuelle session PTY visible du // même agent. Le flag interne garde les deux canaux séparés : PTY pour la // cellule humaine, `AgentSession::send` pour `idea_ask_agent`. 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)), allow_structured_alongside_pty: true, }) .await?; // Le launcher a inséré la session dans le registre partagé : la relire. structured .session_for_agent(&agent_id) .map(Some) .ok_or_else(|| { AppError::Process(format!( "agent {agent_id} : aucune session structurée vivante 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(), // `idea_create_skill` carries no description argument; the affordance // falls back to the body's first line (see `effective_description`). description: None, 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))) } /// Resolves the target agent profile's **submit config** /// (`submit_sequence`/`submit_delay_ms`, ARCHITECTURE §20.3) **and** whether it /// declares an **MCP bridge**, in a single profile lookup. The submit config is /// carried into `bind_handle_with_submit` (echoed on the next `DelegationReady` so /// the frontend write-portal knows how to submit); the MCP flag drives the /// cold-launch gate (its `initialize` connection releases a deferred first turn). /// Returns `(default, false)` when the agent or its profile is absent — the safe /// fallback (no submit ⇒ the front applies its own `"\r"`/~60 ms default; no gate). async fn submit_and_mcp_for_agent( &self, project: &Project, agent_id: AgentId, ) -> (SubmitConfig, bool) { let Some(agent) = self .list_agents .execute(ListAgentsInput { project: project.clone(), }) .await .ok() .and_then(|out| out.agents.into_iter().find(|a| a.id == agent_id)) else { return (SubmitConfig::default(), false); }; let Some(profile) = self .profiles .list() .await .ok() .and_then(|ps| ps.into_iter().find(|p| p.id == agent.profile_id)) else { return (SubmitConfig::default(), false); }; let submit = submit_config_for_profile(&profile); // 2e élément : le profil cible déclare-t-il un pont MCP ? Si oui, sa connexion // (`initialize`) sert de signal de readiness de démarrage pour libérer un 1er // tour différé (`release_cold_start`) — c'est ce qui arme le gate cold-launch. (submit, profile.mcp.is_some()) } /// Résout la [`domain::profile::LivenessStrategy`] du profil de la cible (lot 2) : /// le seuil de stagnation (`stall_after_ms`) et le timeout de tour /// (`turn_timeout_ms`). `None`/absent ⇒ `(None, None)` : pas de détection de stall et /// repli sur les bornes par défaut codées en dur (zéro régression pour un profil sans /// bloc `liveness`). async fn liveness_for_agent( &self, project: &Project, agent_id: AgentId, ) -> (Option, Option) { let Some(agent) = self .list_agents .execute(ListAgentsInput { project: project.clone(), }) .await .ok() .and_then(|out| out.agents.into_iter().find(|a| a.id == agent_id)) else { return (None, None); }; let Some(profile) = self .profiles .list() .await .ok() .and_then(|ps| ps.into_iter().find(|p| p.id == agent.profile_id)) else { return (None, None); }; match profile.liveness { Some(l) => (l.stall_after_ms, l.turn_timeout_ms), None => (None, None), } } /// Borne de tour effective pour un `ask` : le `turn_timeout_ms` du profil de la cible /// **quand il existe**, sinon le défaut historique [`ASK_AGENT_TIMEOUT`] (lot 2, /// timeouts pilotés par profil). Arme aussi le seuil de stagnation sur le médiateur /// avant l'enqueue (battement/`sweep_stalled`). async fn turn_timeout_for(&self, project: &Project, agent_id: AgentId) -> Duration { let (stall_after_ms, turn_timeout_ms) = self.liveness_for_agent(project, agent_id).await; if let Some(input) = &self.input { input.set_stall_threshold(agent_id, stall_after_ms); } resolve_turn_timeout(turn_timeout_ms) } /// Borne un `wait` (l'attente synchrone du rendez-vous délégué) par la **fenêtre /// d'inactivité réarmable** (cf. [`rendezvous`](crate::orchestrator::rendezvous)), /// au lieu d'un timeout plat. La fenêtre vaut `window` (profil sinon défaut, la /// **même** valeur que l'ancien timeout plat) ; à chaque expiration de fenêtre, la /// sonde de vivacité de la cible est consultée : progrès ⇒ réarmement jusqu'au /// plafond absolu [`Self::ask_ceiling`]. Verdict : /// - [`WatchdogOutcome::Resolved`] : le `wait` a résolu (succès/erreur de tour) ⇒ rendu tel quel ; /// - [`WatchdogOutcome::NoReply`] : vrai silence (aucun progrès sur une fenêtre) **ou** /// fallback fenêtre plate (pas de sonde câblée) — sémantique **identique** à l'ancien /// timeout de tour (l'appelant mappe vers le timeout typé) ; /// - [`WatchdogOutcome::CeilingActive`] : progrès observé mais plafond atteint ⇒ verdict /// distinct (l'appelant mappe vers [`AppError::TargetCeilingActive`]). async fn run_ask_with_watchdog( &self, wait: impl std::future::Future, window: Duration, project_root: &domain::project::ProjectPath, agent_id: AgentId, target: &str, started: Instant, ) -> WatchdogOutcome { let has_probe = self.ask_liveness_probe.is_some(); let probe = self.ask_liveness_probe.clone(); let root = project_root.clone(); let probe_fn = move || { let probe = probe.clone(); let root = root.clone(); async move { match &probe { Some(p) => p(root, agent_id).await, None => None, } } }; let ext_target = target.to_owned(); let exp_target = target.to_owned(); let ceil_target = target.to_owned(); let window_ms = window.as_millis(); let ceiling_ms = self.ask_ceiling.as_millis(); run_inactivity_watchdog( wait, window, self.ask_ceiling, started, has_probe, probe_fn, move |elapsed| { crate::diag!( "[rendezvous] ask window extended (signe de vie): target={ext_target} \ (agent {agent_id}) window_ms={window_ms} elapsed_ms={}", elapsed.as_millis(), ); }, move |elapsed| { crate::diag!( "[rendezvous] ask EXPIRED (no progress): target={exp_target} \ (agent {agent_id}) window_ms={window_ms} elapsed_ms={}", elapsed.as_millis(), ); }, move |elapsed| { crate::diag!( "[rendezvous] ask CEILING active: target={ceil_target} (agent {agent_id}) \ ceiling_ms={ceiling_ms} elapsed_ms={}", elapsed.as_millis(), ); }, ) .await } /// 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, active_waits: &'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); } { let mut waits = service .active_waits .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if !waits.contains(&(from, to)) { waits.push((from, to)); } } Self { graph: &service.wait_for, active_waits: &service.active_waits, 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); let mut waits = self .active_waits .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); waits.retain(|edge| *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, StructuredAdapter}; use domain::ProfileId; // (c) — timeouts pilotés par profil (lot 2) : `turn_timeout_ms` prime sur le défaut. #[test] fn profile_turn_timeout_overrides_default() { // Seuil de profil défini ⇒ il prime sur ASK_AGENT_TIMEOUT. assert_eq!( resolve_turn_timeout(Some(120_000)), Duration::from_millis(120_000) ); // Absent (profil sans liveness / sans turn_timeout_ms) ⇒ repli sur le défaut. assert_eq!(resolve_turn_timeout(None), ASK_AGENT_TIMEOUT); } 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()); } #[test] fn codex_submit_config_gets_conservative_delay_when_profile_omits_it() { let p = profile(2, "OpenAI Codex CLI", "codex") .with_structured_adapter(StructuredAdapter::Codex); let submit = submit_config_for_profile(&p); assert_eq!(submit.sequence, None); assert_eq!(submit.delay_ms, Some(CODEX_SUBMIT_DELAY_MS)); } #[test] fn explicit_profile_submit_delay_is_preserved() { let p = profile(3, "OpenAI Codex CLI", "codex") .with_structured_adapter(StructuredAdapter::Codex) .with_submit_delay_ms(900); let submit = submit_config_for_profile(&p); assert_eq!(submit.delay_ms, Some(900)); } #[test] fn active_wait_dependencies_are_transitive_and_deduplicated() { let edges = vec![ (aid(1), aid(2)), (aid(2), aid(3)), (aid(1), aid(3)), (aid(9), aid(10)), ]; let deps = OrchestratorService::active_wait_dependencies_from_edges(&edges, aid(1)); assert_eq!(deps, vec![aid(3), aid(2)]); } // --- BusyTurnGuard (RAII de fin de tour) ------------------------------- // // Fakes minimaux pour observer ce que le garde appelle à son Drop : un médiateur // qui enregistre les `mark_idle` et un mailbox qui enregistre les `cancel_head`. use std::sync::Mutex as TestMutex; #[derive(Default)] struct SpyMediator { idled: TestMutex>, } impl domain::input::InputMediator for SpyMediator { fn enqueue( &self, _agent: AgentId, _ticket: domain::mailbox::Ticket, ) -> domain::mailbox::PendingReply { domain::mailbox::PendingReply::new(Box::pin(async { Err(domain::mailbox::MailboxError::Cancelled) })) } fn preempt(&self, _agent: AgentId) {} fn mark_idle(&self, agent: AgentId) { self.idled.lock().unwrap().push(agent); } fn busy_state(&self, _agent: AgentId) -> domain::input::AgentBusyState { domain::input::AgentBusyState::Idle } } #[derive(Default)] struct SpyMailbox { cancelled: TestMutex>, } impl domain::mailbox::AgentMailbox for SpyMailbox { fn enqueue( &self, _agent: AgentId, _ticket: domain::mailbox::Ticket, ) -> domain::mailbox::PendingReply { domain::mailbox::PendingReply::new(Box::pin(async { Err(domain::mailbox::MailboxError::Cancelled) })) } fn resolve( &self, _agent: AgentId, _result: String, ) -> Result<(), domain::mailbox::MailboxError> { Ok(()) } fn cancel_head(&self, agent: AgentId, ticket_id: TicketId) { self.cancelled.lock().unwrap().push((agent, ticket_id)); } } fn aid(n: u128) -> AgentId { AgentId::from_uuid(uuid::Uuid::from_u128(n)) } fn tid(n: u128) -> TicketId { TicketId::from_uuid(uuid::Uuid::from_u128(n)) } /// Drop d'un garde **armé** ⇒ `cancel_head` + `mark_idle` sur la cible (c'est le /// comportement qui débloque un agent resté `Busy` sur un futur abandonné). #[test] fn armed_guard_drop_cancels_head_and_marks_idle() { let med = Arc::new(SpyMediator::default()); let mb = Arc::new(SpyMailbox::default()); { let _g = BusyTurnGuard::new( Arc::clone(&med) as Arc, Arc::clone(&mb) as Arc, aid(1), tid(7), ); } // Drop ici. assert_eq!( *med.idled.lock().unwrap(), vec![aid(1)], "mark_idle au Drop" ); assert_eq!( *mb.cancelled.lock().unwrap(), vec![(aid(1), tid(7))], "cancel_head au Drop" ); } /// Garde **désarmé** (branche succès) ⇒ son Drop est un no-op : pas de `mark_idle` /// parasite, surtout pas de `cancel_head` d'un ticket déjà résolu. #[test] fn disarmed_guard_drop_is_a_noop() { let med = Arc::new(SpyMediator::default()); let mb = Arc::new(SpyMailbox::default()); let g = BusyTurnGuard::new( Arc::clone(&med) as Arc, Arc::clone(&mb) as Arc, aid(1), tid(7), ); g.disarm(); assert!( med.idled.lock().unwrap().is_empty(), "pas de mark_idle après disarm" ); assert!( mb.cancelled.lock().unwrap().is_empty(), "pas de cancel_head après disarm" ); } }