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

@ -35,7 +35,9 @@ use domain::live_state::WorkStatus;
use crate::error::AppError;
use crate::layout::{persist_doc, resolve_doc};
use crate::model_server::{EnsureLocalModelServer, EnsureLocalModelServerInput};
use crate::model_server::{
EnsureLocalModelServer, EnsureLocalModelServerInput, ModelServerProjectUseGuard,
};
use crate::project::project_context_path;
use crate::terminal::{StructuredSessions, TerminalSessions};
use crate::workstate::GetLiveStateLean;
@ -1782,7 +1784,8 @@ impl LaunchAgent {
)
.await?;
self.ensure_local_model_server_for_opencode(&agent, &mut profile)
let local_model_server_guard = self
.ensure_local_model_server_for_opencode(&input.project, &agent, &mut profile)
.await?;
// 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ──
@ -1886,6 +1889,7 @@ impl LaunchAgent {
&spec.env,
spec.sandbox.as_ref(),
structured_policy.as_ref(),
local_model_server_guard,
)
.await;
}
@ -1905,7 +1909,10 @@ impl LaunchAgent {
// 7. For the Stdin strategy, pipe the context once the PTY is live.
if matches!(spec.context_plan, Some(ContextInjectionPlan::Stdin)) {
self.pty.write(&handle, content.as_str().as_bytes())?;
if let Err(err) = self.pty.write(&handle, content.as_str().as_bytes()) {
let _ = self.pty.kill(&handle).await;
return Err(err.into());
}
}
let node_id = input.node_id.unwrap_or_else(NodeId::new_random);
@ -1917,8 +1924,12 @@ impl LaunchAgent {
size,
);
session.status = SessionStatus::Running;
self.sessions
.insert_in_project(input.project.id, handle, session.clone());
self.sessions.insert_in_project_with_model_server_guard(
input.project.id,
handle,
session.clone(),
local_model_server_guard,
);
self.events.publish(DomainEvent::AgentLaunched {
agent_id: agent.id,
@ -1964,6 +1975,7 @@ impl LaunchAgent {
env: &[(String, String)],
sandbox: Option<&SandboxPlan>,
structured_policy: Option<&StructuredProviderLaunchPolicy>,
model_server_guard: Option<ModelServerProjectUseGuard>,
) -> 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.
@ -1986,7 +1998,13 @@ impl LaunchAgent {
// Enregistre la session vivante (invariant « 1 session/agent » : déjà gardé en
// amont sur les deux registres).
structured.insert_in_project(project_id, Arc::clone(&session), agent.id, node_id);
structured.insert_in_project_with_model_server_guard(
project_id,
Arc::clone(&session),
agent.id,
node_id,
model_server_guard,
);
// ── SÉPARATION DES DEUX CLÉS (ARCHITECTURE §19.7, lot P8a) ──
// - **id de paire** (`pair_conversation_id`) : clé **logique** persistée sur
@ -2570,17 +2588,18 @@ impl LaunchAgent {
async fn ensure_local_model_server_for_opencode(
&self,
project: &Project,
agent: &Agent,
profile: &mut AgentProfile,
) -> Result<(), AppError> {
) -> Result<Option<ModelServerProjectUseGuard>, AppError> {
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
return Ok(());
return Ok(None);
}
let Some(opencode) = profile.opencode.as_mut() else {
return Ok(());
return Ok(None);
};
let Some(server_id) = opencode.local_model_server_id else {
return Ok(());
return Ok(None);
};
let Some(ensure) = self.local_model_server.as_ref() else {
let err = AppError::ModelServer {
@ -2592,6 +2611,13 @@ impl LaunchAgent {
self.publish_agent_launch_failed(agent.id, &err);
return Err(err);
};
let guard = match ensure.acquire_project_use(server_id, project.id) {
Ok(guard) => guard,
Err(err) => {
self.publish_agent_launch_failed(agent.id, &err);
return Err(err);
}
};
match ensure
.execute(EnsureLocalModelServerInput { server_id })
@ -2600,7 +2626,7 @@ impl LaunchAgent {
Ok(output) => {
opencode.base_url = output.ready.base_url;
opencode.model = output.ready.model;
Ok(())
Ok(Some(guard))
}
Err(err) => {
self.publish_agent_launch_failed(agent.id, &err);