feat(agent): robustesse routage ask — fix registre session + concurrence (R0+A0) — §14.3/§15
Stabilise le routage de `ask` avant le bind transport MCP (v5). Invariant « 1 agent = 1 employé » durci ; un agent traite un tour à la fois. - R0a garde LaunchAgent : lève AgentAlreadyRunning pour un lancement neuf ciblant un agent déjà vivant sur un autre node (PTY + structuré) ; rebind seulement même-node ou réattache explicite (conversation_id). Idem spawn_agent. - R0b list_live_agents agrège PTY + structuré (LiveSessions) + dédup. - R0c réconciliation des layouts.json à doublons à l'ouverture (host déterministe, idempotent) — corrige « une cellule reset au retour d'onglet ». - R0d UI : option agent désactivée si vivant ailleurs + « aller à la cellule », mapping AGENT_ALREADY_RUNNING. - A0 sérialisation FIFO des tours par agent_id dans ask_agent (verrou tokio par agent ; agents différents en parallèle ; timeout tour 300s, cap attente 600s). Cadrage : .ideai/briefs/orchestration-v5-transport-bind-cadrage.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -874,18 +874,40 @@ impl LaunchAgent {
|
||||
// (§17.4) : un agent est vivant s'il a une session PTY *ou* structurée.
|
||||
// On consulte d'abord le registre PTY (chemin historique), puis le
|
||||
// registre structuré (le cas échéant).
|
||||
//
|
||||
// R0a — discrimination réattache-de-vue vs second lancement (cadrage v5
|
||||
// §3.2, Trou A). Un agent est un **singleton** : une seule session vivante.
|
||||
// Quand l'agent est déjà vivant, on distingue (cf. [`Self::reattach_decision`]) :
|
||||
// - **réattache de vue** (rebind sans respawn) : le `node_id` demandé est
|
||||
// le node hôte vivant, OU la cellule porte une `conversation_id`
|
||||
// (réattache explicite — la cellule sait que l'agent tournait) ;
|
||||
// - **lancement background/idempotent** : ni node, ni conversation ⇒ no-op
|
||||
// (rend la session existante, pas de respawn) ;
|
||||
// - **second lancement neuf** : on vise un **autre** node, sans signal de
|
||||
// réattache ⇒ refus [`AppError::AgentAlreadyRunning`] (node hôte rapporté).
|
||||
if let Some(existing_id) = self.sessions.session_for_agent(&input.agent_id) {
|
||||
if let Some(node_id) = input.node_id {
|
||||
if let Some(session) = self.sessions.rebind_agent_node(&input.agent_id, node_id) {
|
||||
return Ok(LaunchAgentOutput {
|
||||
session,
|
||||
assigned_conversation_id: None,
|
||||
structured: None,
|
||||
let host_node = self.sessions.node_for_agent(&input.agent_id);
|
||||
match reattach_decision(input.node_id, host_node, input.conversation_id.as_deref()) {
|
||||
ReattachDecision::Rebind { node_id } => {
|
||||
if let Some(session) = self.sessions.rebind_agent_node(&input.agent_id, node_id)
|
||||
{
|
||||
return Ok(LaunchAgentOutput {
|
||||
session,
|
||||
assigned_conversation_id: None,
|
||||
structured: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
ReattachDecision::Idempotent => {}
|
||||
ReattachDecision::Refuse { node_id } => {
|
||||
return Err(AppError::AgentAlreadyRunning {
|
||||
agent_id: input.agent_id,
|
||||
node_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Idempotent — hand back the already-registered session, no respawn,
|
||||
// nothing new to persist.
|
||||
// Idempotent (or a rebind that found no entry to move) — 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,
|
||||
@ -894,18 +916,30 @@ impl LaunchAgent {
|
||||
});
|
||||
}
|
||||
}
|
||||
// Garde structurée (§17.4) : même sémantique côté registre IA. Rebind de la
|
||||
// cellule-vue si un node est demandé, sinon idempotence (pas de redémarrage).
|
||||
// Garde structurée (§17.4) : même sémantique côté registre IA. R0a appliqué de
|
||||
// façon identique — rebind de la cellule-vue pour une réattache légitime,
|
||||
// idempotence sans node/conversation, refus d'un second lancement neuf ailleurs.
|
||||
if let Some(structured) = &self.structured {
|
||||
if let Some(existing) = structured.session_for_agent(&input.agent_id) {
|
||||
// Cellule-vue : le node demandé, sinon le node hôte courant, sinon un
|
||||
// node neuf (rebind sans redémarrer le process, « la cellule est une
|
||||
// vue »).
|
||||
let node_id = input.node_id.or_else(|| structured.node_for_agent(&input.agent_id));
|
||||
if let Some(node_id) = node_id {
|
||||
let _ = structured.rebind_agent_node(&input.agent_id, node_id);
|
||||
}
|
||||
let node_id = node_id.unwrap_or_else(NodeId::new_random);
|
||||
let host_node = structured.node_for_agent(&input.agent_id);
|
||||
let node_id = match reattach_decision(
|
||||
input.node_id,
|
||||
host_node,
|
||||
input.conversation_id.as_deref(),
|
||||
) {
|
||||
ReattachDecision::Rebind { node_id } => {
|
||||
let _ = structured.rebind_agent_node(&input.agent_id, node_id);
|
||||
node_id
|
||||
}
|
||||
// Idempotent — garder le node hôte courant, sinon un node neuf.
|
||||
ReattachDecision::Idempotent => host_node.unwrap_or_else(NodeId::new_random),
|
||||
ReattachDecision::Refuse { node_id } => {
|
||||
return Err(AppError::AgentAlreadyRunning {
|
||||
agent_id: input.agent_id,
|
||||
node_id,
|
||||
});
|
||||
}
|
||||
};
|
||||
return Ok(LaunchAgentOutput {
|
||||
session: structured_snapshot(&existing, input.agent_id, node_id, size),
|
||||
assigned_conversation_id: None,
|
||||
@ -1339,6 +1373,80 @@ impl LaunchAgent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of the R0a discrimination between a legitimate **view reattach** and a
|
||||
/// **fresh second launch** of an already-live agent (cadrage v5 §3.2, Trou A).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ReattachDecision {
|
||||
/// Rebind the live session's view to `node_id` without respawning (the request
|
||||
/// targets the live host node, or carries an explicit reattach signal).
|
||||
Rebind { node_id: NodeId },
|
||||
/// Background / idempotent no-op: hand back the existing session unchanged
|
||||
/// (neither a node nor a conversation id was supplied).
|
||||
Idempotent,
|
||||
/// Refuse the launch — a genuine second launch of a singleton agent already
|
||||
/// live on `node_id` (the host node, reported in `AgentAlreadyRunning`).
|
||||
Refuse { node_id: NodeId },
|
||||
}
|
||||
|
||||
impl ReattachDecision {
|
||||
/// Decides reattach vs refuse for an already-live agent. See [`reattach_decision`].
|
||||
#[must_use]
|
||||
pub(crate) fn resolve(
|
||||
requested_node: Option<NodeId>,
|
||||
host_node: Option<NodeId>,
|
||||
conversation_id: Option<&str>,
|
||||
) -> Self {
|
||||
reattach_decision(requested_node, host_node, conversation_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decides whether a launch hitting an already-live agent is a legitimate view
|
||||
/// **reattach** (rebind), a **background/idempotent** no-op, or a **refused** second
|
||||
/// launch (cadrage v5 §3.2, Trou A). Pure (no I/O), unit-testable.
|
||||
///
|
||||
/// Signals (both already present in the launch flow):
|
||||
/// - `requested_node` — the hosting leaf the caller targets (`None` ⇒ no cell, e.g.
|
||||
/// a background launch);
|
||||
/// - `host_node` — the node currently hosting the agent's live session, if known;
|
||||
/// - `conversation_id` — the conversation id recorded on the hosting cell. Its
|
||||
/// presence means the **cell knows the agent was running** ⇒ it is a reattach of a
|
||||
/// view onto an in-progress session, not a brand-new launch.
|
||||
///
|
||||
/// Rules:
|
||||
/// - requested node **is** the host node ⇒ `Rebind` (re-open of the same cell);
|
||||
/// - a `conversation_id` is present ⇒ `Rebind` (explicit reattach, even on another
|
||||
/// node — the cell is a rebindable view, §17.6);
|
||||
/// - no node **and** no conversation ⇒ `Idempotent` (background/no-op relaunch);
|
||||
/// - a different node, no reattach signal ⇒ `Refuse` (genuine second launch).
|
||||
fn reattach_decision(
|
||||
requested_node: Option<NodeId>,
|
||||
host_node: Option<NodeId>,
|
||||
conversation_id: Option<&str>,
|
||||
) -> ReattachDecision {
|
||||
// Explicit reattach: the cell carries a conversation id, so it is re-binding a
|
||||
// view onto an already-running session. Rebind to the requested node, or keep the
|
||||
// current host node when none was supplied.
|
||||
if conversation_id.is_some() {
|
||||
if let Some(node_id) = requested_node.or(host_node) {
|
||||
return ReattachDecision::Rebind { node_id };
|
||||
}
|
||||
return ReattachDecision::Idempotent;
|
||||
}
|
||||
|
||||
match requested_node {
|
||||
// Same cell re-opened ⇒ idempotent rebind (no respawn).
|
||||
Some(node) if host_node == Some(node) => ReattachDecision::Rebind { node_id: node },
|
||||
// A different cell with no reattach signal ⇒ genuine second launch: refuse,
|
||||
// reporting the live host node (falling back to the requested node only if the
|
||||
// host is somehow unknown, so the error always carries a meaningful cell).
|
||||
Some(node) => ReattachDecision::Refuse {
|
||||
node_id: host_node.unwrap_or(node),
|
||||
},
|
||||
// No node and no reattach signal ⇒ background/idempotent no-op.
|
||||
None => ReattachDecision::Idempotent,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the IdeA MCP server declaration written into a CLI's `ConfigFile` MCP
|
||||
/// config (cadrage v3, Décision 3). Coherent **placeholder** for M1: it describes a
|
||||
/// stdio/socket server launched by the `idea` binary — the exact command and the
|
||||
|
||||
Reference in New Issue
Block a user