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

@ -956,11 +956,13 @@ fn nid(n: u128) -> domain::NodeId {
domain::NodeId::from_uuid(Uuid::from_u128(n))
}
/// **Singleton invariant (T1)**: launching an agent that already owns a live
/// session in another cell re-attaches the existing session to the requested
/// node. The PTY is never spawned a second time.
/// **Singleton invariant (R0a, décision « 1 agent = 1 employé »)**: a *fresh*
/// launch (no `conversation_id`) targeting **another** cell of an agent already
/// live in cell A is a genuine second launch ⇒ it is **refused** with
/// `AppError::AgentAlreadyRunning { node_id: A, agent_id }`. The session is NOT
/// silently moved, the PTY is never spawned, and the registry is unchanged.
#[tokio::test]
async fn launch_reattaches_agent_already_running_elsewhere() {
async fn launch_new_in_other_cell_refuses_when_agent_live_elsewhere() {
let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture(
ContextInjection::convention_file("CLAUDE.md").unwrap(),
Some(ContextInjectionPlan::File {
@ -973,17 +975,32 @@ async fn launch_reattaches_agent_already_running_elsewhere() {
seed_live_agent_session(&sessions, agent.id, host, sid(42));
let before = sessions.len();
// Open the same running agent in a different cell B.
// Fresh launch (conversation_id: None) of the same running agent in a
// *different* cell B ⇒ must be refused, not silently rebound.
let mut input = launch_input(agent.id);
let target = nid(2);
input.node_id = Some(target);
let out = launch.execute(input).await.expect("reattach succeeds");
let err = launch
.execute(input)
.await
.expect_err("fresh second launch elsewhere is refused");
assert_eq!(out.session.id, sid(42), "returns the existing session");
assert_eq!(out.session.node_id, target, "session is rebound to target");
assert_eq!(sessions.node_for_agent(&agent.id), Some(target));
match err {
application::AppError::AgentAlreadyRunning { agent_id, node_id } => {
assert_eq!(agent_id, 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:?}"),
}
// No silent move, no respawn, registry untouched.
assert_eq!(
sessions.node_for_agent(&agent.id),
Some(host),
"session stays pinned on its host node"
);
assert_eq!(sessions.len(), before, "registry size is unchanged");
assert!(pty.spawns().is_empty(), "no PTY spawn on reattach");
assert!(pty.spawns().is_empty(), "no PTY spawn on a refused launch");
}
/// A launch with an unspecified node (`node_id: None`) for an already-live agent
@ -1038,6 +1055,40 @@ async fn launch_same_node_is_idempotent_no_respawn() {
assert!(pty.spawns().is_empty(), "no respawn on same-node relaunch");
}
/// **Legitimate explicit reattach (R0a)**: launching an agent that is live in
/// cell A onto a *different* cell B but carrying a `conversation_id` is an
/// explicit view reattach (the cell knows the agent was running) ⇒ rebind, no
/// error, no respawn, same session id.
#[tokio::test]
async fn launch_other_cell_with_conversation_id_rebinds_no_respawn() {
let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture(
ContextInjection::convention_file("CLAUDE.md").unwrap(),
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
);
let host = nid(1);
seed_live_agent_session(&sessions, agent.id, host, sid(42));
let before = sessions.len();
// Different cell B, but an explicit reattach signal (conversation_id present).
let mut input = launch_input(agent.id);
let target = nid(2);
input.node_id = Some(target);
input.conversation_id = Some("conv-live".to_owned());
let out = launch
.execute(input)
.await
.expect("explicit reattach succeeds");
assert_eq!(out.session.id, sid(42), "returns the existing session");
assert_eq!(out.session.node_id, target, "view rebound to target cell");
assert_eq!(sessions.node_for_agent(&agent.id), Some(target));
assert_eq!(sessions.len(), before, "registry size is unchanged");
assert!(pty.spawns().is_empty(), "no PTY spawn on explicit reattach");
}
/// After the live session is removed (cell closed / agent exited), a fresh launch
/// succeeds — the guard only blocks while the agent is actually live.
#[tokio::test]