feat(application): réveil d'agent et rendez-vous comme tâche de fond (B5-B6)

Orchestration du réveil (wake) d'un agent sur complétion/message et
traitement du rendez-vous inter-agent comme tâche de fond de 1re classe.
Couvert par agent_wake (vert).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 15:47:34 +02:00
parent c537da54ef
commit f94b54239b
5 changed files with 1092 additions and 7 deletions

View File

@ -85,10 +85,10 @@ pub use memory::{
};
pub use orchestrator::{
resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,
AskLivenessProbe, ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider,
McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, ProposeContext, ReadContext,
ReadMemory, RecordTurnProvider, WatchdogOutcome, WriteMemory, DEFAULT_RENDEZVOUS_CEILING,
DEFAULT_RENDEZVOUS_WINDOW,
AgentWakeService, AskLivenessProbe, ContextGuardUseCases, LiveStateProvider,
LiveStateReadProvider, McpRuntimeProvider, OrchestratorOutcome, OrchestratorService,
ProposeContext, ReadContext, ReadMemory, RecordTurnProvider, WakeSessionProvider,
WatchdogOutcome, WriteMemory, DEFAULT_RENDEZVOUS_CEILING, DEFAULT_RENDEZVOUS_WINDOW,
};
pub use permission::{
GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput,

View File

@ -7,6 +7,7 @@
mod context_guard;
pub mod rendezvous;
mod service;
pub mod wake;
pub use context_guard::{
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
@ -20,3 +21,4 @@ pub use service::{
AskLivenessProbe, ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider,
McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, RecordTurnProvider,
};
pub use wake::{AgentWakeService, WakeSessionProvider};

View File

@ -23,11 +23,12 @@ use domain::conversation::{ConversationParty, ConversationRegistry, SessionRef,
use domain::conversation_log::{ConversationTurn, TurnId, TurnRole};
use domain::input::{InputMediator, InputSource, SubmitConfig};
use domain::mailbox::{Ticket, TicketId};
use domain::ports::{Clock, EventBus, ProfileStore, PtyHandle};
use domain::ports::{BackgroundTaskStore, Clock, EventBus, ProfileStore, PtyHandle};
use domain::project::ProjectPath;
use domain::{
AgentId, AgentProfile, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId,
Project,
AgentId, AgentProfile, BackgroundTask, BackgroundTaskKind, BackgroundTaskResult,
BackgroundTaskState, BackgroundTaskWakePolicy, DomainEvent, OrchestratorCommand,
OrchestratorVisibility, ProfileId, Project, TaskId,
};
use crate::conversation::RecordTurn;
@ -77,6 +78,17 @@ fn structured_no_reply_error(err: &domain::ports::AgentSessionError) -> bool {
)
}
fn bound_task_text(value: &str, max_bytes: usize) -> String {
if value.len() <= max_bytes {
return value.to_owned();
}
let mut end = max_bytes;
while !value.is_char_boundary(end) {
end -= 1;
}
value[..end].to_owned()
}
/// Bound on the synchronous inter-agent rendezvous (`agent.message` → `AskAgent`).
///
/// A target agent's turn can be long (reasoning + tool use), so the cap is
@ -417,6 +429,10 @@ pub struct OrchestratorService {
/// [`Self::with_ask_ceiling`] (la composition root résout l'override d'env) ; les
/// tests le rétrécissent à quelques ms. Défaut = 4 h.
ask_ceiling: Duration,
/// Store durable des tâches de fond de 1re classe. B6 l'utilise pour tracer un
/// `idea_ask_agent` comme [`BackgroundTaskKind::HeadlessRendezvous`]. `None` ⇒
/// comportement historique sans persistance de tâche (call sites non câblés).
background_tasks: Option<Arc<dyn BackgroundTaskStore>>,
}
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
@ -487,9 +503,27 @@ impl OrchestratorService {
live_state_read: None,
ask_liveness_probe: None,
ask_ceiling: ASK_AGENT_CEILING,
background_tasks: None,
}
}
/// Branche le store des tâches de fond pour tracer les rendez-vous inter-agents
/// comme [`BackgroundTaskKind::HeadlessRendezvous`] (B6).
///
/// Le `clock` fournit les timestamps métier de la tâche. Il réutilise le champ
/// horloge applicatif déjà existant ; si un provider de conversation log l'avait
/// déjà posé, la même horloge reste partagée.
#[must_use]
pub fn with_background_tasks(
mut self,
store: Arc<dyn BackgroundTaskStore>,
clock: Arc<dyn Clock>,
) -> Self {
self.background_tasks = Some(store);
self.clock = Some(clock);
self
}
/// Branche la **sonde de vivacité** (signe de vie) du rendez-vous délégué : la borne
/// de tour devient une **fenêtre d'inactivité** réarmée à chaque progrès observé,
/// sous le plafond [`Self::with_ask_ceiling`]. Builder additif (signature de
@ -610,6 +644,162 @@ impl OrchestratorService {
self
}
fn now_ms(&self) -> u64 {
self.clock
.as_ref()
.map_or(0, |clock| clock.now_millis().max(0) as u64)
}
#[allow(clippy::too_many_arguments)]
async fn start_rendezvous_task(
&self,
project: &Project,
owner_agent_id: AgentId,
requester_agent_id: Option<AgentId>,
target_agent_id: AgentId,
ticket_id: TicketId,
conversation_id: domain::conversation::ConversationId,
) -> Result<Option<TaskId>, AppError> {
let Some(store) = &self.background_tasks else {
return Ok(None);
};
let task_id = TaskId::new_random();
let now = self.now_ms();
let task = BackgroundTask::new(
task_id,
project.id,
owner_agent_id,
BackgroundTaskKind::HeadlessRendezvous {
requester_agent_id,
target_agent_id,
ticket_id,
conversation_id,
},
BackgroundTaskWakePolicy::RecordOnly,
now,
None,
)
.map_err(|err| AppError::Internal(err.to_string()))?
.transition(BackgroundTaskState::Running, now)
.map_err(|err| AppError::Internal(err.to_string()))?;
store
.create(&task)
.await
.map_err(|err| AppError::Store(err.to_string()))?;
if let Some(events) = &self.events {
events.publish(DomainEvent::BackgroundTaskStarted {
project_id: project.id,
task_id,
owner_agent_id,
});
}
Ok(Some(task_id))
}
async fn complete_rendezvous_task_success(
&self,
project: &Project,
owner_agent_id: AgentId,
task_id: Option<TaskId>,
reply: &str,
) -> Result<(), AppError> {
let result = BackgroundTaskResult::Success {
finished_at_ms: self.now_ms(),
exit_code: None,
summary: "Headless rendezvous completed with Final".to_owned(),
stdout_tail: Some(bound_task_text(
reply,
domain::BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES,
)),
stderr_tail: None,
};
self.finish_rendezvous_task(project, owner_agent_id, task_id, result)
.await
}
async fn complete_rendezvous_task_failure(
&self,
project: &Project,
owner_agent_id: AgentId,
task_id: Option<TaskId>,
error: impl Into<String>,
) -> Result<(), AppError> {
let result = BackgroundTaskResult::Failure {
finished_at_ms: self.now_ms(),
exit_code: None,
error: bound_task_text(&error.into(), domain::BACKGROUND_TASK_TEXT_MAX_BYTES),
stdout_tail: None,
stderr_tail: None,
};
self.finish_rendezvous_task(project, owner_agent_id, task_id, result)
.await
}
async fn complete_rendezvous_task_cancelled(
&self,
project: &Project,
owner_agent_id: AgentId,
task_id: Option<TaskId>,
reason: impl Into<String>,
) -> Result<(), AppError> {
let result = BackgroundTaskResult::Cancelled {
finished_at_ms: self.now_ms(),
reason: bound_task_text(&reason.into(), domain::BACKGROUND_TASK_TEXT_MAX_BYTES),
};
self.finish_rendezvous_task(project, owner_agent_id, task_id, result)
.await
}
async fn finish_rendezvous_task(
&self,
project: &Project,
owner_agent_id: AgentId,
task_id: Option<TaskId>,
result: BackgroundTaskResult,
) -> Result<(), AppError> {
let (Some(store), Some(task_id)) = (&self.background_tasks, task_id) else {
return Ok(());
};
let task = store
.get(task_id)
.await
.map_err(|err| AppError::Store(err.to_string()))?
.ok_or_else(|| AppError::Store(format!("background task {task_id} not found")))?;
if task.is_terminal() {
return Ok(());
}
let event = match &result {
BackgroundTaskResult::Success { .. } => DomainEvent::BackgroundTaskCompleted {
project_id: project.id,
task_id,
owner_agent_id,
},
BackgroundTaskResult::Failure { .. } => DomainEvent::BackgroundTaskFailed {
project_id: project.id,
task_id,
owner_agent_id,
},
BackgroundTaskResult::Cancelled { .. } | BackgroundTaskResult::Expired { .. } => {
DomainEvent::BackgroundTaskCancelled {
project_id: project.id,
task_id,
owner_agent_id,
}
}
};
let completed = task
.complete(result)
.map_err(|err| AppError::Internal(err.to_string()))?;
store
.save(&completed)
.await
.map_err(|err| AppError::Store(err.to_string()))?;
if let Some(events) = &self.events {
events.publish(event);
}
Ok(())
}
/// Branche le [`ConversationRegistry`] (cadrage C3 §5.2) pour résoudre
/// paresseusement le fil `A↔B` (ou `User↔B`) d'un `ask` et séparer les contextes.
/// Builder additif (signature de [`Self::new`] inchangée).
@ -1458,6 +1648,16 @@ impl OrchestratorService {
// l'enqueue qui démarre le tour (le médiateur arme alors sa fenêtre de vivacité).
let turn_timeout = self.turn_timeout_for(project, agent_id).await;
let _pending = input.enqueue_silent(agent_id, ticket);
let rendezvous_task = self
.start_rendezvous_task(
project,
agent_id,
requester,
agent_id,
ticket_id,
conversation_id,
)
.await?;
// Auto-update live-state (lot LS3), best-effort : la cible passe `Working` sur
// cette transition d'`ask` acceptée (chemin structuré). `task` est encore vivant
// ici (utilisé par le drain plus bas), on le distille directement.
@ -1514,6 +1714,8 @@ impl OrchestratorService {
.await
{
WatchdogOutcome::Resolved(Ok(content)) => {
self.complete_rendezvous_task_success(project, agent_id, rendezvous_task, &content)
.await?;
crate::diag!(
"[rendezvous] ask resolved (structured): target={target} \
(agent {agent_id}) ticket={ticket_id} after_ms={} reply_len={}",
@ -1526,6 +1728,33 @@ impl OrchestratorService {
// `cancel_head` + `mark_idle` au Drop. On réconcilie la live-state à `Done`
// pour qu'un abandon ne laisse pas un busy fantôme.
WatchdogOutcome::Resolved(Err(err)) => {
let task_error = if matches!(
err,
AppError::TargetReturnedNoReply(_) | AppError::Process(_)
) {
format!("NoReply: {err}")
} else {
err.to_string()
};
if task_error.to_ascii_lowercase().contains("cancelled")
|| task_error.to_ascii_lowercase().contains("annul")
{
self.complete_rendezvous_task_cancelled(
project,
agent_id,
rendezvous_task,
task_error,
)
.await?;
} else {
self.complete_rendezvous_task_failure(
project,
agent_id,
rendezvous_task,
task_error,
)
.await?;
}
self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
.await;
crate::diag!(
@ -1537,6 +1766,13 @@ impl OrchestratorService {
}
// Plafond absolu atteint alors que la cible progresse encore : verdict distinct.
WatchdogOutcome::CeilingActive => {
self.complete_rendezvous_task_failure(
project,
agent_id,
rendezvous_task,
format!("Timeout: rendezvous absolute ceiling reached for target {target}"),
)
.await?;
self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
.await;
crate::diag!(
@ -1549,6 +1785,13 @@ impl OrchestratorService {
// Vrai silence (aucun progrès sur la fenêtre) : timeout typé, identique à
// l'ancienne borne plate. Live-state réconciliée à `Done`.
WatchdogOutcome::NoReply => {
self.complete_rendezvous_task_failure(
project,
agent_id,
rendezvous_task,
format!("Timeout: rendezvous inactivity window expired for target {target}"),
)
.await?;
self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
.await;
crate::diag!(

View File

@ -0,0 +1,316 @@
//! 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_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 Ok(());
}
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?;
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();
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()))?;
}
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")
}
})
}