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:
@ -23,11 +23,12 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::input::{AgentBusyState, InputSource};
|
||||
use domain::ports::AgentContextStore;
|
||||
use domain::ports::{AgentContextStore, BackgroundTaskPortError, BackgroundTaskStore};
|
||||
use domain::{
|
||||
AgentId, AgentQueueSnapshot, ConversationId, ConversationLog, ConversationTurn, Handoff,
|
||||
HandoffStore, InputMediator, NodeId, ProfileId, Project, ProjectPath, QueuedTicketSnapshot,
|
||||
SessionId, TicketId, TurnId, TurnRole,
|
||||
AgentId, AgentQueueSnapshot, BackgroundTask, BackgroundTaskKind, BackgroundTaskResult,
|
||||
BackgroundTaskState, ConversationId, ConversationLog, ConversationTurn, Handoff, HandoffStore,
|
||||
InputMediator, NodeId, ProfileId, Project, ProjectPath, QueuedTicketSnapshot, SessionId,
|
||||
TaskId, TicketId, TurnId, TurnRole,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
@ -150,6 +151,48 @@ pub struct AgentWorkState {
|
||||
pub busy: AgentBusyState,
|
||||
/// Pending/in-progress delegation tickets, in FIFO order.
|
||||
pub tickets: Vec<AgentTicketState>,
|
||||
/// Best-effort first-class background tasks owned by this agent.
|
||||
///
|
||||
/// V1 bound: terminal tasks already delivered to the owner are no longer
|
||||
/// enumerable by the background-task store, so they disappear from Work;
|
||||
/// retry is available only while the terminal completion is still undelivered.
|
||||
pub background_tasks: Vec<AgentBackgroundTaskState>,
|
||||
}
|
||||
|
||||
/// Background task projected into an agent's work state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AgentBackgroundTaskState {
|
||||
/// Stable task id.
|
||||
pub task_id: TaskId,
|
||||
/// Kind discriminant.
|
||||
pub kind: BackgroundTaskKindLabel,
|
||||
/// Current lifecycle state.
|
||||
pub state: BackgroundTaskState,
|
||||
/// Process exit code, when the terminal result carries one.
|
||||
pub exit_code: Option<i32>,
|
||||
/// Human-readable summary / error / reason of the terminal result.
|
||||
pub summary: Option<String>,
|
||||
/// Bounded stdout tail.
|
||||
pub stdout_tail: Option<String>,
|
||||
/// Bounded stderr tail.
|
||||
pub stderr_tail: Option<String>,
|
||||
/// Creation timestamp, epoch milliseconds.
|
||||
pub created_at_ms: u64,
|
||||
/// Last update timestamp, epoch milliseconds.
|
||||
pub updated_at_ms: u64,
|
||||
}
|
||||
|
||||
/// Background task kind label used by the work-state read model.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BackgroundTaskKindLabel {
|
||||
/// A non-interactive command.
|
||||
Command,
|
||||
/// A headless inter-agent rendezvous.
|
||||
HeadlessRendezvous,
|
||||
/// Resume of a structured agent session.
|
||||
SessionResume,
|
||||
/// Internal maintenance work.
|
||||
Maintenance,
|
||||
}
|
||||
|
||||
/// One queued delegation ticket, projected for the work-state read model.
|
||||
@ -229,6 +272,8 @@ pub struct GetProjectWorkState {
|
||||
handoffs: Option<Arc<dyn HandoffProvider>>,
|
||||
/// Per-root log source (fallback), wired best-effort; `None` ⇒ no fallback turns.
|
||||
logs: Option<Arc<dyn ConversationLogProvider>>,
|
||||
/// Background-task read model source, wired best-effort; `None` ⇒ no tasks.
|
||||
background_tasks: Option<Arc<dyn BackgroundTaskStore>>,
|
||||
}
|
||||
|
||||
impl GetProjectWorkState {
|
||||
@ -250,6 +295,7 @@ impl GetProjectWorkState {
|
||||
queue,
|
||||
handoffs: None,
|
||||
logs: None,
|
||||
background_tasks: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -267,6 +313,15 @@ impl GetProjectWorkState {
|
||||
self
|
||||
}
|
||||
|
||||
/// Wires the best-effort background-task source for the Work panel. Builder so
|
||||
/// existing call sites/tests stay unchanged; absent or failing store ⇒ empty
|
||||
/// `background_tasks` without affecting live/busy/tickets.
|
||||
#[must_use]
|
||||
pub fn with_background_tasks(mut self, store: Arc<dyn BackgroundTaskStore>) -> Self {
|
||||
self.background_tasks = Some(store);
|
||||
self
|
||||
}
|
||||
|
||||
/// Executes the read-only aggregation.
|
||||
///
|
||||
/// # Errors
|
||||
@ -278,40 +333,47 @@ impl GetProjectWorkState {
|
||||
) -> Result<ProjectWorkState, AppError> {
|
||||
let manifest = self.contexts.load_manifest(&input.project).await?;
|
||||
let live_by_agent = live_by_agent(self.live.live_agent_snapshots());
|
||||
let agents = manifest
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let agent = entry
|
||||
.to_agent()
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||
let live = live_by_agent
|
||||
.get(&agent.id)
|
||||
.map(|snapshot| LiveWorkSession {
|
||||
node_id: snapshot.node_id,
|
||||
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>>()?;
|
||||
let undelivered_completions = match &self.background_tasks {
|
||||
Some(store) => Some(store.list_undelivered_completions().await),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let mut agents = Vec::with_capacity(manifest.entries.len());
|
||||
for entry in &manifest.entries {
|
||||
let agent = entry
|
||||
.to_agent()
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||
let live = live_by_agent
|
||||
.get(&agent.id)
|
||||
.map(|snapshot| LiveWorkSession {
|
||||
node_id: snapshot.node_id,
|
||||
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 = self.input.busy_state(agent.id);
|
||||
let busy_ticket = busy.ticket();
|
||||
let tickets = self
|
||||
.queue
|
||||
.queue_for(agent.id)
|
||||
.into_iter()
|
||||
.map(|snapshot| ticket_state(snapshot, busy_ticket))
|
||||
.collect();
|
||||
let background_tasks = self
|
||||
.background_tasks_for_agent(&input.project, agent.id, &undelivered_completions)
|
||||
.await;
|
||||
agents.push(AgentWorkState {
|
||||
agent_id: agent.id,
|
||||
name: agent.name,
|
||||
profile_id: agent.profile_id,
|
||||
live,
|
||||
busy,
|
||||
tickets,
|
||||
background_tasks,
|
||||
});
|
||||
}
|
||||
|
||||
// Lot C — best-effort conversation summaries. Only the conversations
|
||||
// visible through the projected tickets are summarised, deduplicated in
|
||||
@ -354,6 +416,106 @@ impl GetProjectWorkState {
|
||||
}
|
||||
summaries
|
||||
}
|
||||
|
||||
async fn background_tasks_for_agent(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
undelivered_completions: &Option<Result<Vec<BackgroundTask>, BackgroundTaskPortError>>,
|
||||
) -> Vec<AgentBackgroundTaskState> {
|
||||
let Some(store) = &self.background_tasks else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(Ok(undelivered_completions)) = undelivered_completions.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(open) = store.list_open_for_agent(agent_id).await else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut by_id: HashMap<TaskId, BackgroundTask> = HashMap::new();
|
||||
for task in open {
|
||||
by_id.insert(task.id, task);
|
||||
}
|
||||
for task in undelivered_completions
|
||||
.iter()
|
||||
.filter(|task| task.owner_agent_id == agent_id)
|
||||
{
|
||||
by_id.entry(task.id).or_insert_with(|| task.clone());
|
||||
}
|
||||
|
||||
let mut tasks: Vec<_> = by_id
|
||||
.into_values()
|
||||
.filter(|task| task.project_id == project.id)
|
||||
.map(AgentBackgroundTaskState::from)
|
||||
.collect();
|
||||
tasks.sort_by_key(|task| (task.created_at_ms, task.task_id));
|
||||
tasks
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BackgroundTask> for AgentBackgroundTaskState {
|
||||
fn from(task: BackgroundTask) -> Self {
|
||||
let (exit_code, summary, stdout_tail, stderr_tail) = flatten_background_result(&task);
|
||||
Self {
|
||||
task_id: task.id,
|
||||
kind: BackgroundTaskKindLabel::from(&task.kind),
|
||||
state: task.state,
|
||||
exit_code,
|
||||
summary,
|
||||
stdout_tail,
|
||||
stderr_tail,
|
||||
created_at_ms: task.created_at_ms,
|
||||
updated_at_ms: task.updated_at_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&BackgroundTaskKind> for BackgroundTaskKindLabel {
|
||||
fn from(kind: &BackgroundTaskKind) -> Self {
|
||||
match kind {
|
||||
BackgroundTaskKind::Command { .. } => Self::Command,
|
||||
BackgroundTaskKind::HeadlessRendezvous { .. } => Self::HeadlessRendezvous,
|
||||
BackgroundTaskKind::SessionResume { .. } => Self::SessionResume,
|
||||
BackgroundTaskKind::Maintenance { .. } => Self::Maintenance,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flatten_background_result(
|
||||
task: &BackgroundTask,
|
||||
) -> (Option<i32>, Option<String>, Option<String>, Option<String>) {
|
||||
match &task.result {
|
||||
Some(BackgroundTaskResult::Success {
|
||||
exit_code,
|
||||
summary,
|
||||
stdout_tail,
|
||||
stderr_tail,
|
||||
..
|
||||
}) => (
|
||||
*exit_code,
|
||||
Some(summary.clone()),
|
||||
stdout_tail.clone(),
|
||||
stderr_tail.clone(),
|
||||
),
|
||||
Some(BackgroundTaskResult::Failure {
|
||||
exit_code,
|
||||
error,
|
||||
stdout_tail,
|
||||
stderr_tail,
|
||||
..
|
||||
}) => (
|
||||
*exit_code,
|
||||
Some(error.clone()),
|
||||
stdout_tail.clone(),
|
||||
stderr_tail.clone(),
|
||||
),
|
||||
Some(BackgroundTaskResult::Cancelled { reason, .. })
|
||||
| Some(BackgroundTaskResult::Expired { reason, .. }) => {
|
||||
(None, Some(reason.clone()), None, None)
|
||||
}
|
||||
None => (None, None, None, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects the conversation ids referenced by the agents' tickets, deduplicated in
|
||||
|
||||
Reference in New Issue
Block a user