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:
2026-06-08 14:43:48 +02:00
parent f3bc3f20d8
commit b39c11a64d
17 changed files with 830 additions and 29 deletions

View File

@ -23,7 +23,8 @@ use application::{
use domain::ports::PtyHandle;
use crate::dto::{
parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_profile_id,
parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_node_id,
parse_profile_id, LiveAgentListDto,
parse_project_id, parse_session_id, parse_template_id, AgentDriftListDto, AgentDto,
AgentListDto, ConfigureProfilesRequestDto, CreateAgentFromTemplateRequestDto,
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateProjectRequestDto,
@ -689,6 +690,30 @@ pub async fn list_agents(
.map_err(ErrorDto::from)
}
/// `list_live_agents` — list the agents that currently own a live PTY session
/// and the cell hosting each, so the UI can disable an agent already running in
/// another cell (the "one live session per agent" invariant).
///
/// `project_id` is accepted for API symmetry and future per-project scoping; the
/// session registry is process-wide today, so the full live set is returned (a
/// project's agent ids are disjoint from other projects' by construction, so the
/// frontend can filter by the agents it knows).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed project id).
#[tauri::command]
pub fn list_live_agents(
project_id: String,
state: State<'_, AppState>,
) -> Result<LiveAgentListDto, ErrorDto> {
// Validate the id shape for a consistent contract, even though the registry
// is not project-scoped yet.
let _ = parse_project_id(&project_id)?;
Ok(LiveAgentListDto::from_pairs(
state.terminal_sessions.live_agents(),
))
}
/// `read_agent_context` — read an agent's Markdown context.
///
/// # Errors
@ -803,6 +828,10 @@ pub async fn launch_agent(
) -> Result<TerminalSessionDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
// The hosting cell drives the singleton-invariant guard. Parse it when the
// frontend supplies one; absent ⇒ `None` (a fresh node is minted, and an
// already-live agent is refused).
let node_id = request.node_id.as_deref().map(parse_node_id).transpose()?;
let output = state
.launch_agent
@ -811,10 +840,9 @@ pub async fn launch_agent(
agent_id,
rows: request.rows,
cols: request.cols,
node_id: None,
node_id,
// Resume id is a property of the hosting cell; the frontend passes the
// leaf's current conversation id here. `None` until the launch_agent
// command is made layout-aware (T4 wiring follow-up).
// leaf's current conversation id here.
conversation_id: request.conversation_id.clone(),
})
.await

View File

@ -944,6 +944,13 @@ pub struct LaunchAgentRequestDto {
/// launch may assign a new id when the profile supports it).
#[serde(default)]
pub conversation_id: Option<String>,
/// The layout leaf (node) hosting this launch. Enforces the "one live session
/// per agent" invariant: a launch into a node different from where the agent
/// is already running is refused (`AGENT_ALREADY_RUNNING`); the same node is
/// idempotent. Absent ⇒ a fresh node is minted (and any already-live agent is
/// refused).
#[serde(default)]
pub node_id: Option<String>,
}
impl From<LaunchAgentOutput> for TerminalSessionDto {
@ -997,6 +1004,40 @@ impl From<InspectConversationOutput> for ConversationDetailsDto {
}
}
/// One currently-live agent and the cell hosting it, for `list_live_agents`.
///
/// Lets the UI disable an agent already running in another cell (it cannot be
/// launched a second time — the "one live session per agent" invariant).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LiveAgentDto {
/// The live agent's id (UUID string).
pub agent_id: String,
/// The hosting layout leaf's node id (UUID string).
pub node_id: String,
}
/// Response DTO for `list_live_agents` (transparent array on the wire).
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct LiveAgentListDto(pub Vec<LiveAgentDto>);
impl LiveAgentListDto {
/// Builds the wire list from the registry's `(AgentId, NodeId)` pairs.
#[must_use]
pub fn from_pairs(pairs: Vec<(AgentId, NodeId)>) -> Self {
Self(
pairs
.into_iter()
.map(|(agent_id, node_id)| LiveAgentDto {
agent_id: agent_id.to_string(),
node_id: node_id.to_string(),
})
.collect(),
)
}
}
/// Parses an agent-id string (UUID) coming from the frontend.
///
/// # Errors

View File

@ -113,6 +113,7 @@ pub fn run() {
commands::configure_profiles,
commands::create_agent,
commands::list_agents,
commands::list_live_agents,
commands::read_agent_context,
commands::update_agent_context,
commands::delete_agent,