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:
2026-07-27 09:47:23 +02:00
parent c807a70fea
commit cf074a7d61
6 changed files with 265 additions and 15 deletions

View File

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