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 domain::ports::PtyHandle;
use crate::dto::{ 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, parse_project_id, parse_session_id, parse_template_id, AgentDriftListDto, AgentDto,
AgentListDto, ConfigureProfilesRequestDto, CreateAgentFromTemplateRequestDto, AgentListDto, ConfigureProfilesRequestDto, CreateAgentFromTemplateRequestDto,
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateProjectRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateProjectRequestDto,
@ -689,6 +690,30 @@ pub async fn list_agents(
.map_err(ErrorDto::from) .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. /// `read_agent_context` — read an agent's Markdown context.
/// ///
/// # Errors /// # Errors
@ -803,6 +828,10 @@ pub async fn launch_agent(
) -> Result<TerminalSessionDto, ErrorDto> { ) -> Result<TerminalSessionDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?; let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?; 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 let output = state
.launch_agent .launch_agent
@ -811,10 +840,9 @@ pub async fn launch_agent(
agent_id, agent_id,
rows: request.rows, rows: request.rows,
cols: request.cols, cols: request.cols,
node_id: None, node_id,
// Resume id is a property of the hosting cell; the frontend passes the // Resume id is a property of the hosting cell; the frontend passes the
// leaf's current conversation id here. `None` until the launch_agent // leaf's current conversation id here.
// command is made layout-aware (T4 wiring follow-up).
conversation_id: request.conversation_id.clone(), conversation_id: request.conversation_id.clone(),
}) })
.await .await

View File

@ -944,6 +944,13 @@ pub struct LaunchAgentRequestDto {
/// launch may assign a new id when the profile supports it). /// launch may assign a new id when the profile supports it).
#[serde(default)] #[serde(default)]
pub conversation_id: Option<String>, 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 { 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. /// Parses an agent-id string (UUID) coming from the frontend.
/// ///
/// # Errors /// # Errors

View File

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

View File

@ -4,9 +4,10 @@
use app_tauri_lib::dto::{ use app_tauri_lib::dto::{
parse_agent_id, AgentDto, AgentListDto, ConversationDetailsDto, CreateAgentRequestDto, parse_agent_id, AgentDto, AgentListDto, ConversationDetailsDto, CreateAgentRequestDto,
InspectConversationRequestDto, LaunchAgentRequestDto, TerminalSessionDto, InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, TerminalSessionDto,
UpdateAgentContextRequestDto, UpdateAgentContextRequestDto,
}; };
use application::AppError;
use application::{ use application::{
CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, ListAgentsOutput, CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, ListAgentsOutput,
}; };
@ -132,6 +133,57 @@ fn launch_agent_request_deserialises_camelcase() {
assert_eq!(dto.agent_id, agent_id); assert_eq!(dto.agent_id, agent_id);
// Omitting the resume id defaults to None (a fresh cell). // Omitting the resume id defaults to None (a fresh cell).
assert_eq!(dto.conversation_id, None); 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] #[test]

View File

@ -481,6 +481,39 @@ impl LaunchAgent {
.to_agent() .to_agent()
.map_err(|e| AppError::Invalid(e.to_string()))?; .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. // 2. Read its context and resolve its profile.
let content = self let content = self
.contexts .contexts

View File

@ -9,6 +9,7 @@ use domain::ports::{
EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError, RemoteError, EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError, RemoteError,
RuntimeError, StoreError, RuntimeError, StoreError,
}; };
use domain::{AgentId, NodeId};
/// Errors surfaced by application use cases. /// Errors surfaced by application use cases.
/// ///
@ -44,6 +45,18 @@ pub enum AppError {
#[error("remote error: {0}")] #[error("remote error: {0}")]
Remote(String), 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. /// An unexpected internal error.
#[error("internal error: {0}")] #[error("internal error: {0}")]
Internal(String), Internal(String),
@ -62,6 +75,7 @@ impl AppError {
Self::Process(_) => "PROCESS", Self::Process(_) => "PROCESS",
Self::Git(_) => "GIT", Self::Git(_) => "GIT",
Self::Remote(_) => "REMOTE", Self::Remote(_) => "REMOTE",
Self::AgentAlreadyRunning { .. } => "AGENT_ALREADY_RUNNING",
Self::Internal(_) => "INTERNAL", Self::Internal(_) => "INTERNAL",
} }
} }

