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

@ -82,7 +82,10 @@ pub use profile::{
McpServerWiring, RateLimitPattern, SessionStrategy,
};
pub use mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId};
pub use mailbox::{
AgentMailbox, AgentQueueSnapshot, MailboxError, PendingReply, QueuedTicketSnapshot, Ticket,
TicketId,
};
pub use conversation::{
Conversation, ConversationError, ConversationId, ConversationParty, ConversationRegistry,

View File

@ -33,6 +33,47 @@ use crate::conversation::ConversationId;
use crate::ids::AgentId;
use crate::input::InputSource;
/// A read-only, cloned view of one queued [`Ticket`] in a target agent's FIFO.
///
/// Carries **only the data** of a ticket (never the one-shot reply sender, which
/// stays inside the adapter), plus its `position` in the FIFO at snapshot time
/// (`0` = head). Pure value object used by the [`AgentQueueSnapshot`] read port so
/// the work-state read model can list pending delegations without touching the
/// mutating [`AgentMailbox`] surface (ISP).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueuedTicketSnapshot {
/// Stable id of the queued ticket.
pub id: TicketId,
/// The origin of the input (Human or a delegating Agent).
pub source: InputSource,
/// The conversation thread this task enters.
pub conversation: ConversationId,
/// Display name (or id) of the requester.
pub requester: String,
/// The full task/message text (preview/truncation is the caller's concern).
pub task: String,
/// Position in the FIFO at snapshot time (`0` = head, recomputed each call).
pub position: u32,
}
/// Read-only inspection port over the per-agent delegation FIFO.
///
/// Segregated from the mutating [`AgentMailbox`] (Interface Segregation): the
/// work-state read model depends on this port to *observe* the queue, never on
/// `enqueue`/`resolve`/`cancel`. Implementations return **cloned** snapshots in
/// FIFO order with recomputed positions; observing the queue must never mutate it.
///
/// Object-safe (`&self`) so the application layer holds it as
/// `Arc<dyn AgentQueueSnapshot>`; one concrete `InMemoryMailbox` can be shared as
/// both an [`AgentMailbox`] (mutation) and an [`AgentQueueSnapshot`] (read) view.
pub trait AgentQueueSnapshot: Send + Sync {
/// Returns a cloned, FIFO-ordered snapshot of `agent`'s queued tickets.
///
/// An agent with no queue yields an empty `Vec`. Positions are recomputed from
/// the current order (`0` = head). Pure read: the queue is left untouched.
fn queue_for(&self, agent: AgentId) -> Vec<QueuedTicketSnapshot>;
}
/// Identifies one queued [`Ticket`] within a target agent's mailbox.
///
/// Newtype around [`uuid::Uuid`]; minted by the adapter on `enqueue`. It is **never
@ -310,4 +351,22 @@ mod tests {
let a = AgentId::from_uuid(uuid::Uuid::from_u128(1));
assert_ne!(MailboxError::NoPendingRequest(a), MailboxError::Cancelled);
}
#[test]
fn queued_ticket_snapshot_carries_ticket_data_and_position() {
let conv = ConversationId::from_uuid(uuid::Uuid::from_u128(3));
let from = AgentId::from_uuid(uuid::Uuid::from_u128(4));
let snap = QueuedTicketSnapshot {
id: TicketId::from_uuid(uuid::Uuid::from_u128(8)),
source: InputSource::agent(from),
conversation: conv,
requester: "Main".to_owned(),
task: "delegate".to_owned(),
position: 2,
};
assert_eq!(snap.source.as_agent(), Some(from));
assert_eq!(snap.conversation, conv);
assert_eq!(snap.position, 2);
assert_eq!(snap.task, "delegate");
}
}