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

@ -15,11 +15,12 @@ use application::{
DeleteLayoutInput, DeleteMemoryInput, DeleteSkillInput, DeleteTemplateInput,
DetectAgentDriftInput, GetMemoryInput, GitBranchesInput, GitCheckoutInput, GitCommitInput,
GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput,
InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput,
InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput, LiveSessions,
ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LoadLayoutInput, MutateLayoutInput,
OpenProjectInput,
ReadAgentContextInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
RenameLayoutInput, ResolveMemoryLinksInput, SetActiveLayoutInput, SnapshotRunningAgentsInput,
ReconcileLayoutsInput, RenameLayoutInput, ResolveMemoryLinksInput, SetActiveLayoutInput,
SnapshotRunningAgentsInput,
SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContextInput,
UpdateMemoryInput, UpdateProjectContextInput, UpdateSkillInput,
};
@ -108,6 +109,15 @@ pub async fn open_project(
.execute(OpenProjectInput { project_id: id })
.await
.map_err(ErrorDto::from)?;
// R0c (§3.4 « Trou C ») : dé-doublonne les `layouts.json` portant plusieurs
// feuilles sur le même agent AVANT toute reprise (`list_resumable_agents`
// relit la version persistée), de sorte qu'on ne propose / ne relance qu'une
// session par agent. Idempotent (no-op sans doublon) et best-effort : un échec
// ne doit pas bloquer l'ouverture.
let _ = state
.reconcile_layouts
.execute(ReconcileLayoutsInput { project_id: id })
.await;
// (Re)start the orchestrator watcher for this project (idempotent, §14.3).
state.ensure_orchestrator_watch(&output.project);
Ok(ProjectDto::from(output))
@ -835,9 +845,18 @@ pub async fn list_agents(
.map_err(ErrorDto::from)
}
/// `list_live_agents` — list the agents that currently own a live PTY session
/// and the cell hosting each, so the UI can disable an agent already running in
/// another cell (the "one live session per agent" invariant).
/// `list_live_agents` — list every agent that currently owns a live session
/// (raw PTY **or** structured/chat) and the cell hosting each, so the UI can
/// disable an agent already running in another cell (the "one live session per
/// agent" invariant).
///
/// Reads the **aggregator** [`LiveSessions::live_agents`], the single source of
/// truth for liveness across both registries (PTY + structured). Reading only
/// `terminal_sessions` here was blind to structured chat agents, so a live chat
/// agent would not be disabled in the UI and could be relaunched elsewhere
/// (cadrage v5 §3.3, Trou B). `LiveSessions` is built on the fly from the two
/// `Arc` registries already held by [`AppState`]; the aggregation logic itself
/// is not duplicated.
///
/// `project_id` is accepted for API symmetry and future per-project scoping; the
/// session registry is process-wide today, so the full live set is returned (a
@ -854,9 +873,11 @@ pub fn list_live_agents(
// Validate the id shape for a consistent contract, even though the registry
// is not project-scoped yet.
let _ = parse_project_id(&project_id)?;
Ok(LiveAgentListDto::from_pairs(
state.terminal_sessions.live_agents(),
))
let live = LiveSessions::new(
std::sync::Arc::clone(&state.terminal_sessions),
std::sync::Arc::clone(&state.structured_sessions),
);
Ok(LiveAgentListDto::from_pairs(live.live_agents()))
}
/// `attach_live_agent` — rebind an already-running agent session to a visible

View File

@ -1349,11 +1349,19 @@ pub struct LiveAgentListDto(pub Vec<LiveAgentDto>);
impl LiveAgentListDto {
/// Builds the wire list from the registry's `(AgentId, NodeId, SessionId)` tuples.
///
/// De-duplicates by `agent_id`: the "one live session per agent" invariant
/// guarantees an agent is live in at most one registry (PTY **or**
/// structured), but the aggregated input could in theory carry the same agent
/// twice; the UI contract is a dup-free set (each agent appears once), so we
/// keep the first occurrence and drop any later one for the same agent.
#[must_use]
pub fn from_pairs(pairs: Vec<(AgentId, NodeId, domain::SessionId)>) -> Self {
let mut seen = std::collections::HashSet::new();
Self(
pairs
.into_iter()
.filter(|(agent_id, _, _)| seen.insert(*agent_id))
.map(|(agent_id, node_id, session_id)| LiveAgentDto {
agent_id: agent_id.to_string(),
node_id: node_id.to_string(),

View File

@ -22,7 +22,8 @@ use application::{
ListTemplates,
LiveAgentRegistry, LoadLayout, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
OpenTerminal, OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext,
RecallMemory, ReferenceProfiles, RenameLayout, ResizeTerminal, ResolveMemoryLinks,
RecallMemory, ReconcileLayouts, ReferenceProfiles, RenameLayout, ResizeTerminal,
ResolveMemoryLinks,
SaveEmbedderProfile, SaveProfile, SetActiveLayout, SnapshotRunningAgents, StructuredSessions,
SuggestedThisSession,
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext,
@ -98,6 +99,9 @@ pub struct AppState {
pub set_active_layout: Arc<SetActiveLayout>,
/// Freeze `agent_was_running` on every agent leaf before a PTY kill (T5).
pub snapshot_running_agents: Arc<SnapshotRunningAgents>,
/// Dé-doublonne, à l'ouverture, les feuilles d'agent en double d'un même
/// agent dans `layouts.json` (R0c). Idempotent : no-op sans doublon.
pub reconcile_layouts: Arc<ReconcileLayouts>,
/// Detect which candidate profiles' CLIs are installed (first-run).
pub detect_profiles: Arc<DetectProfiles>,
/// List configured profiles.
@ -380,6 +384,14 @@ impl AppState {
Arc::clone(&terminal_sessions) as Arc<dyn LiveAgentRegistry>,
));
// Twin of the snapshot above, but at *open* time: dé-doublonne les
// `layouts.json` portant plusieurs feuilles sur le même agent (R0c, §3.4
// « Trou C »). Idempotent : aucun doublon ⇒ aucune écriture.
let reconcile_layouts = Arc::new(ReconcileLayouts::new(
Arc::clone(&store_port),
Arc::clone(&fs_port),
));
// --- Profiles & AI runtime (L5) ---
// One generic, profile-driven runtime adapter (Open/Closed): it holds the
// process spawner used for detection. The profile store persists
@ -761,6 +773,7 @@ impl AppState {
delete_layout,
set_active_layout,
snapshot_running_agents,
reconcile_layouts,
detect_profiles,
list_profiles,
save_profile,

View File

@ -0,0 +1,180 @@
//! 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");
}