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:
2026-06-10 12:53:31 +02:00
parent 97daf3fae5
commit 37e72747d3
30 changed files with 3646 additions and 27 deletions

View File

@ -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(),

View File

@ -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(());
}
}

View File

@ -138,6 +138,64 @@ fn domain_event_dto_maps_pty_output_bytes() {
assert_eq!(v["bytes"], json!([1, 2, 3]));
}
// ---------------------------------------------------------------------------
// M4 — orchestration source on `orchestratorRequestProcessed`
// ---------------------------------------------------------------------------
#[test]
fn orchestration_source_dto_serialises_lowercase() {
use app_tauri_lib::events::OrchestrationSourceDto;
use domain::events::OrchestrationSource;
// File → "file"; Mcp → "mcp" (camelCase rename on a unit enum = lowercase).
let file = serde_json::to_value(OrchestrationSourceDto::from(OrchestrationSource::File)).unwrap();
assert_eq!(file, json!("file"));
let mcp = serde_json::to_value(OrchestrationSourceDto::from(OrchestrationSource::Mcp)).unwrap();
assert_eq!(mcp, json!("mcp"));
}
#[test]
fn domain_event_dto_maps_orchestrator_request_processed_with_file_source() {
use domain::events::OrchestrationSource;
let dto = DomainEventDto::from(&DomainEvent::OrchestratorRequestProcessed {
requester_id: "Main".into(),
action: "spawn_agent".into(),
ok: true,
source: OrchestrationSource::File,
});
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(
v,
json!({
"type": "orchestratorRequestProcessed",
"requesterId": "Main",
"action": "spawn_agent",
"ok": true,
"source": "file",
})
);
}
#[test]
fn domain_event_dto_maps_orchestrator_request_processed_with_mcp_source() {
use domain::events::OrchestrationSource;
let dto = DomainEventDto::from(&DomainEvent::OrchestratorRequestProcessed {
requester_id: "mcp".into(),
action: "idea_ask_agent".into(),
ok: false,
source: OrchestrationSource::Mcp,
});
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["type"], "orchestratorRequestProcessed");
assert_eq!(v["requesterId"], "mcp");
assert_eq!(v["action"], "idea_ask_agent");
assert_eq!(v["ok"], json!(false));
assert_eq!(v["source"], "mcp", "MCP door must badge as `mcp`");
}
// ---------------------------------------------------------------------------
// Terminal DTOs (L3)
// ---------------------------------------------------------------------------

View File

@ -47,6 +47,14 @@ fn has_watcher(state: &AppState, id: &ProjectId) -> bool {
state.orchestrator_watchers.lock().unwrap().contains_key(id)
}
fn mcp_count(state: &AppState) -> usize {
state.mcp_servers.lock().unwrap().len()
}
fn has_mcp(state: &AppState, id: &ProjectId) -> bool {
state.mcp_servers.lock().unwrap().contains_key(id)
}
#[tokio::test]
async fn ensure_watch_registers_a_watcher_and_is_idempotent() {
let state = AppState::build(temp_path("appdata"));
@ -101,3 +109,105 @@ async fn watchers_are_isolated_per_project() {
assert!(has_watcher(&state, &b.id));
assert_eq!(watcher_count(&state), 1);
}
// --- M3: IdeA MCP server lifecycle (twin of the watcher, Décision 4) ---
//
// The MCP server registry (`mcp_servers`) is the twin of `orchestrator_watchers`:
// `ensure_orchestrator_watch` starts both side by side on open/create, and
// `stop_orchestrator_watch` tears both down on close. These tests mirror the
// watcher lifecycle tests above against the MCP registry. The per-project
// supervision task parks on a stop signal (no blocking serve loop), so open/close
// must return promptly — the `#[tokio::test]` harness itself proves no figing
// (the test completes).
#[tokio::test]
async fn ensure_watch_starts_an_mcp_server_per_project() {
let state = AppState::build(temp_path("appdata"));
let project = make_project();
assert_eq!(mcp_count(&state), 0, "no MCP server before open");
state.ensure_orchestrator_watch(&project);
assert!(
has_mcp(&state, &project.id),
"MCP server registered alongside the watcher"
);
assert_eq!(mcp_count(&state), 1);
}
#[tokio::test]
async fn ensure_mcp_server_is_idempotent_per_project() {
let state = AppState::build(temp_path("appdata"));
let project = make_project();
state.ensure_orchestrator_watch(&project);
assert_eq!(mcp_count(&state), 1);
// Opening the same project again must not spawn a second MCP server.
state.ensure_orchestrator_watch(&project);
assert_eq!(
mcp_count(&state),
1,
"MCP server start is idempotent per project"
);
assert!(has_mcp(&state, &project.id));
}
#[tokio::test]
async fn stop_watch_unregisters_the_mcp_server() {
let state = AppState::build(temp_path("appdata"));
let project = make_project();
state.ensure_orchestrator_watch(&project);
assert!(has_mcp(&state, &project.id));
state.stop_orchestrator_watch(&project.id);
assert!(
!has_mcp(&state, &project.id),
"MCP server removed on close"
);
assert_eq!(mcp_count(&state), 0);
// Stopping an unknown project is a no-op (does not panic) for the MCP twin too.
state.stop_orchestrator_watch(&project.id);
}
#[tokio::test]
async fn watcher_and_mcp_server_coexist_and_close_together() {
let state = AppState::build(temp_path("appdata"));
let project = make_project();
// Open: both entry doors onto the same OrchestratorService are live.
state.ensure_orchestrator_watch(&project);
assert!(has_watcher(&state, &project.id), "watcher live on open");
assert!(has_mcp(&state, &project.id), "MCP server live on open");
assert_eq!(watcher_count(&state), 1);
assert_eq!(mcp_count(&state), 1);
// Close: the symmetric teardown removes both.
state.stop_orchestrator_watch(&project.id);
assert!(!has_watcher(&state, &project.id), "watcher gone on close");
assert!(!has_mcp(&state, &project.id), "MCP server gone on close");
assert_eq!(watcher_count(&state), 0);
assert_eq!(mcp_count(&state), 0);
}
#[tokio::test]
async fn mcp_servers_are_isolated_per_project() {
let state = AppState::build(temp_path("appdata"));
let a = make_project();
let b = make_project();
state.ensure_orchestrator_watch(&a);
state.ensure_orchestrator_watch(&b);
assert_eq!(mcp_count(&state), 2, "one MCP server per open project");
assert!(has_mcp(&state, &a.id));
assert!(has_mcp(&state, &b.id));
// Closing one leaves the other's MCP server running.
state.stop_orchestrator_watch(&a.id);
assert!(!has_mcp(&state, &a.id));
assert!(has_mcp(&state, &b.id));
assert_eq!(mcp_count(&state), 1);
}