fix(cross-project): ask/cancel inter-projets routent par agentId résolu, plus par nom — agents homonymes désambiguïsés par projectId+agentId, tests ask/cancel names_collide — #260/#263 (QA verte)
This commit is contained in:
@ -1400,7 +1400,7 @@ impl OrchestratorService {
|
|||||||
crate::diag!(
|
crate::diag!(
|
||||||
"[cross-project] ask correlation={correlation_id} requester={requester_project_id}/{requester_agent_id} target={target_project_id}/{target_agent_id}"
|
"[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
|
.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}"
|
"[cross-project] cancel correlation={correlation_id} requester={requester_project_id}/{requester_agent_id} target={target_project_id}/{target_agent_id}"
|
||||||
);
|
);
|
||||||
let _ = requester_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(
|
async fn run_in_background(
|
||||||
@ -1916,6 +1917,18 @@ impl OrchestratorService {
|
|||||||
.find_agent_by_name(project, &target)
|
.find_agent_by_name(project, &target)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("agent {target}")))?;
|
.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<AgentId>,
|
||||||
|
) -> Result<OrchestratorOutcome, AppError> {
|
||||||
|
let target = agent.name.clone();
|
||||||
let agent_id = agent.id;
|
let agent_id = agent.id;
|
||||||
// Détection de cycle (cadrage C3 §6) : si l'ask vient d'un **agent** A vers la
|
// 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
|
// 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)
|
.find_agent_id_by_name(project, &name)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("agent {name}")))?;
|
.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<AgentId>,
|
||||||
|
) -> Result<OrchestratorOutcome, AppError> {
|
||||||
if let Some(requester) = requester {
|
if let Some(requester) = requester {
|
||||||
let allowed = requester == agent_id
|
let allowed = requester == agent_id
|
||||||
|| self
|
|| self
|
||||||
@ -2898,7 +2921,7 @@ impl OrchestratorService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::Process(err.to_string()))?;
|
.map_err(|err| AppError::Process(err.to_string()))?;
|
||||||
return Ok(OrchestratorOutcome {
|
return Ok(OrchestratorOutcome {
|
||||||
detail: format!("cancelled current work for agent {name}"),
|
detail: format!("cancelled current work for agent {label}"),
|
||||||
reply: None,
|
reply: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -2909,12 +2932,12 @@ impl OrchestratorService {
|
|||||||
.is_empty()
|
.is_empty()
|
||||||
{
|
{
|
||||||
return Err(AppError::NotFound(format!(
|
return Err(AppError::NotFound(format!(
|
||||||
"running session for agent {name}"
|
"running session for agent {label}"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(OrchestratorOutcome {
|
Ok(OrchestratorOutcome {
|
||||||
detail: format!("stopped agent {name}"),
|
detail: format!("stopped agent {label}"),
|
||||||
reply: None,
|
reply: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -106,6 +106,16 @@ impl FakeContexts {
|
|||||||
.find(|e| &e.agent_id == agent)
|
.find(|e| &e.agent_id == agent)
|
||||||
.map(|e| e.md_path.clone())
|
.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]
|
#[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");
|
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<dyn AgentSession>;
|
||||||
|
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<dyn AgentSession>;
|
||||||
|
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]
|
#[tokio::test]
|
||||||
async fn spawn_unknown_agent_creates_then_launches() {
|
async fn spawn_unknown_agent_creates_then_launches() {
|
||||||
let fx = fixture(FakeContexts::new());
|
let fx = fixture(FakeContexts::new());
|
||||||
@ -2041,6 +2139,10 @@ fn ask_fixture_full(
|
|||||||
Arc::clone(&mediator) as Arc<dyn InputMediator>,
|
Arc::clone(&mediator) as Arc<dyn InputMediator>,
|
||||||
Arc::clone(&mailbox) as Arc<dyn AgentMailbox>,
|
Arc::clone(&mailbox) as Arc<dyn AgentMailbox>,
|
||||||
)
|
)
|
||||||
|
.with_project_store(Arc::new(FakeProjects::new(vec![
|
||||||
|
project(),
|
||||||
|
other_project(),
|
||||||
|
])))
|
||||||
.with_conversations(conversations)
|
.with_conversations(conversations)
|
||||||
.with_events(Arc::new(bus.clone()))
|
.with_events(Arc::new(bus.clone()))
|
||||||
.with_structured(Arc::clone(&structured))
|
.with_structured(Arc::clone(&structured))
|
||||||
|
|||||||
Reference in New Issue
Block a user