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:
@ -790,6 +790,7 @@ mod tests {
|
||||
synced_template_version: None,
|
||||
skills: Vec::new(),
|
||||
effort: None,
|
||||
contactable: false,
|
||||
}],
|
||||
},
|
||||
contexts: Mutex::new(contexts),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -24,8 +24,9 @@ use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, BackgroundCompletionStream, BackgroundTaskHandle,
|
||||
BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec, BackgroundTaskStore,
|
||||
ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError,
|
||||
IdGenerator, MemoryError, MemoryStore, OutputStream, PreparedContext, ProfileStore, PtyError,
|
||||
PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
|
||||
IdGenerator, MemoryError, MemoryStore, OutputStream, PreparedContext, ProfileStore,
|
||||
ProjectStore, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore,
|
||||
SpawnSpec, StoreError,
|
||||
};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
|
||||
@ -172,6 +173,45 @@ impl ProfileStore for FakeProfiles {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakeProjects(Arc<Vec<Project>>);
|
||||
|
||||
impl FakeProjects {
|
||||
fn new(projects: Vec<Project>) -> Self {
|
||||
Self(Arc::new(projects))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProjectStore for FakeProjects {
|
||||
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
||||
Ok((*self.0).clone())
|
||||
}
|
||||
|
||||
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
|
||||
self.0
|
||||
.iter()
|
||||
.find(|project| project.id == id)
|
||||
.cloned()
|
||||
.ok_or(StoreError::NotFound)
|
||||
}
|
||||
|
||||
async fn save_project(&self, _project: &Project) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_workspace(
|
||||
&self,
|
||||
_workspace: &domain::layout::Workspace,
|
||||
) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_workspace(&self) -> Result<domain::layout::Workspace, StoreError> {
|
||||
Ok(domain::layout::Workspace::default())
|
||||
}
|
||||
}
|
||||
|
||||
// Empty skill store: the orchestrator tests spawn agents with no assigned skills.
|
||||
#[derive(Default)]
|
||||
struct FakeSkills;
|
||||
@ -795,10 +835,103 @@ fn cmd(json: &str) -> OrchestratorCommand {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn other_project() -> Project {
|
||||
Project::new(
|
||||
ProjectId::from_uuid(Uuid::from_u128(2000)),
|
||||
"other",
|
||||
ProjectPath::new("/home/me/other").unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_700_000_000_001,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Basic dispatch coverage (spawn / stop / update / skill) — non-ask commands.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_project_list_projects_reads_known_project_registry() {
|
||||
let fx = fixture(FakeContexts::new());
|
||||
let svc = fx
|
||||
.service
|
||||
.with_project_store(Arc::new(FakeProjects::new(vec![
|
||||
project(),
|
||||
other_project(),
|
||||
])));
|
||||
|
||||
let out = svc
|
||||
.list_projects_for_mcp()
|
||||
.await
|
||||
.expect("project registry is readable");
|
||||
let rows: serde_json::Value = serde_json::from_str(&out.reply.unwrap()).unwrap();
|
||||
|
||||
assert_eq!(rows.as_array().unwrap().len(), 2);
|
||||
assert_eq!(rows[0]["id"], project_id().to_string());
|
||||
assert_eq!(rows[1]["id"], other_project().id.to_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_project_list_agents_filters_to_contactable_agents() {
|
||||
let visible = scratch_agent(aid(1), "Visible", "agents/visible.md").with_contactable(true);
|
||||
let hidden = scratch_agent(aid(2), "Hidden", "agents/hidden.md");
|
||||
let contexts = {
|
||||
let me = FakeContexts::with_agent(&visible, "# visible");
|
||||
let mut inner = me.0.lock().unwrap();
|
||||
inner
|
||||
.manifest
|
||||
.entries
|
||||
.push(ManifestEntry::from_agent(&hidden));
|
||||
inner
|
||||
.contents
|
||||
.insert(hidden.context_path.clone(), "# hidden".to_owned());
|
||||
drop(inner);
|
||||
me
|
||||
};
|
||||
let fx = fixture(contexts);
|
||||
let target_project = other_project();
|
||||
let svc = fx
|
||||
.service
|
||||
.with_project_store(Arc::new(FakeProjects::new(vec![target_project.clone()])));
|
||||
|
||||
let out = svc
|
||||
.list_project_agents_for_mcp(target_project.id)
|
||||
.await
|
||||
.expect("agent manifest is readable");
|
||||
let rows: serde_json::Value = serde_json::from_str(&out.reply.unwrap()).unwrap();
|
||||
|
||||
assert_eq!(rows.as_array().unwrap().len(), 1);
|
||||
assert_eq!(rows[0]["id"], visible.id.to_string());
|
||||
assert_eq!(rows[0]["name"], "Visible");
|
||||
assert_eq!(rows[0]["contactable"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_project_ask_rejects_non_contactable_agent_before_launch() {
|
||||
let hidden = scratch_agent(aid(2), "Hidden", "agents/hidden.md");
|
||||
let fx = fixture(FakeContexts::with_agent(&hidden, "# hidden"));
|
||||
let target_project = other_project();
|
||||
let svc = fx
|
||||
.service
|
||||
.with_project_store(Arc::new(FakeProjects::new(vec![target_project.clone()])));
|
||||
|
||||
let err = svc
|
||||
.ask_project_agent_for_mcp(
|
||||
target_project.id,
|
||||
hidden.id,
|
||||
"work".to_owned(),
|
||||
project().id,
|
||||
aid(99),
|
||||
"corr-1".to_owned(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "INVALID");
|
||||
assert!(err.to_string().contains("not contactable"));
|
||||
assert!(fx.pty.spawns().is_empty(), "agent must not be launched");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_unknown_agent_creates_then_launches() {
|
||||
let fx = fixture(FakeContexts::new());
|
||||
|
||||
@ -282,6 +282,7 @@ async fn create_template_starts_at_initial_version() {
|
||||
default_mcp_tool_policy: Some(McpToolPolicy {
|
||||
allowed_tools: vec!["idea_template_read".to_owned()],
|
||||
}),
|
||||
default_contactable: true,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@ -341,6 +342,7 @@ async fn update_template_bumps_version_and_publishes_event() {
|
||||
default_agent_name: None,
|
||||
default_system_permissions: None,
|
||||
default_mcp_tool_policy: None,
|
||||
default_contactable: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@ -370,6 +372,7 @@ async fn update_unknown_template_is_not_found() {
|
||||
default_agent_name: None,
|
||||
default_system_permissions: None,
|
||||
default_mcp_tool_policy: None,
|
||||
default_contactable: None,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
@ -393,6 +396,7 @@ async fn update_template_updates_default_profile_for_future_agents() {
|
||||
default_mcp_tool_policy: Some(Some(McpToolPolicy {
|
||||
allowed_tools: vec!["idea_template_read".to_owned()],
|
||||
})),
|
||||
default_contactable: Some(true),
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
@ -411,6 +415,7 @@ async fn update_template_updates_default_profile_for_future_agents() {
|
||||
allowed_tools: vec!["idea_template_read".to_owned()]
|
||||
})
|
||||
);
|
||||
assert!(updated.default_contactable);
|
||||
|
||||
let contexts = FakeContexts::new(vec![]);
|
||||
let out = CreateAgentFromTemplate::new(
|
||||
@ -427,6 +432,7 @@ async fn update_template_updates_default_profile_for_future_agents() {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.agent.contactable);
|
||||
|
||||
assert_eq!(out.agent.name, "Agent Name");
|
||||
assert_eq!(out.agent.profile_id, pid(42));
|
||||
@ -486,6 +492,7 @@ async fn create_agent_from_template_projects_template_permission_overrides() {
|
||||
Some(McpToolPolicy {
|
||||
allowed_tools: vec!["idea_template_read".to_owned()],
|
||||
}),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let store = FakeTemplates::with(vec![template]);
|
||||
@ -512,6 +519,7 @@ async fn create_agent_from_template_projects_template_permission_overrides() {
|
||||
|
||||
assert_eq!(out.agent.name, "Backend");
|
||||
assert_eq!(out.agent.profile_id, pid(7));
|
||||
assert!(out.agent.contactable);
|
||||
assert_eq!(
|
||||
system_permissions.0.lock().unwrap().agents,
|
||||
vec![AgentSystemPermissionOverride::new(
|
||||
|
||||
Reference in New Issue
Block a user