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:
2026-06-20 18:52:16 +02:00
parent 7453181e6c
commit cc7d99a63e
9 changed files with 692 additions and 15 deletions

View File

@ -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");
}
}