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::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<ModelServerProjectUseGuard>,
model_server_guard: Option<ModelServerUseGuard>,
) -> Result<LaunchAgentOutput, AppError> {
// 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<Option<ModelServerProjectUseGuard>, AppError> {
) -> Result<Option<ModelServerUseGuard>, 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);

View File

@ -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<Mutex<HashMap<LocalModelServerId, ProjectServerUse>>>,
usages: Arc<Mutex<HashMap<LocalModelServerId, ServerUse>>>,
}
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<dyn EventBus>,
active: Mutex<HashMap<LocalModelServerId, ActiveServer>>,
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>>,
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<ModelServerProjectUseGuard, AppError> {
) -> Result<ModelServerUseGuard, AppError> {
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),
})
}

View File

@ -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<ModelServerProjectUseGuard>,
_model_server_guard: Option<ModelServerUseGuard>,
}
/// 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<ModelServerProjectUseGuard>,
model_server_guard: Option<ModelServerUseGuard>,
) {
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<ModelServerProjectUseGuard>,
_model_server_guard: Option<ModelServerUseGuard>,
}
#[derive(Clone)]
@ -520,7 +520,7 @@ impl StructuredSessions {
session: Arc<dyn AgentSession>,
agent_id: AgentId,
node_id: NodeId,
model_server_guard: Option<ModelServerProjectUseGuard>,
model_server_guard: Option<ModelServerUseGuard>,
) {
if let Ok(mut map) = self.entries.lock() {
let id = session.id();

View File

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

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> {
return invoke<PluginReview>("plugin_review_package", {
request: { sourceKind: input.sourceKind, path: input.path },
input: { sourceKind: input.sourceKind, path: input.path },
});
}