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

@ -15,7 +15,7 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::agent::{AgentManifest, ManifestEntry};
use domain::events::DomainEvent;
use domain::events::{DomainEvent, OrchestrationSource};
use domain::ids::SkillId;
use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc;
@ -37,7 +37,7 @@ use application::{
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext,
};
use infrastructure::{process_request_file, OrchestratorResponse};
use infrastructure::{process_request_file, FsOrchestratorWatcher, OrchestratorResponse};
// --- temp dir (mirror local_fs.rs) ---
struct TempDir(PathBuf);
@ -720,3 +720,68 @@ async fn failure_response_omits_reply_key() {
"reply key must be absent on failure — got {raw}"
);
}
// ---------------------------------------------------------------------------
// M4 — observability: the filesystem watcher tags processed requests source=File.
//
// Drives the *public* `FsOrchestratorWatcher::start` against a real temp project
// root with a capturing events closure (the same sink shape the composition root
// wires), then polls (bounded) until the OrchestratorRequestProcessed beacon for
// the dropped request lands. Asserts the tag is `OrchestrationSource::File`.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn watcher_publishes_processed_event_tagged_file_source() {
use std::time::Duration;
let tmp = TempDir::new();
// A project rooted at the temp dir so the watcher scans <tmp>/.ideai/requests/.
let project = Project::new(
ProjectId::from_uuid(Uuid::from_u128(2000)),
"demo",
ProjectPath::new(tmp.0.to_str().unwrap()).unwrap(),
RemoteRef::local(),
1_700_000_000_000,
)
.unwrap();
let service = build_service(FakeContexts::new());
let captured = Arc::new(Mutex::new(Vec::<DomainEvent>::new()));
let sink = captured.clone();
let publish: Arc<dyn Fn(DomainEvent) + Send + Sync> =
Arc::new(move |e| sink.lock().unwrap().push(e));
let handle = FsOrchestratorWatcher::start(project, Arc::clone(&service), publish);
// Drop a valid request under .ideai/requests/<requester>/.
let req_dir = tmp.0.join(".ideai").join("requests").join("Main");
std::fs::create_dir_all(&req_dir).unwrap();
std::fs::write(
req_dir.join("req-1.json"),
br#"{ "action": "spawn_agent", "name": "dev-backend", "profile": "claude-code" }"#,
)
.unwrap();
// Poll (bounded) until the processed beacon for our request shows up.
let mut found: Option<DomainEvent> = None;
for _ in 0..50 {
if let Some(e) = captured.lock().unwrap().iter().find(|e| {
matches!(e, DomainEvent::OrchestratorRequestProcessed { action, .. } if action == "spawn_agent")
}) {
found = Some(e.clone());
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
handle.stop();
let event = found.expect("watcher must publish a processed beacon within the poll budget");
match event {
DomainEvent::OrchestratorRequestProcessed { source, ok, requester_id, .. } => {
assert_eq!(source, OrchestrationSource::File, "fs door must tag File");
assert!(ok, "the spawn request succeeded");
assert_eq!(requester_id, "Main", "requester id = request subdirectory");
}
other => panic!("expected OrchestratorRequestProcessed, got {other:?}"),
}
}