fix(runtime): detection non bloquante, bornée dans le temps, HTTP pour les profils locaux (#28)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 08:11:14 +02:00
parent 3c6cd04bd8
commit 3abf2fce98
12 changed files with 143 additions and 52 deletions

View File

@ -3992,8 +3992,9 @@ mod mcp_serve_peer_tests {
} }
struct FakeRuntime; struct FakeRuntime;
#[async_trait]
impl AgentRuntime for FakeRuntime { impl AgentRuntime for FakeRuntime {
fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> { async fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
Ok(true) Ok(true)
} }
fn prepare_invocation( fn prepare_invocation(
@ -5374,8 +5375,9 @@ mod mcp_e2e_loopback_tests {
} }
struct FakeRuntime; struct FakeRuntime;
#[async_trait]
impl AgentRuntime for FakeRuntime { impl AgentRuntime for FakeRuntime {
fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> { async fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
Ok(true) Ok(true)
} }
fn prepare_invocation( fn prepare_invocation(

View File

@ -72,14 +72,11 @@ impl DetectProfiles {
&self, &self,
input: DetectProfilesInput, input: DetectProfilesInput,
) -> Result<DetectProfilesOutput, AppError> { ) -> Result<DetectProfilesOutput, AppError> {
let results = input let mut results = Vec::with_capacity(input.candidates.len());
.candidates for profile in input.candidates {
.into_iter() let available = self.runtime.detect(&profile).await.unwrap_or(false);
.map(|profile| { results.push(ProfileAvailability { profile, available });
let available = self.runtime.detect(&profile).unwrap_or(false); }
ProfileAvailability { profile, available }
})
.collect();
Ok(DetectProfilesOutput { results }) Ok(DetectProfilesOutput { results })
} }
} }

View File

@ -318,8 +318,9 @@ impl FakeRuntime {
} }
} }
#[async_trait]
impl AgentRuntime for FakeRuntime { impl AgentRuntime for FakeRuntime {
fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> { async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
Ok(true) Ok(true)
} }
fn prepare_invocation( fn prepare_invocation(

View File

@ -344,8 +344,9 @@ impl FakeRuntime {
} }
} }
#[async_trait]
impl AgentRuntime for FakeRuntime { impl AgentRuntime for FakeRuntime {
fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> { async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
Ok(true) Ok(true)
} }
fn prepare_invocation( fn prepare_invocation(

View File

@ -262,8 +262,9 @@ impl SkillStore for RecordingSkills {
} }
struct FakeRuntime; struct FakeRuntime;
#[async_trait]
impl AgentRuntime for FakeRuntime { impl AgentRuntime for FakeRuntime {
fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> { async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
Ok(true) Ok(true)
} }
fn prepare_invocation( fn prepare_invocation(

View File

@ -86,9 +86,11 @@ struct StubRuntime {
by_command: HashMap<String, DetectResult>, by_command: HashMap<String, DetectResult>,
} }
struct YieldingRuntime;
#[async_trait] #[async_trait]
impl AgentRuntime for StubRuntime { impl AgentRuntime for StubRuntime {
fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError> { async fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError> {
match self.by_command.get(&profile.command) { match self.by_command.get(&profile.command) {
Some(DetectResult::Available) => Ok(true), Some(DetectResult::Available) => Ok(true),
Some(DetectResult::Missing) | None => Ok(false), 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<bool, RuntimeError> {
tokio::task::yield_now().await;
Ok(true)
}
fn prepare_invocation(
&self,
_profile: &AgentProfile,
_ctx: &PreparedContext,
_cwd: &ProjectPath,
_session: &SessionPlan,
) -> Result<SpawnSpec, RuntimeError> {
unreachable!("not used in these tests")
}
}
fn profile(id: u128, name: &str, command: &str) -> AgentProfile { fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
AgentProfile::new( AgentProfile::new(
ProfileId::from_uuid(uuid::Uuid::from_u128(id)), 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 // ConfigureProfiles
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -284,8 +284,9 @@ impl FakeRuntime {
} }
} }
#[async_trait]
impl AgentRuntime for FakeRuntime { impl AgentRuntime for FakeRuntime {
fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> { async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
Ok(true) Ok(true)
} }
fn prepare_invocation( fn prepare_invocation(

View File

@ -745,13 +745,14 @@ pub struct GraphCommit {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Launch/drive an AI CLI according to an [`AgentProfile`], handling context /// 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 { pub trait AgentRuntime: Send + Sync {
/// Detects whether the profile's command is available. /// Detects whether the profile's command is available.
/// ///
/// # Errors /// # Errors
/// [`RuntimeError`] on detection failure. /// [`RuntimeError`] on detection failure.
fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError>; async fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError>;
/// Builds a [`SpawnSpec`] (command + args + injection plan) for launching /// Builds a [`SpawnSpec`] (command + args + injection plan) for launching
/// the agent in `cwd` with the prepared context. /// the agent in `cwd` with the prepared context.

View File

@ -17,7 +17,7 @@
//! `cwd` (template substitution), and the context-injection plan derived from //! `cwd` (template substitution), and the context-injection plan derived from
//! the profile's [`ContextInjection`]. This is the testable core of L5. //! 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; use async_trait::async_trait;
@ -25,9 +25,11 @@ use domain::ports::{
AgentRuntime, ContextInjectionPlan, PreparedContext, ProcessSpawner, RuntimeError, SessionPlan, AgentRuntime, ContextInjectionPlan, PreparedContext, ProcessSpawner, RuntimeError, SessionPlan,
SpawnSpec, SpawnSpec,
}; };
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy}; use domain::profile::{AgentProfile, ContextInjection, SessionStrategy, StructuredAdapter};
use domain::project::ProjectPath; use domain::project::ProjectPath;
const DETECTION_TIMEOUT: Duration = Duration::from_millis(800);
/// The single generic AI-runtime adapter. Holds a [`ProcessSpawner`] (used only /// The single generic AI-runtime adapter. Holds a [`ProcessSpawner`] (used only
/// by [`detect`](AgentRuntime::detect)); [`prepare_invocation`] is pure. /// by [`detect`](AgentRuntime::detect)); [`prepare_invocation`] is pure.
#[derive(Clone)] #[derive(Clone)]
@ -179,14 +181,16 @@ impl CliAgentRuntime {
#[async_trait] #[async_trait]
impl AgentRuntime for CliAgentRuntime { impl AgentRuntime for CliAgentRuntime {
fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError> { async fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError> {
if profile.structured_adapter == Some(StructuredAdapter::OpenAiCompatible) {
return Ok(Self::detect_openai_compatible(profile).await);
}
let spec = Self::detection_spec(profile)?; 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 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()))?; .map_err(|e| RuntimeError::Detection(e.to_string()))?;
Ok(output.status.code == Some(0)) 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 impl CliAgentRuntime {
/// current-thread tokio runtime. Used only by [`CliAgentRuntime::detect`], whose async fn detect_openai_compatible(profile: &AgentProfile) -> bool {
/// port signature is synchronous while the [`ProcessSpawner`] it calls is async. let Some(chat) = &profile.chat_http else {
fn futures_block_on<F: std::future::Future>(fut: F) -> F::Output { return false;
tokio::runtime::Builder::new_current_thread() };
.enable_all() let url = openai_compatible_models_endpoint(&chat.endpoint);
let probe = async move {
let client = reqwest::Client::builder()
.timeout(DETECTION_TIMEOUT)
.build() .build()
.expect("failed to build a current-thread runtime for detection") .ok()?;
.block_on(fut) 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")
} }

View File

@ -18,7 +18,9 @@ use domain::ports::{
AgentRuntime, ContextInjectionPlan, ExitStatus, Output, PreparedContext, ProcessError, AgentRuntime, ContextInjectionPlan, ExitStatus, Output, PreparedContext, ProcessError,
ProcessSpawner, RuntimeError, SessionPlan, SpawnSpec, ProcessSpawner, RuntimeError, SessionPlan, SpawnSpec,
}; };
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy}; use domain::profile::{
AgentProfile, ContextInjection, HttpChatConfig, SessionStrategy, StructuredAdapter,
};
use domain::project::ProjectPath; use domain::project::ProjectPath;
use domain::MarkdownDoc; use domain::MarkdownDoc;
use infrastructure::CliAgentRuntime; use infrastructure::CliAgentRuntime;
@ -297,60 +299,85 @@ fn detection_spec_falls_back_to_command_version() {
// detect (mocked spawner) // detect (mocked spawner)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[test] #[tokio::test]
fn detect_true_on_exit_zero() { async fn detect_true_on_exit_zero() {
let rt = runtime_with(Ok(Output { let rt = runtime_with(Ok(Output {
status: ExitStatus { code: Some(0) }, status: ExitStatus { code: Some(0) },
stdout: Vec::new(), stdout: Vec::new(),
stderr: Vec::new(), stderr: Vec::new(),
})); }));
let p = profile(ContextInjection::stdin(), "{projectRoot}"); let p = profile(ContextInjection::stdin(), "{projectRoot}");
assert!(rt.detect(&p).unwrap()); assert!(rt.detect(&p).await.unwrap());
} }
#[test] #[tokio::test]
fn detect_false_on_nonzero_exit() { async fn detect_false_on_nonzero_exit() {
let rt = runtime_with(Ok(Output { let rt = runtime_with(Ok(Output {
status: ExitStatus { code: Some(127) }, status: ExitStatus { code: Some(127) },
stdout: Vec::new(), stdout: Vec::new(),
stderr: Vec::new(), stderr: Vec::new(),
})); }));
let p = profile(ContextInjection::stdin(), "{projectRoot}"); let p = profile(ContextInjection::stdin(), "{projectRoot}");
assert!(!rt.detect(&p).unwrap()); assert!(!rt.detect(&p).await.unwrap());
} }
#[test] #[tokio::test]
fn detect_false_on_signal_terminated() { async fn detect_false_on_signal_terminated() {
let rt = runtime_with(Ok(Output { let rt = runtime_with(Ok(Output {
status: ExitStatus { code: None }, status: ExitStatus { code: None },
stdout: Vec::new(), stdout: Vec::new(),
stderr: Vec::new(), stderr: Vec::new(),
})); }));
let p = profile(ContextInjection::stdin(), "{projectRoot}"); let p = profile(ContextInjection::stdin(), "{projectRoot}");
assert!(!rt.detect(&p).unwrap()); assert!(!rt.detect(&p).await.unwrap());
} }
#[test] #[tokio::test]
fn detect_propagates_spawner_error() { async fn detect_propagates_spawner_error() {
let rt = runtime_with(Err(ProcessError::Spawn("no such file".to_owned()))); let rt = runtime_with(Err(ProcessError::Spawn("no such file".to_owned())));
let p = profile(ContextInjection::stdin(), "{projectRoot}"); 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:?}"); assert!(matches!(err, RuntimeError::Detection(_)), "got {err:?}");
} }
#[test] #[tokio::test]
fn detect_runs_the_detection_spec_command() { async fn detect_runs_the_detection_spec_command() {
let recorder = Arc::new(RecordingSpawner(std::sync::Mutex::new(None))); let recorder = Arc::new(RecordingSpawner(std::sync::Mutex::new(None)));
let rt = CliAgentRuntime::new(recorder.clone()); let rt = CliAgentRuntime::new(recorder.clone());
let p = profile(ContextInjection::stdin(), "{projectRoot}"); 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"); let spec = recorder.0.lock().unwrap().clone().expect("spec recorded");
assert_eq!(spec.command, "mycli"); assert_eq!(spec.command, "mycli");
assert_eq!(spec.args, vec!["probe", "--json"]); 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) // prepare_invocation — session args (T3 truth table)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -227,8 +227,9 @@ impl domain::ports::MemoryRecall for FakeRecall {
} }
struct FakeRuntime; struct FakeRuntime;
#[async_trait]
impl AgentRuntime for FakeRuntime { impl AgentRuntime for FakeRuntime {
fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> { async fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
Ok(true) Ok(true)
} }
fn prepare_invocation( fn prepare_invocation(

View File

@ -226,8 +226,9 @@ impl domain::ports::MemoryRecall for FakeRecall {
} }
struct FakeRuntime; struct FakeRuntime;
#[async_trait]
impl AgentRuntime for FakeRuntime { impl AgentRuntime for FakeRuntime {
fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> { async fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
Ok(true) Ok(true)
} }
fn prepare_invocation( fn prepare_invocation(