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:
@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user