fix(runtime): isolate agent state by project (#101)

This commit is contained in:
2026-07-25 22:14:02 +02:00
parent 6a87c4635f
commit 6e98fd89f7
52 changed files with 1894 additions and 805 deletions

View File

@ -11,7 +11,7 @@ use std::sync::{Arc, Mutex};
use domain::conversation::ConversationId;
use domain::ports::{AgentSession, PtyHandle};
use domain::{AgentId, IssueRef, NodeId, SessionId, SessionKind, TerminalSession};
use domain::{AgentId, IssueRef, NodeId, ProjectId, SessionId, SessionKind, TerminalSession};
/// Runtime family of a live agent session.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -25,6 +25,8 @@ pub enum LiveSessionKind {
/// Read-only coordinates of one live agent session.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LiveSessionSnapshot {
/// The project owning the live session.
pub project_id: ProjectId,
/// The agent owning the live session.
pub agent_id: AgentId,
/// The layout node currently hosting the session view.
@ -38,6 +40,7 @@ pub struct LiveSessionSnapshot {
/// A registered, live terminal: its PTY handle plus the domain snapshot.
#[derive(Debug, Clone)]
struct Entry {
project_id: ProjectId,
handle: PtyHandle,
session: TerminalSession,
}
@ -51,7 +54,7 @@ struct Entry {
/// implementation.
pub trait LiveAgentRegistry: Send + Sync {
/// Whether `agent_id` currently has a live session in the registry.
fn is_agent_live(&self, agent_id: &AgentId) -> bool;
fn is_agent_live(&self, project_id: ProjectId, agent_id: &AgentId) -> bool;
/// Whether `node_id` (a layout leaf) currently hosts a live session.
///
@ -75,8 +78,9 @@ pub struct TerminalSessions {
}
impl LiveAgentRegistry for TerminalSessions {
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
self.session_for_agent(agent_id).is_some()
fn is_agent_live(&self, project_id: ProjectId, agent_id: &AgentId) -> bool {
self.session_for_agent_in_project(project_id, agent_id)
.is_some()
}
fn is_node_live(&self, node_id: &NodeId) -> bool {
@ -130,11 +134,16 @@ impl TerminalSessions {
/// may take part in several threads, so this is the **plural** of
/// [`Self::session_for_agent`]).
#[must_use]
pub fn sessions_for_agent(&self, agent_id: &AgentId) -> Vec<SessionId> {
pub fn sessions_for_agent_in_project(
&self,
project_id: ProjectId,
agent_id: &AgentId,
) -> Vec<SessionId> {
self.entries
.lock()
.map(|m| {
m.values()
.filter(|e| e.project_id == project_id)
.filter(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id))
.map(|e| e.session.id)
.collect()
@ -142,13 +151,38 @@ impl TerminalSessions {
.unwrap_or_default()
}
/// Legacy project-less test helper. Production code must use
/// [`Self::sessions_for_agent_in_project`].
#[must_use]
pub fn sessions_for_agent(&self, agent_id: &AgentId) -> Vec<SessionId> {
self.sessions_for_agent_in_project(ProjectId::from_uuid(uuid::Uuid::nil()), agent_id)
}
/// Inserts a freshly-opened session.
pub fn insert(&self, handle: PtyHandle, session: TerminalSession) {
pub fn insert_in_project(
&self,
project_id: ProjectId,
handle: PtyHandle,
session: TerminalSession,
) {
if let Ok(mut map) = self.entries.lock() {
map.insert(session.id, Entry { handle, session });
map.insert(
session.id,
Entry {
project_id,
handle,
session,
},
);
}
}
/// Legacy project-less test helper. Production code must use
/// [`Self::insert_in_project`].
pub fn insert(&self, handle: PtyHandle, session: TerminalSession) {
self.insert_in_project(ProjectId::from_uuid(uuid::Uuid::nil()), handle, session);
}
/// Returns the [`PtyHandle`] for a session, if registered.
#[must_use]
pub fn handle(&self, id: &SessionId) -> Option<PtyHandle> {
@ -179,14 +213,26 @@ impl TerminalSessions {
/// one live session per agent, so the first match is *the* match. `find`
/// short-circuits on it.
#[must_use]
pub fn session_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> {
pub fn session_for_agent_in_project(
&self,
project_id: ProjectId,
agent_id: &AgentId,
) -> Option<SessionId> {
self.entries.lock().ok().and_then(|m| {
m.values()
.filter(|e| e.project_id == project_id)
.find(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id))
.map(|e| e.session.id)
})
}
/// Legacy project-less test helper. Production code must use
/// [`Self::session_for_agent_in_project`].
#[must_use]
pub fn session_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> {
self.session_for_agent_in_project(ProjectId::from_uuid(uuid::Uuid::nil()), agent_id)
}
/// Returns the [`NodeId`] of the live cell hosting a given agent, if any.
///
/// Companion to [`Self::session_for_agent`]: the launch guard needs the
@ -194,25 +240,41 @@ impl TerminalSessions {
/// [`crate::error::AppError::AgentAlreadyRunning`]. Unambiguous by the same
/// one-live-session-per-agent invariant.
#[must_use]
pub fn node_for_agent(&self, agent_id: &AgentId) -> Option<NodeId> {
pub fn node_for_agent_in_project(
&self,
project_id: ProjectId,
agent_id: &AgentId,
) -> Option<NodeId> {
self.entries.lock().ok().and_then(|m| {
m.values()
.filter(|e| e.project_id == project_id)
.find(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id))
.map(|e| e.session.node_id)
})
}
/// Legacy project-less test helper. Production code must use
/// [`Self::node_for_agent_in_project`].
#[must_use]
pub fn node_for_agent(&self, agent_id: &AgentId) -> Option<NodeId> {
self.node_for_agent_in_project(ProjectId::from_uuid(uuid::Uuid::nil()), agent_id)
}
/// Lists every currently-live agent, its current host cell and session id.
///
/// One `(AgentId, NodeId, SessionId)` tuple per session tagged [`SessionKind::Agent`].
/// Used by the `list_live_agents` query so the UI can disable an agent that
/// is already running elsewhere (it cannot be launched in a second cell).
#[must_use]
pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> {
pub fn live_agents_in_project(
&self,
project_id: ProjectId,
) -> Vec<(AgentId, NodeId, SessionId)> {
self.entries
.lock()
.map(|m| {
m.values()
.filter(|e| e.project_id == project_id)
.filter_map(|e| match e.session.kind {
SessionKind::Agent { agent_id } => {
Some((agent_id, e.session.node_id, e.session.id))
@ -224,6 +286,13 @@ impl TerminalSessions {
.unwrap_or_default()
}
/// Legacy project-less test helper. Production code should prefer
/// [`Self::live_agents_in_project`] or [`LiveSessions::live_agent_snapshots`].
#[must_use]
pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> {
self.live_agents_in_project(ProjectId::from_uuid(uuid::Uuid::nil()))
}
/// Rebinds a live agent session to a new visible layout node without
/// respawning the CLI process.
///
@ -232,20 +301,36 @@ impl TerminalSessions {
/// in another cell updates only the view binding (`node_id`). The PTY handle,
/// session id, scrollback and process stay untouched.
#[must_use]
pub fn rebind_agent_node(
pub fn rebind_agent_node_in_project(
&self,
project_id: ProjectId,
agent_id: &AgentId,
node_id: NodeId,
) -> Option<TerminalSession> {
self.entries.lock().ok().and_then(|mut m| {
let entry = m.values_mut().find(
|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id),
|e| e.project_id == project_id
&& matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id),
)?;
entry.session.node_id = node_id;
Some(entry.session.clone())
})
}
/// Legacy project-less test helper. Production code must pass `project_id`.
#[must_use]
pub fn rebind_agent_node(
&self,
agent_id: &AgentId,
node_id: NodeId,
) -> Option<TerminalSession> {
self.rebind_agent_node_in_project(
ProjectId::from_uuid(uuid::Uuid::nil()),
agent_id,
node_id,
)
}
/// Returns the [`PtyHandle`]s of every currently-registered session.
///
/// Used at application shutdown to kill all live PTYs cleanly (the
@ -295,6 +380,8 @@ impl TerminalSessions {
/// le `node_id` (cellule-vue, rebindable) pour offrir la **même** surface que
/// [`TerminalSessions`].
struct StructuredEntry {
/// Projet propriétaire de la session runtime.
project_id: ProjectId,
/// La session vivante (ressource process/SDK), derrière le port domaine.
session: Arc<dyn AgentSession>,
/// L'agent IA pilotant cette session (invariant « 1 session vivante/agent »).
@ -328,8 +415,9 @@ pub struct StructuredSessions {
}
impl LiveAgentRegistry for StructuredSessions {
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
self.session_for_agent(agent_id).is_some()
fn is_agent_live(&self, project_id: ProjectId, agent_id: &AgentId) -> bool {
self.session_for_agent_in_project(project_id, agent_id)
.is_some()
}
fn is_node_live(&self, node_id: &NodeId) -> bool {
@ -352,12 +440,19 @@ impl StructuredSessions {
/// Enregistre une session fraîchement démarrée pour `agent_id`, hébergée par
/// la cellule `node_id`. Clé par l'id de session ([`AgentSession::id`]).
pub fn insert(&self, session: Arc<dyn AgentSession>, agent_id: AgentId, node_id: NodeId) {
pub fn insert_in_project(
&self,
project_id: ProjectId,
session: Arc<dyn AgentSession>,
agent_id: AgentId,
node_id: NodeId,
) {
if let Ok(mut map) = self.entries.lock() {
let id = session.id();
map.insert(
id,
StructuredEntry {
project_id,
session,
agent_id,
node_id,
@ -366,6 +461,17 @@ impl StructuredSessions {
}
}
/// Legacy project-less test helper. Production code must use
/// [`Self::insert_in_project`].
pub fn insert(&self, session: Arc<dyn AgentSession>, agent_id: AgentId, node_id: NodeId) {
self.insert_in_project(
ProjectId::from_uuid(uuid::Uuid::nil()),
session,
agent_id,
node_id,
);
}
/// Retourne la session enregistrée pour un id, si présente.
#[must_use]
pub fn session(&self, id: &SessionId) -> Option<Arc<dyn AgentSession>> {
@ -434,36 +540,72 @@ impl StructuredSessions {
/// Jumeau de [`TerminalSessions::session_for_agent`] : **non ambigu** par
/// l'invariant « 1 session vivante/agent » — le premier match est *le* match.
#[must_use]
pub fn session_for_agent(&self, agent_id: &AgentId) -> Option<Arc<dyn AgentSession>> {
pub fn session_for_agent_in_project(
&self,
project_id: ProjectId,
agent_id: &AgentId,
) -> Option<Arc<dyn AgentSession>> {
self.entries.lock().ok().and_then(|m| {
m.values()
.filter(|e| e.project_id == project_id)
.find(|e| &e.agent_id == agent_id)
.map(|e| Arc::clone(&e.session))
})
}
/// Legacy project-less test helper. Production code must use
/// [`Self::session_for_agent_in_project`].
#[must_use]
pub fn session_for_agent(&self, agent_id: &AgentId) -> Option<Arc<dyn AgentSession>> {
self.session_for_agent_in_project(ProjectId::from_uuid(uuid::Uuid::nil()), agent_id)
}
/// Retourne l'[`SessionId`] de la session vivante hébergeant `agent_id`, si any.
#[must_use]
pub fn session_id_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> {
pub fn session_id_for_agent_in_project(
&self,
project_id: ProjectId,
agent_id: &AgentId,
) -> Option<SessionId> {
self.entries.lock().ok().and_then(|m| {
m.values()
.filter(|e| e.project_id == project_id)
.find(|e| &e.agent_id == agent_id)
.map(|e| e.session.id())
})
}
/// Legacy project-less test helper. Production code must use
/// [`Self::session_id_for_agent_in_project`].
#[must_use]
pub fn session_id_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> {
self.session_id_for_agent_in_project(ProjectId::from_uuid(uuid::Uuid::nil()), agent_id)
}
/// Retourne le [`NodeId`] de la cellule vivante hébergeant `agent_id`, si any.
///
/// Jumeau de [`TerminalSessions::node_for_agent`].
#[must_use]
pub fn node_for_agent(&self, agent_id: &AgentId) -> Option<NodeId> {
pub fn node_for_agent_in_project(
&self,
project_id: ProjectId,
agent_id: &AgentId,
) -> Option<NodeId> {
self.entries.lock().ok().and_then(|m| {
m.values()
.filter(|e| e.project_id == project_id)
.find(|e| &e.agent_id == agent_id)
.map(|e| e.node_id)
})
}
/// Legacy project-less test helper. Production code must use
/// [`Self::node_for_agent_in_project`].
#[must_use]
pub fn node_for_agent(&self, agent_id: &AgentId) -> Option<NodeId> {
self.node_for_agent_in_project(ProjectId::from_uuid(uuid::Uuid::nil()), agent_id)
}
/// Résout les coordonnées `(agent_id, node_id, conversation_id)` d'une session structurée par son
/// [`SessionId`] (LS7, tap niveau 1 des limites de session, §21.10).
///
@ -473,10 +615,19 @@ impl StructuredSessions {
/// passer à [`SessionLimitService::on_rate_limited`](crate::SessionLimitService) sur
/// un signal `RateLimited`. `None` si l'id n'est pas (ou plus) une session vivante.
#[must_use]
pub fn meta_for_session(&self, id: &SessionId) -> Option<(AgentId, NodeId, Option<String>)> {
pub fn meta_for_session(
&self,
id: &SessionId,
) -> Option<(ProjectId, AgentId, NodeId, Option<String>)> {
self.entries.lock().ok().and_then(|m| {
m.get(id)
.map(|e| (e.agent_id, e.node_id, e.session.conversation_id()))
m.get(id).map(|e| {
(
e.project_id,
e.agent_id,
e.node_id,
e.session.conversation_id(),
)
})
})
}
@ -485,17 +636,28 @@ impl StructuredSessions {
/// Jumeau de [`TerminalSessions::live_agents`] : un tuple
/// `(AgentId, NodeId, SessionId)` par session structurée vivante.
#[must_use]
pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> {
pub fn live_agents_in_project(
&self,
project_id: ProjectId,
) -> Vec<(AgentId, NodeId, SessionId)> {
self.entries
.lock()
.map(|m| {
m.values()
.filter(|e| e.project_id == project_id)
.map(|e| (e.agent_id, e.node_id, e.session.id()))
.collect()
})
.unwrap_or_default()
}
/// Legacy project-less test helper. Production code should prefer
/// [`Self::live_agents_in_project`] or [`LiveSessions::live_agent_snapshots`].
#[must_use]
pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> {
self.live_agents_in_project(ProjectId::from_uuid(uuid::Uuid::nil()))
}
/// Rebinde la session vivante d'un agent vers une nouvelle cellule-vue sans
/// redémarrer la conversation (« la cellule est une vue », §17.6).
///
@ -503,16 +665,33 @@ impl StructuredSessions {
/// change ; la session, son id et sa conversation restent intacts. Retourne la
/// session rebindée, ou `None` si l'agent n'a pas de session vivante.
#[must_use]
pub fn rebind_agent_node_in_project(
&self,
project_id: ProjectId,
agent_id: &AgentId,
node_id: NodeId,
) -> Option<Arc<dyn AgentSession>> {
self.entries.lock().ok().and_then(|mut m| {
let entry = m
.values_mut()
.find(|e| e.project_id == project_id && &e.agent_id == agent_id)?;
entry.node_id = node_id;
Some(Arc::clone(&entry.session))
})
}
/// Legacy project-less test helper. Production code must pass `project_id`.
#[must_use]
pub fn rebind_agent_node(
&self,
agent_id: &AgentId,
node_id: NodeId,
) -> Option<Arc<dyn AgentSession>> {
self.entries.lock().ok().and_then(|mut m| {
let entry = m.values_mut().find(|e| &e.agent_id == agent_id)?;
entry.node_id = node_id;
Some(Arc::clone(&entry.session))
})
self.rebind_agent_node_in_project(
ProjectId::from_uuid(uuid::Uuid::nil()),
agent_id,
node_id,
)
}
/// Retire une session du registre, retournant la session si présente (pour que
@ -605,25 +784,35 @@ impl LiveSessions {
/// L'[`SessionId`] de la session vivante d'un agent, PTY **ou** structurée.
#[must_use]
pub fn session_id_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> {
pub fn session_id_for_agent(
&self,
project_id: ProjectId,
agent_id: &AgentId,
) -> Option<SessionId> {
self.pty
.session_for_agent(agent_id)
.or_else(|| self.structured.session_id_for_agent(agent_id))
.session_for_agent_in_project(project_id, agent_id)
.or_else(|| {
self.structured
.session_id_for_agent_in_project(project_id, agent_id)
})
}
/// La cellule hôte de la session vivante d'un agent, PTY **ou** structurée.
#[must_use]
pub fn node_for_agent(&self, agent_id: &AgentId) -> Option<NodeId> {
pub fn node_for_agent(&self, project_id: ProjectId, agent_id: &AgentId) -> Option<NodeId> {
self.pty
.node_for_agent(agent_id)
.or_else(|| self.structured.node_for_agent(agent_id))
.node_for_agent_in_project(project_id, agent_id)
.or_else(|| {
self.structured
.node_for_agent_in_project(project_id, agent_id)
})
}
/// Tous les agents vivants des deux registres (PTY puis structurés).
#[must_use]
pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> {
let mut all = self.pty.live_agents();
all.extend(self.structured.live_agents());
pub fn live_agents(&self, project_id: ProjectId) -> Vec<(AgentId, NodeId, SessionId)> {
let mut all = self.pty.live_agents_in_project(project_id);
all.extend(self.structured.live_agents_in_project(project_id));
all
}
@ -632,30 +821,48 @@ impl LiveSessions {
pub fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
let mut all: Vec<LiveSessionSnapshot> = self
.pty
.live_agents()
.into_iter()
.map(|(agent_id, node_id, session_id)| LiveSessionSnapshot {
agent_id,
node_id,
session_id,
kind: LiveSessionKind::Pty,
.entries
.lock()
.map(|m| {
m.values()
.filter_map(|e| match e.session.kind {
SessionKind::Agent { agent_id } => Some(LiveSessionSnapshot {
project_id: e.project_id,
agent_id,
node_id: e.session.node_id,
session_id: e.session.id,
kind: LiveSessionKind::Pty,
}),
SessionKind::Plain => None,
})
.collect()
})
.collect();
all.extend(self.structured.live_agents().into_iter().map(
|(agent_id, node_id, session_id)| LiveSessionSnapshot {
agent_id,
node_id,
session_id,
kind: LiveSessionKind::Structured,
},
));
.unwrap_or_default();
all.extend(
self.structured
.entries
.lock()
.map(|m| {
m.values()
.map(|e| LiveSessionSnapshot {
project_id: e.project_id,
agent_id: e.agent_id,
node_id: e.node_id,
session_id: e.session.id(),
kind: LiveSessionKind::Structured,
})
.collect::<Vec<_>>()
})
.unwrap_or_default(),
);
all
}
}
impl LiveAgentRegistry for LiveSessions {
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
self.pty.is_agent_live(agent_id) || self.structured.is_agent_live(agent_id)
fn is_agent_live(&self, project_id: ProjectId, agent_id: &AgentId) -> bool {
self.pty.is_agent_live(project_id, agent_id)
|| self.structured.is_agent_live(project_id, agent_id)
}
fn is_node_live(&self, node_id: &NodeId) -> bool {