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