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

@ -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]