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

@ -8,7 +8,7 @@ use async_trait::async_trait;
use application::{
DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer, EnsureLocalModelServerInput,
ModelServerReadinessPolicy,
ModelServerReadinessPolicy, TerminalSessions,
};
use domain::events::DomainEvent;
use domain::model_server::{
@ -23,12 +23,19 @@ use domain::ports::{
ProcessStatus, ProfileStore, RemotePath, SpawnSpec, StoreError,
};
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
use domain::{LocalModelServerId, ProfileId, ProjectPath};
use domain::{
AgentId, LocalModelServerId, NodeId, ProfileId, ProjectId, ProjectPath, PtySize, SessionId,
SessionKind, SessionStatus, TerminalSession,
};
fn sid(n: u128) -> LocalModelServerId {
LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n))
}
fn pid(n: u128) -> ProjectId {
ProjectId::from_uuid(uuid::Uuid::from_u128(n))
}
fn config(
id: LocalModelServerId,
port: u16,
@ -611,6 +618,83 @@ async fn concurrent_ensure_same_server_shares_one_start_attempt() {
assert_eq!(starting_events, 1);
}
#[tokio::test]
async fn local_model_server_use_rejects_distinct_project_until_guard_is_released() {
let usecase = ensure(
Arc::new(FakeRegistry::default()),
Arc::new(FakeProbe::new(Vec::new())),
Arc::new(FakeProcess::default()),
Arc::new(FakeFs::default()),
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();
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());
}
#[tokio::test]
async fn local_model_server_use_allows_same_project_with_refcount() {
let usecase = ensure(
Arc::new(FakeRegistry::default()),
Arc::new(FakeProbe::new(Vec::new())),
Arc::new(FakeProcess::default()),
Arc::new(FakeFs::default()),
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();
assert!(usecase.acquire_project_use(sid(31), pid(2)).is_err());
drop(first);
assert!(usecase.acquire_project_use(sid(31), pid(2)).is_err());
drop(second);
assert!(usecase.acquire_project_use(sid(31), pid(2)).is_ok());
}
#[tokio::test]
async fn terminal_session_removal_releases_local_model_server_use_guard() {
let usecase = ensure(
Arc::new(FakeRegistry::default()),
Arc::new(FakeProbe::new(Vec::new())),
Arc::new(FakeProcess::default()),
Arc::new(FakeFs::default()),
Arc::new(FakeEvents::default()),
);
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 sessions = TerminalSessions::new();
let mut session = TerminalSession::starting(
session_id,
NodeId::from_uuid(uuid::Uuid::from_u128(34)),
ProjectPath::new("/tmp/project-a").unwrap(),
SessionKind::Agent {
agent_id: AgentId::from_uuid(uuid::Uuid::from_u128(35)),
},
PtySize { rows: 24, cols: 80 },
);
session.status = SessionStatus::Running;
sessions.insert_in_project_with_model_server_guard(
project,
domain::ports::PtyHandle { session_id },
session,
Some(guard),
);
assert!(usecase.acquire_project_use(server_id, pid(2)).is_err());
sessions.remove(&session_id);
assert!(usecase.acquire_project_use(server_id, pid(2)).is_ok());
}
#[tokio::test]
async fn missing_model_path_is_path_not_accessible() {
let registry = Arc::new(FakeRegistry::default());