From 3abf2fce988f5c2d014e9f9fccc31066ef2f49ae Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 8 Jul 2026 08:11:14 +0200 Subject: [PATCH] =?UTF-8?q?fix(runtime):=20detection=20non=20bloquante,=20?= =?UTF-8?q?born=C3=A9e=20dans=20le=20temps,=20HTTP=20pour=20les=20profils?= =?UTF-8?q?=20locaux=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le first-run wizard restait figé : `CliAgentRuntime::detect` construisait un runtime tokio courant-thread via `futures_block_on` pour piloter un `ProcessSpawner` async. Appelé depuis le runtime async de Tauri, ce `block_on` imbriqué panique ; la commande IPC ne répond alors jamais, la promesse `detectProfiles` reste pendante et `busy` ne redescend plus. Ce commit s'attaque à la cause côté backend : - `AgentRuntime::detect` devient `async fn` (`#[async_trait]`) : le port cesse de mentir sur sa nature. Il pilote un spawner async, il est async. Le `futures_block_on` disparaît, et avec lui le runtime imbriqué. - La sonde CLI est bornée par `tokio::time::timeout(DETECTION_TIMEOUT)` : un binaire qui ne rend jamais la main dégrade la détection en `Err`, il ne gèle plus l'appelant. - Les profils `StructuredAdapter::OpenAiCompatible` n'ont pas de CLI à spawner : les sonder revenait à tester un binaire inexistant. Ils sont désormais sondés par un GET HTTP sur l'endpoint `/models` dérivé de `chat_http.endpoint`, borné par le même timeout. `DetectProfiles` séquence les `await` sur les candidats. Les fakes `AgentRuntime` des crates application/infrastructure/app-tauri suivent la nouvelle signature. Tests: cargo test -p domain (241), -p application (81), -p infrastructure (269), -p app-tauri (63+15) — verts. `rg futures_block_on crates` sans match. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/state.rs | 6 +- crates/application/src/agent/usecases.rs | 13 ++-- crates/application/tests/agent_lifecycle.rs | 3 +- .../application/tests/change_agent_profile.rs | 3 +- .../application/tests/orchestrator_service.rs | 3 +- crates/application/tests/profile_usecases.rs | 36 ++++++++++- .../application/tests/structured_launch_d3.rs | 3 +- crates/domain/src/ports.rs | 5 +- crates/infrastructure/src/runtime/mod.rs | 58 ++++++++++++------ crates/infrastructure/tests/agent_runtime.rs | 59 ++++++++++++++----- crates/infrastructure/tests/mcp_server.rs | 3 +- .../tests/orchestrator_watcher.rs | 3 +- 12 files changed, 143 insertions(+), 52 deletions(-) diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 6af05b3..2bd8e17 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -3992,8 +3992,9 @@ mod mcp_serve_peer_tests { } struct FakeRuntime; + #[async_trait] impl AgentRuntime for FakeRuntime { - fn detect(&self, _p: &AgentProfile) -> Result { + async fn detect(&self, _p: &AgentProfile) -> Result { Ok(true) } fn prepare_invocation( @@ -5374,8 +5375,9 @@ mod mcp_e2e_loopback_tests { } struct FakeRuntime; + #[async_trait] impl AgentRuntime for FakeRuntime { - fn detect(&self, _p: &AgentProfile) -> Result { + async fn detect(&self, _p: &AgentProfile) -> Result { Ok(true) } fn prepare_invocation( diff --git a/crates/application/src/agent/usecases.rs b/crates/application/src/agent/usecases.rs index c73f7b7..3ce0d03 100644 --- a/crates/application/src/agent/usecases.rs +++ b/crates/application/src/agent/usecases.rs @@ -72,14 +72,11 @@ impl DetectProfiles { &self, input: DetectProfilesInput, ) -> Result { - let results = input - .candidates - .into_iter() - .map(|profile| { - let available = self.runtime.detect(&profile).unwrap_or(false); - ProfileAvailability { profile, available } - }) - .collect(); + let mut results = Vec::with_capacity(input.candidates.len()); + for profile in input.candidates { + let available = self.runtime.detect(&profile).await.unwrap_or(false); + results.push(ProfileAvailability { profile, available }); + } Ok(DetectProfilesOutput { results }) } } diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index b8dc064..9cd4be8 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -318,8 +318,9 @@ impl FakeRuntime { } } +#[async_trait] impl AgentRuntime for FakeRuntime { - fn detect(&self, _profile: &AgentProfile) -> Result { + async fn detect(&self, _profile: &AgentProfile) -> Result { Ok(true) } fn prepare_invocation( diff --git a/crates/application/tests/change_agent_profile.rs b/crates/application/tests/change_agent_profile.rs index 3e4b313..4f69b3b 100644 --- a/crates/application/tests/change_agent_profile.rs +++ b/crates/application/tests/change_agent_profile.rs @@ -344,8 +344,9 @@ impl FakeRuntime { } } +#[async_trait] impl AgentRuntime for FakeRuntime { - fn detect(&self, _profile: &AgentProfile) -> Result { + async fn detect(&self, _profile: &AgentProfile) -> Result { Ok(true) } fn prepare_invocation( diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index 841a1ee..e5709b8 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -262,8 +262,9 @@ impl SkillStore for RecordingSkills { } struct FakeRuntime; +#[async_trait] impl AgentRuntime for FakeRuntime { - fn detect(&self, _profile: &AgentProfile) -> Result { + async fn detect(&self, _profile: &AgentProfile) -> Result { Ok(true) } fn prepare_invocation( diff --git a/crates/application/tests/profile_usecases.rs b/crates/application/tests/profile_usecases.rs index 7b8cebe..b66d988 100644 --- a/crates/application/tests/profile_usecases.rs +++ b/crates/application/tests/profile_usecases.rs @@ -86,9 +86,11 @@ struct StubRuntime { by_command: HashMap, } +struct YieldingRuntime; + #[async_trait] impl AgentRuntime for StubRuntime { - fn detect(&self, profile: &AgentProfile) -> Result { + async fn detect(&self, profile: &AgentProfile) -> Result { match self.by_command.get(&profile.command) { Some(DetectResult::Available) => Ok(true), Some(DetectResult::Missing) | None => Ok(false), @@ -107,6 +109,24 @@ impl AgentRuntime for StubRuntime { } } +#[async_trait] +impl AgentRuntime for YieldingRuntime { + async fn detect(&self, _profile: &AgentProfile) -> Result { + tokio::task::yield_now().await; + Ok(true) + } + + fn prepare_invocation( + &self, + _profile: &AgentProfile, + _ctx: &PreparedContext, + _cwd: &ProjectPath, + _session: &SessionPlan, + ) -> Result { + unreachable!("not used in these tests") + } +} + fn profile(id: u128, name: &str, command: &str) -> AgentProfile { AgentProfile::new( ProfileId::from_uuid(uuid::Uuid::from_u128(id)), @@ -167,6 +187,20 @@ async fn detect_error_degrades_to_unavailable_not_hard_failure() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn detect_execute_awaits_async_runtime_without_nested_runtime_panic() { + let detect = DetectProfiles::new(Arc::new(YieldingRuntime)); + let out = detect + .execute(DetectProfilesInput { + candidates: vec![profile(1, "Claude", "claude")], + }) + .await + .expect("async detection must complete inside a multi-thread Tokio runtime"); + + assert_eq!(out.results.len(), 1); + assert!(out.results[0].available); +} + // --------------------------------------------------------------------------- // ConfigureProfiles // --------------------------------------------------------------------------- diff --git a/crates/application/tests/structured_launch_d3.rs b/crates/application/tests/structured_launch_d3.rs index affc8ac..26e3260 100644 --- a/crates/application/tests/structured_launch_d3.rs +++ b/crates/application/tests/structured_launch_d3.rs @@ -284,8 +284,9 @@ impl FakeRuntime { } } +#[async_trait] impl AgentRuntime for FakeRuntime { - fn detect(&self, _profile: &AgentProfile) -> Result { + async fn detect(&self, _profile: &AgentProfile) -> Result { Ok(true) } fn prepare_invocation( diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 124350f..592eb2e 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -745,13 +745,14 @@ pub struct GraphCommit { // --------------------------------------------------------------------------- /// Launch/drive an AI CLI according to an [`AgentProfile`], handling context -/// injection. CPU-bound and synchronous — kept plain `fn`. +/// injection. +#[async_trait] pub trait AgentRuntime: Send + Sync { /// Detects whether the profile's command is available. /// /// # Errors /// [`RuntimeError`] on detection failure. - fn detect(&self, profile: &AgentProfile) -> Result; + async fn detect(&self, profile: &AgentProfile) -> Result; /// Builds a [`SpawnSpec`] (command + args + injection plan) for launching /// the agent in `cwd` with the prepared context. diff --git a/crates/infrastructure/src/runtime/mod.rs b/crates/infrastructure/src/runtime/mod.rs index 0e2a730..ddb0100 100644 --- a/crates/infrastructure/src/runtime/mod.rs +++ b/crates/infrastructure/src/runtime/mod.rs @@ -17,7 +17,7 @@ //! `cwd` (template substitution), and the context-injection plan derived from //! the profile's [`ContextInjection`]. This is the testable core of L5. -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; use async_trait::async_trait; @@ -25,9 +25,11 @@ use domain::ports::{ AgentRuntime, ContextInjectionPlan, PreparedContext, ProcessSpawner, RuntimeError, SessionPlan, SpawnSpec, }; -use domain::profile::{AgentProfile, ContextInjection, SessionStrategy}; +use domain::profile::{AgentProfile, ContextInjection, SessionStrategy, StructuredAdapter}; use domain::project::ProjectPath; +const DETECTION_TIMEOUT: Duration = Duration::from_millis(800); + /// The single generic AI-runtime adapter. Holds a [`ProcessSpawner`] (used only /// by [`detect`](AgentRuntime::detect)); [`prepare_invocation`] is pure. #[derive(Clone)] @@ -179,14 +181,16 @@ impl CliAgentRuntime { #[async_trait] impl AgentRuntime for CliAgentRuntime { - fn detect(&self, profile: &AgentProfile) -> Result { + async fn detect(&self, profile: &AgentProfile) -> Result { + if profile.structured_adapter == Some(StructuredAdapter::OpenAiCompatible) { + return Ok(Self::detect_openai_compatible(profile).await); + } + let spec = Self::detection_spec(profile)?; - // The port is synchronous but the spawner is async; block on it. The - // adapter is invoked off the Tauri async runtime (detection is a - // short-lived probe), so a transient runtime here is acceptable and keeps - // the `AgentRuntime` port plain-`fn` as the domain declares it. let spawner = Arc::clone(&self.spawner); - let output = futures_block_on(async move { spawner.run(spec).await }) + let output = tokio::time::timeout(DETECTION_TIMEOUT, spawner.run(spec)) + .await + .map_err(|_| RuntimeError::Detection("detection timed out".to_owned()))? .map_err(|e| RuntimeError::Detection(e.to_string()))?; Ok(output.status.code == Some(0)) } @@ -225,13 +229,33 @@ impl AgentRuntime for CliAgentRuntime { } } -/// Minimal block-on helper that drives a future to completion on a fresh -/// current-thread tokio runtime. Used only by [`CliAgentRuntime::detect`], whose -/// port signature is synchronous while the [`ProcessSpawner`] it calls is async. -fn futures_block_on(fut: F) -> F::Output { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("failed to build a current-thread runtime for detection") - .block_on(fut) +impl CliAgentRuntime { + async fn detect_openai_compatible(profile: &AgentProfile) -> bool { + let Some(chat) = &profile.chat_http else { + return false; + }; + let url = openai_compatible_models_endpoint(&chat.endpoint); + let probe = async move { + let client = reqwest::Client::builder() + .timeout(DETECTION_TIMEOUT) + .build() + .ok()?; + let response = client.get(url).send().await.ok()?; + Some(response.status().is_success()) + }; + tokio::time::timeout(DETECTION_TIMEOUT, probe) + .await + .ok() + .flatten() + .unwrap_or(false) + } +} + +fn openai_compatible_models_endpoint(endpoint: &str) -> String { + let trimmed = endpoint.trim_end_matches('/'); + let base = trimmed + .strip_suffix("/chat/completions") + .unwrap_or(trimmed) + .trim_end_matches('/'); + format!("{base}/models") } diff --git a/crates/infrastructure/tests/agent_runtime.rs b/crates/infrastructure/tests/agent_runtime.rs index 431413c..5bfe20a 100644 --- a/crates/infrastructure/tests/agent_runtime.rs +++ b/crates/infrastructure/tests/agent_runtime.rs @@ -18,7 +18,9 @@ use domain::ports::{ AgentRuntime, ContextInjectionPlan, ExitStatus, Output, PreparedContext, ProcessError, ProcessSpawner, RuntimeError, SessionPlan, SpawnSpec, }; -use domain::profile::{AgentProfile, ContextInjection, SessionStrategy}; +use domain::profile::{ + AgentProfile, ContextInjection, HttpChatConfig, SessionStrategy, StructuredAdapter, +}; use domain::project::ProjectPath; use domain::MarkdownDoc; use infrastructure::CliAgentRuntime; @@ -297,60 +299,85 @@ fn detection_spec_falls_back_to_command_version() { // detect (mocked spawner) // --------------------------------------------------------------------------- -#[test] -fn detect_true_on_exit_zero() { +#[tokio::test] +async fn detect_true_on_exit_zero() { let rt = runtime_with(Ok(Output { status: ExitStatus { code: Some(0) }, stdout: Vec::new(), stderr: Vec::new(), })); let p = profile(ContextInjection::stdin(), "{projectRoot}"); - assert!(rt.detect(&p).unwrap()); + assert!(rt.detect(&p).await.unwrap()); } -#[test] -fn detect_false_on_nonzero_exit() { +#[tokio::test] +async fn detect_false_on_nonzero_exit() { let rt = runtime_with(Ok(Output { status: ExitStatus { code: Some(127) }, stdout: Vec::new(), stderr: Vec::new(), })); let p = profile(ContextInjection::stdin(), "{projectRoot}"); - assert!(!rt.detect(&p).unwrap()); + assert!(!rt.detect(&p).await.unwrap()); } -#[test] -fn detect_false_on_signal_terminated() { +#[tokio::test] +async fn detect_false_on_signal_terminated() { let rt = runtime_with(Ok(Output { status: ExitStatus { code: None }, stdout: Vec::new(), stderr: Vec::new(), })); let p = profile(ContextInjection::stdin(), "{projectRoot}"); - assert!(!rt.detect(&p).unwrap()); + assert!(!rt.detect(&p).await.unwrap()); } -#[test] -fn detect_propagates_spawner_error() { +#[tokio::test] +async fn detect_propagates_spawner_error() { let rt = runtime_with(Err(ProcessError::Spawn("no such file".to_owned()))); let p = profile(ContextInjection::stdin(), "{projectRoot}"); - let err = rt.detect(&p).expect_err("spawner error surfaces"); + let err = rt.detect(&p).await.expect_err("spawner error surfaces"); assert!(matches!(err, RuntimeError::Detection(_)), "got {err:?}"); } -#[test] -fn detect_runs_the_detection_spec_command() { +#[tokio::test] +async fn detect_runs_the_detection_spec_command() { let recorder = Arc::new(RecordingSpawner(std::sync::Mutex::new(None))); let rt = CliAgentRuntime::new(recorder.clone()); let p = profile(ContextInjection::stdin(), "{projectRoot}"); - rt.detect(&p).unwrap(); + rt.detect(&p).await.unwrap(); let spec = recorder.0.lock().unwrap().clone().expect("spec recorded"); assert_eq!(spec.command, "mycli"); assert_eq!(spec.args, vec!["probe", "--json"]); } +#[tokio::test] +async fn detect_openai_compatible_uses_http_endpoint_not_cli_spawner() { + let recorder = Arc::new(RecordingSpawner(std::sync::Mutex::new(None))); + let rt = CliAgentRuntime::new(recorder.clone()); + let p = profile(ContextInjection::stdin(), "{projectRoot}") + .with_structured_adapter(StructuredAdapter::OpenAiCompatible) + .with_chat_http( + HttpChatConfig::new( + "http://127.0.0.1:1/v1", + "model", + None, + Some(1000), + Some(1000), + None, + ) + .unwrap(), + ); + + assert!(!rt.detect(&p).await.unwrap()); + assert!( + recorder.0.lock().unwrap().is_none(), + "OpenAI-compatible detection must not spawn the profile command" + ); +} + // --------------------------------------------------------------------------- // prepare_invocation — session args (T3 truth table) // --------------------------------------------------------------------------- diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index 7f1c290..704ace1 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -227,8 +227,9 @@ impl domain::ports::MemoryRecall for FakeRecall { } struct FakeRuntime; +#[async_trait] impl AgentRuntime for FakeRuntime { - fn detect(&self, _p: &AgentProfile) -> Result { + async fn detect(&self, _p: &AgentProfile) -> Result { Ok(true) } fn prepare_invocation( diff --git a/crates/infrastructure/tests/orchestrator_watcher.rs b/crates/infrastructure/tests/orchestrator_watcher.rs index 208e909..621cdf1 100644 --- a/crates/infrastructure/tests/orchestrator_watcher.rs +++ b/crates/infrastructure/tests/orchestrator_watcher.rs @@ -226,8 +226,9 @@ impl domain::ports::MemoryRecall for FakeRecall { } struct FakeRuntime; +#[async_trait] impl AgentRuntime for FakeRuntime { - fn detect(&self, _p: &AgentProfile) -> Result { + async fn detect(&self, _p: &AgentProfile) -> Result { Ok(true) } fn prepare_invocation(