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");
}

View File

@ -874,18 +874,40 @@ impl LaunchAgent {
// (§17.4) : un agent est vivant s'il a une session PTY *ou* structurée.
// On consulte d'abord le registre PTY (chemin historique), puis le
// registre structuré (le cas échéant).
//
// R0a — discrimination réattache-de-vue vs second lancement (cadrage v5
// §3.2, Trou A). Un agent est un **singleton** : une seule session vivante.
// Quand l'agent est déjà vivant, on distingue (cf. [`Self::reattach_decision`]) :
// - **réattache de vue** (rebind sans respawn) : le `node_id` demandé est
// le node hôte vivant, OU la cellule porte une `conversation_id`
// (réattache explicite — la cellule sait que l'agent tournait) ;
// - **lancement background/idempotent** : ni node, ni conversation ⇒ no-op
// (rend la session existante, pas de respawn) ;
// - **second lancement neuf** : on vise un **autre** node, sans signal de
// réattache ⇒ refus [`AppError::AgentAlreadyRunning`] (node hôte rapporté).
if let Some(existing_id) = self.sessions.session_for_agent(&input.agent_id) {
if let Some(node_id) = input.node_id {
if let Some(session) = self.sessions.rebind_agent_node(&input.agent_id, node_id) {
return Ok(LaunchAgentOutput {
session,
assigned_conversation_id: None,
structured: None,
let host_node = self.sessions.node_for_agent(&input.agent_id);
match reattach_decision(input.node_id, host_node, input.conversation_id.as_deref()) {
ReattachDecision::Rebind { node_id } => {
if let Some(session) = self.sessions.rebind_agent_node(&input.agent_id, node_id)
{
return Ok(LaunchAgentOutput {
session,
assigned_conversation_id: None,
structured: None,
});
}
}
ReattachDecision::Idempotent => {}
ReattachDecision::Refuse { node_id } => {
return Err(AppError::AgentAlreadyRunning {
agent_id: input.agent_id,
node_id,
});
}
}
// Idempotent — hand back the already-registered session, no respawn,
// nothing new to persist.
// Idempotent (or a rebind that found no entry to move) — hand back the
// already-registered session, no respawn, nothing new to persist.
if let Some(session) = self.sessions.session(&existing_id) {
return Ok(LaunchAgentOutput {
session,
@ -894,18 +916,30 @@ impl LaunchAgent {
});
}
}
// Garde structurée (§17.4) : même sémantique côté registre IA. Rebind de la
// cellule-vue si un node est demandé, sinon idempotence (pas de redémarrage).
// Garde structurée (§17.4) : même sémantique côté registre IA. R0a appliqué de
// façon identique — rebind de la cellule-vue pour une réattache légitime,
// idempotence sans node/conversation, refus d'un second lancement neuf ailleurs.
if let Some(structured) = &self.structured {
if let Some(existing) = structured.session_for_agent(&input.agent_id) {
// Cellule-vue : le node demandé, sinon le node hôte courant, sinon un
// node neuf (rebind sans redémarrer le process, « la cellule est une
// vue »).
let node_id = input.node_id.or_else(|| structured.node_for_agent(&input.agent_id));
if let Some(node_id) = node_id {
let _ = structured.rebind_agent_node(&input.agent_id, node_id);
}
let node_id = node_id.unwrap_or_else(NodeId::new_random);
let host_node = structured.node_for_agent(&input.agent_id);
let node_id = match reattach_decision(
input.node_id,
host_node,
input.conversation_id.as_deref(),
) {
ReattachDecision::Rebind { node_id } => {
let _ = structured.rebind_agent_node(&input.agent_id, node_id);
node_id
}
// Idempotent — garder le node hôte courant, sinon un node neuf.
ReattachDecision::Idempotent => host_node.unwrap_or_else(NodeId::new_random),
ReattachDecision::Refuse { node_id } => {
return Err(AppError::AgentAlreadyRunning {
agent_id: input.agent_id,
node_id,
});
}
};
return Ok(LaunchAgentOutput {
session: structured_snapshot(&existing, input.agent_id, node_id, size),
assigned_conversation_id: None,
@ -1339,6 +1373,80 @@ impl LaunchAgent {
}
}
/// Outcome of the R0a discrimination between a legitimate **view reattach** and a
/// **fresh second launch** of an already-live agent (cadrage v5 §3.2, Trou A).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReattachDecision {
/// Rebind the live session's view to `node_id` without respawning (the request
/// targets the live host node, or carries an explicit reattach signal).
Rebind { node_id: NodeId },
/// Background / idempotent no-op: hand back the existing session unchanged
/// (neither a node nor a conversation id was supplied).
Idempotent,
/// Refuse the launch — a genuine second launch of a singleton agent already
/// live on `node_id` (the host node, reported in `AgentAlreadyRunning`).
Refuse { node_id: NodeId },
}
impl ReattachDecision {
/// Decides reattach vs refuse for an already-live agent. See [`reattach_decision`].
#[must_use]
pub(crate) fn resolve(
requested_node: Option<NodeId>,
host_node: Option<NodeId>,
conversation_id: Option<&str>,
) -> Self {
reattach_decision(requested_node, host_node, conversation_id)
}
}
/// Decides whether a launch hitting an already-live agent is a legitimate view
/// **reattach** (rebind), a **background/idempotent** no-op, or a **refused** second
/// launch (cadrage v5 §3.2, Trou A). Pure (no I/O), unit-testable.
///
/// Signals (both already present in the launch flow):
/// - `requested_node` — the hosting leaf the caller targets (`None` ⇒ no cell, e.g.
/// a background launch);
/// - `host_node` — the node currently hosting the agent's live session, if known;
/// - `conversation_id` — the conversation id recorded on the hosting cell. Its
/// presence means the **cell knows the agent was running** ⇒ it is a reattach of a
/// view onto an in-progress session, not a brand-new launch.
///
/// Rules:
/// - requested node **is** the host node ⇒ `Rebind` (re-open of the same cell);
/// - a `conversation_id` is present ⇒ `Rebind` (explicit reattach, even on another
/// node — the cell is a rebindable view, §17.6);
/// - no node **and** no conversation ⇒ `Idempotent` (background/no-op relaunch);
/// - a different node, no reattach signal ⇒ `Refuse` (genuine second launch).
fn reattach_decision(
requested_node: Option<NodeId>,
host_node: Option<NodeId>,
conversation_id: Option<&str>,
) -> ReattachDecision {
// Explicit reattach: the cell carries a conversation id, so it is re-binding a
// view onto an already-running session. Rebind to the requested node, or keep the
// current host node when none was supplied.
if conversation_id.is_some() {
if let Some(node_id) = requested_node.or(host_node) {
return ReattachDecision::Rebind { node_id };
}
return ReattachDecision::Idempotent;
}
match requested_node {
// Same cell re-opened ⇒ idempotent rebind (no respawn).
Some(node) if host_node == Some(node) => ReattachDecision::Rebind { node_id: node },
// A different cell with no reattach signal ⇒ genuine second launch: refuse,
// reporting the live host node (falling back to the requested node only if the
// host is somehow unknown, so the error always carries a meaningful cell).
Some(node) => ReattachDecision::Refuse {
node_id: host_node.unwrap_or(node),
},
// No node and no reattach signal ⇒ background/idempotent no-op.
None => ReattachDecision::Idempotent,
}
}
/// Builds the IdeA MCP server declaration written into a CLI's `ConfigFile` MCP
/// config (cadrage v3, Décision 3). Coherent **placeholder** for M1: it describes a
/// stdio/socket server launched by the `idea` binary — the exact command and the

View File

@ -14,6 +14,7 @@ mod structured;
mod usecases;
pub(crate) use lifecycle::unique_md_path;
pub(crate) use lifecycle::ReattachDecision;
pub use structured::send_blocking;

View File

@ -21,6 +21,7 @@
//! `TerminalSessions` registry, keyed by that same `SessionId`.
mod management;
mod reconcile;
mod snapshot;
mod store;
mod usecases;
@ -30,6 +31,7 @@ pub use management::{
DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout,
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput,
};
pub use reconcile::{ReconcileLayouts, ReconcileLayoutsInput, ReconcileLayoutsOutput};
pub use snapshot::{
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput,
};

View File

@ -0,0 +1,101 @@
//! [`ReconcileLayouts`] — réconcilie, à l'**ouverture** d'un projet, les
//! `layouts.json` qui contiennent des feuilles en **doublon** sur un même agent
//! (lot R0c du cadrage orchestration v5, §3.4 « Trou C »).
//!
//! C'est la **jumelle** de [`super::snapshot::SnapshotRunningAgents`] : là où le
//! snapshot **gèle** `agent_was_running` à la *fermeture*, cette réconciliation
//! **dé-doublonne** à l'*ouverture*. Un `layouts.json` persisté peut déjà porter
//! deux feuilles sur le **même** `agent` id ; à la réouverture il ne faut **pas**
//! relancer la 2ᵉ. On garde **une** feuille « hôte » (potentiellement vivante /
//! reprenable) et on transforme les autres en **vues mortes** : leur
//! `agent_was_running` repasse à `false` et leur `conversation_id` est retiré.
//! C'est précisément ce qui éliminait le symptôme « une cellule reset au retour
//! d'onglet ».
//!
//! Le use case est un mince orchestrateur au-dessus de :
//! - le store des layouts persistés ([`super::store`]),
//! - l'opération pure du domaine
//! [`domain::LayoutTree::reconcile_duplicate_agents`] (qui porte la **règle
//! déterministe de choix de l'hôte** et la garantie d'idempotence).
//!
//! **Idempotence / no-op** : un layout sans doublon (ou déjà réconcilié) ressort
//! **identique** de l'opération pure ; on ne persiste alors **rien** (aucune
//! écriture). Une 2ᵉ ouverture du même projet réconcilié est donc un no-op.
//!
//! **Ordre d'insertion** : à appeler à l'ouverture du projet, **après** le
//! chargement/résolution des layouts et **avant** toute reprise
//! ([`crate::ListResumableAgents`]) — qui relit la version persistée — de sorte
//! qu'on ne propose / ne relance qu'**une** session par agent.
use std::sync::Arc;
use domain::ports::{FileSystem, ProjectStore};
use domain::ProjectId;
use crate::error::AppError;
use super::store::{persist_doc, resolve_doc};
/// Input de [`ReconcileLayouts::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReconcileLayoutsInput {
/// Le projet dont les layouts doivent être dé-doublonnés.
pub project_id: ProjectId,
}
/// Output de [`ReconcileLayouts::execute`].
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ReconcileLayoutsOutput {
/// `true` si au moins un layout a été modifié (et donc le doc persisté).
/// `false` ⇒ aucun doublon : no-op, aucune écriture (idempotence).
pub changed: bool,
}
/// Réconcilie les feuilles d'agent en doublon de tous les layouts d'un projet,
/// puis persiste le doc **uniquement** s'il a changé.
pub struct ReconcileLayouts {
store: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
}
impl ReconcileLayouts {
/// Construit le use case à partir de ses ports injectés.
#[must_use]
pub fn new(store: Arc<dyn ProjectStore>, fs: Arc<dyn FileSystem>) -> Self {
Self { store, fs }
}
/// Exécute la réconciliation pour un projet.
///
/// Pour chaque layout, applique l'opération pure
/// [`domain::LayoutTree::reconcile_duplicate_agents`]. Si **aucun** arbre n'a
/// changé, ne persiste **rien** (no-op idempotent). Sinon, persiste tout le
/// doc une fois.
///
/// # Errors
/// - [`AppError::NotFound`] si le projet est inconnu,
/// - [`AppError::Store`] sur défaillance du registre,
/// - [`AppError::FileSystem`] sur défaillance de persistance.
pub async fn execute(
&self,
input: ReconcileLayoutsInput,
) -> Result<ReconcileLayoutsOutput, AppError> {
let project = self.store.load_project(input.project_id).await?;
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
let mut changed = false;
for named in &mut doc.layouts {
let reconciled = named.tree.reconcile_duplicate_agents();
if reconciled != named.tree {
named.tree = reconciled;
changed = true;
}
}
if changed {
persist_doc(self.fs.as_ref(), &project, &doc).await?;
}
Ok(ReconcileLayoutsOutput { changed })
}
}

View File

@ -59,9 +59,10 @@ pub use layout::{
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts,
ListLayoutsInput, ListLayoutsOutput, LoadLayout, LoadLayoutInput, LoadLayoutOutput,
MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, RenameLayout,
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, SnapshotRunningAgents,
SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, LAYOUTS_FILE,
MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, ReconcileLayouts,
ReconcileLayoutsInput, ReconcileLayoutsOutput, RenameLayout, RenameLayoutInput,
SetActiveLayout, SetActiveLayoutInput, SnapshotRunningAgents, SnapshotRunningAgentsInput,
SnapshotRunningAgentsOutput, LAYOUTS_FILE,
};
pub use memory::{
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,

View File

@ -13,15 +13,18 @@
//! no filesystem watching, no JSON, no process spawning here — those are the
//! infrastructure adapter's job. That keeps this fully unit-testable with fakes.
use std::sync::Arc;
use std::collections::HashMap;
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
use tokio::sync::Mutex as AsyncMutex;
use domain::ports::{EventBus, ProfileStore};
use domain::{DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project};
use domain::{AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project};
use crate::agent::{
send_blocking, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput,
ListAgents, ListAgentsInput, UpdateAgentContext, UpdateAgentContextInput,
ListAgents, ListAgentsInput, ReattachDecision, UpdateAgentContext, UpdateAgentContextInput,
};
use crate::error::AppError;
use crate::skill::{CreateSkill, CreateSkillInput};
@ -43,6 +46,21 @@ const DEFAULT_COLS: u16 = 80;
/// without changing the contract).
const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(300);
/// Borne d'attente **en file** pour acquérir le verrou de tour d'un agent (A0,
/// cadrage v5 §4).
///
/// La sérialisation FIFO par agent (« 1 agent = 1 employé : un tour à la fois »)
/// fait qu'un `ask` concurrent vers la **même** cible patiente derrière le tour en
/// cours. Le timeout de tour ([`ASK_AGENT_TIMEOUT`]) borne **le tour lui-même**,
/// **pas** cette attente : on lui donne donc son propre plafond, généreux mais fini,
/// pour qu'un `ask` ne reste jamais bloqué indéfiniment si la file est longue
/// (inanition). À l'expiration, l'`ask` renvoie un timeout typé (réutilise le
/// **même** type que le timeout de tour, cf. [`AppError::Process`] via
/// [`domain::ports::AgentSessionError::Timeout`]) ; le tour en cours n'est pas
/// affecté. Largeur = un tour complet + sa propre file ⇒ on autorise deux tours
/// pleins d'attente.
const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(600);
/// Dispatches validated orchestrator commands to the agent/terminal use cases.
pub struct OrchestratorService {
create_agent: Arc<CreateAgentFromScratch>,
@ -62,6 +80,25 @@ pub struct OrchestratorService {
/// `ask` réussi (§17.4). Injecté via [`Self::with_events`] ; `None` ⇒ pas de
/// publication (l'`ask` fonctionne quand même).
events: Option<Arc<dyn EventBus>>,
/// Verrous de **tour par agent** (A0, cadrage v5 §4) — sérialisation FIFO des
/// `ask` vers une **même** cible : « 1 agent = 1 employé, un tour à la fois ».
///
/// Clé = `AgentId` de la **cible** ; valeur = un `tokio::Mutex` d'unité dont le
/// garde est tenu **à travers** le `.await` de `send_blocking` (d'où le mutex
/// **async**, pas `std`). La `HashMap` elle-même n'est tenue que le temps du
/// get-or-create (jamais à travers un `.await`), donc protégée par un mutex
/// **synchrone** `std` — pas de garde gardé en travers d'un point de suspension.
///
/// Le registre vit **ici** (règle applicative d'orchestration, frontière
/// hexagonale, cadrage v5 §5) et **non** dans [`StructuredSessions`] : sérialiser
/// les tours est une responsabilité de l'orchestrateur, pas du stockage de
/// sessions (SRP) ; le couplage reste minimal. Verrou **par agent** ⇒ deux `ask`
/// vers des agents **différents** n'entrent jamais en contention (un map d'`Arc`
/// distincts).
///
/// Croissance bornée en pratique au nombre d'agents du projet ; une entrée
/// morte ne coûte qu'un `Arc<Mutex<()>>` vide (pas de session, pas de process).
ask_locks: StdMutex<HashMap<AgentId, Arc<AsyncMutex<()>>>>,
}
/// Outcome of dispatching a command — a short, human-readable success summary the
@ -102,9 +139,24 @@ impl OrchestratorService {
sessions,
structured: None,
events: None,
ask_locks: StdMutex::new(HashMap::new()),
}
}
/// Returns the per-agent **turn lock**, creating it on first use.
///
/// Get-or-create under the synchronous map mutex (held only for this lookup,
/// never across an `.await`); the returned `Arc<AsyncMutex<()>>` is the lock the
/// caller acquires (and holds across `send_blocking`) to serialise turns for
/// `agent_id`. Two different agents get two distinct `Arc`s ⇒ no contention.
fn ask_lock_for(&self, agent_id: &AgentId) -> Arc<AsyncMutex<()>> {
let mut locks = self
.ask_locks
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Arc::clone(locks.entry(*agent_id).or_default())
}
/// Branche le registre des sessions **structurées** (§17.5) pour servir
/// `agent.message`/[`OrchestratorCommand::AskAgent`]. Builder additif façon D3 :
/// signature de [`Self::new`] **inchangée** (les tests/call sites legacy restent
@ -202,18 +254,43 @@ impl OrchestratorService {
});
}
OrchestratorVisibility::Visible { node_id } => {
let session = self
.sessions
.rebind_agent_node(&agent_id, node_id)
.ok_or_else(|| {
AppError::NotFound(format!(
"running session {session_id} for agent {name}"
))
})?;
return Ok(OrchestratorOutcome {
detail: format!("attached agent {name} to cell {}", session.node_id),
reply: None,
});
// R0a — même règle que le garde de `LaunchAgent` (cadrage v5 §3.2,
// Trou A) : un `spawn` est un **lancement neuf** (pas de
// conversation portée), donc ré-attacher à la **même** cellule
// hôte est légitime (rebind de vue), mais viser un **autre** node
// pour un agent singleton déjà vivant est un second lancement ⇒
// refus `AgentAlreadyRunning`.
let host_node = self.sessions.node_for_agent(&agent_id);
match ReattachDecision::resolve(Some(node_id), host_node, None) {
ReattachDecision::Rebind { node_id } => {
let session = self
.sessions
.rebind_agent_node(&agent_id, node_id)
.ok_or_else(|| {
AppError::NotFound(format!(
"running session {session_id} for agent {name}"
))
})?;
return Ok(OrchestratorOutcome {
detail: format!(
"attached agent {name} to cell {}",
session.node_id
),
reply: None,
});
}
ReattachDecision::Refuse { node_id } => {
return Err(AppError::AgentAlreadyRunning { agent_id, node_id });
}
// A `Visible` spawn always carries a node, so the decision is
// never `Idempotent`; treat it as a same-node rebind for safety.
ReattachDecision::Idempotent => {
return Ok(OrchestratorOutcome {
detail: format!("agent {name} already running"),
reply: None,
});
}
}
}
}
}
@ -287,6 +364,29 @@ impl OrchestratorService {
.await?
.ok_or_else(|| AppError::NotFound(format!("agent {target}")))?;
// Sérialisation FIFO **par agent** (A0, cadrage v5 §4) : on acquiert le verrou
// de tour de la **cible** avant tout `send_blocking`, et on le tient pour TOUT
// le tour réel (rendez-vous direct *ou* lancement-puis-envoi sur cible morte).
// Le garde `_turn` est RAII : il tombe en fin de portée, y compris sur chaque
// early-return d'erreur ci-dessous ⇒ le tour suivant en file démarre alors.
//
// Verrou par `agent_id` ⇒ un `ask` vers A et un vers B ne se bloquent jamais ;
// un seul verrou tenu (celui de la cible) ⇒ pas d'inter-verrouillage/deadlock.
// Le timeout de TOUR ([`ASK_AGENT_TIMEOUT`]) borne `send_blocking`, **pas**
// l'attente du verrou ; cette attente a son **propre** plafond
// ([`ASK_QUEUE_WAIT_CAP`]) pour éviter l'inanition.
let lock = self.ask_lock_for(&agent_id);
let _turn = match tokio::time::timeout(ASK_QUEUE_WAIT_CAP, lock.lock_owned()).await {
Ok(guard) => guard,
Err(_elapsed) => {
// Réutilise le **même** type de timeout que le tour (cadrage v5 §4) :
// `AgentSessionError::Timeout` ⇒ `AppError::Process`.
return Err(AppError::from(
domain::ports::AgentSessionError::Timeout,
));
}
};
// 1. Session structurée déjà vivante ? ⇒ rendez-vous direct.
if let Some(session) = structured.session_for_agent(&agent_id) {
let content = send_blocking(session.as_ref(), &task, Some(ASK_AGENT_TIMEOUT)).await?;

View File

@ -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]

View File

@ -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.

View 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));
}

View File

@ -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")
}
// ===========================================================================

View File

@ -620,6 +620,87 @@ impl LayoutTree {
out
}
/// Réconcilie les feuilles en doublon sur un même agent : à la réouverture
/// d'un projet, un `layouts.json` persisté peut contenir **plusieurs**
/// feuilles portant le **même** `agent` id (constaté). L'invariant produit
/// est **« 1 session vivante par agent »** : une seule feuille doit rester
/// « hôte » (potentiellement vivante / reprenable), les autres deviennent des
/// **vues mortes** — elles gardent leur agent (la cellule reste), mais ne sont
/// plus considérées « était en cours » : leur `agent_was_running` repasse à
/// `false` et leur `conversation_id` est retiré, de sorte qu'aucune reprise ni
/// relance ne les vise.
///
/// ## Règle déterministe de choix de l'hôte
///
/// Parmi les N feuilles d'un même agent, **dans l'ordre de parcours
/// déterministe de l'arbre** (le même pré-ordre que [`Self::agent_leaves`]),
/// l'hôte est la **première feuille porteuse d'un signal de reprise**
/// (`conversation_id.is_some()` **ou** `agent_was_running`) ; à défaut de tout
/// signal, l'hôte est simplement la **première** feuille rencontrée. On garde
/// ainsi sur l'unique hôte l'état de reprise le plus pertinent, et le choix est
/// totalement déterministe (l'ordre de parcours est stable).
///
/// ## Idempotence
///
/// Un arbre **sans doublon** est renvoyé **inchangé** (`self.clone()` à
/// l'identique). Un arbre **déjà réconcilié** (≤ 1 feuille par agent porte un
/// signal de reprise) l'est aussi : la 2ᵉ passe ne dé-flagge rien. La fonction
/// est donc un point fixe — utile pour garantir le no-op à la 2ᵉ ouverture.
///
/// Pure : renvoie un nouvel arbre (non revalidé — la réconciliation ne touche
/// ni la structure ni les sessions, seuls des champs scalaires de feuille).
#[must_use]
pub fn reconcile_duplicate_agents(&self) -> Self {
use std::collections::{HashMap, HashSet};
// Première passe (lecture seule) : pour chaque agent, déterminer le node
// hôte selon la règle déterministe ci-dessus.
let mut host_of: HashMap<AgentId, NodeId> = HashMap::new();
let mut has_signal: HashSet<AgentId> = HashSet::new();
for (node_id, agent_id) in self.agent_leaves() {
// `leaf` ne peut pas être `None` ici : le node vient de l'arbre.
let signal = self
.leaf(node_id)
.is_some_and(|l| l.conversation_id.is_some() || l.agent_was_running);
match host_of.get(&agent_id) {
// Pas encore d'hôte : cette feuille le devient (provisoirement).
None => {
host_of.insert(agent_id, node_id);
if signal {
has_signal.insert(agent_id);
}
}
// Un hôte sans signal est supplanté par la première feuille à
// signal rencontrée ensuite.
Some(_) if signal && !has_signal.contains(&agent_id) => {
host_of.insert(agent_id, node_id);
has_signal.insert(agent_id);
}
Some(_) => {}
}
}
// Seconde passe : dé-flagger toute feuille d'agent qui n'est pas son hôte.
let root = map_node(&self.root, &mut |node| {
if let LayoutNode::Leaf(leaf) = node {
if let Some(agent) = leaf.agent {
let is_host = host_of.get(&agent) == Some(&leaf.id);
if !is_host && (leaf.agent_was_running || leaf.conversation_id.is_some()) {
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session: leaf.session,
agent: leaf.agent,
conversation_id: None,
agent_was_running: false,
});
}
}
}
node.clone()
});
Self { root }
}
/// Retrouve la [`LeafCell`] portant l'identifiant `node`.
///
/// Renvoie `None` si aucun node ne porte cet id, **ou** si le node existe mais

