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:
@ -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]
|
||||
|
||||
Reference in New Issue
Block a user