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:
@ -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")
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
Reference in New Issue
Block a user