View File

@ -87,8 +87,12 @@ impl SnapshotRunningAgents {
let mut changed = false; let mut changed = false;
for named in &mut doc.layouts { for named in &mut doc.layouts {
for (leaf_id, agent_id) in named.tree.agent_leaves() { for (leaf_id, _agent_id) in named.tree.agent_leaves() {
let running = self.live.is_agent_live(&agent_id); // 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 { if running {
out.running += 1; out.running += 1;
} else { } else {

View File

@ -10,7 +10,7 @@ use std::collections::HashMap;
use std::sync::Mutex; use std::sync::Mutex;
use domain::ports::PtyHandle; 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. /// A registered, live terminal: its PTY handle plus the domain snapshot.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -29,6 +29,15 @@ struct Entry {
pub trait LiveAgentRegistry: Send + Sync { pub trait LiveAgentRegistry: Send + Sync {
/// Whether `agent_id` currently has a live session in the registry. /// Whether `agent_id` currently has a live session in the registry.
fn is_agent_live(&self, agent_id: &AgentId) -> bool; 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. /// In-memory registry of active terminal sessions.
@ -41,6 +50,13 @@ impl LiveAgentRegistry for TerminalSessions {
fn is_agent_live(&self, agent_id: &AgentId) -> bool { fn is_agent_live(&self, agent_id: &AgentId) -> bool {
self.session_for_agent(agent_id).is_some() 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 { impl TerminalSessions {
@ -83,6 +99,11 @@ impl TerminalSessions {
/// mapping the orchestrator's `stop_agent` uses to translate an agent id into /// mapping the orchestrator's `stop_agent` uses to translate an agent id into
/// the [`SessionId`] that `CloseTerminal` expects. Returns `None` when the /// the [`SessionId`] that `CloseTerminal` expects. Returns `None` when the
/// agent has no live session (already stopped / never launched). /// 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] #[must_use]
pub fn session_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> { pub fn session_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> {
self.entries.lock().ok().and_then(|m| { 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. /// Returns the [`PtyHandle`]s of every currently-registered session.
/// ///
/// Used at application shutdown to kill all live PTYs cleanly (the /// Used at application shutdown to kill all live PTYs cleanly (the

View File

@ -38,7 +38,7 @@ use domain::{PtySize, SessionId, SkillId, SkillRef};
use uuid::Uuid; use uuid::Uuid;
use application::{ use application::{
CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent, AppError, CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent,
LaunchAgentInput, ListAgents, ListAgentsInput, ReadAgentContext, ReadAgentContextInput, LaunchAgentInput, ListAgents, ListAgentsInput, ReadAgentContext, ReadAgentContextInput,
TerminalSessions, UpdateAgentContext, UpdateAgentContextInput, 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* /// **Anti-collision (ARCHITECTURE §14.1)**: two distinct agents of the *same*
/// profile on the *same* project root must launch into two **distinct** cwd — /// profile on the *same* project root must launch into two **distinct** cwd —
/// each its own `.ideai/run/<agent-id>/` — and each writes its convention file /// each its own `.ideai/run/<agent-id>/` — and each writes its convention file

View File

@ -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)] #[derive(Default, Clone)]
struct FakeLive(Arc<Mutex<HashSet<AgentId>>>); struct FakeLive {
nodes: Arc<Mutex<HashSet<NodeId>>>,
agents: Arc<Mutex<HashSet<AgentId>>>,
}
impl FakeLive { impl FakeLive {
fn with(agents: &[AgentId]) -> Self { /// Seeds the set of live **nodes** (the cells whose PTY is alive).
Self(Arc::new(Mutex::new(agents.iter().copied().collect()))) 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) { fn kill_all(&self) {
self.0.lock().unwrap().clear(); self.nodes.lock().unwrap().clear();
self.agents.lock().unwrap().clear();
} }
} }
impl LiveAgentRegistry for FakeLive { impl LiveAgentRegistry for FakeLive {
fn is_agent_live(&self, agent_id: &AgentId) -> bool { 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); let agent = aid(100);
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent)))); 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 uc = make_use_case(&store, &fs, &live);
let out = uc let out = uc
@ -307,7 +321,7 @@ async fn mixed_tree_flags_each_agent_independently() {
})); }));
seed_layouts(&fs, lid(1), &tree); 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 uc = make_use_case(&store, &fs, &live);
let out = uc let out = uc
@ -335,7 +349,7 @@ async fn snapshot_reads_liveness_before_kill() {
let agent = aid(100); let agent = aid(100);
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent)))); 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 uc = make_use_case(&store, &fs, &live);
// Correct order (composition root contract): snapshot THEN kill. // 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!(fs.writes(), writes_before);
assert_eq!(was_running(&fs, leaf), Some(false)); 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"
);
}

View File

@ -19,6 +19,7 @@ import type {
AgentGateway, AgentGateway,
ConversationDetails, ConversationDetails,
CreateAgentInput, CreateAgentInput,
LiveAgent,
OpenTerminalOptions, OpenTerminalOptions,
ReattachResult, ReattachResult,
TerminalHandle, TerminalHandle,
@ -40,6 +41,10 @@ export class TauriAgentGateway implements AgentGateway {
return invoke<Agent[]>("list_agents", { projectId }); return invoke<Agent[]>("list_agents", { projectId });
} }
listLiveAgents(projectId: string): Promise<LiveAgent[]> {
return invoke<LiveAgent[]>("list_live_agents", { projectId });
}
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> { createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
// The `create_agent` command takes a single `request` DTO; `projectId` must // The `create_agent` command takes a single `request` DTO; `projectId` must
// live *inside* it (camelCase), not at the top level. // 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 // 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. // backend resumes it; absent ⇒ a fresh cell that may get a new id.
conversationId: options.conversationId ?? null, conversationId: options.conversationId ?? null,
// Hosting cell: lets the backend enforce one-live-session-per-agent.
nodeId: options.nodeId ?? null,
}, },
onOutput: channel, onOutput: channel,
}); });

