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:
@ -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
|
||||
|
||||
@ -826,6 +826,209 @@ fn move_session_preserves_resume_fields_on_both_leaves() {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reconcile_duplicate_agents (R0c: dé-doublonnage à l'ouverture)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Builds an agent-bearing leaf with explicit resume signals.
|
||||
fn agent_leaf_full(
|
||||
id: u128,
|
||||
agent: u128,
|
||||
conv: Option<&str>,
|
||||
running: bool,
|
||||
) -> LeafCell {
|
||||
LeafCell {
|
||||
id: node(id),
|
||||
session: None,
|
||||
agent: Some(agent_id(agent)),
|
||||
conversation_id: conv.map(str::to_string),
|
||||
agent_was_running: running,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps an ordered list of leaves into a single Row split (pre-order = the
|
||||
/// children order, same traversal as `agent_leaves`).
|
||||
fn split_of(leaves: Vec<LeafCell>) -> LayoutTree {
|
||||
LayoutTree::new(LayoutNode::Split(SplitContainer {
|
||||
id: node(900),
|
||||
direction: Direction::Row,
|
||||
children: leaves
|
||||
.into_iter()
|
||||
.map(|l| WeightedChild {
|
||||
node: LayoutNode::Leaf(l),
|
||||
weight: 1.0,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// 1. Two leaves of the SAME agent, both `agent_was_running = true`: after
|
||||
/// reconciliation exactly ONE stays running; the other is neutralised
|
||||
/// (running=false, conversation_id=None) but the leaf/agent SURVIVES.
|
||||
#[test]
|
||||
fn reconcile_basic_duplicate_keeps_single_host() {
|
||||
let tree = split_of(vec![
|
||||
agent_leaf_full(1, 7, None, true),
|
||||
agent_leaf_full(2, 7, None, true),
|
||||
]);
|
||||
let out = tree.reconcile_duplicate_agents();
|
||||
|
||||
// First in pre-order is the host (no resume signal differentiates them).
|
||||
let host = leaf_for(&out, node(1)).expect("leaf 1");
|
||||
let other = leaf_for(&out, node(2)).expect("leaf 2");
|
||||
|
||||
assert_eq!(host.agent_was_running, true, "host stays running");
|
||||
assert_eq!(other.agent_was_running, false, "duplicate neutralised");
|
||||
assert_eq!(other.conversation_id, None);
|
||||
|
||||
// The leaf and its agent binding still exist (cell not removed).
|
||||
assert_eq!(host.agent, Some(agent_id(7)));
|
||||
assert_eq!(other.agent, Some(agent_id(7)));
|
||||
|
||||
// Exactly one running leaf for the agent.
|
||||
let running = [&host, &other]
|
||||
.iter()
|
||||
.filter(|l| l.agent_was_running)
|
||||
.count();
|
||||
assert_eq!(running, 1);
|
||||
}
|
||||
|
||||
/// 2a. Host chosen by resume signal: the FIRST leaf carries no signal, the
|
||||
/// SECOND carries one (conversation_id). The second must become the host; the
|
||||
/// first (no signal) is left untouched (already neutral).
|
||||
#[test]
|
||||
fn reconcile_host_is_first_leaf_carrying_resume_signal() {
|
||||
let tree = split_of(vec![
|
||||
agent_leaf_full(1, 7, None, false), // no signal, first in order
|
||||
agent_leaf_full(2, 7, Some("conv-2"), false), // signal → becomes host
|
||||
]);
|
||||
let out = tree.reconcile_duplicate_agents();
|
||||
|
||||
let first = leaf_for(&out, node(1)).expect("leaf 1");
|
||||
let second = leaf_for(&out, node(2)).expect("leaf 2");
|
||||
|
||||
assert_eq!(
|
||||
second.conversation_id,
|
||||
Some("conv-2".to_string()),
|
||||
"the signal-bearing leaf is kept as host"
|
||||
);
|
||||
// The first leaf has no signal to strip; stays neutral.
|
||||
assert_eq!(first.conversation_id, None);
|
||||
assert_eq!(first.agent_was_running, false);
|
||||
}
|
||||
|
||||
/// 2b. When NO leaf carries a signal, the host is simply the first encountered.
|
||||
/// Here both are inert; reconciliation must leave them unchanged (nothing to
|
||||
/// strip), and not invent a running flag.
|
||||
#[test]
|
||||
fn reconcile_no_signal_host_is_first_and_tree_unchanged() {
|
||||
let tree = split_of(vec![
|
||||
agent_leaf_full(1, 7, None, false),
|
||||
agent_leaf_full(2, 7, None, false),
|
||||
]);
|
||||
let out = tree.reconcile_duplicate_agents();
|
||||
assert_eq!(out, tree, "no resume signal anywhere → nothing to strip");
|
||||
}
|
||||
|
||||
/// 2c. The signal-bearing second leaf wins even against a first leaf that has a
|
||||
/// signal too? No — the FIRST signal-bearer wins. Verify the first running leaf
|
||||
/// stays host and the second (also running) is neutralised.
|
||||
#[test]
|
||||
fn reconcile_first_signal_bearer_wins_over_later_signal() {
|
||||
let tree = split_of(vec![
|
||||
agent_leaf_full(1, 7, Some("conv-1"), true), // first signal → host
|
||||
agent_leaf_full(2, 7, Some("conv-2"), true),
|
||||
]);
|
||||
let out = tree.reconcile_duplicate_agents();
|
||||
|
||||
let host = leaf_for(&out, node(1)).expect("leaf 1");
|
||||
let other = leaf_for(&out, node(2)).expect("leaf 2");
|
||||
|
||||
assert_eq!(host.conversation_id, Some("conv-1".to_string()));
|
||||
assert_eq!(host.agent_was_running, true);
|
||||
assert_eq!(other.conversation_id, None, "later duplicate stripped");
|
||||
assert_eq!(other.agent_was_running, false);
|
||||
}
|
||||
|
||||
/// 3. Triplet (N=3) of the same agent: exactly one host, two neutralised.
|
||||
#[test]
|
||||
fn reconcile_triplet_one_host_two_neutralised() {
|
||||
let tree = split_of(vec![
|
||||
agent_leaf_full(1, 7, None, true),
|
||||
agent_leaf_full(2, 7, None, true),
|
||||
agent_leaf_full(3, 7, Some("c3"), true),
|
||||
]);
|
||||
let out = tree.reconcile_duplicate_agents();
|
||||
|
||||
let l1 = leaf_for(&out, node(1)).unwrap();
|
||||
let l2 = leaf_for(&out, node(2)).unwrap();
|
||||
let l3 = leaf_for(&out, node(3)).unwrap();
|
||||
|
||||
// First signal-bearer in pre-order is leaf 1 (running=true is a signal).
|
||||
let running_count = [&l1, &l2, &l3]
|
||||
.iter()
|
||||
.filter(|l| l.agent_was_running)
|
||||
.count();
|
||||
assert_eq!(running_count, 1, "exactly one host remains running");
|
||||
assert_eq!(l1.agent_was_running, true, "leaf 1 is the host");
|
||||
assert_eq!(l2.agent_was_running, false);
|
||||
assert_eq!(l3.agent_was_running, false);
|
||||
assert_eq!(l3.conversation_id, None, "duplicate conv stripped");
|
||||
// All three cells survive.
|
||||
for l in [&l1, &l2, &l3] {
|
||||
assert_eq!(l.agent, Some(agent_id(7)));
|
||||
}
|
||||
}
|
||||
|
||||
/// 4. Distinct agents are never affected: two leaves with DIFFERENT agents,
|
||||
/// each running, must both stay running.
|
||||
#[test]
|
||||
fn reconcile_distinct_agents_unaffected() {
|
||||
let tree = split_of(vec![
|
||||
agent_leaf_full(1, 7, Some("c1"), true),
|
||||
agent_leaf_full(2, 8, Some("c2"), true),
|
||||
]);
|
||||
let out = tree.reconcile_duplicate_agents();
|
||||
assert_eq!(out, tree, "different agents → no duplicates → unchanged");
|
||||
}
|
||||
|
||||
/// 5a. Idempotence (fixed point): reconcile(reconcile(t)) == reconcile(t).
|
||||
#[test]
|
||||
fn reconcile_is_idempotent_fixed_point() {
|
||||
let tree = split_of(vec![
|
||||
agent_leaf_full(1, 7, Some("c1"), true),
|
||||
agent_leaf_full(2, 7, Some("c2"), true),
|
||||
agent_leaf_full(3, 7, None, true),
|
||||
]);
|
||||
let once = tree.reconcile_duplicate_agents();
|
||||
let twice = once.reconcile_duplicate_agents();
|
||||
assert_eq!(twice, once, "second pass is a no-op (fixed point)");
|
||||
}
|
||||
|
||||
/// 5b. A tree WITHOUT duplicates is returned unchanged (== t).
|
||||
#[test]
|
||||
fn reconcile_no_duplicate_returns_identical() {
|
||||
let tree = split_of(vec![
|
||||
agent_leaf_full(1, 7, Some("c1"), true),
|
||||
agent_leaf_full(2, 8, None, false),
|
||||
leaf(3, Some(100)), // plain non-agent leaf
|
||||
]);
|
||||
let out = tree.reconcile_duplicate_agents();
|
||||
assert_eq!(out, tree, "no duplicate agent → identical tree");
|
||||
}
|
||||
|
||||
/// Source immutability: reconciliation must not mutate the input tree.
|
||||
#[test]
|
||||
fn reconcile_is_immutable_source_unchanged() {
|
||||
let tree = split_of(vec![
|
||||
agent_leaf_full(1, 7, None, true),
|
||||
agent_leaf_full(2, 7, None, true),
|
||||
]);
|
||||
let before = tree.clone();
|
||||
let _ = tree.reconcile_duplicate_agents();
|
||||
assert_eq!(tree, before, "source tree must not be mutated");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// serde: compat ascendante & combinaisons des nouveaux champs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user