- #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::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);
|
||||||
|
|||||||
@ -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),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
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> {
|
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 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user