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:
@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user