View File

@ -826,6 +826,209 @@ fn move_session_preserves_resume_fields_on_both_leaves() {
);
}
// ---------------------------------------------------------------------------
// reconcile_duplicate_agents (R0c: dé-doublonnage à l'ouverture)
// ---------------------------------------------------------------------------
/// Builds an agent-bearing leaf with explicit resume signals.
fn agent_leaf_full(
id: u128,
agent: u128,
conv: Option<&str>,
running: bool,
) -> LeafCell {
LeafCell {
id: node(id),
session: None,
agent: Some(agent_id(agent)),
conversation_id: conv.map(str::to_string),
agent_was_running: running,
}
}
/// Wraps an ordered list of leaves into a single Row split (pre-order = the
/// children order, same traversal as `agent_leaves`).
fn split_of(leaves: Vec<LeafCell>) -> LayoutTree {
LayoutTree::new(LayoutNode::Split(SplitContainer {
id: node(900),
direction: Direction::Row,
children: leaves
.into_iter()
.map(|l| WeightedChild {
node: LayoutNode::Leaf(l),
weight: 1.0,
})
.collect(),
}))
}
/// 1. Two leaves of the SAME agent, both `agent_was_running = true`: after
/// reconciliation exactly ONE stays running; the other is neutralised
/// (running=false, conversation_id=None) but the leaf/agent SURVIVES.
#[test]
fn reconcile_basic_duplicate_keeps_single_host() {
let tree = split_of(vec![
agent_leaf_full(1, 7, None, true),
agent_leaf_full(2, 7, None, true),
]);
let out = tree.reconcile_duplicate_agents();
// First in pre-order is the host (no resume signal differentiates them).
let host = leaf_for(&out, node(1)).expect("leaf 1");
let other = leaf_for(&out, node(2)).expect("leaf 2");
assert_eq!(host.agent_was_running, true, "host stays running");
assert_eq!(other.agent_was_running, false, "duplicate neutralised");
assert_eq!(other.conversation_id, None);
// The leaf and its agent binding still exist (cell not removed).
assert_eq!(host.agent, Some(agent_id(7)));
assert_eq!(other.agent, Some(agent_id(7)));
// Exactly one running leaf for the agent.
let running = [&host, &other]
.iter()
.filter(|l| l.agent_was_running)
.count();
assert_eq!(running, 1);
}
/// 2a. Host chosen by resume signal: the FIRST leaf carries no signal, the
/// SECOND carries one (conversation_id). The second must become the host; the
/// first (no signal) is left untouched (already neutral).
#[test]
fn reconcile_host_is_first_leaf_carrying_resume_signal() {
let tree = split_of(vec![
agent_leaf_full(1, 7, None, false), // no signal, first in order
agent_leaf_full(2, 7, Some("conv-2"), false), // signal → becomes host
]);
let out = tree.reconcile_duplicate_agents();
let first = leaf_for(&out, node(1)).expect("leaf 1");
let second = leaf_for(&out, node(2)).expect("leaf 2");
assert_eq!(
second.conversation_id,
Some("conv-2".to_string()),
"the signal-bearing leaf is kept as host"
);
// The first leaf has no signal to strip; stays neutral.
assert_eq!(first.conversation_id, None);
assert_eq!(first.agent_was_running, false);
}
/// 2b. When NO leaf carries a signal, the host is simply the first encountered.
/// Here both are inert; reconciliation must leave them unchanged (nothing to
/// strip), and not invent a running flag.
#[test]
fn reconcile_no_signal_host_is_first_and_tree_unchanged() {
let tree = split_of(vec![
agent_leaf_full(1, 7, None, false),
agent_leaf_full(2, 7, None, false),
]);
let out = tree.reconcile_duplicate_agents();
assert_eq!(out, tree, "no resume signal anywhere → nothing to strip");
}
/// 2c. The signal-bearing second leaf wins even against a first leaf that has a
/// signal too? No — the FIRST signal-bearer wins. Verify the first running leaf
/// stays host and the second (also running) is neutralised.
#[test]
fn reconcile_first_signal_bearer_wins_over_later_signal() {
let tree = split_of(vec![
agent_leaf_full(1, 7, Some("conv-1"), true), // first signal → host
agent_leaf_full(2, 7, Some("conv-2"), true),
]);
let out = tree.reconcile_duplicate_agents();
let host = leaf_for(&out, node(1)).expect("leaf 1");
let other = leaf_for(&out, node(2)).expect("leaf 2");
assert_eq!(host.conversation_id, Some("conv-1".to_string()));
assert_eq!(host.agent_was_running, true);
assert_eq!(other.conversation_id, None, "later duplicate stripped");
assert_eq!(other.agent_was_running, false);
}
/// 3. Triplet (N=3) of the same agent: exactly one host, two neutralised.
#[test]
fn reconcile_triplet_one_host_two_neutralised() {
let tree = split_of(vec![
agent_leaf_full(1, 7, None, true),
agent_leaf_full(2, 7, None, true),
agent_leaf_full(3, 7, Some("c3"), true),
]);
let out = tree.reconcile_duplicate_agents();
let l1 = leaf_for(&out, node(1)).unwrap();
let l2 = leaf_for(&out, node(2)).unwrap();
let l3 = leaf_for(&out, node(3)).unwrap();
// First signal-bearer in pre-order is leaf 1 (running=true is a signal).
let running_count = [&l1, &l2, &l3]
.iter()
.filter(|l| l.agent_was_running)
.count();
assert_eq!(running_count, 1, "exactly one host remains running");
assert_eq!(l1.agent_was_running, true, "leaf 1 is the host");
assert_eq!(l2.agent_was_running, false);
assert_eq!(l3.agent_was_running, false);
assert_eq!(l3.conversation_id, None, "duplicate conv stripped");
// All three cells survive.
for l in [&l1, &l2, &l3] {
assert_eq!(l.agent, Some(agent_id(7)));
}
}
/// 4. Distinct agents are never affected: two leaves with DIFFERENT agents,
/// each running, must both stay running.
#[test]
fn reconcile_distinct_agents_unaffected() {
let tree = split_of(vec![
agent_leaf_full(1, 7, Some("c1"), true),
agent_leaf_full(2, 8, Some("c2"), true),
]);
let out = tree.reconcile_duplicate_agents();
assert_eq!(out, tree, "different agents → no duplicates → unchanged");
}
/// 5a. Idempotence (fixed point): reconcile(reconcile(t)) == reconcile(t).
#[test]
fn reconcile_is_idempotent_fixed_point() {
let tree = split_of(vec![
agent_leaf_full(1, 7, Some("c1"), true),
agent_leaf_full(2, 7, Some("c2"), true),
agent_leaf_full(3, 7, None, true),
]);
let once = tree.reconcile_duplicate_agents();
let twice = once.reconcile_duplicate_agents();
assert_eq!(twice, once, "second pass is a no-op (fixed point)");
}
/// 5b. A tree WITHOUT duplicates is returned unchanged (== t).
#[test]
fn reconcile_no_duplicate_returns_identical() {
let tree = split_of(vec![
agent_leaf_full(1, 7, Some("c1"), true),
agent_leaf_full(2, 8, None, false),
leaf(3, Some(100)), // plain non-agent leaf
]);
let out = tree.reconcile_duplicate_agents();
assert_eq!(out, tree, "no duplicate agent → identical tree");
}
/// Source immutability: reconciliation must not mutate the input tree.
#[test]
fn reconcile_is_immutable_source_unchanged() {
let tree = split_of(vec![
agent_leaf_full(1, 7, None, true),
agent_leaf_full(2, 7, None, true),
]);
let before = tree.clone();
let _ = tree.reconcile_duplicate_agents();
assert_eq!(tree, before, "source tree must not be mutated");
}
// ---------------------------------------------------------------------------
// serde: compat ascendante & combinaisons des nouveaux champs
// ---------------------------------------------------------------------------