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>
181 lines
6.6 KiB
Rust
181 lines
6.6 KiB
Rust
//! LOT R0b (cadrage orchestration v5 §3.3, Trou B) — `list_live_agents` lit
|
|
//! l'agrégateur `LiveSessions` (PTY **+** structuré), plus seulement le registre
|
|
//! PTY. Un agent chat (structuré) vivant doit apparaître dans la liste, comme un
|
|
//! agent PTY ; les deux ensemble sans doublon ; aucune session ⇒ liste vide.
|
|
//!
|
|
//! Ces tests reproduisent **exactement** ce que fait la commande
|
|
//! `list_live_agents` : construire un `LiveSessions` à partir des deux registres
|
|
//! partagés et passer `live_agents()` à `LiveAgentListDto::from_pairs`. Ils
|
|
//! exercent donc le câblage de l'agrégateur **et** la dé-duplication par agent
|
|
//! portée par le DTO (contrat de liveness pour l'UI). 100 % fakes, sans process.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
|
|
use app_tauri_lib::dto::LiveAgentListDto;
|
|
use application::{LiveSessions, StructuredSessions, TerminalSessions};
|
|
use domain::ports::{AgentSession, AgentSessionError, PtyHandle, ReplyStream};
|
|
use domain::{
|
|
AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession,
|
|
};
|
|
use uuid::Uuid;
|
|
|
|
// --- petits constructeurs déterministes ------------------------------------
|
|
|
|
fn sid(n: u128) -> SessionId {
|
|
SessionId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn aid(n: u128) -> AgentId {
|
|
AgentId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn nid(n: u128) -> NodeId {
|
|
NodeId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
|
|
/// Fake minimal d'`AgentSession` : porte juste l'id (ce que le registre clé).
|
|
struct FakeSession {
|
|
id: SessionId,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AgentSession for FakeSession {
|
|
fn id(&self) -> SessionId {
|
|
self.id
|
|
}
|
|
fn conversation_id(&self) -> Option<String> {
|
|
None
|
|
}
|
|
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
|
Ok(Box::new(std::iter::empty()))
|
|
}
|
|
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn fake(id: SessionId) -> Arc<dyn AgentSession> {
|
|
Arc::new(FakeSession { id })
|
|
}
|
|
|
|
/// Insère un agent PTY dans `TerminalSessions` (jumeau du helper de D1).
|
|
fn insert_pty(pty: &TerminalSessions, s: SessionId, a: AgentId, n: NodeId) {
|
|
let session = TerminalSession::starting(
|
|
s,
|
|
n,
|
|
ProjectPath::new("/p").unwrap(),
|
|
SessionKind::Agent { agent_id: a },
|
|
PtySize::new(24, 80).unwrap(),
|
|
);
|
|
pty.insert(PtyHandle { session_id: s }, session);
|
|
}
|
|
|
|
/// Reproduit le corps de la commande `list_live_agents` (hors validation d'id).
|
|
fn dto_for(pty: &Arc<TerminalSessions>, structured: &Arc<StructuredSessions>) -> LiveAgentListDto {
|
|
let live = LiveSessions::new(Arc::clone(pty), Arc::clone(structured));
|
|
LiveAgentListDto::from_pairs(live.live_agents())
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 1. Un agent PTY vivant apparaît.
|
|
// ===========================================================================
|
|
#[test]
|
|
fn pty_live_agent_is_listed() {
|
|
let pty = Arc::new(TerminalSessions::new());
|
|
let structured = Arc::new(StructuredSessions::new());
|
|
|
|
let a = aid(10);
|
|
insert_pty(&pty, sid(1), a, nid(100));
|
|
|
|
let dto = dto_for(&pty, &structured);
|
|
assert_eq!(dto.0.len(), 1);
|
|
assert_eq!(dto.0[0].agent_id, a.to_string());
|
|
assert_eq!(dto.0[0].node_id, nid(100).to_string());
|
|
assert_eq!(dto.0[0].session_id, sid(1).to_string());
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 2. Un agent structuré/chat vivant apparaît (le cas qui régressait — Trou B).
|
|
// ===========================================================================
|
|
#[test]
|
|
fn structured_live_agent_is_listed() {
|
|
let pty = Arc::new(TerminalSessions::new());
|
|
let structured = Arc::new(StructuredSessions::new());
|
|
|
|
let a = aid(20);
|
|
structured.insert(fake(sid(2)), a, nid(200));
|
|
|
|
let dto = dto_for(&pty, &structured);
|
|
assert_eq!(
|
|
dto.0.len(),
|
|
1,
|
|
"un agent chat vivant doit apparaître (il était invisible avant R0b)"
|
|
);
|
|
assert_eq!(dto.0[0].agent_id, a.to_string());
|
|
assert_eq!(dto.0[0].node_id, nid(200).to_string());
|
|
assert_eq!(dto.0[0].session_id, sid(2).to_string());
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 3. Les deux types vivants en même temps ⇒ présents, sans doublon.
|
|
// ===========================================================================
|
|
#[test]
|
|
fn both_kinds_live_listed_without_duplicates() {
|
|
let pty = Arc::new(TerminalSessions::new());
|
|
let structured = Arc::new(StructuredSessions::new());
|
|
|
|
let pty_agent = aid(10);
|
|
let chat_agent = aid(20);
|
|
insert_pty(&pty, sid(1), pty_agent, nid(100));
|
|
structured.insert(fake(sid(2)), chat_agent, nid(200));
|
|
|
|
let dto = dto_for(&pty, &structured);
|
|
assert_eq!(dto.0.len(), 2, "les deux registres contribuent");
|
|
|
|
let mut ids: Vec<&str> = dto.0.iter().map(|d| d.agent_id.as_str()).collect();
|
|
ids.sort_unstable();
|
|
let mut expected = vec![pty_agent.to_string(), chat_agent.to_string()];
|
|
expected.sort_unstable();
|
|
assert_eq!(ids, expected);
|
|
|
|
// Aucun agent répété (contrat de liveness pour l'UI).
|
|
let mut uniq = ids.clone();
|
|
uniq.dedup();
|
|
assert_eq!(uniq.len(), dto.0.len(), "aucun doublon d'agent");
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 3bis. Même agent présent dans les DEUX registres ⇒ une seule entrée.
|
|
//
|
|
// L'invariant « 1 session vivante/agent » l'interdit normalement, mais le DTO
|
|
// doit rester dup-free par défense en profondeur : un seul `agent_id` sur le fil.
|
|
// ===========================================================================
|
|
#[test]
|
|
fn same_agent_in_both_registries_is_deduplicated() {
|
|
let pty = Arc::new(TerminalSessions::new());
|
|
let structured = Arc::new(StructuredSessions::new());
|
|
|
|
let a = aid(30);
|
|
insert_pty(&pty, sid(1), a, nid(100));
|
|
structured.insert(fake(sid(2)), a, nid(200));
|
|
|
|
let dto = dto_for(&pty, &structured);
|
|
assert_eq!(dto.0.len(), 1, "un même agent ne doit apparaître qu'une fois");
|
|
assert_eq!(dto.0[0].agent_id, a.to_string());
|
|
// On garde la première occurrence (PTY, listé en premier par l'agrégateur).
|
|
assert_eq!(dto.0[0].node_id, nid(100).to_string());
|
|
assert_eq!(dto.0[0].session_id, sid(1).to_string());
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 4. Aucune session ⇒ liste vide.
|
|
// ===========================================================================
|
|
#[test]
|
|
fn no_sessions_yields_empty_list() {
|
|
let pty = Arc::new(TerminalSessions::new());
|
|
let structured = Arc::new(StructuredSessions::new());
|
|
|
|
let dto = dto_for(&pty, &structured);
|
|
assert!(dto.0.is_empty(), "aucune session ⇒ liste vide");
|
|
}
|