fix(runtime): isolate agent state by project (#101)

This commit is contained in:
2026-07-25 22:14:02 +02:00
parent 6a87c4635f
commit 6e98fd89f7
52 changed files with 1894 additions and 805 deletions

View File

@ -24,7 +24,7 @@ use std::sync::Mutex;
use tokio::sync::oneshot;
use domain::ids::AgentId;
use domain::ids::RuntimeAgentKey;
use domain::mailbox::{
AgentMailbox, AgentQueueSnapshot, MailboxError, PendingReply, QueuedTicketSnapshot, Ticket,
TicketId, TurnResolution,
@ -44,7 +44,7 @@ struct Slot {
/// In-memory, per-agent FIFO mailbox (the production [`AgentMailbox`]).
#[derive(Default)]
pub struct InMemoryMailbox {
queues: Mutex<HashMap<AgentId, VecDeque<Slot>>>,
queues: Mutex<HashMap<RuntimeAgentKey, VecDeque<Slot>>>,
}
impl InMemoryMailbox {
@ -58,7 +58,7 @@ impl InMemoryMailbox {
/// Number of tickets currently queued for `agent` (test/inspection helper).
#[must_use]
pub fn pending(&self, agent: &AgentId) -> usize {
pub fn pending(&self, agent: &RuntimeAgentKey) -> usize {
self.queues
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
@ -69,7 +69,7 @@ impl InMemoryMailbox {
/// 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> {
pub fn head_ticket(&self, agent: &RuntimeAgentKey) -> Option<TicketId> {
self.queues
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
@ -80,7 +80,7 @@ impl InMemoryMailbox {
}
impl AgentMailbox for InMemoryMailbox {
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
fn enqueue(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply {
let (tx, rx) = oneshot::channel::<TurnResolution>();
let ticket_id = ticket.id;
let (depth_before, depth_after) = {
@ -105,7 +105,7 @@ impl AgentMailbox for InMemoryMailbox {
}))
}
fn resolve(&self, agent: AgentId, result: String) -> Result<(), MailboxError> {
fn resolve(&self, agent: RuntimeAgentKey, 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.
@ -121,7 +121,7 @@ impl AgentMailbox for InMemoryMailbox {
application::diag!(
"[mailbox] resolve no-pending agent={agent} (réponse orpheline ?)"
);
MailboxError::NoPendingRequest(agent)
MailboxError::NoPendingRequest(agent.agent_id)
})?;
let before = queue.len();
let slot = queue.pop_front().expect("non-empty queue has a head");
@ -140,7 +140,7 @@ impl AgentMailbox for InMemoryMailbox {
fn resolve_ticket(
&self,
agent: AgentId,
agent: RuntimeAgentKey,
ticket_id: TicketId,
result: String,
) -> Result<(), MailboxError> {
@ -159,7 +159,7 @@ impl AgentMailbox for InMemoryMailbox {
application::diag!(
"[mailbox] resolve_ticket no-queue agent={agent} ticket={ticket_id}"
);
MailboxError::NoPendingRequest(agent)
MailboxError::NoPendingRequest(agent.agent_id)
})?;
let before = queue.len();
let pos = queue
@ -170,7 +170,7 @@ impl AgentMailbox for InMemoryMailbox {
"[mailbox] resolve_ticket no-match agent={agent} ticket={ticket_id} \
queue_depth={before}"
);
MailboxError::NoPendingRequest(agent)
MailboxError::NoPendingRequest(agent.agent_id)
})?;
let slot = queue.remove(pos).expect("position just found is in range");
(slot, before, queue.len())
@ -183,7 +183,7 @@ impl AgentMailbox for InMemoryMailbox {
Ok(())
}
fn cancel_head(&self, agent: AgentId, ticket_id: TicketId) {
fn cancel_head(&self, agent: RuntimeAgentKey, ticket_id: TicketId) {
let (depth_before, depth_after, retired) = {
let mut queues = self
.queues
@ -218,7 +218,7 @@ impl AgentMailbox for InMemoryMailbox {
);
}
fn complete_without_reply(&self, agent: AgentId, ticket_id: TicketId) {
fn complete_without_reply(&self, agent: RuntimeAgentKey, ticket_id: TicketId) {
// Head-only, idempotent, and fire-and-forget-safe (see the port contract).
// `outcome` records what happened for the diag beacon: "sent" (head retired +
// caller woken with ReturnedToPromptNoReply), "skip-closed" (receiver already
@ -273,7 +273,7 @@ impl AgentMailbox for InMemoryMailbox {
}
impl AgentQueueSnapshot for InMemoryMailbox {
fn queue_for(&self, agent: AgentId) -> Vec<QueuedTicketSnapshot> {
fn queue_for(&self, agent: RuntimeAgentKey) -> Vec<QueuedTicketSnapshot> {
// Pure read: clone each ticket's *data* (never the `oneshot::Sender`) under
// the lock, recomputing the FIFO position from the current order (0 = head).
// The queue is observed, not mutated.
@ -304,11 +304,19 @@ impl AgentQueueSnapshot for InMemoryMailbox {
#[cfg(test)]
mod tests {
use super::*;
use domain::AgentId;
fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn key(n: u128) -> RuntimeAgentKey {
RuntimeAgentKey::new(
domain::ProjectId::from_uuid(uuid::Uuid::from_u128(1000 + n)),
agent(n),
)
}
fn ticket(n: u128, task: &str) -> Ticket {
Ticket::new(TicketId::from_uuid(uuid::Uuid::from_u128(n)), "Main", task)
}
@ -316,7 +324,7 @@ mod tests {
#[tokio::test]
async fn enqueue_then_resolve_wakes_the_pending_reply() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
let pending = mb.enqueue(a, ticket(10, "do X"));
mb.resolve(a, "done X".to_owned()).expect("resolve ok");
@ -329,7 +337,7 @@ mod tests {
#[tokio::test]
async fn two_asks_same_target_resolve_fifo_head_first() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
let p1 = mb.enqueue(a, ticket(10, "first"));
let p2 = mb.enqueue(a, ticket(11, "second"));
assert_eq!(mb.pending(&a), 2);
@ -353,8 +361,8 @@ mod tests {
#[tokio::test]
async fn different_targets_do_not_block_each_other() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let b = agent(2);
let a = key(1);
let b = key(2);
let pa = mb.enqueue(a, ticket(10, "task a"));
let _pb = mb.enqueue(b, ticket(20, "task b"));
@ -368,17 +376,17 @@ mod tests {
#[test]
fn resolve_without_pending_is_a_typed_error() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
assert_eq!(
mb.resolve(a, "orphan".to_owned()),
Err(MailboxError::NoPendingRequest(a))
Err(MailboxError::NoPendingRequest(a.agent_id))
);
}
#[tokio::test]
async fn cancel_head_retires_the_head_and_advances_the_queue() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
let p1 = mb.enqueue(a, ticket(10, "stuck"));
let p2 = mb.enqueue(a, ticket(11, "next"));
@ -401,7 +409,7 @@ mod tests {
#[tokio::test]
async fn complete_without_reply_wakes_head_caller_with_returned_to_prompt() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
let pending = mb.enqueue(a, ticket(10, "awaited"));
// The target returned to its prompt without idea_reply ⇒ the awaiting caller
@ -417,7 +425,7 @@ mod tests {
#[tokio::test]
async fn complete_without_reply_is_noop_when_receiver_already_gone() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
// Human fire-and-forget: the PendingReply is dropped immediately (not awaited).
drop(mb.enqueue(a, ticket(10, "human submit")));
@ -434,7 +442,7 @@ mod tests {
#[tokio::test]
async fn complete_without_reply_is_noop_when_not_head_and_idempotent() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
let p1 = mb.enqueue(a, ticket(10, "head"));
let _p2 = mb.enqueue(a, ticket(11, "behind"));
@ -455,7 +463,7 @@ mod tests {
#[tokio::test]
async fn reply_before_completion_wins_then_completion_is_noop() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
let pending = mb.enqueue(a, ticket(10, "raced"));
mb.resolve(a, "real answer".to_owned()).unwrap();
@ -472,7 +480,7 @@ mod tests {
#[test]
fn cancel_head_is_a_noop_when_head_is_a_different_ticket() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(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)));
@ -490,13 +498,13 @@ mod tests {
#[test]
fn snapshot_of_empty_queue_is_empty() {
let mb = InMemoryMailbox::new();
assert!(mb.queue_for(agent(1)).is_empty());
assert!(mb.queue_for(key(1)).is_empty());
}
#[test]
fn snapshot_preserves_fifo_order_and_positions() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
let _p1 = mb.enqueue(a, ticket(10, "first"));
let _p2 = mb.enqueue(a, ticket(11, "second"));
@ -516,7 +524,7 @@ mod tests {
use domain::input::InputSource;
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
let from = agent(2);
let conv = ConversationId::from_uuid(uuid::Uuid::from_u128(42));
let _p = mb.enqueue(
@ -535,7 +543,7 @@ mod tests {
#[test]
fn snapshot_is_read_only() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
let _p1 = mb.enqueue(a, ticket(10, "first"));
let _p2 = mb.enqueue(a, ticket(11, "second"));
@ -550,7 +558,7 @@ mod tests {
#[test]
fn snapshot_updates_after_resolve_ticket() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
let _p1 = mb.enqueue(a, ticket(10, "first"));
let _p2 = mb.enqueue(a, ticket(11, "second"));
@ -572,7 +580,7 @@ mod tests {
application::diag::set_log_path(path.clone());
let mb = InMemoryMailbox::new();
let a = agent(777_001);
let a = key(777_001);
let t = tid(777_010);
let _p = mb.enqueue(a, Ticket::new(t, "Main", "diag task"));
mb.resolve(a, "done".to_owned()).expect("resolve ok");
@ -596,7 +604,7 @@ mod tests {
#[test]
fn snapshot_updates_after_cancel_head() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
let _p1 = mb.enqueue(a, ticket(10, "head"));
let _p2 = mb.enqueue(a, ticket(11, "next"));