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

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