From 54495261578608335ee0f3e5af56f1ab74dcbceb Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 22 Aug 2026 11:30:35 +0200 Subject: [PATCH] =?UTF-8?q?fix(orchestrator):=20le=20lancement=20d'une=20c?= =?UTF-8?q?ible=20structur=C3=A9e=20froide=20lie=20la=20session=20au=20fil?= =?UTF-8?q?=20A-B=20r=C3=A9solu=20par=20ask=5Fagent=20=E2=80=94=20#238?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensure_structured_session reçoit désormais le conversation_id déjà résolu et le transmet au plan de spawn au lieu de retomber sur le fallback User-B. WorkState et la session lancée pointent ainsi sur le même fil. Test de régression ask_cold_structured_agent_requester_launches_on_resolved_pair_thread. --- .../application/src/orchestrator/service.rs | 11 ++- .../application/tests/orchestrator_service.rs | 71 ++++++++++++++++++- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 986d93a9..5e01904c 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -1862,7 +1862,13 @@ impl OrchestratorService { ) })?; let session = self - .ensure_structured_session(project, agent_id, &agent.profile_id, structured) + .ensure_structured_session( + project, + agent_id, + &agent.profile_id, + structured, + conversation_id, + ) .await? .ok_or_else(|| { AppError::Invalid(format!( @@ -2558,6 +2564,7 @@ impl OrchestratorService { agent_id: AgentId, profile_id: &ProfileId, structured: &Arc, + conversation_id: domain::conversation::ConversationId, ) -> Result>, AppError> { // Cible déjà chaude : route directe (aucun lancement). if let Some(session) = structured.session_for_agent_in_project(project.id, &agent_id) { @@ -2589,7 +2596,7 @@ impl OrchestratorService { rows: DEFAULT_ROWS, cols: DEFAULT_COLS, node_id: None, - conversation_id: None, + conversation_id: Some(conversation_id.to_string()), mcp_runtime: self .mcp_runtime_provider .as_ref() diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index 4f07ea65..f116a378 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -4293,17 +4293,22 @@ impl AgentSession for EchoSession { struct CountingFactory { starts: Arc, next_id: Arc>, + session_plans: Arc>>, } impl CountingFactory { fn new() -> Self { Self { starts: Arc::new(AtomicUsize::new(0)), next_id: Arc::new(Mutex::new(9000)), + session_plans: Arc::new(Mutex::new(Vec::new())), } } fn start_count(&self) -> usize { self.starts.load(Ordering::SeqCst) } + fn session_plans(&self) -> Vec { + self.session_plans.lock().unwrap().clone() + } } #[async_trait] impl AgentSessionFactory for CountingFactory { @@ -4315,13 +4320,14 @@ impl AgentSessionFactory for CountingFactory { _profile: &AgentProfile, _ctx: &PreparedContext, _cwd: &ProjectPath, - _session: &SessionPlan, + session: &SessionPlan, _requester: Option<&str>, _env: &[(String, String)], _sandbox: Option<&domain::sandbox::SandboxPlan>, _structured_policy: Option<&domain::ports::StructuredProviderLaunchPolicy>, ) -> Result, AgentSessionError> { self.starts.fetch_add(1, Ordering::SeqCst); + self.session_plans.lock().unwrap().push(session.clone()); let id = { let mut n = self.next_id.lock().unwrap(); let id = SessionId::from_uuid(Uuid::from_u128(*n)); @@ -4500,6 +4506,7 @@ struct StructuredAskFixture { session_limits: Arc, background_store: Arc, background_runner: Arc, + conversations: Arc, } fn structured_ask_fixture(contexts: FakeContexts) -> StructuredAskFixture { @@ -4574,7 +4581,7 @@ fn structured_ask_fixture_with_background( Arc::clone(&mailbox), Arc::new(pty.clone()) as Arc, )); - let conversations = Arc::new(TestConversations::new()) as Arc; + let conversations = Arc::new(TestConversations::new()); let session_limits = Arc::new(SessionLimitService::new( Arc::new(FixedMillisClock(1_000)) as Arc, Arc::new(NoopScheduler::default()) as Arc, @@ -4596,7 +4603,7 @@ fn structured_ask_fixture_with_background( Arc::clone(&mediator) as Arc, Arc::clone(&mailbox) as Arc, ) - .with_conversations(conversations) + .with_conversations(Arc::clone(&conversations) as Arc) .with_events(Arc::new(bus.clone())) .with_session_limits(Arc::clone(&session_limits)) // Même registre que le launcher : `ask_agent` y relit la session fraîchement @@ -4626,6 +4633,7 @@ fn structured_ask_fixture_with_background( session_limits, background_store, background_runner, + conversations, } } @@ -4677,6 +4685,63 @@ async fn ask_cold_structured_target_autolaunches_session_and_final_unblocks() { ); } +/// Régression #238 : quand une cible structurée froide est lancée pour un ask A→B, +/// le launcher doit recevoir l'id du fil A↔B déjà résolu par `ask_agent`. Sans cela, +/// `launch_structured` retombe sur le fallback User↔B : WorkState pointe alors vers le +/// ticket/log A↔B tandis que la session/vue fraîchement lancée est liée à User↔B. +#[tokio::test] +async fn ask_cold_structured_agent_requester_launches_on_resolved_pair_thread() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + + let requester = aid(7); + let requester_uuid = uuid::Uuid::from_u128(7); + let ask_json = format!( + r#"{{ "type":"agent.message", "requestedBy":"{requester_uuid}", "targetAgent":"architect", "task":"Analyse §17" }}"# + ); + + let out = fx + .service + .dispatch(&project(), cmd(&ask_json)) + .await + .expect("ask ok"); + + assert_eq!(out.reply.as_deref(), Some("structured: Analyse §17")); + assert_eq!( + fx.factory.start_count(), + 1, + "the cold structured target is launched exactly once" + ); + + let expected = fx + .conversations + .resolve( + project_id(), + domain::ConversationParty::agent(requester), + domain::ConversationParty::agent(aid(1)), + ) + .id; + let user_target = fx + .conversations + .resolve( + project_id(), + domain::ConversationParty::User, + domain::ConversationParty::agent(aid(1)), + ) + .id; + assert_ne!( + expected, user_target, + "the regression guard must distinguish agent-requester and user threads" + ); + assert_eq!( + fx.factory.session_plans(), + vec![SessionPlan::Resume { + conversation_id: expected.to_string() + }], + "cold launch must reuse the conversation id resolved by ask_agent" + ); +} + /// Réutilisation : une **deuxième** délégation vers la **même** cible (désormais chaude) /// ne relance **pas** de session — elle réutilise celle insérée au premier `ask`. Prouve /// que le `session_for_agent` court-circuite l'auto-lancement (idempotence du chemin).