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é).