fix(runtime): isolation d'usage multi-projet #107
Implémentation du garde ModelServerProjectUseGuard pour séparer les retours des agents entre projets concurrents. - Garde RAII par LocalModelServerId partagé entre projets - Refus inter-projets concurrent via model_server_in_use - Partage intra-projet conservé avec refcount - Libération automatique à la fermeture/erreur de session Tests de validation: rejet projet distinct, compteur refs, libération retrait
This commit is contained in:
@ -15,7 +15,7 @@ use domain::ports::{
|
||||
ModelArtifactDownloader, ModelArtifactProgress, ModelServerError, ModelServerProbe,
|
||||
ModelServerRegistry, ModelServerRuntime, ProcessStatus, ProfileStore, RemotePath,
|
||||
};
|
||||
use domain::{LocalModelServerId, StopPolicy};
|
||||
use domain::{LocalModelServerId, ProjectId, StopPolicy};
|
||||
use tokio::sync::{Mutex as AsyncMutex, Notify};
|
||||
use tokio::time::Instant;
|
||||
|
||||
@ -146,6 +146,42 @@ pub struct EnsureLocalModelServerOutput {
|
||||
pub ready: ModelServerReady,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ProjectServerUse {
|
||||
project_id: ProjectId,
|
||||
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.
|
||||
#[derive(Debug)]
|
||||
pub struct ModelServerProjectUseGuard {
|
||||
server_id: LocalModelServerId,
|
||||
project_id: ProjectId,
|
||||
usages: Arc<Mutex<HashMap<LocalModelServerId, ProjectServerUse>>>,
|
||||
}
|
||||
|
||||
impl Drop for ModelServerProjectUseGuard {
|
||||
fn drop(&mut self) {
|
||||
let Ok(mut usages) = self.usages.lock() else {
|
||||
return;
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Readiness retry policy.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ReadinessPolicy {
|
||||
@ -220,6 +256,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>>>,
|
||||
download_cancels: Mutex<HashMap<LocalModelServerId, ModelArtifactCancel>>,
|
||||
readiness: ReadinessPolicy,
|
||||
hf_download_deadline: Duration,
|
||||
@ -247,6 +284,7 @@ impl EnsureLocalModelServer {
|
||||
events,
|
||||
active: Mutex::new(HashMap::new()),
|
||||
inflight: AsyncMutex::new(HashMap::new()),
|
||||
project_usages: Arc::new(Mutex::new(HashMap::new())),
|
||||
download_cancels: Mutex::new(HashMap::new()),
|
||||
readiness: ReadinessPolicy::default(),
|
||||
hf_download_deadline: DEFAULT_HF_DOWNLOAD_DEADLINE,
|
||||
@ -287,6 +325,51 @@ 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.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::ModelServer`] with `code=model_server_in_use` when another
|
||||
/// project currently owns the server usage guard.
|
||||
pub fn acquire_project_use(
|
||||
&self,
|
||||
server_id: LocalModelServerId,
|
||||
project_id: ProjectId,
|
||||
) -> Result<ModelServerProjectUseGuard, AppError> {
|
||||
let mut usages = self
|
||||
.project_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 {
|
||||
server_id,
|
||||
project_id,
|
||||
usages: Arc::clone(&self.project_usages),
|
||||
})
|
||||
}
|
||||
|
||||
/// Ensures the server is reachable.
|
||||
///
|
||||
/// # Errors
|
||||
|
||||
Reference in New Issue
Block a user