Files
IdeA/crates/application/src/orchestrator/wake.rs
Blomios eb9cc16181 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>
2026-07-04 00:21:33 +02:00

320 lines
9.8 KiB
Rust

//! Owner wake service for first-class background task completions.
//!
//! This application adapter implements the domain [`AgentWakePort`] above the
//! existing structured/headless session path. It owns no concrete process or
//! Tauri wiring: B7 injects the session provider that reuses
//! `StructuredSessions`/`LaunchAgent`.
use std::sync::Arc;
use async_trait::async_trait;
use domain::background_task::{BackgroundTask, BackgroundTaskResult};
use domain::events::DomainEvent;
use domain::ids::AgentId;
use domain::inbox::{AgentInbox, InboxItem, InboxItemKind, InboxSource};
use domain::input::InputMediator;
use domain::mailbox::{AgentMailbox, Ticket};
use domain::ports::{
AgentSession, AgentWakePort, BackgroundTaskStore, EventBus, WakeError, WakeReason,
};
use domain::project::Project;
use crate::agent::drain_reply_stream_with_readiness;
/// Provides a structured/headless session for a wake turn.
///
/// The production implementation is expected to return the live
/// `StructuredSessions` entry when present, or launch/reattach the agent using the
/// same mechanism as inter-agent delegation when absent.
#[async_trait]
pub trait WakeSessionProvider: Send + Sync {
/// Returns or starts the structured session used to send the wake prompt.
///
/// # Errors
/// [`WakeError::Session`] when the agent cannot be driven headlessly.
async fn session_for_wake(
&self,
project: &Project,
agent: AgentId,
) -> Result<Arc<dyn AgentSession>, WakeError>;
}
/// Drain-then-wake implementation of [`AgentWakePort`].
pub struct AgentWakeService {
inbox: Arc<dyn AgentInbox>,
input: Arc<dyn InputMediator>,
mailbox: Arc<dyn AgentMailbox>,
tasks: Arc<dyn BackgroundTaskStore>,
sessions: Arc<dyn WakeSessionProvider>,
events: Option<Arc<dyn EventBus>>,
}
impl AgentWakeService {
/// Builds a wake service from its ports.
#[must_use]
pub fn new(
inbox: Arc<dyn AgentInbox>,
input: Arc<dyn InputMediator>,
mailbox: Arc<dyn AgentMailbox>,
tasks: Arc<dyn BackgroundTaskStore>,
sessions: Arc<dyn WakeSessionProvider>,
events: Option<Arc<dyn EventBus>>,
) -> Self {
Self {
inbox,
input,
mailbox,
tasks,
sessions,
events,
}
}
fn publish(&self, event: DomainEvent) {
if let Some(events) = &self.events {
events.publish(event);
}
}
fn publish_failed(&self, project: &Project, agent: AgentId, reason: impl ToString) {
self.publish(DomainEvent::AgentWakeFailed {
project_id: project.id,
agent_id: agent,
reason: reason.to_string(),
});
}
async fn wake_one(
&self,
project: &Project,
agent: AgentId,
reason: WakeReason,
) -> Result<(), WakeError> {
self.publish(DomainEvent::AgentWakeScheduled {
project_id: project.id,
agent_id: agent,
});
if self.input.busy_state(agent).is_busy() {
return Err(WakeError::AgentBusy { agent_id: agent });
}
let Some(item) = self.inbox.dequeue_next(agent) else {
return Ok(());
};
let delivery = self.delivery_from_item(item, &reason).await?;
let ticket = Ticket::new(delivery.ticket_id, "IdeA", delivery.prompt.clone());
let _pending = self.input.enqueue_silent(agent, ticket);
let guard = WakeTurnGuard::new(
Arc::clone(&self.input),
Arc::clone(&self.mailbox),
agent,
delivery.ticket_id,
);
self.publish(DomainEvent::AgentWakeStarted {
project_id: project.id,
agent_id: agent,
});
let session = self.sessions.session_for_wake(project, agent).await?;
let stream = session
.send(&delivery.prompt)
.await
.map_err(|err| WakeError::Session(err.to_string()))?;
if let Some(task_id) = delivery.delivered_task_id {
self.tasks
.mark_completion_delivered(task_id)
.await
.map_err(|err| WakeError::Store(err.to_string()))?;
self.publish(DomainEvent::BackgroundTaskCompletionDelivered {
project_id: project.id,
task_id,
owner_agent_id: agent,
});
}
drain_reply_stream_with_readiness(stream, self.input.as_ref(), agent)
.await
.map_err(|err| WakeError::Session(err.to_string()))?;
self.mailbox.cancel_head(agent, delivery.ticket_id);
self.input.mark_idle(agent);
guard.disarm();
Ok(())
}
async fn delivery_from_item(
&self,
item: InboxItem,
reason: &WakeReason,
) -> Result<WakeDelivery, WakeError> {
match (reason, item.kind, item.source) {
(
WakeReason::BackgroundCompletion { task_id },
InboxItemKind::BackgroundCompletion,
InboxSource::BackgroundTask {
task_id: item_task_id,
},
) if *task_id == item_task_id => {
let task = self.load_task(item_task_id).await?;
Ok(WakeDelivery {
ticket_id: item.id,
prompt: background_completion_prompt(&task),
delivered_task_id: Some(item_task_id),
})
}
(WakeReason::InboxItem { item_id }, _, _) if *item_id == item.id => Ok(WakeDelivery {
ticket_id: item.id,
prompt: system_prompt_from_item(&item),
delivered_task_id: None,
}),
_ => Err(WakeError::UnexpectedInboxItem(format!(
"item={} kind={:?} source={:?} reason={:?}",
item.id, item.kind, item.source, reason
))),
}
}
async fn load_task(&self, task_id: domain::ids::TaskId) -> Result<BackgroundTask, WakeError> {
self.tasks
.get(task_id)
.await
.map_err(|err| WakeError::Store(err.to_string()))?
.ok_or_else(|| WakeError::Task(format!("background task {task_id} not found")))
.and_then(|task| {
if task.result.is_some() {
Ok(task)
} else {
Err(WakeError::Task(format!(
"background task {task_id} has no terminal result"
)))
}
})
}
}
#[async_trait]
impl AgentWakePort for AgentWakeService {
async fn wake_agent(
&self,
project: &Project,
agent: AgentId,
reason: WakeReason,
) -> Result<(), WakeError> {
match self.wake_one(project, agent, reason).await {
Ok(()) => Ok(()),
Err(err) => {
self.publish_failed(project, agent, &err);
Err(err)
}
}
}
}
struct WakeDelivery {
ticket_id: domain::mailbox::TicketId,
prompt: String,
delivered_task_id: Option<domain::ids::TaskId>,
}
struct WakeTurnGuard {
input: Arc<dyn InputMediator>,
mailbox: Arc<dyn AgentMailbox>,
agent: AgentId,
ticket: domain::mailbox::TicketId,
armed: bool,
}
impl WakeTurnGuard {
fn new(
input: Arc<dyn InputMediator>,
mailbox: Arc<dyn AgentMailbox>,
agent: AgentId,
ticket: domain::mailbox::TicketId,
) -> Self {
Self {
input,
mailbox,
agent,
ticket,
armed: true,
}
}
fn disarm(mut self) {
self.armed = false;
}
}
impl Drop for WakeTurnGuard {
fn drop(&mut self) {
if self.armed {
self.mailbox.cancel_head(self.agent, self.ticket);
self.input.mark_idle(self.agent);
}
}
}
fn system_prompt_from_item(item: &InboxItem) -> String {
format!("Message système pour l'agent :\n\n{}", item.body)
}
fn background_completion_prompt(task: &BackgroundTask) -> String {
match task.result.as_ref() {
Some(BackgroundTaskResult::Success {
exit_code,
summary,
stdout_tail,
stderr_tail,
..
}) => format!(
"La tâche de fond {} est terminée : succès{}.\nRésumé : {}\n{}{}",
task.id,
exit_code_text(*exit_code),
summary,
tail_text("stdout", stdout_tail.as_deref()),
tail_text("stderr", stderr_tail.as_deref())
),
Some(BackgroundTaskResult::Failure {
exit_code,
error,
stdout_tail,
stderr_tail,
..
}) => format!(
"La tâche de fond {} est terminée : échec{}.\nErreur : {}\n{}{}",
task.id,
exit_code_text(*exit_code),
error,
tail_text("stdout", stdout_tail.as_deref()),
tail_text("stderr", stderr_tail.as_deref())
),
Some(BackgroundTaskResult::Cancelled { reason, .. }) => format!(
"La tâche de fond {} est terminée : annulée.\nRaison : {}",
task.id, reason
),
Some(BackgroundTaskResult::Expired { reason, .. }) => format!(
"La tâche de fond {} est terminée : expirée.\nRaison : {}",
task.id, reason
),
None => format!(
"La tâche de fond {} est terminée, mais son résultat est indisponible.",
task.id
),
}
}
fn exit_code_text(exit_code: Option<i32>) -> String {
exit_code.map_or_else(String::new, |code| format!(" (exit {code})"))
}
fn tail_text(label: &str, value: Option<&str>) -> String {
value.map_or_else(String::new, |text| {
if text.is_empty() {
String::new()
} else {
format!("{label} :\n{text}\n")
}
})
}