feat(agent): conversation par paire + entrée médiée + pivot terminal/MCP
Coeur inter-agents consolidé et surface front réalignée sur la décision "terminal natif PTY, pas d'UI chat" (Option 1). Domaine - nouveaux modules conversation, mailbox, input, fileguard (ports + types) - orchestrator/profile/events étendus (conversation par paire, FIFO) Application / Infrastructure - orchestrator/service + context_guard : sérialisation FIFO par agent, garde RW mémoire/contexte, dispatch ask/reply - adapters in-memory conversation / mailbox / input / fileguard - registry session + lifecycle agent durcis (1 agent = 1 session vivante) - outils MCP idea_* alignés sur le nouveau dispatch Frontend - MediatedInput + useAgentBusy : entrée utilisateur médiée par IdeA, terminal = vue sortie inchangée - suppression de la vue chat structurée (AgentChatView) — abandonnée - adapter input + ports mis à jour Divers - .ideai/ : mémoire projet + briefs de cadrage versionnés ; requests/ runtime ignoré ; agents projet réels (DevBackend/DevFrontend/QA) Tests : Rust (domain/application/infrastructure/app-tauri) + front (346) verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
261
crates/infrastructure/src/mailbox/mod.rs
Normal file
261
crates/infrastructure/src/mailbox/mod.rs
Normal file
@ -0,0 +1,261 @@
|
||||
//! [`InMemoryMailbox`] — the [`AgentMailbox`] adapter (Option 1, lot B-1).
|
||||
//!
|
||||
//! The driven side of the inter-agent rendezvous: a per-agent FIFO of
|
||||
//! [`Ticket`]s, each paired with a one-shot reply channel. `enqueue` appends a
|
||||
//! ticket and hands the caller a [`PendingReply`] over the receiver; `resolve`
|
||||
//! feeds the target's `idea_reply` result into the **head** ticket's sender.
|
||||
//!
|
||||
//! All the concrete machinery the domain refuses to name lives here: the
|
||||
//! [`std::collections::VecDeque`] queue, its [`std::sync::Mutex`], and the
|
||||
//! [`tokio::sync::oneshot`] channel that bridges the resolving call to the awaiting
|
||||
//! one. The domain port ([`domain::mailbox`]) stays I/O-free.
|
||||
//!
|
||||
//! ## Concurrency
|
||||
//!
|
||||
//! The map is guarded by a **synchronous** [`Mutex`] held only for the O(1)
|
||||
//! enqueue/resolve/cancel mutations — **never across an `.await`** (the await is the
|
||||
//! caller's, on the returned [`PendingReply`], outside the lock). Two `ask`s for the
|
||||
//! **same** target serialise positionally in that target's `VecDeque`; two `ask`s
|
||||
//! for **different** targets touch different queues and never contend on the data,
|
||||
//! only briefly on the map mutex.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use domain::ids::AgentId;
|
||||
use domain::mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId};
|
||||
|
||||
/// One queued request plus the sender that resolves its awaiting [`PendingReply`].
|
||||
struct Slot {
|
||||
ticket: Ticket,
|
||||
reply: oneshot::Sender<String>,
|
||||
}
|
||||
|
||||
/// In-memory, per-agent FIFO mailbox (the production [`AgentMailbox`]).
|
||||
#[derive(Default)]
|
||||
pub struct InMemoryMailbox {
|
||||
queues: Mutex<HashMap<AgentId, VecDeque<Slot>>>,
|
||||
}
|
||||
|
||||
impl InMemoryMailbox {
|
||||
/// Creates an empty mailbox.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
queues: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of tickets currently queued for `agent` (test/inspection helper).
|
||||
#[must_use]
|
||||
pub fn pending(&self, agent: &AgentId) -> usize {
|
||||
self.queues
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(agent)
|
||||
.map_or(0, VecDeque::len)
|
||||
}
|
||||
|
||||
/// The id of the ticket currently at the head of `agent`'s queue, if any
|
||||
/// (test/inspection helper).
|
||||
#[must_use]
|
||||
pub fn head_ticket(&self, agent: &AgentId) -> Option<TicketId> {
|
||||
self.queues
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(agent)
|
||||
.and_then(|q| q.front())
|
||||
.map(|slot| slot.ticket.id)
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentMailbox for InMemoryMailbox {
|
||||
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
|
||||
let (tx, rx) = oneshot::channel::<String>();
|
||||
{
|
||||
let mut queues = self
|
||||
.queues
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
queues
|
||||
.entry(agent)
|
||||
.or_default()
|
||||
.push_back(Slot { ticket, reply: tx });
|
||||
}
|
||||
// The await happens in the application layer, outside the map lock. A closed
|
||||
// channel (sender dropped without a value, e.g. the head was cancelled or the
|
||||
// session ended) maps to a typed `Cancelled` rather than a raw recv error.
|
||||
PendingReply::new(Box::pin(async move {
|
||||
rx.await.map_err(|_| MailboxError::Cancelled)
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve(&self, agent: AgentId, result: String) -> Result<(), MailboxError> {
|
||||
// Pop the head slot under the lock, then send **outside** any await (the send
|
||||
// is non-blocking). If the receiver already went away (caller timed out), the
|
||||
// ticket is still correctly retired from the head — the queue advances.
|
||||
let slot = {
|
||||
let mut queues = self
|
||||
.queues
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let queue = queues
|
||||
.get_mut(&agent)
|
||||
.filter(|q| !q.is_empty())
|
||||
.ok_or(MailboxError::NoPendingRequest(agent))?;
|
||||
queue.pop_front().expect("non-empty queue has a head")
|
||||
};
|
||||
// A dropped receiver (the awaiting caller timed out and went away) is fine:
|
||||
// the reply is simply discarded, the head ticket is already retired.
|
||||
let _ = slot.reply.send(result);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_ticket(
|
||||
&self,
|
||||
agent: AgentId,
|
||||
ticket_id: TicketId,
|
||||
result: String,
|
||||
) -> Result<(), MailboxError> {
|
||||
// Remove the slot whose ticket id matches, anywhere in the queue (multi-thread
|
||||
// correlation), then send outside any await. A missing match is a typed
|
||||
// NoPendingRequest — never a panic.
|
||||
let slot = {
|
||||
let mut queues = self
|
||||
.queues
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let queue = queues
|
||||
.get_mut(&agent)
|
||||
.filter(|q| !q.is_empty())
|
||||
.ok_or(MailboxError::NoPendingRequest(agent))?;
|
||||
let pos = queue
|
||||
.iter()
|
||||
.position(|s| s.ticket.id == ticket_id)
|
||||
.ok_or(MailboxError::NoPendingRequest(agent))?;
|
||||
queue.remove(pos).expect("position just found is in range")
|
||||
};
|
||||
let _ = slot.reply.send(result);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cancel_head(&self, agent: AgentId, ticket_id: TicketId) {
|
||||
let mut queues = self
|
||||
.queues
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(queue) = queues.get_mut(&agent) {
|
||||
// Only retire the head, and only if it is *this* ticket: a head that has
|
||||
// since changed (already resolved, or someone else's ticket now in front)
|
||||
// must not be dropped. Idempotent and positional.
|
||||
if queue.front().map(|s| s.ticket.id) == Some(ticket_id) {
|
||||
queue.pop_front();
|
||||
}
|
||||
if queue.is_empty() {
|
||||
queues.remove(&agent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn agent(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn ticket(n: u128, task: &str) -> Ticket {
|
||||
Ticket::new(TicketId::from_uuid(uuid::Uuid::from_u128(n)), "Main", task)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enqueue_then_resolve_wakes_the_pending_reply() {
|
||||
let mb = InMemoryMailbox::new();
|
||||
let a = agent(1);
|
||||
let pending = mb.enqueue(a, ticket(10, "do X"));
|
||||
|
||||
mb.resolve(a, "done X".to_owned()).expect("resolve ok");
|
||||
|
||||
let reply = pending.await.expect("reply received");
|
||||
assert_eq!(reply, "done X");
|
||||
assert_eq!(mb.pending(&a), 0, "queue drained after resolve");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_asks_same_target_resolve_fifo_head_first() {
|
||||
let mb = InMemoryMailbox::new();
|
||||
let a = agent(1);
|
||||
let p1 = mb.enqueue(a, ticket(10, "first"));
|
||||
let p2 = mb.enqueue(a, ticket(11, "second"));
|
||||
assert_eq!(mb.pending(&a), 2);
|
||||
assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(10))));
|
||||
|
||||
// First resolve goes to the FIRST (head) ticket.
|
||||
mb.resolve(a, "r1".to_owned()).unwrap();
|
||||
assert_eq!(p1.await.unwrap(), "r1");
|
||||
// Now the second is at the head.
|
||||
assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(11))));
|
||||
mb.resolve(a, "r2".to_owned()).unwrap();
|
||||
assert_eq!(p2.await.unwrap(), "r2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn different_targets_do_not_block_each_other() {
|
||||
let mb = InMemoryMailbox::new();
|
||||
let a = agent(1);
|
||||
let b = agent(2);
|
||||
let pa = mb.enqueue(a, ticket(10, "task a"));
|
||||
let _pb = mb.enqueue(b, ticket(20, "task b"));
|
||||
|
||||
// Resolving B leaves A untouched; resolving A then completes pa.
|
||||
mb.resolve(b, "rb".to_owned()).unwrap();
|
||||
assert_eq!(mb.pending(&a), 1, "A's queue is independent of B's");
|
||||
mb.resolve(a, "ra".to_owned()).unwrap();
|
||||
assert_eq!(pa.await.unwrap(), "ra");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_without_pending_is_a_typed_error() {
|
||||
let mb = InMemoryMailbox::new();
|
||||
let a = agent(1);
|
||||
assert_eq!(
|
||||
mb.resolve(a, "orphan".to_owned()),
|
||||
Err(MailboxError::NoPendingRequest(a))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_head_retires_the_head_and_advances_the_queue() {
|
||||
let mb = InMemoryMailbox::new();
|
||||
let a = agent(1);
|
||||
let p1 = mb.enqueue(a, ticket(10, "stuck"));
|
||||
let p2 = mb.enqueue(a, ticket(11, "next"));
|
||||
|
||||
// Caller of ticket 10 timed out: retire exactly its head ticket.
|
||||
mb.cancel_head(a, TicketId::from_uuid(uuid::Uuid::from_u128(10)));
|
||||
assert_eq!(mb.pending(&a), 1);
|
||||
assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(11))));
|
||||
|
||||
// The cancelled pending resolves to Cancelled (its sender was dropped).
|
||||
assert_eq!(p1.await, Err(MailboxError::Cancelled));
|
||||
|
||||
// The next ticket is now resolvable normally.
|
||||
mb.resolve(a, "r2".to_owned()).unwrap();
|
||||
assert_eq!(p2.await.unwrap(), "r2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_head_is_a_noop_when_head_is_a_different_ticket() {
|
||||
let mb = InMemoryMailbox::new();
|
||||
let a = agent(1);
|
||||
let _p1 = mb.enqueue(a, ticket(10, "head"));
|
||||
// Try to cancel a ticket that is NOT the head ⇒ nothing retired.
|
||||
mb.cancel_head(a, TicketId::from_uuid(uuid::Uuid::from_u128(99)));
|
||||
assert_eq!(mb.pending(&a), 1);
|
||||
assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(10))));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user