feat(workstate): snapshot des délégations en file par agent (Lot B backend)
Ajoute un port de lecture ségrégué `AgentQueueSnapshot` (ISP) distinct du `AgentMailbox` mutant : il expose `queue_for(agent)` qui renvoie des `QueuedTicketSnapshot` clonés, ordonnés FIFO, avec position recalculée (0 = tête). Observer la file ne la mute jamais ; le one-shot reply sender reste dans l'adaptateur. - domain : value object `QueuedTicketSnapshot` + trait `AgentQueueSnapshot` (object-safe, partagé en `Arc<dyn …>`). - infrastructure : `InMemoryMailbox` implémente la vue lecture en plus de la vue mutation ; positions recalculées à chaque appel. - application : le read-model work-state liste les délégations en attente via ce port, troncature de l'aperçu de tâche en conservant la longueur d'origine. - app-tauri : DTO `camelCase` des tickets en file câblé dans l'état. Tests verts : domain mailbox (6), infrastructure mailbox (13), application workstate (12), app-tauri dto_agents (20). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -25,7 +25,10 @@ use std::sync::Mutex;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use domain::ids::AgentId;
|
||||
use domain::mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId};
|
||||
use domain::mailbox::{
|
||||
AgentMailbox, AgentQueueSnapshot, MailboxError, PendingReply, QueuedTicketSnapshot, Ticket,
|
||||
TicketId,
|
||||
};
|
||||
|
||||
/// One queued request plus the sender that resolves its awaiting [`PendingReply`].
|
||||
struct Slot {
|
||||
@ -160,6 +163,35 @@ impl AgentMailbox for InMemoryMailbox {
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentQueueSnapshot for InMemoryMailbox {
|
||||
fn queue_for(&self, agent: AgentId) -> 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.
|
||||
let queues = self
|
||||
.queues
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
queues
|
||||
.get(&agent)
|
||||
.map(|queue| {
|
||||
queue
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, slot)| QueuedTicketSnapshot {
|
||||
id: slot.ticket.id,
|
||||
source: slot.ticket.source,
|
||||
conversation: slot.ticket.conversation,
|
||||
requester: slot.ticket.requester.clone(),
|
||||
task: slot.ticket.task.clone(),
|
||||
position: u32::try_from(idx).unwrap_or(u32::MAX),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -270,4 +302,98 @@ mod tests {
|
||||
Some(TicketId::from_uuid(uuid::Uuid::from_u128(10)))
|
||||
);
|
||||
}
|
||||
|
||||
fn tid(n: u128) -> TicketId {
|
||||
TicketId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_of_empty_queue_is_empty() {
|
||||
let mb = InMemoryMailbox::new();
|
||||
assert!(mb.queue_for(agent(1)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_preserves_fifo_order_and_positions() {
|
||||
let mb = InMemoryMailbox::new();
|
||||
let a = agent(1);
|
||||
let _p1 = mb.enqueue(a, ticket(10, "first"));
|
||||
let _p2 = mb.enqueue(a, ticket(11, "second"));
|
||||
|
||||
let snap = mb.queue_for(a);
|
||||
assert_eq!(snap.len(), 2);
|
||||
assert_eq!(snap[0].id, tid(10));
|
||||
assert_eq!(snap[0].position, 0);
|
||||
assert_eq!(snap[0].task, "first");
|
||||
assert_eq!(snap[1].id, tid(11));
|
||||
assert_eq!(snap[1].position, 1);
|
||||
assert_eq!(snap[1].task, "second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_carries_ticket_metadata() {
|
||||
use domain::conversation::ConversationId;
|
||||
use domain::input::InputSource;
|
||||
|
||||
let mb = InMemoryMailbox::new();
|
||||
let a = agent(1);
|
||||
let from = agent(2);
|
||||
let conv = ConversationId::from_uuid(uuid::Uuid::from_u128(42));
|
||||
let _p = mb.enqueue(
|
||||
a,
|
||||
Ticket::from_agent(tid(10), from, conv, "Main", "delegate task"),
|
||||
);
|
||||
|
||||
let snap = mb.queue_for(a);
|
||||
assert_eq!(snap.len(), 1);
|
||||
assert_eq!(snap[0].source, InputSource::agent(from));
|
||||
assert_eq!(snap[0].conversation, conv);
|
||||
assert_eq!(snap[0].requester, "Main");
|
||||
assert_eq!(snap[0].task, "delegate task");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_is_read_only() {
|
||||
let mb = InMemoryMailbox::new();
|
||||
let a = agent(1);
|
||||
let _p1 = mb.enqueue(a, ticket(10, "first"));
|
||||
let _p2 = mb.enqueue(a, ticket(11, "second"));
|
||||
|
||||
let _ = mb.queue_for(a);
|
||||
let _ = mb.queue_for(a);
|
||||
|
||||
// Observing the queue did not mutate it.
|
||||
assert_eq!(mb.pending(&a), 2);
|
||||
assert_eq!(mb.head_ticket(&a), Some(tid(10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_updates_after_resolve_ticket() {
|
||||
let mb = InMemoryMailbox::new();
|
||||
let a = agent(1);
|
||||
let _p1 = mb.enqueue(a, ticket(10, "first"));
|
||||
let _p2 = mb.enqueue(a, ticket(11, "second"));
|
||||
|
||||
mb.resolve_ticket(a, tid(10), "done".to_owned()).unwrap();
|
||||
|
||||
let snap = mb.queue_for(a);
|
||||
assert_eq!(snap.len(), 1);
|
||||
assert_eq!(snap[0].id, tid(11));
|
||||
assert_eq!(snap[0].position, 0, "remaining ticket becomes head");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_updates_after_cancel_head() {
|
||||
let mb = InMemoryMailbox::new();
|
||||
let a = agent(1);
|
||||
let _p1 = mb.enqueue(a, ticket(10, "head"));
|
||||
let _p2 = mb.enqueue(a, ticket(11, "next"));
|
||||
|
||||
mb.cancel_head(a, tid(10));
|
||||
|
||||
let snap = mb.queue_for(a);
|
||||
assert_eq!(snap.len(), 1);
|
||||
assert_eq!(snap[0].id, tid(11));
|
||||
assert_eq!(snap[0].position, 0, "next ticket recomputed to position 0");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user