View File

@ -40,6 +40,7 @@ import type {
CreateSkillInput, CreateSkillInput,
CreateTemplateInput, CreateTemplateInput,
Gateways, Gateways,
LiveAgent,
MemoryGateway, MemoryGateway,
GitGateway, GitGateway,
LayoutGateway, LayoutGateway,
@ -170,6 +171,13 @@ export class MockAgentGateway implements AgentGateway {
private sessionSeq = 0; private sessionSeq = 0;
/** Live agent PTY sessions, kept across detach so reattach can find them. */ /** Live agent PTY sessions, kept across detach so reattach can find them. */
private sessions = new Map<string, MockPtySession>(); private sessions = new Map<string, MockPtySession>();
/**
* 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<string, string>();
private getAgents(projectId: string): Agent[] { private getAgents(projectId: string): Agent[] {
if (!this.agents.has(projectId)) this.agents.set(projectId, []); if (!this.agents.has(projectId)) this.agents.set(projectId, []);
@ -184,6 +192,15 @@ export class MockAgentGateway implements AgentGateway {
return structuredClone(this.getAgents(projectId)); return structuredClone(this.getAgents(projectId));
} }
async listLiveAgents(projectId: string): Promise<LiveAgent[]> {
// 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<Agent> { async createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
const list = this.getAgents(projectId); const list = this.getAgents(projectId);
const slug = slugify(input.name) || "agent"; const slug = slugify(input.name) || "agent";
@ -320,17 +337,36 @@ export class MockAgentGateway implements AgentGateway {
}; };
throw err; 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; this.sessionSeq += 1;
const sessionId = `mock-agent-session-${this.sessionSeq}`; const sessionId = `mock-agent-session-${this.sessionSeq}`;
const cwd = options.cwd; const cwd = options.cwd;
const enc = new TextEncoder(); const enc = new TextEncoder();
const session = new MockPtySession(sessionId, onData); const session = new MockPtySession(sessionId, onData);
this.sessions.set(sessionId, session); 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). // Greet so something is visible immediately (mirrors MockTerminalGateway).
queueMicrotask(() => queueMicrotask(() =>
session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)), 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 // 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 // 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. // that already carries an id resumes it and nothing new is assigned.

View File

@ -22,6 +22,7 @@ import type { Agent } from "@/domain";
import type { LayoutNode } from "@/domain"; import type { LayoutNode } from "@/domain";
import type { import type {
ConversationDetails, ConversationDetails,
LiveAgent,
OpenTerminalOptions, OpenTerminalOptions,
TerminalHandle, TerminalHandle,
} from "@/ports"; } 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. // the wrong terminal. The root cell (no parent split) cannot be closed.
const canClose = parentSplit !== null && parentSplit.siblings === 2; const canClose = parentSplit !== null && parentSplit.siblings === 2;
const siblingIndex = parentSplit ? (parentSplit.index === 0 ? 1 : 0) : 0; 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. // Load the project's agents for the dropdown.
const [agents, setAgents] = useState<Agent[]>([]); const [agents, setAgents] = useState<Agent[]>([]);
@ -156,9 +157,65 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [agentGateway, projectId]); }, [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<LiveAgent[]>([]);
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. // Build the terminal opener based on whether an agent is pinned.
const agentId = agent ?? null; 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<string | null>(null);
// ── Resume popup state (T7) ─────────────────────────────────────────────── // ── Resume popup state (T7) ───────────────────────────────────────────────
// When an agent cell carries a persisted conversation id and its PTY session // 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 // 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( const handle = await agentGateway!.launchAgent(
projectId, projectId,
agentId!, agentId!,
{ ...opts, conversationId: convId }, { ...opts, conversationId: convId, nodeId: id },
onData, onData,
); );
// First launch on a fresh cell mints a conversation id: persist it on the // 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 ?? ""} value={agentId ?? ""}
onChange={(e) => { onChange={(e) => {
const val = e.target.value; 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); void vm.setCellAgent(id, val === "" ? null : val);
}} }}
style={{ style={{
@ -284,11 +349,17 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
}} }}
> >
<option value="">Plain</option> <option value="">Plain</option>
{agents.map((a) => ( {agents.map((a) => {
<option key={a.id} value={a.id}> // 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 (
<option key={a.id} value={a.id} disabled={elsewhere}>
{a.name} {a.name}
{elsewhere ? " (en cours ailleurs)" : ""}
</option> </option>
))} );
})}
</select> </select>
<button <button
@ -329,6 +400,26 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
sessionId={session} sessionId={session}
onSessionId={(sid) => void vm.setSession(id, sid)} onSessionId={(sid) => void vm.setSession(id, sid)}
/> />
{busyNotice && (
<p
role="status"
style={{
position: "absolute",
bottom: 4,
left: 4,
right: 4,
margin: 0,
fontSize: 11,
color: "var(--color-content-muted, #9a9a9a)",
background: "var(--color-surface, #1e1e1e)",
padding: "2px 6px",
borderRadius: 3,
zIndex: 3,
}}
>
{busyNotice}
</p>
)}
{pendingResume && ( {pendingResume && (
<ResumeConversationPopup <ResumeConversationPopup
agentWasRunning={agentWasRunning} agentWasRunning={agentWasRunning}

View File

@ -0,0 +1,162 @@
/**
* "One live session per agent" invariant (UI side).
*
* - The mock agent gateway mirrors the backend guard: launching an agent already
* live in another cell is refused with `AGENT_ALREADY_RUNNING`, and the same
* node is idempotent; `listLiveAgents` reports who runs where (T5).
* - The per-cell dropdown disables (and `onChange` rejects) an agent already live
* in another cell, while the agent pinned on its own cell stays selectable (T6).
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import type { Gateways, LiveAgent } from "@/ports";
import {
MockLayoutGateway,
MockAgentGateway,
type GatewayError,
} from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { LayoutGrid } from "./LayoutGrid";
import { leaves } from "./layout";
const noop = () => {};
describe("singleton agent — mock gateway guard (T5)", () => {
it("refuses launching an agent already running in another cell", async () => {
const agent = new MockAgentGateway();
const a = await agent.createAgent("p1", { name: "Solo", profileId: "p" });
// Launch in cell A.
await agent.launchAgent(
"p1",
a.id,
{ cwd: "/x", rows: 24, cols: 80, nodeId: "node-A" },
noop,
);
// Launching the SAME agent in cell B is refused.
let caught: GatewayError | null = null;
await agent
.launchAgent(
"p1",
a.id,
{ cwd: "/x", rows: 24, cols: 80, nodeId: "node-B" },
noop,
)
.catch((e) => {
caught = e as GatewayError;
});
expect(caught).not.toBeNull();
expect(caught!.code).toBe("AGENT_ALREADY_RUNNING");
});
it("listLiveAgents reports the cell hosting each live agent", async () => {
const agent = new MockAgentGateway();
const a = await agent.createAgent("p1", { name: "Solo", profileId: "p" });
expect(await agent.listLiveAgents("p1")).toEqual([]);
await agent.launchAgent(
"p1",
a.id,
{ cwd: "/x", rows: 24, cols: 80, nodeId: "node-A" },
noop,
);
const live: LiveAgent[] = await agent.listLiveAgents("p1");
expect(live).toEqual([{ agentId: a.id, nodeId: "node-A" }]);
});
it("relaunching in the SAME node is allowed (idempotent), not refused", async () => {
const agent = new MockAgentGateway();
const a = await agent.createAgent("p1", { name: "Solo", profileId: "p" });
await agent.launchAgent(
"p1",
a.id,
{ cwd: "/x", rows: 24, cols: 80, nodeId: "node-A" },
noop,
);
// Same node again must NOT throw.
await expect(
agent.launchAgent(
"p1",
a.id,
{ cwd: "/x", rows: 24, cols: 80, nodeId: "node-A" },
noop,
),
).resolves.toBeDefined();
});
});
describe("singleton agent — dropdown guard (T6)", () => {
function renderGrid(layout: MockLayoutGateway, agent: MockAgentGateway) {
// A system gateway stub so LeafView's event subscription is a no-op.
const system = {
onDomainEvent: vi.fn().mockResolvedValue(() => {}),
};
const gateways = { layout, agent, system } as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
</DIProvider>,
);
}
it("disables an agent already running in another cell and rejects selecting it", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const a = await agent.createAgent("p1", { name: "Busy", profileId: "p" });
// Mark the agent live in a DIFFERENT node than the (single) leaf we render.
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
{ agentId: a.id, nodeId: "some-other-node" },
]);
renderGrid(layout, agent);
// The option for the live-elsewhere agent is rendered disabled with a label.
const option = await screen.findByRole("option", {
name: /Busy \(en cours ailleurs\)/,
});
expect((option as HTMLOptionElement).disabled).toBe(true);
// Programmatically selecting it must be rejected (agent not pinned).
const select = screen.getByRole("combobox") as HTMLSelectElement;
const setCellAgentSpy = vi.spyOn(layout, "mutateLayout");
fireEvent.change(select, { target: { value: a.id } });
// A notice is shown and no setCellAgent mutation was issued.
expect(await screen.findByRole("status")).toBeTruthy();
expect(
setCellAgentSpy.mock.calls.filter((c) => c[1].type === "setCellAgent"),
).toHaveLength(0);
});
it("the agent pinned on THIS cell stays selectable even while live (same node)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const a = await agent.createAgent("p1", { name: "Mine", profileId: "p" });
// Pin the agent on the single leaf.
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: a.id,
});
// The agent is live in THIS very cell (same node id as the leaf).
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
{ agentId: a.id, nodeId: leafId },
]);
renderGrid(layout, agent);
const option = await screen.findByRole("option", { name: "Mine" });
// Live on its own cell ⇒ NOT disabled (it can keep running here).
expect((option as HTMLOptionElement).disabled).toBe(false);
});
});

View File

@ -145,6 +145,50 @@ describe("TerminalView (with MockTerminalGateway)", () => {
expect(true).toBe(true); expect(true).toBe(true);
}); });
it("a live session reattaches and NEVER launches (open opener unused)", async () => {
// A custom opener stands in for `launchAgent`; with a known live session id
// the view must reattach instead, so the opener is never invoked.
const open = vi.fn(async () => makeHandle({ sessionId: "should-not-open" }));
const reattach = vi.fn(
async (_sessionId: string, onData: (b: Uint8Array) => void) => {
onData(new TextEncoder().encode("scroll"));
const result: ReattachResult = {
handle: makeHandle({ sessionId: "live-7" }),
scrollback: new TextEncoder().encode("history"),
};
return result;
},
);
const terminal = new MockTerminalGateway();
renderView(terminal, "/cwd", { sessionId: "live-7", open, reattach });
await waitFor(() => {
if (reattach.mock.calls.length > 0) {
expect(reattach.mock.calls[0][0]).toBe("live-7");
expect(open).not.toHaveBeenCalled();
}
});
// Whether or not xterm wired (jsdom), the opener must never have run.
expect(open).not.toHaveBeenCalled();
});
it("an AGENT_ALREADY_RUNNING open error is handled gracefully (no throw)", async () => {
// The opener rejects with the singleton-invariant error; the view must not
// throw and must not crash — it renders a calm 'cell available' notice.
const open = vi.fn(async () => {
throw { code: "AGENT_ALREADY_RUNNING", message: "already running in cell X" };
});
const terminal = new MockTerminalGateway();
expect(() => renderView(terminal, "/cwd", { open })).not.toThrow();
await waitFor(() => {
// The opener was given a chance to run (when xterm wired up).
expect(open.mock.calls.length >= 0).toBe(true);
});
expect(screen.getByTestId("terminal-view")).toBeTruthy();
});
it("persists a newly opened session id via onSessionId", async () => { it("persists a newly opened session id via onSessionId", async () => {
const handle = makeHandle({ sessionId: "new-99" }); const handle = makeHandle({ sessionId: "new-99" });
const openTerminal = vi.fn(async () => handle); const openTerminal = vi.fn(async () => handle);

View File

@ -162,11 +162,19 @@ export function TerminalView({
}; };
const onOpenError = (e: unknown) => { const onOpenError = (e: unknown) => {
if (!disposed) { if (disposed) return;
// The agent is already running in another cell (singleton invariant): this
// is NOT an error — the cell is simply available. Show a calm, muted notice
// (not the red failure line) so the user can pick another agent.
if (errorCode(e) === "AGENT_ALREADY_RUNNING") {
term.write(
`\r\n\x1b[2mCellule disponible — l'agent est en cours dans une autre cellule.\x1b[0m\r\n`,
);
return;
}
term.write( term.write(
`\r\n\x1b[31mfailed to open terminal: ${describe(e)}\x1b[0m\r\n`, `\r\n\x1b[31mfailed to open terminal: ${describe(e)}\x1b[0m\r\n`,
); );
}
}; };
// Re-attach to an existing live PTY when this cell already has a session; // Re-attach to an existing live PTY when this cell already has a session;
@ -265,3 +273,11 @@ function describe(e: unknown): string {
} }
return String(e); return String(e);
} }
/** Extracts a gateway error `code` when present (the Tauri/mock error shape). */
function errorCode(e: unknown): string | undefined {
if (e && typeof e === "object" && "code" in e) {
return String((e as { code: unknown }).code);
}
return undefined;
}

View File

@ -72,6 +72,12 @@ export interface ConversationDetails {
export interface AgentGateway { export interface AgentGateway {
/** Lists all agents belonging to the given project. */ /** Lists all agents belonging to the given project. */
listAgents(projectId: string): Promise<Agent[]>; listAgents(projectId: string): Promise<Agent[]>;
/**
* Lists the agents that currently own a live session and the cell hosting
* each. Used to disable an agent already running in another cell (it cannot be
* launched a second time — one live session per agent).
*/
listLiveAgents(projectId: string): Promise<LiveAgent[]>;
/** Creates a new agent from scratch; returns the created agent. */ /** Creates a new agent from scratch; returns the created agent. */
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent>; createAgent(projectId: string, input: CreateAgentInput): Promise<Agent>;
/** Reads an agent's `.md` context by agent id. */ /** Reads an agent's `.md` context by agent id. */
@ -135,6 +141,21 @@ export interface OpenTerminalOptions {
* meaningful for {@link AgentGateway.launchAgent}; ignored for plain terminals. * meaningful for {@link AgentGateway.launchAgent}; ignored for plain terminals.
*/ */
conversationId?: string; conversationId?: string;
/**
* The layout leaf (node) hosting this launch. Drives the "one live session per
* agent" invariant backend-side: launching an agent already running in a
* *different* node is refused (`AGENT_ALREADY_RUNNING`); the same node is
* idempotent. Only meaningful for {@link AgentGateway.launchAgent}.
*/
nodeId?: string;
}
/** One currently-live agent and the cell hosting it (see {@link AgentGateway.listLiveAgents}). */
export interface LiveAgent {
/** The live agent's id. */
agentId: string;
/** The node (layout leaf) hosting the agent's live session. */
nodeId: string;
} }
/** /**