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

@ -9,8 +9,9 @@
use serde::{Deserialize, Serialize};
use application::{
AppError, CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, HealthReport,
LayoutKind, ListProjectsOutput, LiveSessionKind, OpenProjectOutput, ProjectWorkState,
AgentTicketState, AppError, CreateProjectInput, CreateProjectOutput, GitGraphOutput,
HealthInput, HealthReport, LayoutKind, ListProjectsOutput, LiveSessionKind, OpenProjectOutput,
ProjectWorkState, TicketWorkSource, TicketWorkStatus,
};
use domain::{AgentBusyState, Project, ProjectId};
@ -1503,6 +1504,87 @@ pub struct LiveWorkSessionDto {
pub kind: LiveWorkSessionKindDto,
}
/// Derived processing status of a queued ticket.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum TicketWorkStatusDto {
/// The agent's current busy turn is running this ticket.
InProgress,
/// Waiting behind the head / the agent is idle.
Queued,
}
impl From<TicketWorkStatus> for TicketWorkStatusDto {
fn from(status: TicketWorkStatus) -> Self {
match status {
TicketWorkStatus::InProgress => Self::InProgress,
TicketWorkStatus::Queued => Self::Queued,
}
}
}
/// Origin of a queued ticket (human operator or a delegating agent).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum TicketWorkSourceDto {
/// The human operator.
Human,
/// Another agent delegating via `idea_ask_agent`.
#[serde(rename_all = "camelCase")]
Agent {
/// The delegating agent id.
agent_id: String,
},
}
impl From<TicketWorkSource> for TicketWorkSourceDto {
fn from(source: TicketWorkSource) -> Self {
match source {
TicketWorkSource::Human => Self::Human,
TicketWorkSource::Agent { agent_id } => Self::Agent {
agent_id: agent_id.to_string(),
},
}
}
}
/// One queued/in-progress delegation ticket for an agent.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentTicketStateDto {
/// Stable id of the queued ticket.
pub ticket_id: String,
/// Conversation thread this task enters.
pub conversation_id: String,
/// FIFO position at snapshot time (`0` = head).
pub position: u32,
/// Derived status (in-progress vs queued).
pub status: TicketWorkStatusDto,
/// Origin of the ticket.
pub source: TicketWorkSourceDto,
/// Display label of the requester.
pub requester_label: String,
/// Bounded excerpt of the task.
pub task_preview: String,
/// Character length of the original (un-truncated) task.
pub task_len: usize,
}
impl From<AgentTicketState> for AgentTicketStateDto {
fn from(ticket: AgentTicketState) -> Self {
Self {
ticket_id: ticket.ticket_id.to_string(),
conversation_id: ticket.conversation_id.to_string(),
position: ticket.position,
status: ticket.status.into(),
source: ticket.source.into(),
requester_label: ticket.requester_label,
task_preview: ticket.task_preview,
task_len: ticket.task_len,
}
}
}
/// One manifest agent's current live/busy state.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@ -1517,6 +1599,8 @@ pub struct AgentWorkStateDto {
pub live: Option<LiveWorkSessionDto>,
/// Current mediated-input busy state.
pub busy: AgentBusyState,
/// Pending/in-progress delegation tickets, in FIFO order.
pub tickets: Vec<AgentTicketStateDto>,
}
/// Project-level read model for conversation/delegation UX.
@ -1543,6 +1627,11 @@ impl From<ProjectWorkState> for ProjectWorkStateDto {
kind: live.kind.into(),
}),
busy: agent.busy,
tickets: agent
.tickets
.into_iter()
.map(AgentTicketStateDto::from)
.collect(),
})
.collect(),
}