fix(agents): enforcer l'invariant « 1 session vivante par agent » (singleton)
Un agent ne peut tourner que dans une seule cellule à la fois. La garde dans LaunchAgent refuse le spawn si l'agent est déjà vivant dans un autre node (AGENT_ALREADY_RUNNING) ; idempotent sur le même node ; le chemin resume (agent mort) reste inchangé. Le node_id est désormais plombé jusqu'au use case. Corrige le reset asymétrique d'une cellule au changement d'onglet : deux leaves partageant le même agent id rendaient session_for_agent/is_agent_live/stop_agent ambigus (cible arbitraire). Le churn reset/reattach déclenchait aussi les accents mélangés (FIFO intact, non touché). - snapshot agentWasRunning calculé par node (is_node_live) et non par agent - commande list_live_agents + live_agents()/node_for_agent()/is_node_live() - UI : dropdown grise les agents déjà placés ailleurs ; 2e cellule en doublon affiche « disponible » au lieu d'une relance fantôme Tests : cargo test (application + app-tauri) vert ; tsc + vitest vert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -481,6 +481,39 @@ impl LaunchAgent {
|
||||
.to_agent()
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
|
||||
// 1b. Enforce the "one live session per agent" invariant (decision: an
|
||||
// agent is a singleton that runs in a single cell at a time). This
|
||||
// runs AFTER the NotFound resolution above (so an unknown agent still
|
||||
// errors NotFound) but BEFORE any I/O (run dir, seed, spawn). If the
|
||||
// agent already owns a live session:
|
||||
// - on a *different* (or unspecified) node → refuse the launch with
|
||||
// `AgentAlreadyRunning`, pointing at the existing host cell;
|
||||
// - on the *same* node → idempotent (a concurrent double-launch of the
|
||||
// very same cell): return the existing session without respawning,
|
||||
// assigning no new conversation id.
|
||||
// The resume path (agent dead ⇒ no live session) is unaffected.
|
||||
if let Some(existing_id) = self.sessions.session_for_agent(&input.agent_id) {
|
||||
let host_node = self
|
||||
.sessions
|
||||
.node_for_agent(&input.agent_id)
|
||||
.expect("a live session always has a host node");
|
||||
let same_node = input.node_id.as_ref() == Some(&host_node);
|
||||
if !same_node {
|
||||
return Err(AppError::AgentAlreadyRunning {
|
||||
agent_id: input.agent_id,
|
||||
node_id: host_node,
|
||||
});
|
||||
}
|
||||
// Same node: idempotent — hand back the already-registered session,
|
||||
// no respawn, nothing new to persist.
|
||||
if let Some(session) = self.sessions.session(&existing_id) {
|
||||
return Ok(LaunchAgentOutput {
|
||||
session,
|
||||
assigned_conversation_id: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Read its context and resolve its profile.
|
||||
let content = self
|
||||
.contexts
|
||||
|
||||
@ -9,6 +9,7 @@ use domain::ports::{
|
||||
EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError, RemoteError,
|
||||
RuntimeError, StoreError,
|
||||
};
|
||||
use domain::{AgentId, NodeId};
|
||||
|
||||
/// Errors surfaced by application use cases.
|
||||
///
|
||||
@ -44,6 +45,18 @@ pub enum AppError {
|
||||
#[error("remote error: {0}")]
|
||||
Remote(String),
|
||||
|
||||
/// An agent is already running in a live cell and cannot be launched again
|
||||
/// (the "one live session per agent" invariant). Reuse goes through
|
||||
/// templates (instantiate → distinct agents); IdeA never multi-instances a
|
||||
/// single agent.
|
||||
#[error("agent {agent_id} is already running in cell {node_id}")]
|
||||
AgentAlreadyRunning {
|
||||
/// The agent that is already live.
|
||||
agent_id: AgentId,
|
||||
/// The layout cell (leaf) currently hosting that agent's live session.
|
||||
node_id: NodeId,
|
||||
},
|
||||
|
||||
/// An unexpected internal error.
|
||||
#[error("internal error: {0}")]
|
||||
Internal(String),
|
||||
@ -62,6 +75,7 @@ impl AppError {
|
||||
Self::Process(_) => "PROCESS",
|
||||
Self::Git(_) => "GIT",
|
||||
Self::Remote(_) => "REMOTE",
|
||||
Self::AgentAlreadyRunning { .. } => "AGENT_ALREADY_RUNNING",
|
||||
Self::Internal(_) => "INTERNAL",
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,8 +87,12 @@ impl SnapshotRunningAgents {
|
||||
let mut changed = false;
|
||||
|
||||
for named in &mut doc.layouts {
|
||||
for (leaf_id, agent_id) in named.tree.agent_leaves() {
|
||||
let running = self.live.is_agent_live(&agent_id);
|
||||
for (leaf_id, _agent_id) in named.tree.agent_leaves() {
|
||||
// Liveness is keyed on the hosting *node*, not the agent: with the
|
||||
// one-live-session-per-agent invariant, an agent pinned on several
|
||||
// leaves is live in at most one cell, so only that cell must be
|
||||
// marked running (a duplicate leaf for the same agent stays false).
|
||||
let running = self.live.is_node_live(&leaf_id);
|
||||
if running {
|
||||
out.running += 1;
|
||||
} else {
|
||||
|
||||
@ -10,7 +10,7 @@ use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use domain::ports::PtyHandle;
|
||||
use domain::{AgentId, SessionId, SessionKind, TerminalSession};
|
||||
use domain::{AgentId, NodeId, SessionId, SessionKind, TerminalSession};
|
||||
|
||||
/// A registered, live terminal: its PTY handle plus the domain snapshot.
|
||||
#[derive(Debug, Clone)]
|
||||
@ -29,6 +29,15 @@ struct Entry {
|
||||
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;
|
||||
|
||||
/// Whether `node_id` (a layout leaf) currently hosts a live session.
|
||||
///
|
||||
/// This is the per-cell liveness the close-time snapshot uses: with the
|
||||
/// "one live session per agent" invariant in place, the same agent can be
|
||||
/// pinned on several leaves but be *live* in at most one — so liveness must
|
||||
/// be keyed on the hosting node, not the agent (otherwise a duplicate leaf
|
||||
/// would be wrongly marked as still running).
|
||||
fn is_node_live(&self, node_id: &NodeId) -> bool;
|
||||
}
|
||||
|
||||
/// In-memory registry of active terminal sessions.
|
||||
@ -41,6 +50,13 @@ impl LiveAgentRegistry for TerminalSessions {
|
||||
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
|
||||
self.session_for_agent(agent_id).is_some()
|
||||
}
|
||||
|
||||
fn is_node_live(&self, node_id: &NodeId) -> bool {
|
||||
self.entries
|
||||
.lock()
|
||||
.map(|m| m.values().any(|e| e.session.node_id == *node_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalSessions {
|
||||
@ -83,6 +99,11 @@ impl TerminalSessions {
|
||||
/// mapping the orchestrator's `stop_agent` uses to translate an agent id into
|
||||
/// the [`SessionId`] that `CloseTerminal` expects. Returns `None` when the
|
||||
/// agent has no live session (already stopped / never launched).
|
||||
///
|
||||
/// **Unambiguous by construction**: the "one live session per agent"
|
||||
/// invariant (enforced in [`crate::agent::LaunchAgent`]) guarantees at most
|
||||
/// 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> {
|
||||
self.entries.lock().ok().and_then(|m| {
|
||||
@ -92,6 +113,41 @@ impl TerminalSessions {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the [`NodeId`] of the live cell hosting a given agent, if any.
|
||||
///
|
||||
/// Companion to [`Self::session_for_agent`]: the launch guard needs the
|
||||
/// *host node* of an already-live agent to report it in
|
||||
/// [`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> {
|
||||
self.entries.lock().ok().and_then(|m| {
|
||||
m.values()
|
||||
.find(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id))
|
||||
.map(|e| e.session.node_id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Lists every currently-live agent and the cell hosting it.
|
||||
///
|
||||
/// One `(AgentId, NodeId)` pair 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)> {
|
||||
self.entries
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.filter_map(|e| match e.session.kind {
|
||||
SessionKind::Agent { agent_id } => Some((agent_id, e.session.node_id)),
|
||||
SessionKind::Plain => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Returns the [`PtyHandle`]s of every currently-registered session.
|
||||
///
|
||||
/// Used at application shutdown to kill all live PTYs cleanly (the
|
||||
|
||||
Reference in New Issue
Block a user