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

@ -19,7 +19,8 @@ pub(crate) use lifecycle::ReattachDecision;
pub use session_limit::{AgentResumer, SessionLimitService, RESUME_PROMPT};
pub use structured::{
drain_with_readiness, drain_with_readiness_outcome, send_blocking, TurnOutcome,
drain_reply_stream_with_readiness, drain_with_readiness, drain_with_readiness_outcome,
send_blocking, TurnOutcome,
};
pub use catalogue::{

View File

@ -16,7 +16,7 @@ use std::time::Duration;
use domain::ids::AgentId;
use domain::input::InputMediator;
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent};
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
use domain::readiness::{ReadinessPolicy, ReadinessSignal};
/// Issue d'un tour drainé (ARCHITECTURE §21.2-T4, réconciliation de la limite de
@ -113,6 +113,36 @@ pub async fn drain_with_readiness(
}
}
/// Draine un flux de réponse déjà ouvert, avec la même politique de readiness que
/// [`drain_with_readiness`].
///
/// Ce point d'entrée sert aux appelants qui doivent effectuer une action durable
/// après `AgentSession::send` réussi mais avant le drain complet du tour.
pub async fn drain_reply_stream_with_readiness(
stream: ReplyStream,
mediator: &dyn InputMediator,
agent: AgentId,
) -> Result<String, AgentSessionError> {
match drain_stream_to_final(
stream,
|event| {
if !matches!(event, ReplyEvent::Final { .. }) {
mediator.mark_alive(agent);
}
},
|signal| {
if signal == ReadinessSignal::TurnEnded {
mediator.mark_idle(agent);
}
},
)? {
TurnOutcome::Completed(content) => Ok(content),
TurnOutcome::RateLimited { .. } => Err(AgentSessionError::Io(
"le tour s'est clos en limite de session, sans contenu Final".to_string(),
)),
}
}
/// Variante **riche** de [`drain_with_readiness`] : même branchement readiness, mais
/// retourne le [`TurnOutcome`] complet au lieu de réduire la limite à une erreur.
///
@ -209,6 +239,15 @@ async fn drain_bounded_events(
async fn drain_to_final(
session: &dyn AgentSession,
prompt: &str,
on_event: impl FnMut(&ReplyEvent),
on_signal: impl FnMut(ReadinessSignal),
) -> Result<TurnOutcome, AgentSessionError> {
let stream = session.send(prompt).await?;
drain_stream_to_final(stream, on_event, on_signal)
}
fn drain_stream_to_final(
stream: ReplyStream,
mut on_event: impl FnMut(&ReplyEvent),
mut on_signal: impl FnMut(ReadinessSignal),
) -> Result<TurnOutcome, AgentSessionError> {
@ -216,7 +255,6 @@ async fn drain_to_final(
// `RateLimited` traverse le flux. Sert UNIQUEMENT au cas « clos sans Final » —
// un `Final` ultérieur l'emporte toujours (le tour a réellement abouti).
let mut last_rate_limit: Option<Option<i64>> = None;
let stream = session.send(prompt).await?;
for event in stream {
// Battement de vivacité (lot 2) : notifié pour CHAQUE événement brut, avant le
// classement readiness. Le sink décide (les non-terminaux prouvent la vivacité).

View File

@ -193,10 +193,7 @@ impl CancelBackgroundTask {
///
/// # Errors
/// [`AppError`] if the runner or store fails.
pub async fn execute(
&self,
task_id: TaskId,
) -> Result<CancelBackgroundTaskOutput, AppError> {
pub async fn execute(&self, task_id: TaskId) -> Result<CancelBackgroundTaskOutput, AppError> {
self.runner.cancel(task_id).await.map_err(map_port_err)?;
let task = self.store.get(task_id).await.map_err(map_port_err)?;
Ok(CancelBackgroundTaskOutput { task })
@ -256,10 +253,7 @@ impl RetryBackgroundTask {
/// # Errors
/// [`AppError::NotFound`] if the task is unknown, [`AppError::Invalid`] if it
/// is not a terminal command task or its command is no longer available.
pub async fn execute(
&self,
task_id: TaskId,
) -> Result<SpawnBackgroundCommandOutput, AppError> {
pub async fn execute(&self, task_id: TaskId) -> Result<SpawnBackgroundCommandOutput, AppError> {
let old = self
.store
.get(task_id)

View File

@ -33,25 +33,27 @@ pub mod window;
pub mod workstate;
pub use agent::{
drain_with_readiness, drain_with_readiness_outcome, reference_profile_id, reference_profiles,
selectable_reference_profiles, send_blocking, AgentResumer, ChangeAgentProfile,
ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput,
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider,
InjectedLiveRow, InspectConversation, InspectConversationInput, InspectConversationOutput,
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime,
PermissionProjectorRegistry, ProfileAvailability, ProviderSessionProvider, ReadAgentContext,
ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput,
ResumableAgent, SaveProfile, SaveProfileInput, SaveProfileOutput, SessionLimitService,
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput,
AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
drain_reply_stream_with_readiness, drain_with_readiness, drain_with_readiness_outcome,
reference_profile_id, reference_profiles, selectable_reference_profiles, send_blocking,
AgentResumer, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput,
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch,
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile,
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
FirstRunStateOutput, HandoffProvider, InjectedLiveRow, InspectConversation,
InspectConversationInput, InspectConversationOutput, LaunchAgent, LaunchAgentInput,
LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListProfiles,
ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput,
LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry, ProfileAvailability,
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput,
SaveProfileOutput, SessionLimitService, StructuredSessionDescriptor, TurnOutcome,
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
};
pub use background::{
BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput, RetryBackgroundTask,
SpawnBackgroundCommand, SpawnBackgroundCommandInput, SpawnBackgroundCommandOutput,
BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput,
RetryBackgroundTask, SpawnBackgroundCommand, SpawnBackgroundCommandInput,
SpawnBackgroundCommandOutput,
};
pub use conversation::{
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn,
@ -140,11 +142,11 @@ pub use terminal::{
};
pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};
pub use workstate::{
AgentTicketState, AgentWorkState, AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput,
ConversationLogProvider, ConversationPreviewStatus, ConversationTurnWorkPreview,
ConversationWorkSummary, GetLiveStateLean, GetProjectWorkState, GetProjectWorkStateInput,
LeanLiveEntry, LeanLiveState, LiveWorkSession, ProjectWorkState, ReconcileLiveState,
ReconcileLiveStateInput, StopLiveAgent, StopLiveAgentInput, StopLiveAgentOutput,
TicketWorkSource, TicketWorkStatus, UpdateLiveState, UpdateLiveStateInput,
LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS,
AgentBackgroundTaskState, AgentTicketState, AgentWorkState, AttachLiveAgent,
AttachLiveAgentInput, AttachLiveAgentOutput, BackgroundTaskKindLabel, ConversationLogProvider,
ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary,
GetLiveStateLean, GetProjectWorkState, GetProjectWorkStateInput, LeanLiveEntry, LeanLiveState,
LiveWorkSession, ProjectWorkState, ReconcileLiveState, ReconcileLiveStateInput, StopLiveAgent,
StopLiveAgentInput, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, UpdateLiveState,
UpdateLiveStateInput, LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS,
};

View File

@ -19,7 +19,7 @@ use domain::ports::{
};
use domain::project::Project;
use crate::agent::drain_with_readiness;
use crate::agent::drain_reply_stream_with_readiness;
/// Provides a structured/headless session for a wake turn.
///
@ -96,7 +96,7 @@ impl AgentWakeService {
});
if self.input.busy_state(agent).is_busy() {
return Ok(());
return Err(WakeError::AgentBusy { agent_id: agent });
}
let Some(item) = self.inbox.dequeue_next(agent) else {
@ -117,26 +117,29 @@ impl AgentWakeService {
agent_id: agent,
});
let session = self.sessions.session_for_wake(project, agent).await?;
drain_with_readiness(
session.as_ref(),
&delivery.prompt,
None,
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();
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(())
}

View File

@ -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