feat(agent): orchestration v3 — surface MCP model-agnostic (M0→M4) — §14.3
Expose l'orchestration IdeA comme serveur MCP par-dessus le même OrchestratorService::dispatch, avec repli fichier .ideai/requests pour les CLI sans MCP. v3 réduite à la surface MCP : la messagerie inter-agents et la corrélation requête↔réponse étaient déjà résolues par §17 (send_blocking). - M0 capacité MCP sur le profil (McpCapability/McpConfigStrategy/McpTransport) - M1 injection conf MCP au LaunchAgent + prose adaptée selon la surface - M2 serveur/adapter MCP (JSON-RPC 2.0 maison ; outils idea_*) + ListAgents - M3 câblage par projet (registre mcp_servers jumeau du watcher) - M4 observabilité UI : OrchestratorRequestProcessed.source = file|mcp + badge Trois portes d'entrée (fichier, MCP, UI) → un seul dispatch ; aucun nouveau port applicatif ; MCP confiné à l'adapter infra. Tous lots verts (cycle §3). Cadrage : .ideai/briefs/orchestration-v3-cadrage.md ; ARCHITECTURE.md §14.3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -12,7 +12,7 @@
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use domain::events::DomainEvent;
|
||||
use domain::events::{DomainEvent, OrchestrationSource};
|
||||
use infrastructure::TokioBroadcastEventBus;
|
||||
|
||||
/// Name of the Tauri event carrying relayed [`DomainEvent`]s.
|
||||
@ -126,6 +126,10 @@ pub enum DomainEventDto {
|
||||
action: String,
|
||||
/// Whether IdeA handled it successfully.
|
||||
ok: bool,
|
||||
/// Which entry door the request arrived through (`"file"` watcher vs
|
||||
/// `"mcp"` server). Serialised as a lowercase string so the frontend can
|
||||
/// badge the source.
|
||||
source: OrchestrationSourceDto,
|
||||
},
|
||||
/// A memory note was created or updated.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -170,6 +174,27 @@ pub enum DomainEventDto {
|
||||
},
|
||||
}
|
||||
|
||||
/// Wire mirror of [`OrchestrationSource`]: which entry door a processed
|
||||
/// orchestration request arrived through. Serialised as a lowercase string
|
||||
/// (`"file"` / `"mcp"`) the frontend badges on the event.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum OrchestrationSourceDto {
|
||||
/// A `.ideai/requests` JSON file (filesystem watcher).
|
||||
File,
|
||||
/// A `tools/call` on the MCP server.
|
||||
Mcp,
|
||||
}
|
||||
|
||||
impl From<OrchestrationSource> for OrchestrationSourceDto {
|
||||
fn from(source: OrchestrationSource) -> Self {
|
||||
match source {
|
||||
OrchestrationSource::File => Self::File,
|
||||
OrchestrationSource::Mcp => Self::Mcp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&DomainEvent> for DomainEventDto {
|
||||
fn from(e: &DomainEvent) -> Self {
|
||||
match e {
|
||||
@ -239,10 +264,12 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
requester_id,
|
||||
action,
|
||||
ok,
|
||||
source,
|
||||
} => Self::OrchestratorRequestProcessed {
|
||||
requester_id: requester_id.clone(),
|
||||
action: action.clone(),
|
||||
ok: *ok,
|
||||
source: (*source).into(),
|
||||
},
|
||||
DomainEvent::MemorySaved { slug } => Self::MemorySaved {
|
||||
slug: slug.as_str().to_string(),
|
||||
|
||||
@ -41,9 +41,9 @@ use infrastructure::{
|
||||
embedder_from_profile, AdaptiveMemoryRecall, ClaudeTranscriptInspector, CliAgentRuntime,
|
||||
EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore, FsMemoryStore,
|
||||
FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore,
|
||||
Git2Repository, IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, NaiveMemoryRecall,
|
||||
OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory, SystemClock,
|
||||
TokioBroadcastEventBus,
|
||||
Git2Repository, IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, McpServer,
|
||||
NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory,
|
||||
SystemClock, TokioBroadcastEventBus,
|
||||
UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
@ -245,6 +245,13 @@ pub struct AppState {
|
||||
/// Guarded by a `Mutex` so the open/close commands can register/unregister
|
||||
/// watchers concurrently.
|
||||
pub orchestrator_watchers: Mutex<HashMap<ProjectId, OrchestratorWatchHandle>>,
|
||||
/// Live IdeA MCP servers, keyed by project — the **twin** of
|
||||
/// [`orchestrator_watchers`](Self::orchestrator_watchers). One server per open
|
||||
/// project is the MCP entry door onto the *same* [`OrchestratorService`] the
|
||||
/// file watcher feeds; both coexist (Décision 4). Started on open/create and
|
||||
/// stopped on close, exactly like the watcher, via the same `Mutex`-guarded
|
||||
/// per-project registry.
|
||||
pub mcp_servers: Mutex<HashMap<ProjectId, McpServerHandle>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@ -814,6 +821,7 @@ impl AppState {
|
||||
dismiss_embedder_suggestion,
|
||||
orchestrator_service,
|
||||
orchestrator_watchers: Mutex::new(HashMap::new()),
|
||||
mcp_servers: Mutex::new(HashMap::new()),
|
||||
move_tab,
|
||||
}
|
||||
}
|
||||
@ -844,6 +852,46 @@ impl AppState {
|
||||
events,
|
||||
);
|
||||
watchers.insert(project.id, handle);
|
||||
drop(watchers);
|
||||
|
||||
// Start the MCP server for this project, beside (and in parallel with) the
|
||||
// file watcher — both are entry doors onto the *same* OrchestratorService
|
||||
// (Décision 4). Idempotent like the watcher above.
|
||||
self.ensure_mcp_server(project);
|
||||
}
|
||||
|
||||
/// Starts an [`McpServer`] for `project` if one is not already registered
|
||||
/// (idempotent), the **twin** of [`ensure_orchestrator_watch`]. Registers its
|
||||
/// lifecycle handle in [`mcp_servers`](Self::mcp_servers); dropping/stopping the
|
||||
/// handle tears down the supervision task.
|
||||
///
|
||||
/// ## Transport decision (point ouvert S-MCP)
|
||||
///
|
||||
/// At project-open time there is **no CLI connected yet**: a real `stdio`
|
||||
/// transport only has a peer once an MCP-capable agent is launched with the
|
||||
/// injected MCP config (M1), and the exact transport↔session bind is the open
|
||||
/// **S-MCP** point of the cadrage. We therefore deliberately do **not** run a
|
||||
/// blocking `McpServer::serve` here (that would have no peer to talk to and must
|
||||
/// never figer the open/close of a project). Instead this hook owns the server's
|
||||
/// **lifecycle** per project — creation now, clean stop on close — parked on a
|
||||
/// stop signal exactly like the watcher. The per-session transport binding is
|
||||
/// finalised later (S-MCP) without touching this wiring.
|
||||
fn ensure_mcp_server(&self, project: &Project) {
|
||||
let mut servers = self
|
||||
.mcp_servers
|
||||
.lock()
|
||||
.expect("mcp server registry poisoned");
|
||||
if servers.contains_key(&project.id) {
|
||||
return;
|
||||
}
|
||||
let bus = Arc::clone(&self.event_bus);
|
||||
let events: Arc<dyn Fn(DomainEvent) + Send + Sync> =
|
||||
Arc::new(move |event| bus.publish(event));
|
||||
let handle = McpServerHandle::start(
|
||||
McpServer::new(Arc::clone(&self.orchestrator_service), project.clone())
|
||||
.with_events(events),
|
||||
);
|
||||
servers.insert(project.id, handle);
|
||||
}
|
||||
|
||||
/// Returns the ids of every currently-open project.
|
||||
@ -862,6 +910,8 @@ impl AppState {
|
||||
|
||||
/// Stops and removes the orchestrator watcher for `project_id`, if any.
|
||||
/// Called from `close_project` so a closed project stops consuming requests.
|
||||
/// Symmetrically stops the project's MCP server (its twin) so both entry doors
|
||||
/// are torn down together.
|
||||
pub fn stop_orchestrator_watch(&self, project_id: &ProjectId) {
|
||||
if let Some(handle) = self
|
||||
.orchestrator_watchers
|
||||
@ -871,6 +921,50 @@ impl AppState {
|
||||
{
|
||||
handle.stop();
|
||||
}
|
||||
if let Some(handle) = self
|
||||
.mcp_servers
|
||||
.lock()
|
||||
.expect("mcp server registry poisoned")
|
||||
.remove(project_id)
|
||||
{
|
||||
handle.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lifecycle handle for a per-project [`McpServer`] supervision task — the **twin**
|
||||
/// of [`OrchestratorWatchHandle`](infrastructure::OrchestratorWatchHandle).
|
||||
///
|
||||
/// It carries the **same** stop mechanism as the watcher (a one-shot
|
||||
/// `mpsc::Sender<()>`): [`stop`](Self::stop) signals the task to exit, and dropping
|
||||
/// the handle stops it too (the channel closes). The supervision task keeps the
|
||||
/// `McpServer` alive and ready, parked on the stop signal; it spawns **no blocking
|
||||
/// loop**, so it never figes the open/close of a project (see the transport
|
||||
/// decision on [`AppState::ensure_mcp_server`]).
|
||||
pub struct McpServerHandle {
|
||||
stop: tokio::sync::mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
impl McpServerHandle {
|
||||
/// Spawns the supervision task that owns `server` until stopped. Must run inside
|
||||
/// the ambient Tokio runtime (Tauri async commands satisfy this), exactly like
|
||||
/// [`FsOrchestratorWatcher::start`](infrastructure::FsOrchestratorWatcher).
|
||||
#[must_use]
|
||||
fn start(server: McpServer) -> Self {
|
||||
let (stop_tx, mut stop_rx) = tokio::sync::mpsc::channel::<()>(1);
|
||||
tokio::spawn(async move {
|
||||
// Hold the server for this project's lifetime; park until stopped. When a
|
||||
// per-session transport bind lands (S-MCP), it attaches to this `server`.
|
||||
let _server = server;
|
||||
let _ = stop_rx.recv().await;
|
||||
});
|
||||
Self { stop: stop_tx }
|
||||
}
|
||||
|
||||
/// Signals the supervision task to stop (best-effort; dropping the handle also
|
||||
/// stops it). Mirrors [`OrchestratorWatchHandle::stop`](infrastructure::OrchestratorWatchHandle::stop).
|
||||
pub fn stop(&self) {
|
||||
let _ = self.stop.try_send(());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user