From 3abf2fce988f5c2d014e9f9fccc31066ef2f49ae Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 8 Jul 2026 08:11:14 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(runtime):=20detection=20non=20bloquante?= =?UTF-8?q?,=20born=C3=A9e=20dans=20le=20temps,=20HTTP=20pour=20les=20prof?= =?UTF-8?q?ils=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( From 0389e2e0b0bcc3ccb7a1eca5cfb2c006bdd6723a Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 8 Jul 2026 08:11:24 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(first-run):=20d=C3=A9couple=20`busy`=20?= =?UTF-8?q?de=20la=20d=C3=A9tection=20des=20CLI=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Même backend réparé, le wizard restait à la merci d'une détection qui ne répond pas : `reload()` et `detect()` awaitaient `detectProfiles` sous le même drapeau `busy` qui grise « Save and continue » et « Detect installed CLIs ». Une promesse IPC jamais résolue laissait donc les deux boutons grisés à vie — sans issue pour l'utilisateur. La détection redevient ce qu'elle est : une étape best-effort, jamais bloquante. - `busy` ne garde plus que ce dont le wizard ne peut pas se passer (`firstRunState`) ou qui mute l'état (`configureProfiles`). Il ne dépend plus jamais de `detectProfiles`. L'invariant est documenté en tête de module. - Un drapeau `detecting` distinct suit la sonde et n'inhibe aucune action. Il est relâché par un timer (`DETECT_TIMEOUT_MS`), jamais par la seule promesse : celle-ci peut rester pendante indéfiniment. - Un identifiant de tour monotone (`detectRun`) invalide les tours périmés et ceux qui survivent au démontage, évitant un `setState` hors montage. - `reload()` rend les lignes immédiatement puis lance la détection en tâche détachée. Si elle échoue, les lignes restent cochables à la main ; seul le bouton explicite remonte l'erreur. Tests: vitest 59 fichiers / 569 tests verts, tsc --noEmit exit 0. Co-Authored-By: Claude Opus 4.8 --- .../first-run/FirstRunWizard.test.tsx | 66 +++++++++ .../src/features/first-run/FirstRunWizard.tsx | 7 + .../src/features/first-run/useFirstRun.ts | 137 +++++++++++++----- 3 files changed, 172 insertions(+), 38 deletions(-) diff --git a/frontend/src/features/first-run/FirstRunWizard.test.tsx b/frontend/src/features/first-run/FirstRunWizard.test.tsx index 03caf1a..6bde8e6 100644 --- a/frontend/src/features/first-run/FirstRunWizard.test.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.test.tsx @@ -6,6 +6,7 @@ */ import { describe, it, expect, vi } from "vitest"; import { + act, render, screen, waitFor, @@ -13,9 +14,11 @@ import { } from "@testing-library/react"; import { MockProfileGateway } from "@/adapters/mock"; +import type { ProfileAvailability } from "@/domain"; import type { Gateways } from "@/ports"; import { DIProvider } from "@/app/di"; import { FirstRunWizard } from "./FirstRunWizard"; +import { DETECT_TIMEOUT_MS } from "./useFirstRun"; function renderWizard( profile: MockProfileGateway = new MockProfileGateway(), @@ -301,6 +304,69 @@ describe("FirstRunWizard — OpenAI-compatible local/LAN profile (ticket #14)", }); }); +// Ticket #28 — the wizard must survive a detection that never answers. When the +// backend `detect_profiles` command panics, the `invoke` promise is neither +// resolved nor rejected: nothing after `await detectProfiles(...)` ever runs. +// `busy` must therefore never be tied to detection, or both buttons stay greyed +// out for good and the first run cannot be completed. +describe("FirstRunWizard — detection that never answers (ticket #28)", () => { + /** A gateway whose `detectProfiles` promise stays pending forever. */ + class HangingDetectGateway extends MockProfileGateway { + override detectProfiles(): Promise { + return new Promise(() => {}); + } + } + + it("keeps Save and Detect enabled once firstRunState answered", async () => { + renderWizard(new HangingDetectGateway()); + await waitForLoaded(); + + // The rows are rendered, so the blocking load is over: both actions are live. + const save = screen.getByRole("button", { name: "Save and continue" }); + const detect = screen.getByRole("button", { name: "Detect installed CLIs" }); + expect((save as HTMLButtonElement).disabled).toBe(false); + expect((detect as HTMLButtonElement).disabled).toBe(false); + + // Detection is merely reported as in flight, never as a blocking state. + expect(screen.getByRole("status").textContent).toMatch(/detecting/i); + + // Re-probing by hand must not freeze them either. + fireEvent.click(detect); + expect((save as HTMLButtonElement).disabled).toBe(false); + expect((detect as HTMLButtonElement).disabled).toBe(false); + }); + + it("still finishes the first run while detection hangs", async () => { + const profile = new HangingDetectGateway(); + const { onDone } = renderWizard(profile, vi.fn()); + await waitForLoaded(); + + // Nothing got pre-checked (detection never answered): tick a profile by hand. + fireEvent.click(screen.getByLabelText("use Claude Code")); + fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); + + await waitFor(() => expect(onDone).toHaveBeenCalled()); + const saved = await profile.listProfiles(); + expect(saved.map((p) => p.command)).toEqual(["claude"]); + }); + + it("drops the detecting flag on timeout even if the promise never settles", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + renderWizard(new HangingDetectGateway()); + await waitForLoaded(); + expect(screen.getByRole("status").textContent).toMatch(/detecting/i); + + await act(async () => { + vi.advanceTimersByTime(DETECT_TIMEOUT_MS + 1); + }); + expect(screen.queryByRole("status")).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); +}); + describe("FirstRunWizard reopening after the first run (forceOpen)", () => { /** A gateway whose first run is already done (profiles configured). */ async function configuredGateway() { diff --git a/frontend/src/features/first-run/FirstRunWizard.tsx b/frontend/src/features/first-run/FirstRunWizard.tsx index 1476ed0..a84a224 100644 --- a/frontend/src/features/first-run/FirstRunWizard.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.tsx @@ -78,6 +78,8 @@ export function FirstRunWizard({ )} + {/* `detecting` deliberately does NOT disable this button (ticket #28): + detection is best-effort and may never answer. */} + {vm.detecting && ( + + Detecting… + + )}
    diff --git a/frontend/src/features/first-run/useFirstRun.ts b/frontend/src/features/first-run/useFirstRun.ts index 38497cc..ff7b9eb 100644 --- a/frontend/src/features/first-run/useFirstRun.ts +++ b/frontend/src/features/first-run/useFirstRun.ts @@ -3,9 +3,17 @@ * (L5). Owns the wizard state (selectable reference candidates + selection + * availability) and the actions. Consumes the {@link ProfileGateway} * exclusively — never `invoke()` — so the feature is testable with a mock. + * + * **Invariant (ticket #28)**: `busy` gates only the requests the wizard cannot + * render without (`firstRunState`) or that mutate state (`configureProfiles`). + * It NEVER depends on `detectProfiles`: CLI detection is a detached, best-effort + * step tracked by the separate {@link FirstRunViewModel.detecting} flag, bounded + * by {@link DETECT_TIMEOUT_MS}. A detection that panics backend-side (leaving the + * `invoke` promise forever pending) must not freeze "Save and continue" / + * "Detect installed CLIs". */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { AgentProfile, @@ -15,6 +23,9 @@ import type { } from "@/domain"; import { useGateways } from "@/app/di"; +/** How long the UI waits for a detection round before giving up on it. */ +export const DETECT_TIMEOUT_MS = 3_000; + /** One row in the wizard: a candidate profile, selected state, availability. */ export interface WizardEntry { profile: AgentProfile; @@ -31,8 +42,10 @@ export interface FirstRunViewModel { entries: WizardEntry[]; /** Last error message, or null. */ error: string | null; - /** Whether a request is in flight. */ + /** Whether a blocking request is in flight (load / save). Never detection. */ busy: boolean; + /** Whether a best-effort detection round is in flight. Never disables actions. */ + detecting: boolean; /** Toggles selection of a candidate by id. */ toggle: (id: string) => void; /** Replaces a candidate's profile (edited command/args/injection). */ @@ -60,41 +73,101 @@ export function useFirstRun(): FirstRunViewModel { const [entries, setEntries] = useState([]); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const [detecting, setDetecting] = useState(false); + + /** Monotonic id of the latest detection round; older rounds are ignored. */ + const detectRun = useRef(0); + const detectTimer = useRef | null>(null); + + useEffect( + () => () => { + // Unmount: invalidate any in-flight round (its promise may never settle). + detectRun.current += 1; + if (detectTimer.current !== null) clearTimeout(detectTimer.current); + }, + [], + ); + + /** + * Best-effort detection round, detached from `busy`. A round is abandoned + * after {@link DETECT_TIMEOUT_MS} — the underlying promise may stay pending + * forever (backend panic ⇒ no IPC response), so `detecting` is released by a + * timer, never by the promise alone. `preselect` ticks the installed CLIs (the + * auto-detection on open); the manual button only refreshes availability. + */ + const runDetection = useCallback( + async ( + candidates: AgentProfile[], + opts: { preselect: boolean; reportError: boolean }, + ) => { + const run = detectRun.current + 1; + detectRun.current = run; + const stale = () => detectRun.current !== run; + + setDetecting(true); + if (detectTimer.current !== null) clearTimeout(detectTimer.current); + detectTimer.current = setTimeout(() => { + if (!stale()) setDetecting(false); + }, DETECT_TIMEOUT_MS); + + try { + const results: ProfileAvailability[] = + await profile.detectProfiles(candidates); + if (stale()) return; + const byId = new Map(results.map((r) => [r.profile.id, r.available])); + setEntries((prev) => + prev.map((e) => { + const available = byId.get(e.profile.id) ?? e.available; + return { + ...e, + available, + selected: opts.preselect ? available === true : e.selected, + }; + }), + ); + } catch (e) { + // Detection unavailable: rows stay as they are (the user can tick them + // by hand and re-run). Only the explicit button surfaces the failure. + if (stale()) return; + if (opts.reportError) setError(describe(e)); + } finally { + if (!stale()) { + if (detectTimer.current !== null) clearTimeout(detectTimer.current); + detectTimer.current = null; + setDetecting(false); + } + } + }, + [profile], + ); const reload = useCallback(async () => { setBusy(true); setError(null); + let refs: AgentProfile[] | null = null; try { const state: FirstRunState = await profile.firstRunState(); setIsFirstRun(state.isFirstRun); - const refs = state.referenceProfiles; + refs = state.referenceProfiles; // Show the rows immediately (nothing pre-checked yet). setEntries( refs.map((p) => ({ profile: p, selected: false, available: null })), ); - // Auto-detect once on open: only the CLIs actually installed end up - // pre-checked. The manual "Detect installed CLIs" button stays available to - // re-probe. If detection is unavailable, rows stay unchecked (the user can - // still tick them by hand and re-run detection). - try { - const results = await profile.detectProfiles(refs); - const byId = new Map(results.map((r) => [r.profile.id, r.available])); - setEntries( - refs.map((p) => { - const available = byId.get(p.id) ?? null; - return { profile: p, selected: available === true, available }; - }), - ); - } catch { - /* detection unavailable: leave rows unchecked */ - } } catch (e) { setError(describe(e)); setIsFirstRun(false); } finally { + // Released as soon as the rows are rendered — detection is not awaited. setBusy(false); } - }, [profile]); + + // Auto-detect once on open, detached: only the CLIs actually installed end + // up pre-checked. The manual "Detect installed CLIs" button stays available + // to re-probe, whatever happens to this round. + if (refs !== null) { + void runDetection(refs, { preselect: true, reportError: false }); + } + }, [profile, runDetection]); useEffect(() => { void reload(); @@ -119,25 +192,12 @@ export function useFirstRun(): FirstRunViewModel { }, []); const detect = useCallback(async () => { - setBusy(true); setError(null); - try { - const candidates = entries.map((e) => e.profile); - const results: ProfileAvailability[] = - await profile.detectProfiles(candidates); - const byId = new Map(results.map((r) => [r.profile.id, r.available])); - setEntries((prev) => - prev.map((e) => ({ - ...e, - available: byId.get(e.profile.id) ?? e.available, - })), - ); - } catch (e) { - setError(describe(e)); - } finally { - setBusy(false); - } - }, [profile, entries]); + await runDetection( + entries.map((e) => e.profile), + { preselect: false, reportError: true }, + ); + }, [runDetection, entries]); const finish = useCallback(async () => { setBusy(true); @@ -158,6 +218,7 @@ export function useFirstRun(): FirstRunViewModel { entries, error, busy, + detecting, toggle, updateProfile, remove,