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

@ -9,11 +9,12 @@
use serde::{Deserialize, Serialize};
use application::{
AgentTicketState, AppError, AttachLiveAgentOutput, ConversationPreviewStatus,
ConversationTurnWorkPreview, ConversationWorkSummary, CreateProjectInput, CreateProjectOutput,
GitGraphOutput, HealthInput, HealthReport, LayoutKind, ListProjectsOutput, LiveSessionKind,
LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState, StopLiveAgentOutput,
TicketWorkSource, TicketWorkStatus, TurnPage, TurnSource, TurnView,
AgentBackgroundTaskState, AgentTicketState, AppError, AttachLiveAgentOutput,
BackgroundTaskKindLabel, ConversationPreviewStatus, ConversationTurnWorkPreview,
ConversationWorkSummary, CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput,
HealthReport, LayoutKind, ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot,
OpenProjectOutput, ProjectWorkState, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus,
TurnPage, TurnSource, TurnView,
};
use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, TurnRole};
@ -1607,6 +1608,50 @@ impl From<AgentTicketState> for AgentTicketStateDto {
}
}
/// One background task owned by an agent in the Work panel read model.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentBackgroundTaskStateDto {
/// Stable task id (UUID string).
pub task_id: String,
/// Kind discriminant.
pub kind: String,
/// Lifecycle state.
pub state: String,
/// Process exit code, when the terminal result carries one.
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
/// Human-readable summary / error / reason of the terminal result.
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
/// Bounded stdout tail.
#[serde(skip_serializing_if = "Option::is_none")]
pub stdout_tail: Option<String>,
/// Bounded stderr tail.
#[serde(skip_serializing_if = "Option::is_none")]
pub stderr_tail: Option<String>,
/// Creation timestamp, epoch milliseconds.
pub created_at_ms: u64,
/// Last update timestamp, epoch milliseconds.
pub updated_at_ms: u64,
}
impl From<AgentBackgroundTaskState> for AgentBackgroundTaskStateDto {
fn from(task: AgentBackgroundTaskState) -> Self {
Self {
task_id: task.task_id.to_string(),
kind: background_kind_label_from_work_state(task.kind).to_owned(),
state: background_state_label(task.state).to_owned(),
exit_code: task.exit_code,
summary: task.summary,
stdout_tail: task.stdout_tail,
stderr_tail: task.stderr_tail,
created_at_ms: task.created_at_ms,
updated_at_ms: task.updated_at_ms,
}
}
}
/// One manifest agent's current live/busy state.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@ -1623,6 +1668,8 @@ pub struct AgentWorkStateDto {
pub busy: AgentBusyState,
/// Pending/in-progress delegation tickets, in FIFO order.
pub tickets: Vec<AgentTicketStateDto>,
/// Best-effort first-class background tasks owned by this agent.
pub background_tasks: Vec<AgentBackgroundTaskStateDto>,
}
/// How much of a [`ConversationWorkSummaryDto`] could be derived, best-effort.
@ -1748,6 +1795,11 @@ impl From<ProjectWorkState> for ProjectWorkStateDto {
.into_iter()
.map(AgentTicketStateDto::from)
.collect(),
background_tasks: agent
.background_tasks
.into_iter()
.map(AgentBackgroundTaskStateDto::from)
.collect(),
})
.collect(),
conversations: state
@ -2924,6 +2976,16 @@ fn background_kind_label(kind: &BackgroundTaskKind) -> &'static str {
}
}
/// Kind discriminant string for a work-state [`BackgroundTaskKindLabel`].
fn background_kind_label_from_work_state(kind: BackgroundTaskKindLabel) -> &'static str {
match kind {
BackgroundTaskKindLabel::Command => "command",
BackgroundTaskKindLabel::HeadlessRendezvous => "headlessRendezvous",
BackgroundTaskKindLabel::SessionResume => "sessionResume",
BackgroundTaskKindLabel::Maintenance => "maintenance",
}
}
/// Lifecycle state string for a [`BackgroundTaskState`].
fn background_state_label(state: BackgroundTaskState) -> &'static str {
match state {