- #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:
@ -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);
|
||||
|
||||
@ -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 => {
|
||||
let active = usages.entry(server_id).or_default();
|
||||
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(
|
||||
Ok(ModelServerUseGuard {
|
||||
server_id,
|
||||
ProjectServerUse {
|
||||
project_id,
|
||||
refs: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(ModelServerProjectUseGuard {
|
||||
server_id,
|
||||
project_id,
|
||||
usages: Arc::clone(&self.project_usages),
|
||||
usages: Arc::clone(&self.usages),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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]
|
||||
|
||||
26
frontend/src/adapters/plugin.test.ts
Normal file
26
frontend/src/adapters/plugin.test.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user