fix(agents): enforcer l'invariant « 1 session vivante par agent » (singleton)
Un agent ne peut tourner que dans une seule cellule à la fois. La garde dans LaunchAgent refuse le spawn si l'agent est déjà vivant dans un autre node (AGENT_ALREADY_RUNNING) ; idempotent sur le même node ; le chemin resume (agent mort) reste inchangé. Le node_id est désormais plombé jusqu'au use case. Corrige le reset asymétrique d'une cellule au changement d'onglet : deux leaves partageant le même agent id rendaient session_for_agent/is_agent_live/stop_agent ambigus (cible arbitraire). Le churn reset/reattach déclenchait aussi les accents mélangés (FIFO intact, non touché). - snapshot agentWasRunning calculé par node (is_node_live) et non par agent - commande list_live_agents + live_agents()/node_for_agent()/is_node_live() - UI : dropdown grise les agents déjà placés ailleurs ; 2e cellule en doublon affiche « disponible » au lieu d'une relance fantôme Tests : cargo test (application + app-tauri) vert ; tsc + vitest vert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -23,7 +23,8 @@ use application::{
|
||||
use domain::ports::PtyHandle;
|
||||
|
||||
use crate::dto::{
|
||||
parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_profile_id,
|
||||
parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_node_id,
|
||||
parse_profile_id, LiveAgentListDto,
|
||||
parse_project_id, parse_session_id, parse_template_id, AgentDriftListDto, AgentDto,
|
||||
AgentListDto, ConfigureProfilesRequestDto, CreateAgentFromTemplateRequestDto,
|
||||
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateProjectRequestDto,
|
||||
@ -689,6 +690,30 @@ pub async fn list_agents(
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `list_live_agents` — list the agents that currently own a live PTY session
|
||||
/// and the cell hosting each, so the UI can disable an agent already running in
|
||||
/// another cell (the "one live session per agent" invariant).
|
||||
///
|
||||
/// `project_id` is accepted for API symmetry and future per-project scoping; the
|
||||
/// session registry is process-wide today, so the full live set is returned (a
|
||||
/// project's agent ids are disjoint from other projects' by construction, so the
|
||||
/// frontend can filter by the agents it knows).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] (`INVALID` for a malformed project id).
|
||||
#[tauri::command]
|
||||
pub fn list_live_agents(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<LiveAgentListDto, ErrorDto> {
|
||||
// Validate the id shape for a consistent contract, even though the registry
|
||||
// is not project-scoped yet.
|
||||
let _ = parse_project_id(&project_id)?;
|
||||
Ok(LiveAgentListDto::from_pairs(
|
||||
state.terminal_sessions.live_agents(),
|
||||
))
|
||||
}
|
||||
|
||||
/// `read_agent_context` — read an agent's Markdown context.
|
||||
///
|
||||
/// # Errors
|
||||
@ -803,6 +828,10 @@ pub async fn launch_agent(
|
||||
) -> Result<TerminalSessionDto, ErrorDto> {
|
||||
let project = resolve_project(&request.project_id, &state).await?;
|
||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||
// The hosting cell drives the singleton-invariant guard. Parse it when the
|
||||
// frontend supplies one; absent ⇒ `None` (a fresh node is minted, and an
|
||||
// already-live agent is refused).
|
||||
let node_id = request.node_id.as_deref().map(parse_node_id).transpose()?;
|
||||
|
||||
let output = state
|
||||
.launch_agent
|
||||
@ -811,10 +840,9 @@ pub async fn launch_agent(
|
||||
agent_id,
|
||||
rows: request.rows,
|
||||
cols: request.cols,
|
||||
node_id: None,
|
||||
node_id,
|
||||
// Resume id is a property of the hosting cell; the frontend passes the
|
||||
// leaf's current conversation id here. `None` until the launch_agent
|
||||
// command is made layout-aware (T4 wiring follow-up).
|
||||
// leaf's current conversation id here.
|
||||
conversation_id: request.conversation_id.clone(),
|
||||
})
|
||||
.await
|
||||
|
||||
@ -944,6 +944,13 @@ pub struct LaunchAgentRequestDto {
|
||||
/// launch may assign a new id when the profile supports it).
|
||||
#[serde(default)]
|
||||
pub conversation_id: Option<String>,
|
||||
/// The layout leaf (node) hosting this launch. Enforces the "one live session
|
||||
/// per agent" invariant: a launch into a node different from where the agent
|
||||
/// is already running is refused (`AGENT_ALREADY_RUNNING`); the same node is
|
||||
/// idempotent. Absent ⇒ a fresh node is minted (and any already-live agent is
|
||||
/// refused).
|
||||
#[serde(default)]
|
||||
pub node_id: Option<String>,
|
||||
}
|
||||
|
||||
impl From<LaunchAgentOutput> for TerminalSessionDto {
|
||||
@ -997,6 +1004,40 @@ impl From<InspectConversationOutput> for ConversationDetailsDto {
|
||||
}
|
||||
}
|
||||
|
||||
/// One currently-live agent and the cell hosting it, for `list_live_agents`.
|
||||
///
|
||||
/// Lets the UI disable an agent already running in another cell (it cannot be
|
||||
/// launched a second time — the "one live session per agent" invariant).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LiveAgentDto {
|
||||
/// The live agent's id (UUID string).
|
||||
pub agent_id: String,
|
||||
/// The hosting layout leaf's node id (UUID string).
|
||||
pub node_id: String,
|
||||
}
|
||||
|
||||
/// Response DTO for `list_live_agents` (transparent array on the wire).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct LiveAgentListDto(pub Vec<LiveAgentDto>);
|
||||
|
||||
impl LiveAgentListDto {
|
||||
/// Builds the wire list from the registry's `(AgentId, NodeId)` pairs.
|
||||
#[must_use]
|
||||
pub fn from_pairs(pairs: Vec<(AgentId, NodeId)>) -> Self {
|
||||
Self(
|
||||
pairs
|
||||
.into_iter()
|
||||
.map(|(agent_id, node_id)| LiveAgentDto {
|
||||
agent_id: agent_id.to_string(),
|
||||
node_id: node_id.to_string(),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an agent-id string (UUID) coming from the frontend.
|
||||
///
|
||||
/// # Errors
|
||||
|
||||
@ -113,6 +113,7 @@ pub fn run() {
|
||||
commands::configure_profiles,
|
||||
commands::create_agent,
|
||||
commands::list_agents,
|
||||
commands::list_live_agents,
|
||||
commands::read_agent_context,
|
||||
commands::update_agent_context,
|
||||
commands::delete_agent,
|
||||
|
||||
@ -4,9 +4,10 @@
|
||||
|
||||
use app_tauri_lib::dto::{
|
||||
parse_agent_id, AgentDto, AgentListDto, ConversationDetailsDto, CreateAgentRequestDto,
|
||||
InspectConversationRequestDto, LaunchAgentRequestDto, TerminalSessionDto,
|
||||
InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, TerminalSessionDto,
|
||||
UpdateAgentContextRequestDto,
|
||||
};
|
||||
use application::AppError;
|
||||
use application::{
|
||||
CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, ListAgentsOutput,
|
||||
};
|
||||
@ -132,6 +133,57 @@ fn launch_agent_request_deserialises_camelcase() {
|
||||
assert_eq!(dto.agent_id, agent_id);
|
||||
// Omitting the resume id defaults to None (a fresh cell).
|
||||
assert_eq!(dto.conversation_id, None);
|
||||
// Omitting the node id defaults to None (a fresh node is minted backend-side).
|
||||
assert_eq!(dto.node_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_agent_request_carries_node_id() {
|
||||
let node_id = Uuid::from_u128(33).to_string();
|
||||
let raw = json!({
|
||||
"projectId": Uuid::from_u128(1).to_string(),
|
||||
"agentId": Uuid::from_u128(2).to_string(),
|
||||
"rows": 24,
|
||||
"cols": 80,
|
||||
"nodeId": node_id
|
||||
});
|
||||
let dto: LaunchAgentRequestDto = serde_json::from_value(raw).unwrap();
|
||||
// The hosting cell reaches the backend so the singleton guard can enforce
|
||||
// "one live session per agent".
|
||||
assert_eq!(dto.node_id.as_deref(), Some(node_id.as_str()));
|
||||
// No snake_case leak on the wire.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AGENT_ALREADY_RUNNING error code + LiveAgentListDto (T2/T3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn agent_already_running_error_code_is_stable() {
|
||||
let err = AppError::AgentAlreadyRunning {
|
||||
agent_id: AgentId::from_uuid(Uuid::from_u128(2)),
|
||||
node_id: NodeId::from_uuid(Uuid::from_u128(3)),
|
||||
};
|
||||
let dto = app_tauri_lib::dto::ErrorDto::from(err);
|
||||
// Option A: a stable code + a text message (the node is NOT enriched into the
|
||||
// ErrorDto — the frontend branches on the code alone).
|
||||
assert_eq!(dto.code, "AGENT_ALREADY_RUNNING");
|
||||
assert!(dto.message.contains("already running"), "message: {}", dto.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_agent_list_dto_serialises_camelcase_array() {
|
||||
let agent_a = AgentId::from_uuid(Uuid::from_u128(11));
|
||||
let node_a = NodeId::from_uuid(Uuid::from_u128(21));
|
||||
let dto = LiveAgentListDto::from_pairs(vec![(agent_a, node_a)]);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
let arr = v.as_array().expect("transparent array");
|
||||
assert_eq!(arr.len(), 1);
|
||||
assert_eq!(arr[0]["agentId"], agent_a.to_string());
|
||||
assert_eq!(arr[0]["nodeId"], node_a.to_string());
|
||||
// No snake_case leak.
|
||||
assert!(arr[0].get("agent_id").is_none());
|
||||
assert!(arr[0].get("node_id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<SessionId> {
|
||||
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.
|
||||
///
|
||||
/// Used at application shutdown to kill all live PTYs cleanly (the
|
||||
|
||||
@ -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/<agent-id>/` — and each writes its convention 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)]
|
||||
struct FakeLive(Arc<Mutex<HashSet<AgentId>>>);
|
||||
struct FakeLive {
|
||||
nodes: Arc<Mutex<HashSet<NodeId>>>,
|
||||
agents: Arc<Mutex<HashSet<AgentId>>>,
|
||||
}
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ import type {
|
||||
AgentGateway,
|
||||
ConversationDetails,
|
||||
CreateAgentInput,
|
||||
LiveAgent,
|
||||
OpenTerminalOptions,
|
||||
ReattachResult,
|
||||
TerminalHandle,
|
||||
@ -40,6 +41,10 @@ export class TauriAgentGateway implements AgentGateway {
|
||||
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> {
|
||||
// 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,
|
||||
});
|
||||
|
||||
@ -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<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[] {
|
||||
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<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> {
|
||||
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.
|
||||
|
||||
@ -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<Agent[]>([]);
|
||||
@ -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<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.
|
||||
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) ───────────────────────────────────────────────
|
||||
// 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
|
||||
}}
|
||||
>
|
||||
<option value="">Plain</option>
|
||||
{agents.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{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 (
|
||||
<option key={a.id} value={a.id} disabled={elsewhere}>
|
||||
{a.name}
|
||||
{elsewhere ? " (en cours ailleurs)" : ""}
|
||||
</option>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
|
||||
<button
|
||||
@ -329,6 +400,26 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
sessionId={session}
|
||||
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 && (
|
||||
<ResumeConversationPopup
|
||||
agentWasRunning={agentWasRunning}
|
||||
|
||||
162
frontend/src/features/layout/singletonAgent.test.tsx
Normal file
162
frontend/src/features/layout/singletonAgent.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -145,6 +145,50 @@ describe("TerminalView (with MockTerminalGateway)", () => {
|
||||
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 () => {
|
||||
const handle = makeHandle({ sessionId: "new-99" });
|
||||
const openTerminal = vi.fn(async () => handle);
|
||||
|
||||
@ -162,11 +162,19 @@ export function TerminalView({
|
||||
};
|
||||
|
||||
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(
|
||||
`\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;
|
||||
@ -265,3 +273,11 @@ function describe(e: unknown): string {
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@ -72,6 +72,12 @@ export interface ConversationDetails {
|
||||
export interface AgentGateway {
|
||||
/** Lists all agents belonging to the given project. */
|
||||
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. */
|
||||
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent>;
|
||||
/** 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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user