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:
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -4,9 +4,10 @@
|
||||
|
||||
use app_tauri_lib::dto::{
|
||||
parse_agent_id, AgentDto, AgentListDto, ConversationDetailsDto, CreateAgentRequestDto,
|
||||
InspectConversationRequestDto, LaunchAgentRequestDto, TerminalSessionDto,
|
||||
InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, TerminalSessionDto,
|
||||
UpdateAgentContextRequestDto,
|
||||
};
|
||||
use application::AppError;
|
||||
use application::{
|
||||
CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, ListAgentsOutput,
|
||||
};
|
||||
@ -132,6 +133,57 @@ fn launch_agent_request_deserialises_camelcase() {
|
||||
assert_eq!(dto.agent_id, agent_id);
|
||||
// Omitting the resume id defaults to None (a fresh cell).
|
||||
assert_eq!(dto.conversation_id, None);
|
||||
// Omitting the node id defaults to None (a fresh node is minted backend-side).
|
||||
assert_eq!(dto.node_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_agent_request_carries_node_id() {
|
||||
let node_id = Uuid::from_u128(33).to_string();
|
||||
let raw = json!({
|
||||
"projectId": Uuid::from_u128(1).to_string(),
|
||||
"agentId": Uuid::from_u128(2).to_string(),
|
||||
"rows": 24,
|
||||
"cols": 80,
|
||||
"nodeId": node_id
|
||||
});
|
||||
let dto: LaunchAgentRequestDto = serde_json::from_value(raw).unwrap();
|
||||
// The hosting cell reaches the backend so the singleton guard can enforce
|
||||
// "one live session per agent".
|
||||
assert_eq!(dto.node_id.as_deref(), Some(node_id.as_str()));
|
||||
// No snake_case leak on the wire.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AGENT_ALREADY_RUNNING error code + LiveAgentListDto (T2/T3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn agent_already_running_error_code_is_stable() {
|
||||
let err = AppError::AgentAlreadyRunning {
|
||||
agent_id: AgentId::from_uuid(Uuid::from_u128(2)),
|
||||
node_id: NodeId::from_uuid(Uuid::from_u128(3)),
|
||||
};
|
||||
let dto = app_tauri_lib::dto::ErrorDto::from(err);
|
||||
// Option A: a stable code + a text message (the node is NOT enriched into the
|
||||
// ErrorDto — the frontend branches on the code alone).
|
||||
assert_eq!(dto.code, "AGENT_ALREADY_RUNNING");
|
||||
assert!(dto.message.contains("already running"), "message: {}", dto.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_agent_list_dto_serialises_camelcase_array() {
|
||||
let agent_a = AgentId::from_uuid(Uuid::from_u128(11));
|
||||
let node_a = NodeId::from_uuid(Uuid::from_u128(21));
|
||||
let dto = LiveAgentListDto::from_pairs(vec![(agent_a, node_a)]);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
let arr = v.as_array().expect("transparent array");
|
||||
assert_eq!(arr.len(), 1);
|
||||
assert_eq!(arr[0]["agentId"], agent_a.to_string());
|
||||
assert_eq!(arr[0]["nodeId"], node_a.to_string());
|
||||
// No snake_case leak.
|
||||
assert!(arr[0].get("agent_id").is_none());
|
||||
assert!(arr[0].get("node_id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user