From b39c11a64d769f0f564b6cc366ada00f0ec4d5e3 Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 8 Jun 2026 14:43:48 +0200 Subject: [PATCH] =?UTF-8?q?fix(agents):=20enforcer=20l'invariant=20=C2=AB?= =?UTF-8?q?=201=20session=20vivante=20par=20agent=20=C2=BB=20(singleton)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/app-tauri/src/commands.rs | 36 +++- crates/app-tauri/src/dto.rs | 41 +++++ crates/app-tauri/src/lib.rs | 1 + crates/app-tauri/tests/dto_agents.rs | 54 +++++- crates/application/src/agent/lifecycle.rs | 33 ++++ crates/application/src/error.rs | 14 ++ crates/application/src/layout/snapshot.rs | 8 +- crates/application/src/terminal/registry.rs | 58 ++++++- crates/application/tests/agent_lifecycle.rs | 133 +++++++++++++- .../tests/snapshot_running_agents.rs | 84 +++++++-- frontend/src/adapters/agent.ts | 7 + frontend/src/adapters/mock/index.ts | 38 +++- frontend/src/features/layout/LayoutGrid.tsx | 105 +++++++++++- .../features/layout/singletonAgent.test.tsx | 162 ++++++++++++++++++ .../features/terminals/TerminalView.test.tsx | 44 +++++ .../src/features/terminals/TerminalView.tsx | 20 ++- frontend/src/ports/index.ts | 21 +++ 17 files changed, 830 insertions(+), 29 deletions(-) create mode 100644 frontend/src/features/layout/singletonAgent.test.tsx diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 97240c6..02bfe54 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -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 { + // 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 { 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 diff --git a/crates/app-tauri/src/dto.rs b/crates/app-tauri/src/dto.rs index 023bc81..197a70d 100644 --- a/crates/app-tauri/src/dto.rs +++ b/crates/app-tauri/src/dto.rs @@ -944,6 +944,13 @@ pub struct LaunchAgentRequestDto { /// launch may assign a new id when the profile supports it). #[serde(default)] pub conversation_id: Option, + /// 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, } impl From for TerminalSessionDto { @@ -997,6 +1004,40 @@ impl From 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); + +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 diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index b49c5fb..0d343b2 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -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, diff --git a/crates/app-tauri/tests/dto_agents.rs b/crates/app-tauri/tests/dto_agents.rs index 56fae65..aa171c3 100644 --- a/crates/app-tauri/tests/dto_agents.rs +++ b/crates/app-tauri/tests/dto_agents.rs @@ -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] diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 9afcb7a..66830c6 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -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 diff --git a/crates/application/src/error.rs b/crates/application/src/error.rs index 9c9522d..76438e8 100644 --- a/crates/application/src/error.rs +++ b/crates/application/src/error.rs @@ -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", } } diff --git a/crates/application/src/layout/snapshot.rs b/crates/application/src/layout/snapshot.rs index ebe0145..7e5af5a 100644 --- a/crates/application/src/layout/snapshot.rs +++ b/crates/application/src/layout/snapshot.rs @@ -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 { diff --git a/crates/application/src/terminal/registry.rs b/crates/application/src/terminal/registry.rs index 2375c85..31c73be 100644 --- a/crates/application/src/terminal/registry.rs +++ b/crates/application/src/terminal/registry.rs @@ -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 { 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 { + 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 diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 50da1ed..38963ca 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -38,7 +38,7 @@ use domain::{PtySize, SessionId, SkillId, SkillRef}; use uuid::Uuid; use application::{ - CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent, + AppError, CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput, ReadAgentContext, ReadAgentContextInput, TerminalSessions, UpdateAgentContext, UpdateAgentContextInput, }; @@ -835,6 +835,137 @@ async fn launch_orders_prepare_then_injection_then_spawn() { ); } +/// Inserts a live agent session into the registry, simulating an already-running +/// agent pinned on `node`. Returns the session id. +fn seed_live_agent_session( + sessions: &TerminalSessions, + agent_id: AgentId, + node: domain::NodeId, + session_id: SessionId, +) { + let size = PtySize::new(24, 80).unwrap(); + let mut session = domain::TerminalSession::starting( + session_id, + node, + ProjectPath::new("/home/me/proj/.ideai/run/x").unwrap(), + domain::SessionKind::Agent { agent_id }, + size, + ); + session.status = domain::SessionStatus::Running; + sessions.insert(PtyHandle { session_id }, session); +} + +fn nid(n: u128) -> domain::NodeId { + domain::NodeId::from_uuid(Uuid::from_u128(n)) +} + +/// **Singleton invariant (T1)**: launching an agent that already owns a live +/// session in *another* cell is refused with `AgentAlreadyRunning`, pointing at +/// the existing host node. The registry is left untouched and the PTY is never +/// spawned. +#[tokio::test] +async fn launch_refuses_agent_already_running_elsewhere() { + let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture( + ContextInjection::convention_file("CLAUDE.md").unwrap(), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); + + // The agent is already live in cell A. + let host = nid(1); + seed_live_agent_session(&sessions, agent.id, host, sid(42)); + let before = sessions.len(); + + // Attempt to launch the same agent into a *different* cell B. + let mut input = launch_input(agent.id); + input.node_id = Some(nid(2)); + let err = launch.execute(input).await.expect_err("must be refused"); + + match err { + AppError::AgentAlreadyRunning { agent_id, node_id } => { + assert_eq!(agent_id, agent.id); + assert_eq!(node_id, host, "reports the existing host cell"); + } + other => panic!("expected AgentAlreadyRunning, got {other:?}"), + } + assert_eq!(err.code(), "AGENT_ALREADY_RUNNING"); + // Invariant preserved: registry unchanged, no spawn happened. + assert_eq!(sessions.len(), before, "registry must be unchanged"); + assert!(pty.spawns().is_empty(), "no PTY spawn on a refused launch"); +} + +/// A launch with an unspecified node (`node_id: None`) for an already-live agent +/// is also refused — it cannot be the same cell. +#[tokio::test] +async fn launch_refuses_already_running_agent_with_no_node() { + let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture( + ContextInjection::convention_file("CLAUDE.md").unwrap(), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); + seed_live_agent_session(&sessions, agent.id, nid(1), sid(42)); + + // node_id defaults to None in launch_input. + let err = launch + .execute(launch_input(agent.id)) + .await + .expect_err("must be refused"); + assert!(matches!(err, AppError::AgentAlreadyRunning { .. })); + assert!(pty.spawns().is_empty()); +} + +/// Idempotence on the SAME node: a double-launch of the very same cell returns +/// the already-registered session without respawning, and assigns no new +/// conversation id. +#[tokio::test] +async fn launch_same_node_is_idempotent_no_respawn() { + let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture( + ContextInjection::convention_file("CLAUDE.md").unwrap(), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); + + let host = nid(7); + seed_live_agent_session(&sessions, agent.id, host, sid(42)); + let before = sessions.len(); + + let mut input = launch_input(agent.id); + input.node_id = Some(host); + let out = launch.execute(input).await.expect("idempotent launch"); + + assert_eq!(out.session.id, sid(42), "returns the existing session"); + assert!(out.assigned_conversation_id.is_none(), "nothing new assigned"); + assert_eq!(sessions.len(), before, "no new session registered"); + assert!(pty.spawns().is_empty(), "no respawn on same-node relaunch"); +} + +/// After the live session is removed (cell closed / agent exited), a fresh launch +/// succeeds — the guard only blocks while the agent is actually live. +#[tokio::test] +async fn launch_succeeds_after_session_removed() { + let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture( + ContextInjection::convention_file("CLAUDE.md").unwrap(), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); + + // Live, then removed (close/exit). + seed_live_agent_session(&sessions, agent.id, nid(1), sid(42)); + sessions.remove(&sid(42)); + assert!(sessions.session_for_agent(&agent.id).is_none()); + + let mut input = launch_input(agent.id); + input.node_id = Some(nid(2)); + let out = launch.execute(input).await.expect("relaunch must succeed"); + + assert_eq!(out.session.id, sid(777), "spawned a fresh PTY session"); + assert_eq!(pty.spawns().len(), 1, "exactly one spawn after the agent died"); +} + /// **Anti-collision (ARCHITECTURE §14.1)**: two distinct agents of the *same* /// profile on the *same* project root must launch into two **distinct** cwd — /// each its own `.ideai/run//` — and each writes its convention file diff --git a/crates/application/tests/snapshot_running_agents.rs b/crates/application/tests/snapshot_running_agents.rs index 89c38db..bec8986 100644 --- a/crates/application/tests/snapshot_running_agents.rs +++ b/crates/application/tests/snapshot_running_agents.rs @@ -121,23 +121,37 @@ impl ProjectStore for FakeStore { } } -/// A controllable liveness registry: an agent is "live" iff it is in the set. +/// A controllable liveness registry. Liveness is keyed on the hosting **node** +/// (the leaf), matching the snapshot's per-cell query: a leaf is "live" iff its +/// node id is in the set. An optional agent set backs the legacy +/// `is_agent_live` query (unused by the snapshot now, kept for the trait). #[derive(Default, Clone)] -struct FakeLive(Arc>>); +struct FakeLive { + nodes: Arc>>, + agents: Arc>>, +} impl FakeLive { - fn with(agents: &[AgentId]) -> Self { - Self(Arc::new(Mutex::new(agents.iter().copied().collect()))) + /// Seeds the set of live **nodes** (the cells whose PTY is alive). + fn with_nodes(nodes: &[NodeId]) -> Self { + Self { + nodes: Arc::new(Mutex::new(nodes.iter().copied().collect())), + agents: Arc::new(Mutex::new(HashSet::new())), + } } - /// Simulates a PTY kill: forget every live agent. + /// Simulates a PTY kill: forget every live node/agent. fn kill_all(&self) { - self.0.lock().unwrap().clear(); + self.nodes.lock().unwrap().clear(); + self.agents.lock().unwrap().clear(); } } impl LiveAgentRegistry for FakeLive { fn is_agent_live(&self, agent_id: &AgentId) -> bool { - self.0.lock().unwrap().contains(agent_id) + self.agents.lock().unwrap().contains(agent_id) + } + fn is_node_live(&self, node_id: &NodeId) -> bool { + self.nodes.lock().unwrap().contains(node_id) } } @@ -235,7 +249,7 @@ async fn live_agent_is_marked_running() { let agent = aid(100); seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent)))); - let live = FakeLive::with(&[agent]); + let live = FakeLive::with_nodes(&[leaf]); let uc = make_use_case(&store, &fs, &live); let out = uc @@ -307,7 +321,7 @@ async fn mixed_tree_flags_each_agent_independently() { })); seed_layouts(&fs, lid(1), &tree); - let live = FakeLive::with(&[live_agent]); + let live = FakeLive::with_nodes(&[live_leaf]); let uc = make_use_case(&store, &fs, &live); let out = uc @@ -335,7 +349,7 @@ async fn snapshot_reads_liveness_before_kill() { let agent = aid(100); seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent)))); - let live = FakeLive::with(&[agent]); + let live = FakeLive::with_nodes(&[leaf]); let uc = make_use_case(&store, &fs, &live); // Correct order (composition root contract): snapshot THEN kill. @@ -387,3 +401,53 @@ async fn no_agent_leaf_is_noop() { assert_eq!(fs.writes(), writes_before); assert_eq!(was_running(&fs, leaf), Some(false)); } + +/// Duplicate leaves for the **same** agent (the singleton-invariant case): only +/// the cell whose PTY is actually live is flagged running. The other leaf — +/// pinning the same agent but never launched (or refused by the guard) — stays +/// `false`. This is exactly why liveness is keyed on the node, not the agent: +/// an agent-keyed query would wrongly mark BOTH leaves as running. +#[tokio::test] +async fn duplicate_leaves_same_agent_only_live_node_is_running() { + let store = FakeStore::default(); + let fs = FakeFs::default(); + register_project(&store, pid(1)).await; + + let leaf_a = nid(10); + let leaf_b = nid(11); + let agent = aid(100); // SAME agent on both leaves + + let tree = LayoutTree::new(LayoutNode::Split(SplitContainer { + id: nid(1), + direction: Direction::Row, + children: vec![ + WeightedChild { + node: LayoutNode::Leaf(agent_leaf(leaf_a, Some(agent))), + weight: 1.0, + }, + WeightedChild { + node: LayoutNode::Leaf(agent_leaf(leaf_b, Some(agent))), + weight: 1.0, + }, + ], + })); + seed_layouts(&fs, lid(1), &tree); + + // Only node A hosts a live session. + let live = FakeLive::with_nodes(&[leaf_a]); + let uc = make_use_case(&store, &fs, &live); + + let out = uc + .execute(SnapshotRunningAgentsInput { project_id: pid(1) }) + .await + .unwrap(); + + assert_eq!(out.running, 1, "only the live cell counts as running"); + assert_eq!(out.stopped, 1, "the duplicate (dead) cell counts as stopped"); + assert_eq!(was_running(&fs, leaf_a), Some(true)); + assert_eq!( + was_running(&fs, leaf_b), + Some(false), + "the duplicate leaf for the same agent must NOT be marked running" + ); +} diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts index 6ab9602..c1516ab 100644 --- a/frontend/src/adapters/agent.ts +++ b/frontend/src/adapters/agent.ts @@ -19,6 +19,7 @@ import type { AgentGateway, ConversationDetails, CreateAgentInput, + LiveAgent, OpenTerminalOptions, ReattachResult, TerminalHandle, @@ -40,6 +41,10 @@ export class TauriAgentGateway implements AgentGateway { return invoke("list_agents", { projectId }); } + listLiveAgents(projectId: string): Promise { + return invoke("list_live_agents", { projectId }); + } + createAgent(projectId: string, input: CreateAgentInput): Promise { // The `create_agent` command takes a single `request` DTO; `projectId` must // live *inside* it (camelCase), not at the top level. @@ -91,6 +96,8 @@ export class TauriAgentGateway implements AgentGateway { // Resume id: the leaf's persisted conversation id, when any (T4b). The // backend resumes it; absent ⇒ a fresh cell that may get a new id. conversationId: options.conversationId ?? null, + // Hosting cell: lets the backend enforce one-live-session-per-agent. + nodeId: options.nodeId ?? null, }, onOutput: channel, }); diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 09a0e8e..293b2e1 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -40,6 +40,7 @@ import type { CreateSkillInput, CreateTemplateInput, Gateways, + LiveAgent, MemoryGateway, GitGateway, LayoutGateway, @@ -170,6 +171,13 @@ export class MockAgentGateway implements AgentGateway { private sessionSeq = 0; /** Live agent PTY sessions, kept across detach so reattach can find them. */ private sessions = new Map(); + /** + * The cell each live agent currently runs in (`agentId → nodeId`). Mirrors the + * backend "one live session per agent" invariant: launching an agent already + * present here in a *different* node is refused; closing its session clears it. + * Only populated when the caller supplies a `nodeId`. + */ + private liveByAgent = new Map(); private getAgents(projectId: string): Agent[] { if (!this.agents.has(projectId)) this.agents.set(projectId, []); @@ -184,6 +192,15 @@ export class MockAgentGateway implements AgentGateway { return structuredClone(this.getAgents(projectId)); } + async listLiveAgents(projectId: string): Promise { + // Only report agents that belong to this project (ids are disjoint across + // projects in the mock, mirroring the backend's per-project agent ids). + const known = new Set(this.getAgents(projectId).map((a) => a.id)); + return [...this.liveByAgent.entries()] + .filter(([agentId]) => known.has(agentId)) + .map(([agentId, nodeId]) => ({ agentId, nodeId })); + } + async createAgent(projectId: string, input: CreateAgentInput): Promise { const list = this.getAgents(projectId); const slug = slugify(input.name) || "agent"; @@ -320,17 +337,36 @@ export class MockAgentGateway implements AgentGateway { }; throw err; } + // Singleton invariant: refuse a launch when the agent is already live in a + // *different* cell (mirrors the backend `AGENT_ALREADY_RUNNING`). The same + // node is allowed (idempotent re-launch of the very same cell). + const liveNode = this.liveByAgent.get(agentId); + if (liveNode !== undefined && options.nodeId && liveNode !== options.nodeId) { + const err: GatewayError = { + code: "AGENT_ALREADY_RUNNING", + message: `agent ${agentId} is already running in cell ${liveNode}`, + }; + throw err; + } this.sessionSeq += 1; const sessionId = `mock-agent-session-${this.sessionSeq}`; const cwd = options.cwd; const enc = new TextEncoder(); const session = new MockPtySession(sessionId, onData); this.sessions.set(sessionId, session); + // Record liveness for `listLiveAgents` + the guard above (only when the + // caller pins a node). + if (options.nodeId) this.liveByAgent.set(agentId, options.nodeId); // Greet so something is visible immediately (mirrors MockTerminalGateway). queueMicrotask(() => session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)), ); - const handle = makeMockHandle(session, () => this.sessions.delete(sessionId)); + const handle = makeMockHandle(session, () => { + this.sessions.delete(sessionId); + if (this.liveByAgent.get(agentId) === options.nodeId) { + this.liveByAgent.delete(agentId); + } + }); // Simulate session assignment (T4b): a fresh cell (no conversation id) gets a // newly-minted id surfaced on the handle so the caller persists it; a cell // that already carries an id resumes it and nothing new is assigned. diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index bffb80f..4b45661 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -22,6 +22,7 @@ import type { Agent } from "@/domain"; import type { LayoutNode } from "@/domain"; import type { ConversationDetails, + LiveAgent, OpenTerminalOptions, TerminalHandle, } from "@/ports"; @@ -143,7 +144,7 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm // the wrong terminal. The root cell (no parent split) cannot be closed. const canClose = parentSplit !== null && parentSplit.siblings === 2; const siblingIndex = parentSplit ? (parentSplit.index === 0 ? 1 : 0) : 0; - const { agent: agentGateway } = useGateways(); + const { agent: agentGateway, system } = useGateways(); // Load the project's agents for the dropdown. const [agents, setAgents] = useState([]); @@ -156,9 +157,65 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm return () => { cancelled = true; }; }, [agentGateway, projectId]); + // Load the agents currently running (and where), so the dropdown can disable an + // agent already live in another cell — it cannot run in two cells at once. The + // backend refuses such a launch (`AGENT_ALREADY_RUNNING`); disabling it here is + // the matching UI affordance. + const [liveAgents, setLiveAgents] = useState([]); + const refreshLive = () => { + if (!agentGateway?.listLiveAgents) return; + agentGateway.listLiveAgents(projectId).then(setLiveAgents).catch(() => { + /* ignore — treat as none live */ + }); + }; + useEffect(() => { + if (!agentGateway?.listLiveAgents) return; + let cancelled = false; + agentGateway.listLiveAgents(projectId).then((list) => { + if (!cancelled) setLiveAgents(list); + }).catch(() => {/* ignore */}); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [agentGateway, projectId]); + + // Live refresh on agent lifecycle events: a launch/exit in any cell changes + // who is running where, so re-pull the live set to keep every dropdown in sync. + useEffect(() => { + if (!system) return; + let unsubscribe: (() => void) | undefined; + let cancelled = false; + void system + .onDomainEvent((event) => { + if (event.type === "agentLaunched" || event.type === "agentExited") { + refreshLive(); + } + }) + .then((un) => { + if (cancelled) un(); + else unsubscribe = un; + }); + return () => { + cancelled = true; + unsubscribe?.(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [system, agentGateway, projectId]); + // Build the terminal opener based on whether an agent is pinned. const agentId = agent ?? null; + /** + * Whether `candidate` is currently running in a cell *other than this one*. + * Such an agent cannot be launched here (one live session per agent), so the + * dropdown disables it and `onChange` rejects selecting it. + */ + const isLiveElsewhere = (candidate: string): boolean => + liveAgents.some((la) => la.agentId === candidate && la.nodeId !== id); + + // A transient notice shown when an action is blocked by the singleton + // invariant (selecting / launching an agent already live in another cell). + const [busyNotice, setBusyNotice] = useState(null); + // ── Resume popup state (T7) ─────────────────────────────────────────────── // When an agent cell carries a persisted conversation id and its PTY session // is dead, the opener is about to relaunch in Resume mode. We intercept that @@ -177,7 +234,7 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm const handle = await agentGateway!.launchAgent( projectId, agentId!, - { ...opts, conversationId: convId }, + { ...opts, conversationId: convId, nodeId: id }, onData, ); // First launch on a fresh cell mints a conversation id: persist it on the @@ -271,6 +328,14 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm value={agentId ?? ""} onChange={(e) => { const val = e.target.value; + // Reject pinning an agent that is already running in another cell. + // (The dropdown also disables such options, but guard the change in + // case it is set programmatically.) + if (val !== "" && isLiveElsewhere(val)) { + setBusyNotice("Cet agent tourne déjà dans une autre cellule."); + return; + } + setBusyNotice(null); void vm.setCellAgent(id, val === "" ? null : val); }} style={{ @@ -284,11 +349,17 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm }} > - {agents.map((a) => ( - - ))} + {agents.map((a) => { + // An agent already running in another cell cannot be pinned here. + // The agent pinned on THIS cell stays selectable (same node). + const elsewhere = isLiveElsewhere(a.id); + return ( + + ); + })}