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

@ -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)
// ---------------------------------------------------------------------------

View File

@ -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(

View File

@ -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(