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

@ -119,6 +119,6 @@ pub use terminal::{
};
pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};
pub use workstate::{
AgentWorkState, GetProjectWorkState, GetProjectWorkStateInput, LiveWorkSession,
ProjectWorkState,
AgentTicketState, AgentWorkState, GetProjectWorkState, GetProjectWorkStateInput,
LiveWorkSession, ProjectWorkState, TicketWorkSource, TicketWorkStatus,
};

View File

@ -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 {

View File

@ -9,16 +9,18 @@ use async_trait::async_trait;
use application::{
GetProjectWorkState, GetProjectWorkStateInput, LiveSessionKind, LiveSessions,
StructuredSessions, TerminalSessions,
StructuredSessions, TerminalSessions, TicketWorkSource, TicketWorkStatus,
};
use domain::mailbox::{
AgentQueueSnapshot, MailboxError, PendingReply, QueuedTicketSnapshot, Ticket,
};
use domain::mailbox::{MailboxError, PendingReply, Ticket};
use domain::ports::{
AgentContextStore, AgentSession, AgentSessionError, PtyHandle, ReplyStream, StoreError,
};
use domain::{
Agent, AgentBusyState, AgentId, AgentManifest, AgentOrigin, InputMediator, ManifestEntry,
MarkdownDoc, NodeId, ProfileId, Project, ProjectId, ProjectPath, PtySize, RemoteRef, SessionId,
SessionKind, TerminalSession,
Agent, AgentBusyState, AgentId, AgentManifest, AgentOrigin, ConversationId, InputMediator,
InputSource, ManifestEntry, MarkdownDoc, NodeId, ProfileId, Project, ProjectId, ProjectPath,
PtySize, RemoteRef, SessionId, SessionKind, TerminalSession, TicketId,
};
use uuid::Uuid;
@ -138,6 +140,45 @@ impl InputMediator for FakeInput {
}
}
#[derive(Default)]
struct FakeQueue {
queues: Mutex<HashMap<AgentId, Vec<QueuedTicketSnapshot>>>,
}
impl FakeQueue {
fn set(&self, agent: AgentId, tickets: Vec<QueuedTicketSnapshot>) {
self.queues.lock().unwrap().insert(agent, tickets);
}
}
impl AgentQueueSnapshot for FakeQueue {
fn queue_for(&self, agent: AgentId) -> Vec<QueuedTicketSnapshot> {
self.queues
.lock()
.unwrap()
.get(&agent)
.cloned()
.unwrap_or_default()
}
}
fn snapshot(
id: u128,
position: u32,
source: InputSource,
requester: &str,
task: &str,
) -> QueuedTicketSnapshot {
QueuedTicketSnapshot {
id: TicketId::from_uuid(Uuid::from_u128(id)),
source,
conversation: ConversationId::from_uuid(Uuid::from_u128(id + 1000)),
requester: requester.to_owned(),
task: task.to_owned(),
position,
}
}
struct FakeSession {
id: SessionId,
}
@ -188,6 +229,7 @@ struct Fixture {
pty: Arc<TerminalSessions>,
structured: Arc<StructuredSessions>,
input: Arc<FakeInput>,
queue: Arc<FakeQueue>,
project: Project,
}
@ -197,18 +239,22 @@ fn fixture(agents: &[Agent]) -> Fixture {
let live = Arc::new(LiveSessions::new(Arc::clone(&pty), Arc::clone(&structured)));
let input = Arc::new(FakeInput::default());
let input_port = Arc::clone(&input) as Arc<dyn InputMediator>;
let queue = Arc::new(FakeQueue::default());
let queue_port = Arc::clone(&queue) as Arc<dyn AgentQueueSnapshot>;
let usecase = GetProjectWorkState::new(
Arc::new(FakeContexts {
manifest: manifest(agents),
}),
live,
input_port,
queue_port,
);
Fixture {
usecase,
pty,
structured,
input,
queue,
project: project(),
}
}
@ -305,3 +351,169 @@ async fn workstate_ignores_live_agents_absent_from_manifest() {
assert_eq!(out.agents[0].agent_id, a.id);
assert_eq!(out.agents[0].live, None);
}
#[tokio::test]
async fn workstate_agent_without_queue_has_no_tickets() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
assert!(out.agents[0].tickets.is_empty());
}
#[tokio::test]
async fn workstate_lists_two_tickets_in_fifo_order() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
let from = aid(20);
f.queue.set(
a.id,
vec![
snapshot(1, 0, InputSource::agent(from), "Main", "first"),
snapshot(2, 1, InputSource::agent(from), "Main", "second"),
],
);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let tickets = &out.agents[0].tickets;
assert_eq!(tickets.len(), 2);
assert_eq!(tickets[0].ticket_id, ticket_id(1));
assert_eq!(tickets[0].position, 0);
assert_eq!(tickets[1].ticket_id, ticket_id(2));
assert_eq!(tickets[1].position, 1);
}
#[tokio::test]
async fn workstate_marks_busy_head_in_progress_and_rest_queued() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
let from = aid(20);
f.queue.set(
a.id,
vec![
snapshot(1, 0, InputSource::agent(from), "Main", "first"),
snapshot(2, 1, InputSource::agent(from), "Main", "second"),
],
);
f.input.set_busy(
a.id,
AgentBusyState::Busy {
ticket: ticket_id(1),
since_ms: 5,
},
);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let tickets = &out.agents[0].tickets;
assert_eq!(tickets[0].status, TicketWorkStatus::InProgress);
assert_eq!(tickets[1].status, TicketWorkStatus::Queued);
}
#[tokio::test]
async fn workstate_marks_all_queued_when_agent_idle() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
let from = aid(20);
f.queue.set(
a.id,
vec![snapshot(1, 0, InputSource::agent(from), "Main", "first")],
);
// Agent left Idle (default): even the head is only queued, not in-progress.
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
assert_eq!(out.agents[0].tickets[0].status, TicketWorkStatus::Queued);
}
#[tokio::test]
async fn workstate_ignores_queue_for_agent_absent_from_manifest() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
// A queue exists for an agent that is not in the manifest: it must not surface.
f.queue.set(
aid(999),
vec![snapshot(1, 0, InputSource::Human, "User", "ghost")],
);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
assert_eq!(out.agents.len(), 1);
assert!(out.agents[0].tickets.is_empty());
}
#[tokio::test]
async fn workstate_truncates_task_preview_and_keeps_original_len() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
// 400 chars with runs of whitespace to normalise; well over the 160 cap.
let long_task = format!("start{}end", " x ".repeat(80));
let original_len = long_task.chars().count();
f.queue.set(
a.id,
vec![snapshot(1, 0, InputSource::Human, "User", &long_task)],
);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let ticket = &out.agents[0].tickets[0];
assert_eq!(ticket.task_preview.chars().count(), 160);
assert!(!ticket.task_preview.contains(" "), "whitespace normalised");
assert_eq!(ticket.task_len, original_len);
assert!(ticket.task_len > 160);
}
#[tokio::test]
async fn workstate_maps_human_and_agent_ticket_sources() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
let from = aid(20);
f.queue.set(
a.id,
vec![
snapshot(1, 0, InputSource::Human, "User", "from human"),
snapshot(2, 1, InputSource::agent(from), "Main", "from agent"),
],
);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let tickets = &out.agents[0].tickets;
assert_eq!(tickets[0].source, TicketWorkSource::Human);
assert_eq!(tickets[0].requester_label, "User");
assert_eq!(
tickets[1].source,
TicketWorkSource::Agent { agent_id: from }
);
assert_eq!(tickets[1].requester_label, "Main");
}