diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 0d58a68..0cd6da2 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -36,7 +36,7 @@ use domain::live_state::WorkStatus; use crate::error::AppError; use crate::layout::{persist_doc, resolve_doc}; use crate::model_server::{ - EnsureLocalModelServer, EnsureLocalModelServerInput, ModelServerProjectUseGuard, + EnsureLocalModelServer, EnsureLocalModelServerInput, ModelServerUseGuard, }; use crate::project::project_context_path; use crate::terminal::{StructuredSessions, TerminalSessions}; @@ -1975,7 +1975,7 @@ impl LaunchAgent { env: &[(String, String)], sandbox: Option<&SandboxPlan>, structured_policy: Option<&StructuredProviderLaunchPolicy>, - model_server_guard: Option, + model_server_guard: Option, ) -> Result { // Relaie le plan de sandbox OS (lot LP4-4) à la fabrique : `spec.sandbox`, // déjà compilé (pur, domaine) en step 5d. `None` ⇒ exécution native inchangée. @@ -2601,10 +2601,10 @@ impl LaunchAgent { async fn ensure_local_model_server_for_opencode( &self, - project: &Project, + _project: &Project, agent: &Agent, profile: &mut AgentProfile, - ) -> Result, AppError> { + ) -> Result, AppError> { if profile.structured_adapter != Some(StructuredAdapter::OpenCode) { return Ok(None); } @@ -2624,7 +2624,7 @@ impl LaunchAgent { self.publish_agent_launch_failed(agent.id, &err); return Err(err); }; - let guard = match ensure.acquire_project_use(server_id, project.id) { + let guard = match ensure.acquire_use(server_id) { Ok(guard) => guard, Err(err) => { self.publish_agent_launch_failed(agent.id, &err); diff --git a/crates/application/src/model_server.rs b/crates/application/src/model_server.rs index bf40166..e7bc1c4 100644 --- a/crates/application/src/model_server.rs +++ b/crates/application/src/model_server.rs @@ -16,7 +16,7 @@ use domain::ports::{ ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus, ProfileStore, ProjectStore, RemotePath, }; -use domain::{LocalModelServerId, ProjectId, StopPolicy}; +use domain::{LocalModelServerId, StopPolicy}; use tokio::sync::{Mutex as AsyncMutex, Notify}; use tokio::time::Instant; @@ -396,25 +396,23 @@ pub struct EnsureLocalModelServerOutput { pub ready: ModelServerReady, } -#[derive(Debug)] -struct ProjectServerUse { - project_id: ProjectId, +#[derive(Debug, Default)] +struct ServerUse { refs: usize, } /// RAII guard held by live OpenCode sessions while they use a local model server. /// -/// A local llama.cpp server may be shared by several agents of the same project, -/// but concurrent use by distinct projects is refused to avoid context cross-talk -/// through the shared OpenAI-compatible endpoint. +/// Local llama.cpp servers expose a stateless OpenAI-compatible endpoint, so they +/// may be shared by legitimate OpenCode launches across projects. The guard only +/// tracks liveness so session removal releases the usage reference. #[derive(Debug)] -pub struct ModelServerProjectUseGuard { +pub struct ModelServerUseGuard { server_id: LocalModelServerId, - project_id: ProjectId, - usages: Arc>>, + usages: Arc>>, } -impl Drop for ModelServerProjectUseGuard { +impl Drop for ModelServerUseGuard { fn drop(&mut self) { let Ok(mut usages) = self.usages.lock() else { return; @@ -422,9 +420,6 @@ impl Drop for ModelServerProjectUseGuard { let Some(active) = usages.get_mut(&self.server_id) else { return; }; - if active.project_id != self.project_id { - return; - } active.refs = active.refs.saturating_sub(1); if active.refs == 0 { usages.remove(&self.server_id); @@ -506,7 +501,7 @@ pub struct EnsureLocalModelServer { events: Arc, active: Mutex>, inflight: AsyncMutex>>, - project_usages: Arc>>, + usages: Arc>>, download_cancels: Mutex>, readiness: ReadinessPolicy, hf_download_deadline: Duration, @@ -534,7 +529,7 @@ impl EnsureLocalModelServer { events, active: Mutex::new(HashMap::new()), inflight: AsyncMutex::new(HashMap::new()), - project_usages: Arc::new(Mutex::new(HashMap::new())), + usages: Arc::new(Mutex::new(HashMap::new())), download_cancels: Mutex::new(HashMap::new()), readiness: ReadinessPolicy::default(), hf_download_deadline: DEFAULT_HF_DOWNLOAD_DEADLINE, @@ -575,48 +570,24 @@ impl EnsureLocalModelServer { self } - /// Acquires exclusive cross-project use of `server_id` for `project_id`. - /// - /// Multiple agents from the same project can hold the guard concurrently. A - /// different project receives the existing `model_server_in_use` error channel. + /// Acquires a live-use reference of `server_id`. /// /// # Errors - /// [`AppError::ModelServer`] with `code=model_server_in_use` when another - /// project currently owns the server usage guard. - pub fn acquire_project_use( + /// [`AppError::ModelServer`] with `code=model_server_in_use` if the usage + /// tracker lock is poisoned. + pub fn acquire_use( &self, server_id: LocalModelServerId, - project_id: ProjectId, - ) -> Result { + ) -> Result { let mut usages = self - .project_usages + .usages .lock() .map_err(|_| ModelServerError::InUse(server_id.to_string()))?; - match usages.get_mut(&server_id) { - Some(active) if active.project_id == project_id => { - active.refs = active.refs.saturating_add(1); - } - Some(active) => { - return Err(ModelServerError::InUse(format!( - "local model server {server_id} is already in use by project {}", - active.project_id - )) - .into()); - } - None => { - usages.insert( - server_id, - ProjectServerUse { - project_id, - refs: 1, - }, - ); - } - } - Ok(ModelServerProjectUseGuard { + let active = usages.entry(server_id).or_default(); + active.refs = active.refs.saturating_add(1); + Ok(ModelServerUseGuard { server_id, - project_id, - usages: Arc::clone(&self.project_usages), + usages: Arc::clone(&self.usages), }) } diff --git a/crates/application/src/terminal/registry.rs b/crates/application/src/terminal/registry.rs index c1ee5cd..9d278fb 100644 --- a/crates/application/src/terminal/registry.rs +++ b/crates/application/src/terminal/registry.rs @@ -13,7 +13,7 @@ use domain::conversation::ConversationId; use domain::ports::{AgentSession, PtyHandle}; use domain::{AgentId, IssueRef, NodeId, ProjectId, SessionId, SessionKind, TerminalSession}; -use crate::model_server::ModelServerProjectUseGuard; +use crate::model_server::ModelServerUseGuard; /// Runtime family of a live agent session. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -45,7 +45,7 @@ struct Entry { project_id: ProjectId, handle: PtyHandle, session: TerminalSession, - _model_server_guard: Option, + _model_server_guard: Option, } /// Read-only liveness query over the agents that currently own a live PTY. @@ -203,7 +203,7 @@ impl TerminalSessions { project_id: ProjectId, handle: PtyHandle, session: TerminalSession, - model_server_guard: Option, + model_server_guard: Option, ) { if let Ok(mut map) = self.entries.lock() { map.insert( @@ -430,7 +430,7 @@ struct StructuredEntry { /// La cellule (feuille de layout) qui héberge actuellement la vue. node_id: NodeId, /// Optional local-model-server usage guard retained for the live session. - _model_server_guard: Option, + _model_server_guard: Option, } #[derive(Clone)] @@ -520,7 +520,7 @@ impl StructuredSessions { session: Arc, agent_id: AgentId, node_id: NodeId, - model_server_guard: Option, + model_server_guard: Option, ) { if let Ok(mut map) = self.entries.lock() { let id = session.id(); diff --git a/crates/application/tests/model_server.rs b/crates/application/tests/model_server.rs index 6386388..dec88d7 100644 --- a/crates/application/tests/model_server.rs +++ b/crates/application/tests/model_server.rs @@ -803,7 +803,7 @@ async fn concurrent_ensure_same_server_shares_one_start_attempt() { } #[tokio::test] -async fn local_model_server_use_rejects_distinct_project_until_guard_is_released() { +async fn local_model_server_use_allows_distinct_projects_concurrently() { let usecase = ensure( Arc::new(FakeRegistry::default()), Arc::new(FakeProbe::new(Vec::new())), @@ -812,18 +812,17 @@ async fn local_model_server_use_rejects_distinct_project_until_guard_is_released Arc::new(FakeEvents::default()), ); - let guard = usecase.acquire_project_use(sid(30), pid(1)).unwrap(); - let err = usecase.acquire_project_use(sid(30), pid(2)).unwrap_err(); + let first_project = usecase.acquire_use(sid(30)).unwrap(); + let second_project = usecase.acquire_use(sid(30)).unwrap(); - assert_eq!(err.code(), "MODEL_SERVER"); - assert!(err.to_string().contains("model_server_in_use")); - - drop(guard); - assert!(usecase.acquire_project_use(sid(30), pid(2)).is_ok()); + drop(first_project); + assert!(usecase.acquire_use(sid(30)).is_ok()); + drop(second_project); + assert!(usecase.acquire_use(sid(30)).is_ok()); } #[tokio::test] -async fn local_model_server_use_allows_same_project_with_refcount() { +async fn local_model_server_use_refcounts_concurrent_sessions() { let usecase = ensure( Arc::new(FakeRegistry::default()), Arc::new(FakeProbe::new(Vec::new())), @@ -832,14 +831,13 @@ async fn local_model_server_use_allows_same_project_with_refcount() { Arc::new(FakeEvents::default()), ); - let first = usecase.acquire_project_use(sid(31), pid(1)).unwrap(); - let second = usecase.acquire_project_use(sid(31), pid(1)).unwrap(); + let first = usecase.acquire_use(sid(31)).unwrap(); + let second = usecase.acquire_use(sid(31)).unwrap(); - assert!(usecase.acquire_project_use(sid(31), pid(2)).is_err()); drop(first); - assert!(usecase.acquire_project_use(sid(31), pid(2)).is_err()); + assert!(usecase.acquire_use(sid(31)).is_ok()); drop(second); - assert!(usecase.acquire_project_use(sid(31), pid(2)).is_ok()); + assert!(usecase.acquire_use(sid(31)).is_ok()); } #[tokio::test] @@ -854,7 +852,7 @@ async fn terminal_session_removal_releases_local_model_server_use_guard() { let project = pid(1); let server_id = sid(32); let session_id = SessionId::from_uuid(uuid::Uuid::from_u128(33)); - let guard = usecase.acquire_project_use(server_id, project).unwrap(); + let guard = usecase.acquire_use(server_id).unwrap(); let sessions = TerminalSessions::new(); let mut session = TerminalSession::starting( session_id, @@ -873,10 +871,10 @@ async fn terminal_session_removal_releases_local_model_server_use_guard() { session, Some(guard), ); - assert!(usecase.acquire_project_use(server_id, pid(2)).is_err()); + assert!(usecase.acquire_use(server_id).is_ok()); sessions.remove(&session_id); - assert!(usecase.acquire_project_use(server_id, pid(2)).is_ok()); + assert!(usecase.acquire_use(server_id).is_ok()); } #[tokio::test] diff --git a/frontend/src/adapters/plugin.test.ts b/frontend/src/adapters/plugin.test.ts new file mode 100644 index 0000000..c0fc0a4 --- /dev/null +++ b/frontend/src/adapters/plugin.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const invoke = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invoke(...args), +})); + +import { TauriPluginGateway } from "./plugin"; + +describe("TauriPluginGateway invoke payloads", () => { + beforeEach(() => invoke.mockReset().mockResolvedValue({})); + + it("reviewPackage sends the package DTO under the Tauri input arg", async () => { + await new TauriPluginGateway().reviewPackage({ + sourceKind: "archive", + path: "/tmp/example.ideaplug", + }); + + expect(invoke).toHaveBeenCalledWith("plugin_review_package", { + input: { + sourceKind: "archive", + path: "/tmp/example.ideaplug", + }, + }); + }); +}); diff --git a/frontend/src/adapters/plugin.ts b/frontend/src/adapters/plugin.ts index 6499f8f..1e85e13 100644 --- a/frontend/src/adapters/plugin.ts +++ b/frontend/src/adapters/plugin.ts @@ -28,7 +28,7 @@ export class TauriPluginGateway implements PluginGateway { reviewPackage(input: ReviewPluginPackageInput): Promise { return invoke("plugin_review_package", { - request: { sourceKind: input.sourceKind, path: input.path }, + input: { sourceKind: input.sourceKind, path: input.path }, }); }