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

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

View File

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

View File

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

View File

@ -86,9 +86,11 @@ struct StubRuntime {
by_command: HashMap<String, DetectResult>,
}
struct YieldingRuntime;
#[async_trait]
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) {
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<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 {
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
// ---------------------------------------------------------------------------

View File

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