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

@ -770,37 +770,119 @@ async fn non_structured_profile_takes_pty_path_unchanged() {
assert_eq!(out.session.id, sid(777));
}
/// Invariant « 1 session vivante/agent » côté structuré : relancer un agent
/// structuré déjà vivant ⇒ rebind/idempotent (pas de 2e `factory.start`).
/// Invariant « 1 agent = 1 employé » côté structuré (R0a) : un **lancement neuf**
/// (sans `conversation_id`) sur une **autre** cellule B d'un agent structuré déjà
/// vivant en cellule A est un second lancement ⇒ **refus**
/// `AppError::AgentAlreadyRunning { node_id: A }`, **pas** de 2e `factory.start`,
/// une seule session structurée vivante.
#[tokio::test]
async fn structured_relaunch_is_idempotent_no_second_start() {
async fn structured_launch_new_in_other_cell_refuses_when_live_elsewhere() {
let factory = FakeFactory::new(500, Some("engine-conv"));
let f = launch_fixture(structured_profile(pid(9)), factory);
// 1er lancement sur la cellule A.
let host = nid(1);
let mut first = launch_input(f.agent.id);
first.node_id = Some(nid(1));
first.node_id = Some(host);
f.launch.execute(first).await.expect("first launch");
assert_eq!(f.factory.start_count(), 1);
assert_eq!(f.structured.len(), 1);
// Relance dans une cellule B : rebind de la vue, PAS de 2e start.
// Lancement NEUF (conversation_id: None) dans une cellule B refus.
let mut second = launch_input(f.agent.id);
second.node_id = Some(nid(2));
let out = f.launch.execute(second).await.expect("relaunch");
let target = nid(2);
second.node_id = Some(target);
let err = second_launch_err(&f, second).await;
match err {
application::AppError::AgentAlreadyRunning { agent_id, node_id } => {
assert_eq!(agent_id, f.agent.id, "reports the live agent");
assert_eq!(node_id, host, "reports the live HOST node A, not target B");
}
other => panic!("expected AgentAlreadyRunning, got {other:?}"),
}
assert_eq!(
f.factory.start_count(),
1,
"no second factory.start on relaunch of a live structured agent"
"no second factory.start on a refused launch"
);
assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn");
assert_eq!(f.structured.len(), 1, "still a single live structured session");
// La vue est rebindée sur la cellule B.
assert_eq!(f.structured.node_for_agent(&f.agent.id), Some(nid(2)));
assert_eq!(
f.structured.node_for_agent(&f.agent.id),
Some(host),
"session stays pinned on its host node A"
);
}
/// **Réattache légitime même node (R0a, structuré)** : relancer sur la **même**
/// cellule hôte ⇒ rebind, pas d'erreur, pas de 2e `factory.start`, même session.
#[tokio::test]
async fn structured_relaunch_same_node_rebinds_no_second_start() {
let factory = FakeFactory::new(500, Some("engine-conv"));
let f = launch_fixture(structured_profile(pid(9)), factory);
let host = nid(1);
let mut first = launch_input(f.agent.id);
first.node_id = Some(host);
f.launch.execute(first).await.expect("first launch");
assert_eq!(f.factory.start_count(), 1);
// Re-ouverture de la MÊME cellule.
let mut again = launch_input(f.agent.id);
again.node_id = Some(host);
let out = f.launch.execute(again).await.expect("same-node rebind");
assert_eq!(f.factory.start_count(), 1, "no second factory.start");
assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn");
assert_eq!(f.structured.len(), 1, "single live structured session");
assert_eq!(f.structured.node_for_agent(&f.agent.id), Some(host));
let desc = out.structured.expect("descriptor on rebind");
assert_eq!(desc.session_id, sid(500), "same live session id");
assert_eq!(desc.node_id, nid(2));
assert_eq!(desc.node_id, host);
}
/// **Réattache explicite par conversation (R0a, structuré)** : relancer sur une
/// **autre** cellule B mais avec un `conversation_id` ⇒ rebind légitime de la vue,
/// pas d'erreur, pas de 2e `factory.start`, même session, vue déplacée sur B.
#[tokio::test]
async fn structured_relaunch_other_cell_with_conversation_id_rebinds() {
let factory = FakeFactory::new(500, Some("engine-conv"));
let f = launch_fixture(structured_profile(pid(9)), factory);
let host = nid(1);
let mut first = launch_input(f.agent.id);
first.node_id = Some(host);
f.launch.execute(first).await.expect("first launch");
assert_eq!(f.factory.start_count(), 1);
// Cellule B + signal de réattache explicite.
let mut second = launch_input(f.agent.id);
let target = nid(2);
second.node_id = Some(target);
second.conversation_id = Some("conv-live".to_owned());
let out = f.launch.execute(second).await.expect("explicit reattach");
assert_eq!(
f.factory.start_count(),
1,
"no second factory.start on explicit reattach"
);
assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn");
assert_eq!(f.structured.len(), 1, "still a single live structured session");
assert_eq!(f.structured.node_for_agent(&f.agent.id), Some(target));
let desc = out.structured.expect("descriptor on rebind");
assert_eq!(desc.session_id, sid(500), "same live session id");
assert_eq!(desc.node_id, target);
}
/// Helper: executes a launch that is expected to be refused, returning the error.
async fn second_launch_err(f: &LaunchFixture, input: LaunchAgentInput) -> application::AppError {
f.launch
.execute(input)
.await
.expect_err("fresh second launch elsewhere is refused")
}
// ===========================================================================