Files
IdeA/crates/application/src/orchestrator/service.rs
Blomios aa5a4f30ae feat(inter-agent): backend des annonces live d'agent (B0-B3)
Redémarre le ticket #4 sur la base develop. Émission d'annonces live d'un
agent vers l'UI, distinctes du Final de délégation :

- domain: ReplyEvent::Announcement/Final et DomainEvent::AgentAnnouncement
  (events.rs), port d'émission (ports.rs), gating de readiness (readiness.rs).
- application: mapping des événements structurés en annonces
  (agent/structured.rs, agent/mod.rs, lib.rs) et relais côté orchestrateur
  (orchestrator/service.rs).
- infrastructure/session: parse des annonces + fix du Final pour Claude et
  Codex, propagé aux adaptateurs et à la conformance
  (claude.rs, codex.rs, conformance.rs, mod.rs, process.rs, sandbox_e2e.rs).
- app-tauri: relais Tauri des annonces vers le front (events.rs, chat.rs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:50:03 +02:00

3043 lines
130 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! [`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<String>,
) -> Result<ProjectPath, AppError> {
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<PathBuf, AppError> {
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<Box<dyn std::future::Future<Output = Option<u64>> + 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<u32>) -> 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<dyn InputMediator>,
mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
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<dyn InputMediator>,
mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
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<McpRuntime>;
}
/// 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** (`<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<Arc<RecordTurn>>;
}
/// 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**
/// (`<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<Arc<UpdateLiveState>>;
}
/// 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<Arc<GetLiveStateLean>>;
}
/// Dispatches validated orchestrator commands to the agent/terminal use cases.
pub struct OrchestratorService {
create_agent: Arc<CreateAgentFromScratch>,
launch_agent: Arc<LaunchAgent>,
list_agents: Arc<ListAgents>,
close_terminal: Arc<CloseTerminal>,
update_context: Arc<UpdateAgentContext>,
create_skill: Arc<CreateSkill>,
profiles: Arc<dyn ProfileStore>,
sessions: Arc<TerminalSessions>,
/// 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<Arc<dyn InputMediator>>,
/// 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<Arc<dyn domain::mailbox::AgentMailbox>>,
/// 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<Arc<dyn ConversationRegistry>>,
/// 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<WaitForGraph>,
/// 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<Vec<(AgentId, AgentId)>>,
/// 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<Arc<dyn EventBus>>,
/// 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<Mutex<()>>` vide (pas de session, pas de process).
ask_locks: StdMutex<HashMap<AgentId, Arc<AsyncMutex<()>>>>,
/// 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<Arc<dyn McpRuntimeProvider>>,
/// 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<Arc<ContextGuardUseCases>>,
/// 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<Arc<dyn RecordTurnProvider>>,
/// 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<Arc<dyn Clock>>,
/// 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<Arc<StructuredSessions>>,
/// 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<Arc<HarvestMemoryFromTurn>>,
/// 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<Arc<ReadSkill>>,
/// 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<Arc<dyn LiveStateProvider>>,
/// 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<Arc<dyn LiveStateReadProvider>>,
/// **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<AskLivenessProbe>,
/// 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<Arc<dyn BackgroundTaskStore>>,
/// 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<Arc<SpawnBackgroundCommand>>,
}
/// 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<ReadContext>,
/// Proposition/écriture d'un contexte `.md` IdeA sous le garde.
pub propose_context: Arc<ProposeContext>,
/// Lecture mémoire sous read-lease.
pub read_memory: Arc<ReadMemory>,
/// Écriture mémoire sous write-lease.
pub write_memory: Arc<WriteMemory>,
}
/// 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<String>,
}
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<CreateAgentFromScratch>,
launch_agent: Arc<LaunchAgent>,
list_agents: Arc<ListAgents>,
close_terminal: Arc<CloseTerminal>,
update_context: Arc<UpdateAgentContext>,
create_skill: Arc<CreateSkill>,
profiles: Arc<dyn ProfileStore>,
sessions: Arc<TerminalSessions>,
) -> 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<SpawnBackgroundCommand>) -> 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<dyn BackgroundTaskStore>,
clock: Arc<dyn Clock>,
) -> 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<ReadSkill>) -> 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<StructuredSessions>) -> 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<ContextGuardUseCases>) -> 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<AsyncMutex<()>>` 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<AsyncMutex<()>> {
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<AgentId> {
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<AgentId> {
let mut out = Vec::new();
let mut stack: Vec<AgentId> = 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<dyn InputMediator>,
mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
) -> 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<AgentId>,
target_agent_id: AgentId,
ticket_id: TicketId,
conversation_id: domain::conversation::ConversationId,
) -> Result<Option<TaskId>, 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<TaskId>,
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<TaskId>,
error: impl Into<String>,
) -> 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<TaskId>,
reason: impl Into<String>,
) -> 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<TaskId>,
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<dyn ConversationRegistry>) -> 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<dyn EventBus>) -> 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<dyn McpRuntimeProvider>) -> 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<dyn RecordTurnProvider>,
clock: Arc<dyn Clock>,
) -> 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<HarvestMemoryFromTurn>) -> 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<dyn LiveStateProvider>) -> 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<dyn LiveStateReadProvider>) -> 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::<Vec<_>>()
.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<OrchestratorOutcome, AppError> {
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<String>,
cwd: Option<String>,
deadline_ms: Option<u64>,
) -> Result<OrchestratorOutcome, AppError> {
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<String>,
requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> {
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<String>,
content: String,
requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> {
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<String>,
requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> {
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<OrchestratorOutcome, AppError> {
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<OrchestratorOutcome, AppError> {
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<AgentId, String> =
listed.agents.into_iter().map(|a| (a.id, a.name)).collect();
let rows: Vec<serde_json::Value> = 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<String>,
progress: Option<String>,
ticket: Option<TicketId>,
last_delegation: Option<TicketId>,
) -> Result<OrchestratorOutcome, AppError> {
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<OrchestratorOutcome, AppError> {
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<String>,
context: Option<String>,
visibility: OrchestratorVisibility,
) -> Result<OrchestratorOutcome, AppError> {
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<AgentId>,
) -> Result<OrchestratorOutcome, AppError> {
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<AgentId>,
task: String,
session: &dyn domain::ports::AgentSession,
) -> Result<OrchestratorOutcome, AppError> {
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<String, AppError>`
// 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<OrchestratorOutcome, AppError> {
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<OrchestratorOutcome, AppError> {
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<Option<domain::AgentId>, 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<AgentId>,
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<TicketId>,
result: String,
) -> Result<OrchestratorOutcome, AppError> {
// 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<StructuredSessions>,
) -> Result<Option<Arc<dyn domain::ports::AgentSession>>, 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<AgentId>) -> 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<String> {
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<OrchestratorOutcome, AppError> {
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<OrchestratorOutcome, AppError> {
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<OrchestratorOutcome, AppError> {
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 `<root>/.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<OrchestratorOutcome, AppError> {
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<Option<domain::AgentId>, 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<Option<domain::Agent>, 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<u32>, Option<u32>) {
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<R>(
&self,
wait: impl std::future::Future<Output = R>,
window: Duration,
project_root: &domain::project::ProjectPath,
agent_id: AgentId,
target: &str,
started: Instant,
) -> WatchdogOutcome<R> {
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<ProfileId, AppError> {
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<WaitForGraph>,
active_waits: &'a StdMutex<Vec<(AgentId, AgentId)>>,
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<Vec<AgentId>>,
}
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<Vec<(AgentId, TicketId)>>,
}
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<dyn domain::input::InputMediator>,
Arc::clone(&mb) as Arc<dyn domain::mailbox::AgentMailbox>,
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<dyn domain::input::InputMediator>,
Arc::clone(&mb) as Arc<dyn domain::mailbox::AgentMailbox>,
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"
);
}
}