feat(cross-project): découverte et contact inter-projets — tools MCP idea_project_{list,read_context,list_agents,ask_agent,cancel_agent}, permission contactable par agent + default_contactable persistée dans les templates, routage orchestrator par projectId+agentId — #260 (tests ciblés verts, QA en attente)

This commit is contained in:
2026-09-07 16:31:14 +02:00
parent d7b24ad183
commit 98954986a7
18 changed files with 721 additions and 15 deletions

View File

@ -790,6 +790,7 @@ mod tests {
synced_template_version: None,
skills: Vec::new(),
effort: None,
contactable: false,
}],
},
contexts: Mutex::new(contexts),

View File

@ -25,7 +25,8 @@ use domain::conversation_log::{ConversationTurn, TurnId, TurnRole};
use domain::input::{InputMediator, InputSource, SubmitConfig};
use domain::mailbox::{Ticket, TicketId};
use domain::ports::{
BackgroundTaskRunner, BackgroundTaskStore, Clock, EventBus, ProfileStore, PtyHandle,
BackgroundTaskRunner, BackgroundTaskStore, Clock, EventBus, ProfileStore, ProjectStore,
PtyHandle,
};
use domain::project::ProjectPath;
use domain::{
@ -407,6 +408,7 @@ pub struct OrchestratorService {
update_context: Arc<UpdateAgentContext>,
create_skill: Arc<CreateSkill>,
profiles: Arc<dyn ProfileStore>,
projects: Option<Arc<dyn ProjectStore>>,
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
@ -460,7 +462,7 @@ pub struct OrchestratorService {
///
/// 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<()>>>>,
ask_locks: StdMutex<HashMap<RuntimeAgentKey, 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
@ -600,6 +602,7 @@ impl OrchestratorService {
update_context,
create_skill,
profiles,
projects: None,
sessions,
input: None,
mailbox: None,
@ -634,6 +637,13 @@ impl OrchestratorService {
self
}
/// Branche le registre des projets connus pour les outils MCP cross-project.
#[must_use]
pub fn with_project_store(mut self, projects: Arc<dyn ProjectStore>) -> Self {
self.projects = Some(projects);
self
}
/// 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 {
@ -719,12 +729,12 @@ impl OrchestratorService {
/// 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<()>> {
fn ask_lock_for(&self, project: &Project, 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())
Arc::clone(locks.entry(runtime_key(project, agent_id)).or_default())
}
fn active_rendezvous_link(
@ -1301,6 +1311,125 @@ impl OrchestratorService {
}
}
fn require_project_store(&self) -> Result<&Arc<dyn ProjectStore>, AppError> {
self.projects.as_ref().ok_or_else(|| {
AppError::Invalid("cross-project MCP tools are not configured".to_owned())
})
}
async fn load_project_for_cross_tool(
&self,
project_id: domain::ProjectId,
) -> Result<Project, AppError> {
self.require_project_store()?
.load_project(project_id)
.await
.map_err(AppError::from)
}
/// Lists every project known to this IdeA instance for cross-project MCP discovery.
pub async fn list_projects_for_mcp(&self) -> Result<OrchestratorOutcome, AppError> {
let projects = self
.require_project_store()?
.list_projects()
.await
.map_err(AppError::from)?;
let reply = serde_json::to_string(&projects)
.map_err(|e| AppError::Invalid(format!("failed to serialise project list: {e}")))?;
Ok(OrchestratorOutcome {
detail: format!("listed {} project(s)", projects.len()),
reply: Some(reply),
})
}
/// Reads the target project's global context, even when it is not currently open.
pub async fn read_project_context_for_mcp(
&self,
project_id: domain::ProjectId,
requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> {
let project = self.load_project_for_cross_tool(project_id).await?;
self.read_context(&project, None, requester).await
}
/// Lists contactable agents in the target project, even when it is not currently open.
pub async fn list_project_agents_for_mcp(
&self,
project_id: domain::ProjectId,
) -> Result<OrchestratorOutcome, AppError> {
let project = self.load_project_for_cross_tool(project_id).await?;
let listed = self
.list_agents
.execute(ListAgentsInput {
project: project.clone(),
})
.await?;
let entries: Vec<_> = listed
.discovery_entries()
.into_iter()
.filter(|entry| entry.agent.contactable)
.collect();
let reply = serde_json::to_string(&entries)
.map_err(|e| AppError::Invalid(format!("failed to serialise agent list: {e}")))?;
Ok(OrchestratorOutcome {
detail: format!("listed {} contactable agent(s)", entries.len()),
reply: Some(reply),
})
}
/// Asks one contactable agent in another project by stable ids.
pub async fn ask_project_agent_for_mcp(
&self,
target_project_id: domain::ProjectId,
target_agent_id: AgentId,
task: String,
requester_project_id: domain::ProjectId,
requester_agent_id: AgentId,
correlation_id: String,
) -> Result<OrchestratorOutcome, AppError> {
let project = self.load_project_for_cross_tool(target_project_id).await?;
let target = self
.find_agent_by_id(&project, target_agent_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("agent {target_agent_id}")))?;
if !target.contactable {
return Err(AppError::Invalid(format!(
"agent {target_agent_id} is not contactable cross-project"
)));
}
crate::diag!(
"[cross-project] ask correlation={correlation_id} requester={requester_project_id}/{requester_agent_id} target={target_project_id}/{target_agent_id}"
);
self.ask_agent(&project, target.name, task, Some(requester_agent_id))
.await
}
/// Cancels/stops one contactable agent in another project by stable ids.
pub async fn cancel_project_agent_for_mcp(
&self,
target_project_id: domain::ProjectId,
target_agent_id: AgentId,
requester_project_id: domain::ProjectId,
requester_agent_id: AgentId,
correlation_id: String,
) -> Result<OrchestratorOutcome, AppError> {
let project = self.load_project_for_cross_tool(target_project_id).await?;
let target = self
.find_agent_by_id(&project, target_agent_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("agent {target_agent_id}")))?;
if !target.contactable {
return Err(AppError::Invalid(format!(
"agent {target_agent_id} is not contactable cross-project"
)));
}
crate::diag!(
"[cross-project] cancel correlation={correlation_id} requester={requester_project_id}/{requester_agent_id} target={target_project_id}/{target_agent_id}"
);
let _ = requester_agent_id;
self.stop_agent(&project, target.name, None).await
}
async fn run_in_background(
&self,
project: &Project,
@ -1818,7 +1947,7 @@ impl OrchestratorService {
// 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);
let lock = self.ask_lock_for(project, 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.
@ -2886,6 +3015,21 @@ impl OrchestratorService {
.find(|a| a.name.eq_ignore_ascii_case(name)))
}
/// Finds the full [`domain::Agent`] by stable id in the project manifest.
async fn find_agent_by_id(
&self,
project: &Project,
agent_id: AgentId,
) -> Result<Option<domain::Agent>, AppError> {
let listed = self
.list_agents
.execute(ListAgentsInput {
project: project.clone(),
})
.await?;
Ok(listed.agents.into_iter().find(|a| a.id == agent_id))
}
/// 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

View File

@ -45,6 +45,8 @@ pub struct CreateTemplateInput {
pub default_system_permissions: Option<SystemPermissionSet>,
/// Optional agent-level MCP tool permission override applied at creation time.
pub default_mcp_tool_policy: Option<McpToolPolicy>,
/// Whether agents created from this template are cross-project contactable.
pub default_contactable: bool,
}
/// Output of [`CreateTemplate::execute`].
@ -90,6 +92,7 @@ impl CreateTemplate {
input.default_agent_name,
input.default_system_permissions,
input.default_mcp_tool_policy,
input.default_contactable,
)
})
.map_err(|e| AppError::Invalid(e.to_string()))?;
@ -122,6 +125,8 @@ pub struct UpdateTemplateInput {
/// New default MCP tool policy. Outer `None` leaves the current value; inner
/// `None` preserves project inheritance for future agents.
pub default_mcp_tool_policy: Option<Option<McpToolPolicy>>,
/// New default contactable flag. `None` leaves the current value.
pub default_contactable: Option<bool>,
}
/// Output of [`UpdateTemplate::execute`].
@ -175,6 +180,9 @@ impl UpdateTemplate {
input
.default_mcp_tool_policy
.unwrap_or_else(|| updated.default_mcp_tool_policy.clone()),
input
.default_contactable
.unwrap_or(updated.default_contactable),
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.templates.save(&updated).await?;
@ -397,7 +405,8 @@ impl CreateAgentFromTemplate {
origin,
input.synchronized,
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
.map_err(|e| AppError::Invalid(e.to_string()))?
.with_contactable(template.default_contactable);
let mut entries = manifest.entries;
entries.push(ManifestEntry::from_agent(&agent));