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 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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
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);
|
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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user