feat(background): livraison auto au propriétaire + projection des tâches de fond dans le work-state (T1+T3)

T1 — Wake automatique du propriétaire à la complétion.
La complétion d'une tâche de fond est désormais livrée à l'agent
propriétaire dès que la session accepte l'envoi (wake.rs :
mark_completion_delivered au send accepté), via un drain de flux dédié
(structured.rs : drain_reply_stream_with_readiness). L'inbox médiée
enfile l'item sans démarrer de tour ni marquer l'agent busy
(input/mod.rs : enqueue FIFO silencieux). Régression couverte
(tests/agent_wake.rs, tests input/mod.rs).

T3 — Tâches de fond projetées dans le read-model du panneau Work.
AgentWorkState porte désormais background_tasks
(VO AgentBackgroundTaskState), alimenté par un builder best-effort
with_background_tasks(store) : union list_open_for_agent + dispatch des
completions non livrées par owner_agent_id, erreur store => Vec vide
(aucune régression live/busy/tickets). DTO Tauri backgroundTasks et
wiring du BackgroundTaskStore côté state.rs. Le frontend, déjà câblé,
affiche Cancel/Retry (mapping queued/waiting -> pending, tri sur
updatedAtMs). Borne V1 : une tâche terminale déjà livrée n'est plus
énumérable (Retry limité à la fenêtre non livrée).

Tests : cargo build --workspace OK ; cargo test -p application /
-p app-tauri / -p infrastructure verts ; frontend build + vitest verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 00:21:33 +02:00
parent f08dae62eb
commit eb9cc16181
21 changed files with 946 additions and 148 deletions

View File

@ -298,6 +298,16 @@ impl BackgroundTaskStore for FakeTaskStore {
#[derive(Default)]
struct FakeSession {
prompts: Mutex<Vec<String>>,
events: Mutex<Option<Vec<ReplyEvent>>>,
}
impl FakeSession {
fn with_events(events: Vec<ReplyEvent>) -> Self {
Self {
prompts: Mutex::new(Vec::new()),
events: Mutex::new(Some(events)),
}
}
}
#[async_trait]
@ -312,12 +322,12 @@ impl AgentSession for FakeSession {
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
self.prompts.lock().unwrap().push(prompt.to_owned());
Ok(Box::new(
let events = self.events.lock().unwrap().take().unwrap_or_else(|| {
vec![ReplyEvent::Final {
content: "ack".to_owned(),
}]
.into_iter(),
))
});
Ok(Box::new(events.into_iter()))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
@ -421,15 +431,16 @@ async fn owner_busy_does_not_start_concurrent_wake_and_keeps_item_queued() {
.unwrap();
turns.force_busy(owner, ticket(99));
service(inbox.clone(), turns, tasks, sessions)
let err = service(inbox.clone(), turns, tasks, sessions)
.wake_agent(
&project,
owner,
WakeReason::BackgroundCompletion { task_id },
)
.await
.unwrap();
.unwrap_err();
assert_eq!(err, WakeError::AgentBusy { agent_id: owner });
assert!(session.prompts.lock().unwrap().is_empty());
assert_eq!(inbox.snapshot(owner).depth, 1);
}
@ -489,6 +500,35 @@ async fn completion_is_marked_delivered_after_successful_wake() {
assert_eq!(tasks.delivered(), vec![task_id]);
}
#[tokio::test]
async fn completion_is_marked_delivered_once_send_is_accepted_even_if_drain_fails() {
let project = project();
let owner = agent(1);
let task_id = task_id(10);
let inbox = Arc::new(FakeInbox::default());
let turns = Arc::new(SharedTurnState::default());
let tasks = Arc::new(FakeTaskStore::default());
let session = Arc::new(FakeSession::with_events(Vec::new()));
let sessions = Arc::new(FakeSessionProvider::with_session(session.clone()));
tasks.insert(completed_task(&project, owner, task_id, "done"));
inbox
.enqueue_message(owner, completion_item(owner, task_id, ticket(20)))
.unwrap();
let err = service(inbox, turns, tasks.clone(), sessions)
.wake_agent(
&project,
owner,
WakeReason::BackgroundCompletion { task_id },
)
.await
.unwrap_err();
assert!(matches!(err, WakeError::Session(_)));
assert_eq!(session.prompts.lock().unwrap().len(), 1);
assert_eq!(tasks.delivered(), vec![task_id]);
}
#[tokio::test]
async fn wake_drains_exactly_one_item_per_turn() {
let project = project();