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:
@ -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<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)?;
|
||||
// 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<F: std::future::Future>(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")
|
||||
}
|
||||
|
||||
@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -227,8 +227,9 @@ impl domain::ports::MemoryRecall for FakeRecall {
|
||||
}
|
||||
|
||||
struct FakeRuntime;
|
||||
#[async_trait]
|
||||
impl AgentRuntime for FakeRuntime {
|
||||
fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
|
||||
async fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
|
||||
Ok(true)
|
||||
}
|
||||
fn prepare_invocation(
|
||||
|
||||
@ -226,8 +226,9 @@ impl domain::ports::MemoryRecall for FakeRecall {
|
||||
}
|
||||
|
||||
struct FakeRuntime;
|
||||
#[async_trait]
|
||||
impl AgentRuntime for FakeRuntime {
|
||||
fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
|
||||
async fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
|
||||
Ok(true)
|
||||
}
|
||||
fn prepare_invocation(
|
||||
|
||||
Reference in New Issue
Block a user