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

@ -17,6 +17,7 @@ mod tail;
pub use runner::CommandBackgroundRunner;
pub use sink::{
start_background_ready_inbox_bridge, BackgroundCompletionSink, BackgroundCompletionSinkError,
BackgroundCompletionSinkOutcome, BackgroundReadyInboxBridgeHandle, BackgroundTaskReadyToDeliver,
BackgroundCompletionSinkOutcome, BackgroundReadyInboxBridgeHandle,
BackgroundTaskReadyToDeliver,
};
pub use tail::{bounded_tail, tail_cap_bytes, BoundedTail};

View File

@ -170,7 +170,10 @@ impl CommandBackgroundRunner {
}
};
running.lock().expect("runner registry poisoned").remove(&task_id);
running
.lock()
.expect("runner registry poisoned")
.remove(&task_id);
let _ = completion_tx.send(BackgroundTaskCompletion { task_id, result });
}
@ -213,10 +216,7 @@ impl CommandBackgroundRunner {
let Ok(stream) = pty.subscribe_output(&handle) else {
return;
};
let _ = tokio::task::spawn_blocking(move || {
for _chunk in stream {}
})
.await;
let _ = tokio::task::spawn_blocking(move || for _chunk in stream {}).await;
}
}

View File

@ -1077,10 +1077,7 @@ impl AgentInbox for MediatedInbox {
let item_id = item.id;
let ticket = inbox_item_to_ticket(&item);
self.inbox_items().insert(item_id, item);
let _pending = match ticket.source {
domain::input::InputSource::Human => self.enqueue(agent, ticket),
domain::input::InputSource::Agent { .. } => self.enqueue_silent(agent, ticket),
};
let _pending = self.mailbox.enqueue(agent, ticket);
let depth = self.mailbox.pending(&agent);
if let Some(events) = &self.tracker.events {
events.publish(DomainEvent::AgentInboxQueued {
@ -1347,6 +1344,36 @@ mod tests {
assert_eq!(inbox.mailbox.pending(&a), 1);
}
#[test]
fn enqueue_message_queues_inbox_item_without_starting_turn() {
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
let task_id = domain::TaskId::from_uuid(uuid::Uuid::from_u128(42));
let item = domain::InboxItem {
id: TicketId::from_uuid(uuid::Uuid::from_u128(10)),
agent_id: a,
source: InboxSource::BackgroundTask { task_id },
kind: domain::InboxItemKind::BackgroundCompletion,
body: "Background task completed.".to_owned(),
created_at_ms: 1,
correlation_id: Some(task_id.to_string()),
};
let receipt = inbox.enqueue_message(a, item.clone()).unwrap();
assert_eq!(receipt.status, InboxReceiptStatus::Queued);
assert_eq!(inbox.busy_state(a), AgentBusyState::Idle);
assert_eq!(inbox.mailbox.pending(&a), 1);
assert!(
bus.busy_events().is_empty(),
"inbox enqueue must not start a mediated turn"
);
assert_eq!(inbox.dequeue_next(a), Some(item));
assert_eq!(inbox.mailbox.pending(&a), 0);
}
#[test]
fn delegation_ready_carries_bound_submit_config() {
let bus = Arc::new(RecordingBus::default());

View File

@ -40,8 +40,9 @@ pub mod timeparse;
pub use background_task::{
bounded_tail, start_background_ready_inbox_bridge, tail_cap_bytes, BackgroundCompletionSink,
BackgroundCompletionSinkError, BackgroundCompletionSinkOutcome, BackgroundReadyInboxBridgeHandle,
BackgroundTaskReadyToDeliver, BoundedTail, CommandBackgroundRunner,
BackgroundCompletionSinkError, BackgroundCompletionSinkOutcome,
BackgroundReadyInboxBridgeHandle, BackgroundTaskReadyToDeliver, BoundedTail,
CommandBackgroundRunner,
};
pub use clock::SystemClock;
pub use conversation::InMemoryConversationRegistry;