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:
2026-06-10 17:34:57 +02:00
parent 37e72747d3
commit 6ca519b815
21 changed files with 2402 additions and 85 deletions

View File

@ -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

View File

@ -14,6 +14,7 @@ mod structured;
mod usecases;
pub(crate) use lifecycle::unique_md_path;
pub(crate) use lifecycle::ReattachDecision;
pub use structured::send_blocking;