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

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