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

@ -620,6 +620,87 @@ impl LayoutTree {
out
}
/// Réconcilie les feuilles en doublon sur un même agent : à la réouverture
/// d'un projet, un `layouts.json` persisté peut contenir **plusieurs**
/// feuilles portant le **même** `agent` id (constaté). L'invariant produit
/// est **« 1 session vivante par agent »** : une seule feuille doit rester
/// « hôte » (potentiellement vivante / reprenable), les autres deviennent des
/// **vues mortes** — elles gardent leur agent (la cellule reste), mais ne sont
/// plus considérées « était en cours » : leur `agent_was_running` repasse à
/// `false` et leur `conversation_id` est retiré, de sorte qu'aucune reprise ni
/// relance ne les vise.
///
/// ## Règle déterministe de choix de l'hôte
///
/// Parmi les N feuilles d'un même agent, **dans l'ordre de parcours
/// déterministe de l'arbre** (le même pré-ordre que [`Self::agent_leaves`]),
/// l'hôte est la **première feuille porteuse d'un signal de reprise**
/// (`conversation_id.is_some()` **ou** `agent_was_running`) ; à défaut de tout
/// signal, l'hôte est simplement la **première** feuille rencontrée. On garde
/// ainsi sur l'unique hôte l'état de reprise le plus pertinent, et le choix est
/// totalement déterministe (l'ordre de parcours est stable).
///
/// ## Idempotence
///
/// Un arbre **sans doublon** est renvoyé **inchangé** (`self.clone()` à
/// l'identique). Un arbre **déjà réconcilié** (≤ 1 feuille par agent porte un
/// signal de reprise) l'est aussi : la 2ᵉ passe ne dé-flagge rien. La fonction
/// est donc un point fixe — utile pour garantir le no-op à la 2ᵉ ouverture.
///
/// Pure : renvoie un nouvel arbre (non revalidé — la réconciliation ne touche
/// ni la structure ni les sessions, seuls des champs scalaires de feuille).
#[must_use]
pub fn reconcile_duplicate_agents(&self) -> Self {
use std::collections::{HashMap, HashSet};
// Première passe (lecture seule) : pour chaque agent, déterminer le node
// hôte selon la règle déterministe ci-dessus.
let mut host_of: HashMap<AgentId, NodeId> = HashMap::new();
let mut has_signal: HashSet<AgentId> = HashSet::new();
for (node_id, agent_id) in self.agent_leaves() {
// `leaf` ne peut pas être `None` ici : le node vient de l'arbre.
let signal = self
.leaf(node_id)
.is_some_and(|l| l.conversation_id.is_some() || l.agent_was_running);
match host_of.get(&agent_id) {
// Pas encore d'hôte : cette feuille le devient (provisoirement).
None => {
host_of.insert(agent_id, node_id);
if signal {
has_signal.insert(agent_id);
}
}
// Un hôte sans signal est supplanté par la première feuille à
// signal rencontrée ensuite.
Some(_) if signal && !has_signal.contains(&agent_id) => {
host_of.insert(agent_id, node_id);
has_signal.insert(agent_id);
}
Some(_) => {}
}
}
// Seconde passe : dé-flagger toute feuille d'agent qui n'est pas son hôte.
let root = map_node(&self.root, &mut |node| {
if let LayoutNode::Leaf(leaf) = node {
if let Some(agent) = leaf.agent {
let is_host = host_of.get(&agent) == Some(&leaf.id);
if !is_host && (leaf.agent_was_running || leaf.conversation_id.is_some()) {
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session: leaf.session,
agent: leaf.agent,
conversation_id: None,
agent_was_running: false,
});
}
}
}
node.clone()
});
Self { root }
}
/// Retrouve la [`LeafCell`] portant l'identifiant `node`.
///
/// Renvoie `None` si aucun node ne porte cet id, **ou** si le node existe mais