From 032fbaeeef7e1cd1001645dc21bf64115d141706 Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 7 Sep 2026 16:55:37 +0200 Subject: [PATCH] =?UTF-8?q?fix(cross-project):=20ask/cancel=20inter-projet?= =?UTF-8?q?s=20routent=20par=20agentId=20r=C3=A9solu,=20plus=20par=20nom?= =?UTF-8?q?=20=E2=80=94=20agents=20homonymes=20d=C3=A9sambigu=C3=AFs=C3=A9?= =?UTF-8?q?s=20par=20projectId+agentId,=20tests=20ask/cancel=20names=5Fcol?= =?UTF-8?q?lide=20=E2=80=94=20#260/#263=20(QA=20verte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/src/orchestrator/service.rs | 33 +++++- .../application/tests/orchestrator_service.rs | 102 ++++++++++++++++++ 2 files changed, 130 insertions(+), 5 deletions(-) diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 218e4971..f3ddb14a 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -1400,7 +1400,7 @@ impl OrchestratorService { 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)) + self.ask_resolved_agent(&project, target, task, Some(requester_agent_id)) .await } @@ -1427,7 +1427,8 @@ impl OrchestratorService { "[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 + self.stop_agent_by_id(&project, target.id, target.name, None) + .await } async fn run_in_background( @@ -1916,6 +1917,18 @@ impl OrchestratorService { .find_agent_by_name(project, &target) .await? .ok_or_else(|| AppError::NotFound(format!("agent {target}")))?; + self.ask_resolved_agent(project, agent, task, requester) + .await + } + + async fn ask_resolved_agent( + &self, + project: &Project, + agent: domain::Agent, + task: String, + requester: Option, + ) -> Result { + let target = agent.name.clone(); let agent_id = agent.id; // Détection de cycle (cadrage C3 §6) : si l'ask vient d'un **agent** A vers la // cible B, refuser AVANT tout enqueue si poser l'arête A→B fermerait un cycle @@ -2855,7 +2868,17 @@ impl OrchestratorService { .find_agent_id_by_name(project, &name) .await? .ok_or_else(|| AppError::NotFound(format!("agent {name}")))?; + self.stop_agent_by_id(project, agent_id, name, requester) + .await + } + async fn stop_agent_by_id( + &self, + project: &Project, + agent_id: AgentId, + label: String, + requester: Option, + ) -> Result { if let Some(requester) = requester { let allowed = requester == agent_id || self @@ -2898,7 +2921,7 @@ impl OrchestratorService { .await .map_err(|err| AppError::Process(err.to_string()))?; return Ok(OrchestratorOutcome { - detail: format!("cancelled current work for agent {name}"), + detail: format!("cancelled current work for agent {label}"), reply: None, }); } @@ -2909,12 +2932,12 @@ impl OrchestratorService { .is_empty() { return Err(AppError::NotFound(format!( - "running session for agent {name}" + "running session for agent {label}" ))); } Ok(OrchestratorOutcome { - detail: format!("stopped agent {name}"), + detail: format!("stopped agent {label}"), reply: None, }) } diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index 8826a7f2..4c8191b1 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -106,6 +106,16 @@ impl FakeContexts { .find(|e| &e.agent_id == agent) .map(|e| e.md_path.clone()) } + fn add_agent(&self, agent: &Agent, content: &str) { + let mut inner = self.0.lock().unwrap(); + inner + .manifest + .entries + .push(ManifestEntry::from_agent(agent)); + inner + .contents + .insert(agent.context_path.clone(), content.to_owned()); + } } #[async_trait] @@ -932,6 +942,94 @@ async fn cross_project_ask_rejects_non_contactable_agent_before_launch() { assert!(fx.pty.spawns().is_empty(), "agent must not be launched"); } +#[tokio::test] +async fn cross_project_ask_routes_by_agent_id_when_names_collide() { + let first = + scratch_agent(aid(1), "Architect", "agents/architect-first.md").with_contactable(true); + let second = + scratch_agent(aid(2), "Architect", "agents/architect-second.md").with_contactable(true); + let contexts = FakeContexts::with_agent(&first, "# first"); + contexts.add_agent(&second, "# second"); + let fx = ask_fixture(contexts); + let target_project = other_project(); + + fx.mailbox + .completions() + .push(first.id, TestCompletion::Replied("wrong first".to_owned())); + fx.mailbox.completions().push( + second.id, + TestCompletion::Replied("right second".to_owned()), + ); + + let out = timeout( + TEST_GUARD, + fx.service.ask_project_agent_for_mcp( + target_project.id, + second.id, + "work".to_owned(), + project().id, + aid(99), + "corr-homonym-ask".to_owned(), + ), + ) + .await + .expect("cross-project ask must not hang") + .expect("cross-project ask ok"); + + assert_eq!(out.reply.as_deref(), Some("right second")); + assert_eq!(fx.mailbox.pending(&first.id), 0); + assert_eq!(fx.mailbox.pending(&second.id), 0); +} + +#[tokio::test] +async fn cross_project_cancel_routes_by_agent_id_when_names_collide() { + let first = + scratch_agent(aid(1), "Architect", "agents/architect-first.md").with_contactable(true); + let second = + scratch_agent(aid(2), "Architect", "agents/architect-second.md").with_contactable(true); + let contexts = FakeContexts::with_agent(&first, "# first"); + contexts.add_agent(&second, "# second"); + let fx = ask_fixture(contexts); + let target_project = other_project(); + let first_cancelled = Arc::new(Mutex::new(0_u32)); + let second_cancelled = Arc::new(Mutex::new(0_u32)); + let first_session = Arc::new(CompletionSession { + id: sid(9101), + agent: first.id, + completions: fx.mailbox.completions(), + cancel_called: Some(Arc::clone(&first_cancelled)), + shutdown_called: None, + }) as Arc; + let second_session = Arc::new(CompletionSession { + id: sid(9102), + agent: second.id, + completions: fx.mailbox.completions(), + cancel_called: Some(Arc::clone(&second_cancelled)), + shutdown_called: None, + }) as Arc; + fx.structured + .insert_in_project(target_project.id, first_session, first.id, nid(9101)); + fx.structured + .insert_in_project(target_project.id, second_session, second.id, nid(9102)); + + timeout( + TEST_GUARD, + fx.service.cancel_project_agent_for_mcp( + target_project.id, + second.id, + project().id, + aid(99), + "corr-homonym-cancel".to_owned(), + ), + ) + .await + .expect("cross-project cancel must not hang") + .expect("cross-project cancel ok"); + + assert_eq!(*first_cancelled.lock().unwrap(), 0); + assert_eq!(*second_cancelled.lock().unwrap(), 1); +} + #[tokio::test] async fn spawn_unknown_agent_creates_then_launches() { let fx = fixture(FakeContexts::new()); @@ -2041,6 +2139,10 @@ fn ask_fixture_full( Arc::clone(&mediator) as Arc, Arc::clone(&mailbox) as Arc, ) + .with_project_store(Arc::new(FakeProjects::new(vec![ + project(), + other_project(), + ]))) .with_conversations(conversations) .with_events(Arc::new(bus.clone())) .with_structured(Arc::clone(&structured))