fix(runtime): isolate agent state by project (#101)
This commit is contained in:
@ -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"));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user