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]
|
||||
|
||||
@ -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.
|
||||
|
||||
347
crates/application/tests/reconcile_layouts.rs
Normal file
347
crates/application/tests/reconcile_layouts.rs
Normal file
@ -0,0 +1,347 @@
|
||||
//! R0c tests for [`ReconcileLayouts`].
|
||||
//!
|
||||
//! At project open, a persisted `layouts.json` may already hold several leaves
|
||||
//! pinning the **same** agent id. The use case de-duplicates them through the
|
||||
//! pure domain op [`domain::LayoutTree::reconcile_duplicate_agents`] and
|
||||
//! persists the doc **only** when something changed (idempotent no-op
|
||||
//! otherwise).
|
||||
//!
|
||||
//! Harness mirrors `tests/snapshot_running_agents.rs` (its twin use case): the
|
||||
//! same in-memory `FakeFs` / `FakeStore`. The fake FS additionally counts the
|
||||
//! number of `write` **calls** so test 7 can prove that a no-op performs zero
|
||||
//! writes (file-count alone cannot, since `layouts.json` already exists).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::layout::Workspace;
|
||||
use domain::ports::{DirEntry, FileSystem, FsError, ProjectStore, RemotePath, StoreError};
|
||||
use domain::{
|
||||
AgentId, Direction, LayoutId, LayoutNode, LayoutTree, LeafCell, NodeId, Project, ProjectId,
|
||||
ProjectPath, RemoteRef, SplitContainer, WeightedChild,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{ReconcileLayouts, ReconcileLayoutsInput};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fakes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeFsInner {
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
dirs: HashSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct FakeFs {
|
||||
inner: Arc<Mutex<FakeFsInner>>,
|
||||
/// Count of `write` **calls** (not files). Lets a no-op be asserted even
|
||||
/// though the target file already exists.
|
||||
write_calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl FakeFs {
|
||||
fn read_file(&self, path: &str) -> Option<Vec<u8>> {
|
||||
self.inner.lock().unwrap().files.get(path).cloned()
|
||||
}
|
||||
fn put(&self, path: &str, data: &[u8]) {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap()
|
||||
.files
|
||||
.insert(path.to_owned(), data.to_vec());
|
||||
}
|
||||
fn write_calls(&self) -> usize {
|
||||
self.write_calls.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileSystem for FakeFs {
|
||||
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap()
|
||||
.files
|
||||
.get(path.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| FsError::NotFound(path.as_str().to_owned()))
|
||||
}
|
||||
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
||||
self.write_calls.fetch_add(1, Ordering::SeqCst);
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap()
|
||||
.files
|
||||
.insert(path.as_str().to_owned(), data.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
|
||||
let inner = self.inner.lock().unwrap();
|
||||
Ok(inner.files.contains_key(path.as_str()) || inner.dirs.contains(path.as_str()))
|
||||
}
|
||||
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap()
|
||||
.dirs
|
||||
.insert(path.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeStoreInner {
|
||||
projects: Vec<Project>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct FakeStore(Arc<Mutex<FakeStoreInner>>);
|
||||
|
||||
#[async_trait]
|
||||
impl ProjectStore for FakeStore {
|
||||
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
||||
Ok(self.0.lock().unwrap().projects.clone())
|
||||
}
|
||||
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.projects
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
.cloned()
|
||||
.ok_or(StoreError::NotFound)
|
||||
}
|
||||
async fn save_project(&self, project: &Project) -> Result<(), StoreError> {
|
||||
self.0.lock().unwrap().projects.push(project.clone());
|
||||
Ok(())
|
||||
}
|
||||
async fn save_workspace(&self, _w: &Workspace) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
|
||||
Ok(Workspace::default())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ROOT: &str = "/home/me/proj";
|
||||
const LAYOUTS_PATH: &str = "/home/me/proj/.ideai/layouts.json";
|
||||
|
||||
fn pid(n: u128) -> ProjectId {
|
||||
ProjectId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn nid(n: u128) -> NodeId {
|
||||
NodeId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn lid(n: u128) -> LayoutId {
|
||||
LayoutId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn agent_leaf(node: NodeId, agent: Option<AgentId>, conv: Option<&str>, running: bool) -> LeafCell {
|
||||
LeafCell {
|
||||
id: node,
|
||||
session: None,
|
||||
agent,
|
||||
conversation_id: conv.map(str::to_string),
|
||||
agent_was_running: running,
|
||||
}
|
||||
}
|
||||
|
||||
/// Two leaves of the same agent, both flagged running, under a Row split.
|
||||
fn duplicate_tree(agent: AgentId, a: NodeId, b: NodeId) -> LayoutTree {
|
||||
LayoutTree::new(LayoutNode::Split(SplitContainer {
|
||||
id: nid(1),
|
||||
direction: Direction::Row,
|
||||
children: vec![
|
||||
WeightedChild {
|
||||
node: LayoutNode::Leaf(agent_leaf(a, Some(agent), Some("c-a"), true)),
|
||||
weight: 1.0,
|
||||
},
|
||||
WeightedChild {
|
||||
node: LayoutNode::Leaf(agent_leaf(b, Some(agent), Some("c-b"), true)),
|
||||
weight: 1.0,
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
async fn register_project(store: &FakeStore, id: ProjectId) {
|
||||
let project = Project::new(
|
||||
id,
|
||||
"Demo",
|
||||
ProjectPath::new(ROOT).unwrap(),
|
||||
RemoteRef::Local,
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
store.save_project(&project).await.unwrap();
|
||||
}
|
||||
|
||||
fn seed_layouts(fs: &FakeFs, id: LayoutId, tree: &LayoutTree) {
|
||||
let doc = serde_json::json!({
|
||||
"version": 1,
|
||||
"activeId": id.to_string(),
|
||||
"layouts": [ { "id": id.to_string(), "name": "Default", "tree": tree } ],
|
||||
});
|
||||
fs.put(LAYOUTS_PATH, &serde_json::to_vec(&doc).unwrap());
|
||||
}
|
||||
|
||||
fn doc_json(fs: &FakeFs) -> serde_json::Value {
|
||||
serde_json::from_slice(&fs.read_file(LAYOUTS_PATH).expect("layouts.json present")).unwrap()
|
||||
}
|
||||
|
||||
/// `agent_was_running` for the leaf `node` in the active persisted layout.
|
||||
fn was_running(fs: &FakeFs, node: NodeId) -> Option<bool> {
|
||||
let doc = doc_json(fs);
|
||||
let tree: LayoutTree =
|
||||
serde_json::from_value(doc["layouts"][0]["tree"].clone()).expect("tree parseable");
|
||||
fn find(node: &LayoutNode, target: NodeId) -> Option<bool> {
|
||||
match node {
|
||||
LayoutNode::Leaf(l) if l.id == target => Some(l.agent_was_running),
|
||||
LayoutNode::Leaf(_) => None,
|
||||
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
||||
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
||||
}
|
||||
}
|
||||
find(&tree.root, node)
|
||||
}
|
||||
|
||||
fn make_use_case(store: &FakeStore, fs: &FakeFs) -> ReconcileLayouts {
|
||||
ReconcileLayouts::new(
|
||||
Arc::new(store.clone()) as Arc<dyn ProjectStore>,
|
||||
Arc::new(fs.clone()) as Arc<dyn FileSystem>,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 6. A persisted layout with a duplicate agent ⇒ `changed == true` and the
|
||||
/// persisted version keeps a single "was running" leaf for the agent.
|
||||
#[tokio::test]
|
||||
async fn reconcile_persists_and_reports_changed_on_duplicate() {
|
||||
let store = FakeStore::default();
|
||||
let fs = FakeFs::default();
|
||||
register_project(&store, pid(1)).await;
|
||||
|
||||
let a = nid(10);
|
||||
let b = nid(11);
|
||||
let agent = aid(100);
|
||||
seed_layouts(&fs, lid(1), &duplicate_tree(agent, a, b));
|
||||
|
||||
let uc = make_use_case(&store, &fs);
|
||||
let out = uc
|
||||
.execute(ReconcileLayoutsInput { project_id: pid(1) })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.changed, true, "duplicate must be reconciled");
|
||||
|
||||
// Exactly one of the two leaves is still flagged running in the persisted doc.
|
||||
let ra = was_running(&fs, a).expect("leaf a");
|
||||
let rb = was_running(&fs, b).expect("leaf b");
|
||||
assert_eq!(
|
||||
[ra, rb].iter().filter(|x| **x).count(),
|
||||
1,
|
||||
"only one host stays running after reconciliation"
|
||||
);
|
||||
// First in pre-order is the host (both carried a signal).
|
||||
assert_eq!(ra, true);
|
||||
assert_eq!(rb, false);
|
||||
}
|
||||
|
||||
/// 7. No-op: a second `execute` on an already-reconciled project ⇒
|
||||
/// `changed == false` and **zero** writes occur.
|
||||
#[tokio::test]
|
||||
async fn reconcile_second_pass_is_noop_no_write() {
|
||||
let store = FakeStore::default();
|
||||
let fs = FakeFs::default();
|
||||
register_project(&store, pid(1)).await;
|
||||
|
||||
let a = nid(10);
|
||||
let b = nid(11);
|
||||
let agent = aid(100);
|
||||
seed_layouts(&fs, lid(1), &duplicate_tree(agent, a, b));
|
||||
|
||||
let uc = make_use_case(&store, &fs);
|
||||
|
||||
// First pass reconciles + writes once.
|
||||
let first = uc
|
||||
.execute(ReconcileLayoutsInput { project_id: pid(1) })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.changed, true);
|
||||
assert_eq!(fs.write_calls(), 1, "first pass persists once");
|
||||
|
||||
// Second pass must be a strict no-op.
|
||||
let writes_before = fs.write_calls();
|
||||
let second = uc
|
||||
.execute(ReconcileLayoutsInput { project_id: pid(1) })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(second.changed, false, "already reconciled → no change");
|
||||
assert_eq!(
|
||||
fs.write_calls(),
|
||||
writes_before,
|
||||
"no-op must not write anything"
|
||||
);
|
||||
}
|
||||
|
||||
/// 8. A layout WITHOUT duplicates ⇒ `changed == false`, unchanged, no write.
|
||||
#[tokio::test]
|
||||
async fn reconcile_no_duplicate_is_noop() {
|
||||
let store = FakeStore::default();
|
||||
let fs = FakeFs::default();
|
||||
register_project(&store, pid(1)).await;
|
||||
|
||||
let a = nid(10);
|
||||
let b = nid(11);
|
||||
// Distinct agents — no duplicate.
|
||||
let tree = LayoutTree::new(LayoutNode::Split(SplitContainer {
|
||||
id: nid(1),
|
||||
direction: Direction::Row,
|
||||
children: vec![
|
||||
WeightedChild {
|
||||
node: LayoutNode::Leaf(agent_leaf(a, Some(aid(100)), Some("c-a"), true)),
|
||||
weight: 1.0,
|
||||
},
|
||||
WeightedChild {
|
||||
node: LayoutNode::Leaf(agent_leaf(b, Some(aid(101)), None, false)),
|
||||
weight: 1.0,
|
||||
},
|
||||
],
|
||||
}));
|
||||
seed_layouts(&fs, lid(1), &tree);
|
||||
let before = doc_json(&fs);
|
||||
let writes_before = fs.write_calls();
|
||||
|
||||
let uc = make_use_case(&store, &fs);
|
||||
let out = uc
|
||||
.execute(ReconcileLayoutsInput { project_id: pid(1) })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.changed, false, "no duplicate → no change");
|
||||
assert_eq!(fs.write_calls(), writes_before, "no write on no-op");
|
||||
assert_eq!(doc_json(&fs), before, "persisted doc unchanged");
|
||||
assert_eq!(was_running(&fs, a), Some(true));
|
||||
}
|
||||
@ -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