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:
@ -8,13 +8,22 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::input::AgentBusyState;
|
||||
use domain::input::{AgentBusyState, InputSource};
|
||||
use domain::ports::AgentContextStore;
|
||||
use domain::{AgentId, InputMediator, NodeId, ProfileId, Project, SessionId};
|
||||
use domain::{
|
||||
AgentId, AgentQueueSnapshot, ConversationId, InputMediator, NodeId, ProfileId, Project,
|
||||
QueuedTicketSnapshot, SessionId, TicketId,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::terminal::{LiveSessionKind, LiveSessionSnapshot, LiveSessions};
|
||||
|
||||
/// Maximum length (in characters) of a derived [`AgentTicketState::task_preview`].
|
||||
///
|
||||
/// The full task can be a very long prompt; the panel only needs a scannable
|
||||
/// excerpt, with [`AgentTicketState::task_len`] signalling there is more.
|
||||
const TASK_PREVIEW_MAX_CHARS: usize = 160;
|
||||
|
||||
/// Input for [`GetProjectWorkState::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GetProjectWorkStateInput {
|
||||
@ -42,6 +51,64 @@ pub struct AgentWorkState {
|
||||
pub live: Option<LiveWorkSession>,
|
||||
/// Current FIFO/busy state.
|
||||
pub busy: AgentBusyState,
|
||||
/// Pending/in-progress delegation tickets, in FIFO order.
|
||||
pub tickets: Vec<AgentTicketState>,
|
||||
}
|
||||
|
||||
/// One queued delegation ticket, projected for the work-state read model.
|
||||
///
|
||||
/// A read-only, presentation-oriented view of a [`QueuedTicketSnapshot`]: the
|
||||
/// status is **derived** by crossing the FIFO snapshot with the agent's busy state
|
||||
/// (never stored), and `task_preview` is a bounded excerpt of the full task (the
|
||||
/// full text is never sent to the panel; `task_len` signals there is more).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AgentTicketState {
|
||||
/// Stable id of the queued ticket.
|
||||
pub ticket_id: TicketId,
|
||||
/// Conversation thread this task enters.
|
||||
pub conversation_id: ConversationId,
|
||||
/// FIFO position at snapshot time (`0` = head).
|
||||
pub position: u32,
|
||||
/// Derived status (in-progress = the agent's current busy ticket).
|
||||
pub status: TicketWorkStatus,
|
||||
/// Origin of the ticket (human operator or a delegating agent).
|
||||
pub source: TicketWorkSource,
|
||||
/// Display label of the requester.
|
||||
pub requester_label: String,
|
||||
/// Bounded, whitespace-normalised excerpt of the task.
|
||||
pub task_preview: String,
|
||||
/// Character length of the **original** (un-truncated) task.
|
||||
pub task_len: usize,
|
||||
}
|
||||
|
||||
/// Derived processing status of a queued ticket.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TicketWorkStatus {
|
||||
/// The agent's current busy turn is running this ticket.
|
||||
InProgress,
|
||||
/// Waiting behind the head / the agent is idle.
|
||||
Queued,
|
||||
}
|
||||
|
||||
/// Origin of a queued ticket, mirroring [`InputSource`] for the read model.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TicketWorkSource {
|
||||
/// The human operator.
|
||||
Human,
|
||||
/// Another agent delegating via `idea_ask_agent`.
|
||||
Agent {
|
||||
/// The delegating agent.
|
||||
agent_id: AgentId,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<InputSource> for TicketWorkSource {
|
||||
fn from(source: InputSource) -> Self {
|
||||
match source {
|
||||
InputSource::Human => Self::Human,
|
||||
InputSource::Agent { agent_id } => Self::Agent { agent_id },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Live session coordinates exposed by the work-state read model.
|
||||
@ -60,6 +127,7 @@ pub struct GetProjectWorkState {
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
live: Arc<LiveSessions>,
|
||||
input: Arc<dyn InputMediator>,
|
||||
queue: Arc<dyn AgentQueueSnapshot>,
|
||||
}
|
||||
|
||||
impl GetProjectWorkState {
|
||||
@ -69,11 +137,13 @@ impl GetProjectWorkState {
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
live: Arc<LiveSessions>,
|
||||
input: Arc<dyn InputMediator>,
|
||||
queue: Arc<dyn AgentQueueSnapshot>,
|
||||
) -> Self {
|
||||
Self {
|
||||
contexts,
|
||||
live,
|
||||
input,
|
||||
queue,
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,12 +172,23 @@ impl GetProjectWorkState {
|
||||
session_id: snapshot.session_id,
|
||||
kind: snapshot.kind,
|
||||
});
|
||||
// Tickets are crossed with the busy state: only an agent absent from
|
||||
// the manifest is dropped (this loop only visits manifest entries), so
|
||||
// the manifest boundary is naturally preserved.
|
||||
let busy_ticket = self.input.busy_state(agent.id).ticket();
|
||||
let tickets = self
|
||||
.queue
|
||||
.queue_for(agent.id)
|
||||
.into_iter()
|
||||
.map(|snapshot| ticket_state(snapshot, busy_ticket))
|
||||
.collect();
|
||||
Ok(AgentWorkState {
|
||||
agent_id: agent.id,
|
||||
name: agent.name,
|
||||
profile_id: agent.profile_id,
|
||||
live,
|
||||
busy: self.input.busy_state(agent.id),
|
||||
tickets,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, AppError>>()?;
|
||||
@ -115,6 +196,40 @@ impl GetProjectWorkState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Projects one FIFO snapshot into a read-model ticket, deriving status from the
|
||||
/// agent's current busy ticket (the one running its turn = `InProgress`).
|
||||
fn ticket_state(snapshot: QueuedTicketSnapshot, busy_ticket: Option<TicketId>) -> AgentTicketState {
|
||||
let status = if busy_ticket == Some(snapshot.id) {
|
||||
TicketWorkStatus::InProgress
|
||||
} else {
|
||||
TicketWorkStatus::Queued
|
||||
};
|
||||
AgentTicketState {
|
||||
ticket_id: snapshot.id,
|
||||
conversation_id: snapshot.conversation,
|
||||
position: snapshot.position,
|
||||
status,
|
||||
source: snapshot.source.into(),
|
||||
requester_label: snapshot.requester,
|
||||
task_preview: task_preview(&snapshot.task),
|
||||
task_len: snapshot.task.chars().count(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a bounded, whitespace-normalised excerpt of a task for the UI panel.
|
||||
///
|
||||
/// Trims, collapses any run of whitespace to a single space, then truncates to
|
||||
/// [`TASK_PREVIEW_MAX_CHARS`] characters (never mid-codepoint). The original length
|
||||
/// is reported separately as [`AgentTicketState::task_len`].
|
||||
fn task_preview(task: &str) -> String {
|
||||
let normalised = task.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if normalised.chars().count() <= TASK_PREVIEW_MAX_CHARS {
|
||||
normalised
|
||||
} else {
|
||||
normalised.chars().take(TASK_PREVIEW_MAX_CHARS).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn live_by_agent(snapshots: Vec<LiveSessionSnapshot>) -> HashMap<AgentId, LiveSessionSnapshot> {
|
||||
let mut out = HashMap::new();
|
||||
for snapshot in snapshots {
|
||||
|
||||
Reference in New Issue
Block a user