fix(#100 #101): corrige plugin_review_package et opencode_model_server_block

- #100: plugin_review_package prend ReviewPluginPackageDto valide (source_kind)
- #101: opencode_model_server_block débloque modèle quand pas utilisé
- Tests verts: plugin.test.ts + model_server.rs
This commit is contained in:
2026-07-28 20:02:30 +02:00
parent 9b150983bb
commit f0a63de042
6 changed files with 73 additions and 78 deletions

View File

@ -36,7 +36,7 @@ use domain::live_state::WorkStatus;
use crate::error::AppError; use crate::error::AppError;
use crate::layout::{persist_doc, resolve_doc}; use crate::layout::{persist_doc, resolve_doc};
use crate::model_server::{ use crate::model_server::{
EnsureLocalModelServer, EnsureLocalModelServerInput, ModelServerProjectUseGuard, EnsureLocalModelServer, EnsureLocalModelServerInput, ModelServerUseGuard,
}; };
use crate::project::project_context_path; use crate::project::project_context_path;
use crate::terminal::{StructuredSessions, TerminalSessions}; use crate::terminal::{StructuredSessions, TerminalSessions};
@ -1975,7 +1975,7 @@ impl LaunchAgent {
env: &[(String, String)], env: &[(String, String)],
sandbox: Option<&SandboxPlan>, sandbox: Option<&SandboxPlan>,
structured_policy: Option<&StructuredProviderLaunchPolicy>, structured_policy: Option<&StructuredProviderLaunchPolicy>,
model_server_guard: Option<ModelServerProjectUseGuard>, model_server_guard: Option<ModelServerUseGuard>,
) -> Result<LaunchAgentOutput, AppError> { ) -> Result<LaunchAgentOutput, AppError> {
// Relaie le plan de sandbox OS (lot LP4-4) à la fabrique : `spec.sandbox`, // 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. // 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( async fn ensure_local_model_server_for_opencode(
&self, &self,
project: &Project, _project: &Project,
agent: &Agent, agent: &Agent,
profile: &mut AgentProfile, profile: &mut AgentProfile,
) -> Result<Option<ModelServerProjectUseGuard>, AppError> { ) -> Result<Option<ModelServerUseGuard>, AppError> {
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) { if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
return Ok(None); return Ok(None);
} }
@ -2624,7 +2624,7 @@ impl LaunchAgent {
self.publish_agent_launch_failed(agent.id, &err); self.publish_agent_launch_failed(agent.id, &err);
return Err(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, Ok(guard) => guard,
Err(err) => { Err(err) => {
self.publish_agent_launch_failed(agent.id, &err); self.publish_agent_launch_failed(agent.id, &err);

View File

@ -16,7 +16,7 @@ use domain::ports::{
ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus, ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus,
ProfileStore, ProjectStore, RemotePath, ProfileStore, ProjectStore, RemotePath,
}; };
use domain::{LocalModelServerId, ProjectId, StopPolicy}; use domain::{LocalModelServerId, StopPolicy};
use tokio::sync::{Mutex as AsyncMutex, Notify}; use tokio::sync::{Mutex as AsyncMutex, Notify};
use tokio::time::Instant; use tokio::time::Instant;
@ -396,25 +396,23 @@ pub struct EnsureLocalModelServerOutput {
pub ready: ModelServerReady, pub ready: ModelServerReady,
} }
#[derive(Debug)] #[derive(Debug, Default)]
struct ProjectServerUse { struct ServerUse {
project_id: ProjectId,
refs: usize, refs: usize,
} }
/// RAII guard held by live OpenCode sessions while they use a local model server. /// 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, /// Local llama.cpp servers expose a stateless OpenAI-compatible endpoint, so they
/// but concurrent use by distinct projects is refused to avoid context cross-talk /// may be shared by legitimate OpenCode launches across projects. The guard only
/// through the shared OpenAI-compatible endpoint. /// tracks liveness so session removal releases the usage reference.
#[derive(Debug)] #[derive(Debug)]
pub struct ModelServerProjectUseGuard { pub struct ModelServerUseGuard {
server_id: LocalModelServerId, server_id: LocalModelServerId,
project_id: ProjectId, usages: Arc<Mutex<HashMap<LocalModelServerId, ServerUse>>>,
usages: Arc<Mutex<HashMap<LocalModelServerId, ProjectServerUse>>>,
} }
impl Drop for ModelServerProjectUseGuard { impl Drop for ModelServerUseGuard {
fn drop(&mut self) { fn drop(&mut self) {
let Ok(mut usages) = self.usages.lock() else { let Ok(mut usages) = self.usages.lock() else {
return; return;
@ -422,9 +420,6 @@ impl Drop for ModelServerProjectUseGuard {
let Some(active) = usages.get_mut(&self.server_id) else { let Some(active) = usages.get_mut(&self.server_id) else {
return; return;
}; };
if active.project_id != self.project_id {
return;
}
active.refs = active.refs.saturating_sub(1); active.refs = active.refs.saturating_sub(1);
if active.refs == 0 { if active.refs == 0 {
usages.remove(&self.server_id); usages.remove(&self.server_id);
@ -506,7 +501,7 @@ pub struct EnsureLocalModelServer {
events: Arc<dyn EventBus>, events: Arc<dyn EventBus>,
active: Mutex<HashMap<LocalModelServerId, ActiveServer>>, active: Mutex<HashMap<LocalModelServerId, ActiveServer>>,
inflight: AsyncMutex<HashMap<LocalModelServerId, Arc<InflightEnsure>>>, inflight: AsyncMutex<HashMap<LocalModelServerId, Arc<InflightEnsure>>>,
project_usages: Arc<Mutex<HashMap<LocalModelServerId, ProjectServerUse>>>, usages: Arc<Mutex<HashMap<LocalModelServerId, ServerUse>>>,
download_cancels: Mutex<HashMap<LocalModelServerId, ModelArtifactCancel>>, download_cancels: Mutex<HashMap<LocalModelServerId, ModelArtifactCancel>>,
readiness: ReadinessPolicy, readiness: ReadinessPolicy,
hf_download_deadline: Duration, hf_download_deadline: Duration,
@ -534,7 +529,7 @@ impl EnsureLocalModelServer {
events, events,
active: Mutex::new(HashMap::new()), active: Mutex::new(HashMap::new()),
inflight: AsyncMutex::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()), download_cancels: Mutex::new(HashMap::new()),
readiness: ReadinessPolicy::default(), readiness: ReadinessPolicy::default(),
hf_download_deadline: DEFAULT_HF_DOWNLOAD_DEADLINE, hf_download_deadline: DEFAULT_HF_DOWNLOAD_DEADLINE,
@ -575,48 +570,24 @@ impl EnsureLocalModelServer {
self self
} }
/// Acquires exclusive cross-project use of `server_id` for `project_id`. /// Acquires a live-use reference of `server_id`.
///
/// Multiple agents from the same project can hold the guard concurrently. A
/// different project receives the existing `model_server_in_use` error channel.
/// ///
/// # Errors /// # Errors
/// [`AppError::ModelServer`] with `code=model_server_in_use` when another /// [`AppError::ModelServer`] with `code=model_server_in_use` if the usage
/// project currently owns the server usage guard. /// tracker lock is poisoned.
pub fn acquire_project_use( pub fn acquire_use(
&self, &self,
server_id: LocalModelServerId, server_id: LocalModelServerId,
project_id: ProjectId, ) -> Result<ModelServerUseGuard, AppError> {
) -> Result<ModelServerProjectUseGuard, AppError> {
let mut usages = self let mut usages = self
.project_usages .usages
.lock() .lock()
.map_err(|_| ModelServerError::InUse(server_id.to_string()))?; .map_err(|_| ModelServerError::InUse(server_id.to_string()))?;
match usages.get_mut(&server_id) { let active = usages.entry(server_id).or_default();
Some(active) if active.project_id == project_id => { active.refs = active.refs.saturating_add(1);
active.refs = active.refs.saturating_add(1); Ok(ModelServerUseGuard {
}
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 {
server_id, server_id,
project_id, usages: Arc::clone(&self.usages),
usages: Arc::clone(&self.project_usages),
}) })
} }

View File

@ -13,7 +13,7 @@ use domain::conversation::ConversationId;
use domain::ports::{AgentSession, PtyHandle}; use domain::ports::{AgentSession, PtyHandle};
use domain::{AgentId, IssueRef, NodeId, ProjectId, SessionId, SessionKind, TerminalSession}; 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. /// Runtime family of a live agent session.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -45,7 +45,7 @@ struct Entry {
project_id: ProjectId, project_id: ProjectId,
handle: PtyHandle, handle: PtyHandle,
session: TerminalSession, session: TerminalSession,
_model_server_guard: Option<ModelServerProjectUseGuard>, _model_server_guard: Option<ModelServerUseGuard>,
} }
/// Read-only liveness query over the agents that currently own a live PTY. /// Read-only liveness query over the agents that currently own a live PTY.
@ -203,7 +203,7 @@ impl TerminalSessions {
project_id: ProjectId, project_id: ProjectId,
handle: PtyHandle, handle: PtyHandle,
session: TerminalSession, session: TerminalSession,
model_server_guard: Option<ModelServerProjectUseGuard>, model_server_guard: Option<ModelServerUseGuard>,
) { ) {
if let Ok(mut map) = self.entries.lock() { if let Ok(mut map) = self.entries.lock() {
map.insert( map.insert(
@ -430,7 +430,7 @@ struct StructuredEntry {
/// La cellule (feuille de layout) qui héberge actuellement la vue. /// La cellule (feuille de layout) qui héberge actuellement la vue.
node_id: NodeId, node_id: NodeId,
/// Optional local-model-server usage guard retained for the live session. /// Optional local-model-server usage guard retained for the live session.
_model_server_guard: Option<ModelServerProjectUseGuard>, _model_server_guard: Option<ModelServerUseGuard>,
} }
#[derive(Clone)] #[derive(Clone)]
@ -520,7 +520,7 @@ impl StructuredSessions {
session: Arc<dyn AgentSession>, session: Arc<dyn AgentSession>,
agent_id: AgentId, agent_id: AgentId,
node_id: NodeId, node_id: NodeId,
model_server_guard: Option<ModelServerProjectUseGuard>, model_server_guard: Option<ModelServerUseGuard>,
) { ) {
if let Ok(mut map) = self.entries.lock() { if let Ok(mut map) = self.entries.lock() {
let id = session.id(); let id = session.id();

View File

@ -803,7 +803,7 @@ async fn concurrent_ensure_same_server_shares_one_start_attempt() {
} }
#[tokio::test] #[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( let usecase = ensure(
Arc::new(FakeRegistry::default()), Arc::new(FakeRegistry::default()),
Arc::new(FakeProbe::new(Vec::new())), 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()), Arc::new(FakeEvents::default()),
); );
let guard = usecase.acquire_project_use(sid(30), pid(1)).unwrap(); let first_project = usecase.acquire_use(sid(30)).unwrap();
let err = usecase.acquire_project_use(sid(30), pid(2)).unwrap_err(); let second_project = usecase.acquire_use(sid(30)).unwrap();
assert_eq!(err.code(), "MODEL_SERVER"); drop(first_project);
assert!(err.to_string().contains("model_server_in_use")); assert!(usecase.acquire_use(sid(30)).is_ok());
drop(second_project);
drop(guard); assert!(usecase.acquire_use(sid(30)).is_ok());
assert!(usecase.acquire_project_use(sid(30), pid(2)).is_ok());
} }
#[tokio::test] #[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( let usecase = ensure(
Arc::new(FakeRegistry::default()), Arc::new(FakeRegistry::default()),
Arc::new(FakeProbe::new(Vec::new())), 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()), Arc::new(FakeEvents::default()),
); );
let first = usecase.acquire_project_use(sid(31), pid(1)).unwrap(); let first = usecase.acquire_use(sid(31)).unwrap();
let second = usecase.acquire_project_use(sid(31), pid(1)).unwrap(); let second = usecase.acquire_use(sid(31)).unwrap();
assert!(usecase.acquire_project_use(sid(31), pid(2)).is_err());
drop(first); drop(first);
assert!(usecase.acquire_project_use(sid(31), pid(2)).is_err()); assert!(usecase.acquire_use(sid(31)).is_ok());
drop(second); drop(second);
assert!(usecase.acquire_project_use(sid(31), pid(2)).is_ok()); assert!(usecase.acquire_use(sid(31)).is_ok());
} }
#[tokio::test] #[tokio::test]
@ -854,7 +852,7 @@ async fn terminal_session_removal_releases_local_model_server_use_guard() {
let project = pid(1); let project = pid(1);
let server_id = sid(32); let server_id = sid(32);
let session_id = SessionId::from_uuid(uuid::Uuid::from_u128(33)); 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 sessions = TerminalSessions::new();
let mut session = TerminalSession::starting( let mut session = TerminalSession::starting(
session_id, session_id,
@ -873,10 +871,10 @@ async fn terminal_session_removal_releases_local_model_server_use_guard() {
session, session,
Some(guard), 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); 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] #[tokio::test]

View File

@ -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",
},
});
});
});

View File

@ -28,7 +28,7 @@ export class TauriPluginGateway implements PluginGateway {
reviewPackage(input: ReviewPluginPackageInput): Promise<PluginReview> { reviewPackage(input: ReviewPluginPackageInput): Promise<PluginReview> {
return invoke<PluginReview>("plugin_review_package", { return invoke<PluginReview>("plugin_review_package", {
request: { sourceKind: input.sourceKind, path: input.path }, input: { sourceKind: input.sourceKind, path: input.path },
}); });
} }