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

@ -781,8 +781,48 @@ async fn agent_run_existing_agent_does_not_require_profile() {
assert!(fx.sessions.session(&sid(777)).is_some());
}
/// **Réattache même cellule (R0a, orchestrateur)** : un `spawn`/`agent.run`
/// `Visible` qui re-cible la **même** cellule hôte d'un agent déjà vivant est un
/// rebind de vue ⇒ pas d'erreur, pas de respawn.
#[tokio::test]
async fn agent_run_visible_reattaches_existing_session() {
async fn agent_run_visible_same_cell_rebinds_existing_session() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = fixture(FakeContexts::with_agent(&agent, "# persona"));
let cell = nid(10);
fx.service
.dispatch(
&project(),
cmd(&format!(
r#"{{ "action":"spawn_agent", "name":"architect", "profile":"claude", "visibility":"visible", "nodeId":"{cell}" }}"#
)),
)
.await
.expect("first launch ok");
let out = fx
.service
.dispatch(
&project(),
cmd(&format!(
r#"{{ "type":"agent.run", "targetAgent":"architect", "visibility":"visible", "attachToCell":"{cell}" }}"#
)),
)
.await
.expect("same-cell rebind ok");
assert!(out.detail.contains("attached agent architect"));
let session = fx.sessions.session(&sid(777)).expect("live session");
assert_eq!(session.node_id, cell);
assert_eq!(fx.pty.spawns().len(), 1, "rebind must not respawn");
}
/// **Second lancement ailleurs refusé (R0a, orchestrateur)** : un `spawn`/`agent.run`
/// `Visible` qui vise une **autre** cellule d'un agent singleton déjà vivant est un
/// second lancement ⇒ refus `AgentAlreadyRunning` (node hôte rapporté), pas de
/// respawn, session non déplacée.
#[tokio::test]
async fn agent_run_visible_other_cell_refuses_when_live_elsewhere() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = fixture(FakeContexts::with_agent(&agent, "# persona"));
let first_cell = nid(10);
@ -798,7 +838,7 @@ async fn agent_run_visible_reattaches_existing_session() {
.await
.expect("first launch ok");
let out = fx
let err = fx
.service
.dispatch(
&project(),
@ -807,12 +847,20 @@ async fn agent_run_visible_reattaches_existing_session() {
)),
)
.await
.expect("reattach ok");
.expect_err("fresh second launch elsewhere is refused");
assert!(out.detail.contains("attached agent architect"));
assert_eq!(err.code(), "AGENT_ALREADY_RUNNING", "got {err:?}");
match err {
application::AppError::AgentAlreadyRunning { node_id, .. } => {
assert_eq!(node_id, first_cell, "reports the live host cell, not target");
}
other => panic!("expected AgentAlreadyRunning, got {other:?}"),
}
// Session not moved, no respawn.
let session = fx.sessions.session(&sid(777)).expect("live session");
assert_eq!(session.node_id, next_cell);
assert_eq!(fx.pty.spawns().len(), 1, "reattach must not respawn");
assert_eq!(session.node_id, first_cell, "session stays on its host cell");
assert_eq!(fx.pty.spawns().len(), 1, "refused launch must not respawn");
}
#[tokio::test]
@ -1276,3 +1324,379 @@ async fn non_regression_agent_run_reply_is_none() {
.expect("run ok");
assert_eq!(out.reply, None, "agent.run must not carry a reply");
}
// ---------------------------------------------------------------------------
// A0 — sérialisation FIFO des tours par agent (cadrage v5 §4)
//
// Le verrou `ask_locks` de l'orchestrateur doit garantir : (1) deux `ask`
// concurrents vers la **même** cible sont sérialisés FIFO (le 2ᵉ `send` ne
// démarre PAS tant que le 1ᵉ tour n'a pas rendu son `Final`) ; (2) deux `ask`
// vers des agents **différents** s'exécutent en parallèle ; (3) le verrou est
// relâché même si le tour se solde par une erreur.
//
// Pour observer le *timing*, on remplace le `FakeSession` scripté one-shot par
// un `GatedSession` **pilotable** : chaque `send` enregistre son démarrage
// (compteur + ordre) puis **bloque** sur un permis de `Semaphore` que le test
// délivre à la demande, avant de retourner son `Final`. Cela matérialise un tour
// dont on contrôle la fin (« retient le Final jusqu'à ce que le test le libère »).
// ---------------------------------------------------------------------------
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::Semaphore;
use tokio::time::{timeout, Duration};
/// Borne de sûreté : aucun `await` de test ne doit jamais dépasser ça (garde-fou
/// anti-hang du lot de concurrence). Largement au-dessus du temps réel attendu.
const TEST_GUARD: Duration = Duration::from_secs(10);
/// Une session **pilotable** : chaque `send` signale son démarrage (incrémente
/// `started`, pousse le prompt dans `order`) puis **attend un permis** du
/// sémaphore `gate` avant de retourner son `Final` (contenu = prompt reçu, pour
/// vérifier l'ordre de complétion). Le test délivre les permis un par un
/// (`release_one`) ⇒ il contrôle exactement quand chaque tour se termine.
///
/// `mode` choisit l'issue du tour : `Final` (succès) ou `NoFinal` (flux vide ⇒
/// `send_blocking` renvoie une erreur, pour tester la libération du verrou sur
/// erreur).
struct GatedSession {
id: SessionId,
/// Nombre de `send` **démarrés** (avant déblocage). Observé par le test pour
/// prouver qu'un 2ᵉ tour n'a PAS démarré pendant que le 1ᵉ est retenu.
started: AtomicUsize,
/// Nombre de `send` **terminés** (Final rendu / flux retourné).
finished: AtomicUsize,
/// Ordre des prompts reçus (FIFO attendu).
order: Arc<Mutex<Vec<String>>>,
/// Permis débloquant un tour à la fois.
gate: Semaphore,
/// Nombre de `shutdown` (doit rester 0 : l'orchestrateur ne tue jamais la
/// session de la cible).
shutdowns: AtomicUsize,
mode: GateMode,
}
#[derive(Clone, Copy)]
enum GateMode {
/// Le tour rend un `Final` dont le contenu reprend le prompt.
FinalEchoesPrompt,
/// Le flux est vide (pas de `Final`) ⇒ tour interrompu, erreur typée.
NoFinal,
}
impl GatedSession {
fn new(id: SessionId, mode: GateMode) -> Arc<Self> {
Arc::new(Self {
id,
started: AtomicUsize::new(0),
finished: AtomicUsize::new(0),
order: Arc::new(Mutex::new(Vec::new())),
gate: Semaphore::new(0),
shutdowns: AtomicUsize::new(0),
mode,
})
}
fn started(&self) -> usize {
self.started.load(Ordering::SeqCst)
}
fn finished(&self) -> usize {
self.finished.load(Ordering::SeqCst)
}
fn order(&self) -> Vec<String> {
self.order.lock().unwrap().clone()
}
/// Délivre un permis ⇒ débloque exactement UN tour en attente.
fn release_one(&self) {
self.gate.add_permits(1);
}
}
#[async_trait]
impl AgentSession for GatedSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
None
}
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
self.started.fetch_add(1, Ordering::SeqCst);
self.order.lock().unwrap().push(prompt.to_owned());
// Bloque jusqu'à ce que le test délivre un permis (retient le Final).
let permit = self.gate.acquire().await.expect("gate not closed");
permit.forget();
self.finished.fetch_add(1, Ordering::SeqCst);
match self.mode {
GateMode::FinalEchoesPrompt => Ok(Box::new(std::iter::once(final_(prompt)))),
GateMode::NoFinal => Ok(Box::new(std::iter::empty())),
}
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
self.shutdowns.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
/// Attend (borné) qu'une condition observée sur le fake devienne vraie, en cédant
/// la main au runtime entre deux lectures. Évite tout `sleep` fixe fragile.
async fn await_until<F: Fn() -> bool>(cond: F) {
timeout(TEST_GUARD, async {
while !cond() {
tokio::task::yield_now().await;
}
})
.await
.expect("condition never reached within guard (possible hang/deadlock)");
}
const ASK_ARCHITECT: &str =
r#"{ "type":"agent.message", "requestedBy":"Main", "targetAgent":"architect", "task":"T1" }"#;
const ASK_ARCHITECT_2: &str =
r#"{ "type":"agent.message", "requestedBy":"Main", "targetAgent":"architect", "task":"T2" }"#;
/// **A0 #1 — FIFO même cible (cœur du lot).** Deux `ask` concurrents vers le
/// **même** agent, avec une session pilotable qui retient son `Final`. On prouve
/// que le 2ᵉ tour (`send`) ne **démarre pas** tant que le 1ᵉ n'a pas rendu son
/// `Final`, puis qu'en libérant, les deux complètent **dans l'ordre FIFO** (T1
/// avant T2), sans entrelacement.
#[tokio::test]
async fn ask_same_target_serialises_turns_fifo() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let session = GatedSession::new(sid(500), GateMode::FinalEchoesPrompt);
let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")]));
let fx = ask_fixture(
FakeContexts::with_agent(&agent, "# persona"),
throwaway,
false,
);
fx.structured
.insert(Arc::clone(&session) as Arc<dyn AgentSession>, aid(1), nid(1));
let proj = project();
let svc = &fx.service;
timeout(TEST_GUARD, async {
// Tour 1 : on le pilote jusqu'à ce que son `send` soit en vol (verrou
// acquis + bloqué sur le gate) AVANT de lancer le tour 2 ⇒ ordre d'entrée
// en file déterministe (T1 devant T2). `biased` : on tente d'abord r1.
let r1 = svc.dispatch(&proj, cmd(ASK_ARCHITECT));
tokio::pin!(r1);
loop {
tokio::select! {
biased;
_ = &mut r1 => panic!("turn 1 should stay blocked on its Final"),
() = await_until(|| session.started() >= 1) => break,
}
}
// À ce point : tour 1 démarré et BLOQUÉ sur le Final.
assert_eq!(session.started(), 1, "exactly the first turn started");
assert_eq!(session.finished(), 0, "first turn is still blocked on Final");
// Lance le tour 2 concurremment. Il doit rester EN FILE (verrou de la même
// cible tenu par le tour 1) : son `send` ne doit PAS démarrer. On poll les
// DEUX futures pendant qu'on laisse au runtime des occasions de mal faire.
let r2 = svc.dispatch(&proj, cmd(ASK_ARCHITECT_2));
tokio::pin!(r2);
for _ in 0..50 {
tokio::select! {
biased;
_ = &mut r1 => panic!("turn 1 still blocked, must not complete yet"),
_ = &mut r2 => panic!("turn 2 must not run before turn 1 releases the lock"),
() = tokio::task::yield_now() => {}
}
}
assert_eq!(
session.started(),
1,
"second turn MUST NOT start while the first holds the per-agent lock (no interleaving)"
);
// Libère le tour 1 ⇒ il rend son Final, relâche le verrou, le tour 2 démarre.
session.release_one();
let out1 = (&mut r1).await.expect("turn 1 ok");
assert_eq!(out1.reply.as_deref(), Some("T1"));
// Le tour 2 peut maintenant démarrer (verrou libre) ; il bloque sur SON
// gate. On le pilote jusqu'à son démarrage, puis on le libère.
loop {
tokio::select! {
biased;
res = &mut r2 => {
// Démarré ET libéré dans la même boucle : accepte la complétion.
let out2 = res.expect("turn 2 ok");
assert_eq!(out2.reply.as_deref(), Some("T2"));
break;
}
() = await_until(|| session.started() >= 2) => {
session.release_one();
}
}
}
})
.await
.expect("FIFO scenario hung (deadlock?)");
// Complétion FIFO stricte : T1 puis T2, sans entrelacement.
assert_eq!(session.order(), vec!["T1".to_owned(), "T2".to_owned()]);
assert_eq!(session.finished(), 2);
assert_eq!(
session.shutdowns.load(Ordering::SeqCst),
0,
"ask must never shutdown the target session"
);
}
/// **A0 #2 — agents différents en parallèle.** Un `ask` vers A (retenu sur son
/// `Final`) ne doit PAS bloquer un `ask` vers B : le tour de B démarre et
/// **complète** pendant que A est encore en cours. Verrou **par agent_id** ⇒ pas
/// de contention croisée. Si A bloquait B, l'attente de complétion de B
/// dépasserait le garde-fou et le test échouerait.
#[tokio::test]
async fn ask_different_targets_run_in_parallel() {
// Deux agents distincts dans le manifeste.
let contexts = FakeContexts::new();
{
let agent_a = scratch_agent(aid(1), "alpha", "agents/alpha.md");
let agent_b = scratch_agent(aid(2), "beta", "agents/beta.md");
let mut inner = contexts.0.lock().unwrap();
inner
.manifest
.entries
.push(ManifestEntry::from_agent(&agent_a));
inner
.manifest
.entries
.push(ManifestEntry::from_agent(&agent_b));
inner
.contents
.insert(agent_a.context_path.clone(), "# a".to_owned());
inner
.contents
.insert(agent_b.context_path.clone(), "# b".to_owned());
}
let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")]));
let fx = ask_fixture(contexts, throwaway, false);
// A : retenu indéfiniment (jamais libéré pendant le test). B : libérable.
let sess_a = GatedSession::new(sid(500), GateMode::FinalEchoesPrompt);
let sess_b = GatedSession::new(sid(600), GateMode::FinalEchoesPrompt);
fx.structured
.insert(Arc::clone(&sess_a) as Arc<dyn AgentSession>, aid(1), nid(1));
fx.structured
.insert(Arc::clone(&sess_b) as Arc<dyn AgentSession>, aid(2), nid(2));
let proj = project();
let svc = &fx.service;
let ask_a = r#"{ "type":"agent.message", "targetAgent":"alpha", "task":"A1" }"#;
let ask_b = r#"{ "type":"agent.message", "targetAgent":"beta", "task":"B1" }"#;
timeout(TEST_GUARD, async {
// Lance A et attends qu'il soit en vol (bloqué sur son Final), sans le libérer.
let a_fut = svc.dispatch(&proj, cmd(ask_a));
tokio::pin!(a_fut);
// Pilote a_fut jusqu'à ce que A soit en vol.
let drive_a_until_inflight = async {
// a_fut bloque dans send → on le poll une fois via select avec la condition.
loop {
tokio::select! {
biased;
_ = &mut a_fut => panic!("A should stay blocked on its Final"),
() = await_until(|| sess_a.started() >= 1) => break,
}
}
};
drive_a_until_inflight.await;
assert_eq!(sess_a.started(), 1);
assert_eq!(sess_a.finished(), 0, "A is held on its Final");
// Pendant que A est retenu, B doit pouvoir démarrer ET compléter.
let b_out = async {
// B bloque aussi sur son gate : on attend qu'il démarre, on le libère.
let b_fut = svc.dispatch(&proj, cmd(ask_b));
tokio::pin!(b_fut);
loop {
tokio::select! {
biased;
res = &mut b_fut => break res,
() = await_until(|| sess_b.started() >= 1) => {
sess_b.release_one();
}
}
}
}
.await
.expect("B completes while A is still in flight");
assert_eq!(b_out.reply.as_deref(), Some("B1"));
// A est toujours en vol, non terminé : la parallélisme est prouvée.
assert_eq!(sess_a.finished(), 0, "A must still be in flight (not blocked by B, nor B by A)");
assert_eq!(sess_b.finished(), 1);
})
.await
.expect("parallel scenario hung — A likely blocked B (cross-agent contention)");
}
/// **A0 #3 — libération du verrou sur erreur.** Un 1ᵉ tour qui se solde par une
/// **erreur** (flux sans `Final` ⇒ `PROCESS`) doit néanmoins **relâcher** le
/// verrou de la cible : un `ask` suivant vers le **même** agent peut alors
/// acquérir le verrou et démarrer (pas de verrou fuité / pas de deadlock).
#[tokio::test]
async fn ask_releases_lock_after_errored_turn() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
// 1ᵉ tour : flux vide ⇒ send_blocking renvoie une erreur (PROCESS).
let session = GatedSession::new(sid(500), GateMode::NoFinal);
let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")]));
let fx = ask_fixture(
FakeContexts::with_agent(&agent, "# persona"),
throwaway,
false,
);
fx.structured
.insert(Arc::clone(&session) as Arc<dyn AgentSession>, aid(1), nid(1));
let proj = project();
let svc = &fx.service;
timeout(TEST_GUARD, async {
// 1ᵉ tour : on le débloque immédiatement, il échoue (pas de Final).
session.release_one();
let err = svc
.dispatch(&proj, cmd(ASK_ARCHITECT))
.await
.expect_err("first turn errors (no Final)");
assert_eq!(err.code(), "PROCESS", "errored turn surfaces typed PROCESS: {err:?}");
assert_eq!(session.started(), 1);
// 2ᵉ tour vers la MÊME cible : si le verrou avait fuité sur l'erreur, ce
// `dispatch` resterait bloqué sur `lock_owned()` et le garde-fou sauterait.
// On débloque le 2ᵉ tour et on attend qu'il RENDE LA MAIN — preuve que le
// verrou était libre. (Cette session est `NoFinal` ⇒ le 2ᵉ tour échoue lui
// aussi ; ce qui compte ici est qu'il a pu *acquérir le verrou et démarrer*,
// pas l'issue du tour — l'issue est couverte par le test FIFO.)
session.release_one();
let err2 = svc
.dispatch(&proj, cmd(ASK_ARCHITECT_2))
.await
.expect_err("NoFinal session ⇒ second turn also errors typed");
assert_eq!(err2.code(), "PROCESS", "got {err2:?}");
assert_eq!(
session.started(),
2,
"second turn actually started — the lock was freed after the errored first turn"
);
})
.await
.expect("lock appears leaked after an errored turn (second ask never acquired it)");
}
// A0 #4 — Plafond d'attente en file (`ASK_QUEUE_WAIT_CAP = 600 s`). NON testé en
// unitaire : il n'est ni injectable ni réductible depuis l'extérieur (constante
// privée), et un vrai test exigerait d'attendre 600 s — exclu (durée). La
// sémantique nominale (attente puis acquisition du verrou) est couverte par
// `ask_same_target_serialises_turns_fifo` (le 2ᵉ tour attend en file PUIS
// acquiert). Le chemin d'expiration (timeout d'attente ⇒ `AppError::Process`,
// même type que le timeout de tour) reste non couvert ici — signalé au Dev :
// rendre le cap injectable (ex. champ optionnel surchargeable en test) permettrait
// un test déterministe court.