feat(infrastructure): store, sink de complétion et mailbox des tâches de fond (B2-B4)

Adapters de persistance des BackgroundTask (store), sink de récupération
de complétion post-tour et boîte de réception (mailbox) pour les messages
concurrents, câblés sur l'entrée. Couvert par background_task_store,
background_completion_sink, agent_inbox et orchestrator_watcher (verts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 15:47:28 +02:00
parent f4a55e9988
commit c537da54ef
9 changed files with 1889 additions and 67 deletions

View File

@ -26,8 +26,12 @@ use std::time::Duration;
use domain::events::DomainEvent;
use domain::ids::AgentId;
use domain::inbox::{
AgentInbox, AgentInboxSnapshot, InboxError, InboxItem, InboxReceipt, InboxReceiptStatus,
InboxSource, DEFAULT_AGENT_INBOX_CAPACITY,
};
use domain::input::{AgentBusyState, AgentLiveness, InputMediator, SubmitConfig};
use domain::mailbox::{AgentMailbox, PendingReply, Ticket, TicketId};
use domain::mailbox::{AgentMailbox, AgentQueueSnapshot, PendingReply, Ticket, TicketId};
use domain::ports::{EventBus, PtyHandle, PtyPort};
use crate::mailbox::InMemoryMailbox;
@ -496,6 +500,11 @@ pub struct MediatedInbox {
/// `set_stall_threshold` and consumed to arm a fresh liveness window on the enqueue
/// that starts a turn. Absent ⇒ `None` (no stall detection — legacy behaviour).
stall: Mutex<HashMap<AgentId, Option<u32>>>,
/// Rich inbox payloads keyed by the mailbox ticket id. The mailbox remains the
/// only FIFO; this map is metadata only.
inbox_items: Mutex<HashMap<TicketId, InboxItem>>,
/// Bounded inbox capacity per agent.
inbox_capacity: usize,
/// Fenêtre de grâce (G) propagée au [`BusyTracker`] à chaque (re)construction.
/// Prod = [`PROMPT_READY_GRACE`] (2 s) ; surchargée par [`Self::with_grace`] en test.
grace: Duration,
@ -525,6 +534,8 @@ impl MediatedInbox {
front_owned,
submit: Mutex::new(HashMap::new()),
stall: Mutex::new(HashMap::new()),
inbox_items: Mutex::new(HashMap::new()),
inbox_capacity: configured_inbox_capacity(),
grace: PROMPT_READY_GRACE,
}
}
@ -716,6 +727,8 @@ impl MediatedInbox {
front_owned,
submit: Mutex::new(HashMap::new()),
stall: Mutex::new(HashMap::new()),
inbox_items: Mutex::new(HashMap::new()),
inbox_capacity: configured_inbox_capacity(),
grace: PROMPT_READY_GRACE,
}
}
@ -729,6 +742,13 @@ impl MediatedInbox {
)
}
/// Overrides bounded inbox capacity. Intended for tests and explicit wiring.
#[must_use]
pub fn with_inbox_capacity(mut self, capacity: usize) -> Self {
self.inbox_capacity = capacity;
self
}
fn handles(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, PtyHandle>> {
self.handles
.lock()
@ -747,6 +767,12 @@ impl MediatedInbox {
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn inbox_items(&self) -> std::sync::MutexGuard<'_, HashMap<TicketId, InboxItem>> {
self.inbox_items
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// **Détection de stagnation** (lot 2) : balaie les agents `Busy` et bascule
/// `Alive→Stalled` ceux dont le dernier battement remonte à plus de
/// `stall_after_ms`. Émet `AgentLivenessChanged` **une fois par transition**. La
@ -1023,6 +1049,107 @@ impl InputMediator for MediatedInbox {
}
}
impl AgentInbox for MediatedInbox {
fn enqueue_message(&self, agent: AgentId, item: InboxItem) -> Result<InboxReceipt, InboxError> {
if item.agent_id != agent {
return Err(InboxError::AgentMismatch {
agent_id: agent,
item_agent_id: item.agent_id,
});
}
let depth_before = self.mailbox.pending(&agent);
if depth_before >= self.inbox_capacity {
if item.is_lossless_system() {
return Ok(InboxReceipt {
item_id: item.id,
agent_id: agent,
depth: depth_before,
status: InboxReceiptStatus::Deferred,
});
}
return Err(InboxError::InboxFull {
agent_id: agent,
capacity: self.inbox_capacity,
});
}
let item_id = item.id;
let ticket = inbox_item_to_ticket(&item);
self.inbox_items().insert(item_id, item);
let _pending = match ticket.source {
domain::input::InputSource::Human => self.enqueue(agent, ticket),
domain::input::InputSource::Agent { .. } => self.enqueue_silent(agent, ticket),
};
let depth = self.mailbox.pending(&agent);
if let Some(events) = &self.tracker.events {
events.publish(DomainEvent::AgentInboxQueued {
agent_id: agent,
depth,
});
}
Ok(InboxReceipt {
item_id,
agent_id: agent,
depth,
status: InboxReceiptStatus::Queued,
})
}
fn dequeue_next(&self, agent: AgentId) -> Option<InboxItem> {
let head = self.mailbox.head_ticket(&agent)?;
let item = self.inbox_items().remove(&head)?;
self.mailbox.cancel_head(agent, head);
if let Some(events) = &self.tracker.events {
events.publish(DomainEvent::AgentInboxDrained {
agent_id: agent,
depth: self.snapshot(agent).depth,
});
}
Some(item)
}
fn snapshot(&self, agent: AgentId) -> AgentInboxSnapshot {
let items_by_id = self.inbox_items();
let items: Vec<InboxItem> = self
.mailbox
.queue_for(agent)
.into_iter()
.filter_map(|ticket| items_by_id.get(&ticket.id).cloned())
.collect();
AgentInboxSnapshot {
agent_id: agent,
depth: self.mailbox.pending(&agent),
items,
}
}
}
fn configured_inbox_capacity() -> usize {
std::env::var("IDEA_AGENT_INBOX_CAPACITY")
.ok()
.and_then(|raw| raw.parse::<usize>().ok())
.filter(|capacity| *capacity > 0)
.unwrap_or(DEFAULT_AGENT_INBOX_CAPACITY)
}
fn inbox_item_to_ticket(item: &InboxItem) -> Ticket {
let conversation = domain::conversation::ConversationId::from_uuid(uuid::Uuid::nil());
match item.source {
InboxSource::Agent { agent_id } => Ticket::from_agent(
item.id,
agent_id,
conversation,
agent_id.to_string(),
item.body.clone(),
),
InboxSource::Human => Ticket::from_human(item.id, conversation, "vous", item.body.clone()),
InboxSource::BackgroundTask { .. } | InboxSource::System => {
Ticket::from_human(item.id, conversation, "IdeA", item.body.clone())
}
}
}
#[cfg(test)]
mod tests {
use super::*;