Preserve queued agent mail across session eviction (#50087)

## Why

Unread queue-only messages prevented idle agents from unloading, and sending a
message to an evicted agent reloaded its session. Pending mail should not require
an idle recipient to occupy a loaded thread slot.

## What changed

- Retain queue-only mail in an in-memory runtime mailbox when a session unloads.
  Queue new messages for locally evicted agents without reloading them or
  starting a turn.
- Add `AgentControl::take_mailbox` and `AgentControl::watch_mailbox` so sessions
  can consume retained mail and react to host mailbox notifications.
- Preserve message order across eviction, reload, and subsequent follow-ups.
  Keep agents with outstanding durable sleep or turn-triggering mail loaded,
  and discard retained mail when an agent is removed or its tree shuts down.

## Testing

Add integration coverage for retaining mail across eviction, sending while
unloaded, and ordering messages around a follow-up after reload. Add a host
controller test verifying that mailbox notifications wake `wait_agent` and
that the message is delivered exactly once.

GitOrigin-RevId: 4b55647f9ee9d1aaf3753f0688863bda81617327
This commit is contained in:
jif
2026-10-01 20:17:34 +00:00
committed by copyberry
parent 26a49b3882
commit 960e878df4
14 changed files with 573 additions and 23 deletions
+59 -19
View File
@@ -1,9 +1,11 @@
use crate::agent::api::AgentControl;
use crate::agent_communication::PENDING_MAILBOX_MESSAGES;
use crate::state::ActiveTurn;
use crate::state::MailboxDeliveryPhase;
use crate::state::TurnState;
use codex_diagnostics::Gauge;
use codex_diagnostics::GaugeGuard;
use codex_history::ResponseItemEnvelope;
use codex_protocol::ThreadId;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::turn_input::TurnStartOptions;
@@ -17,8 +19,6 @@ use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
use tokio_util::task::AbortOnDropHandle;
static PENDING_MAILBOX_MESSAGES: Gauge = Gauge::new("core.mailbox.pending");
/// Host capture metadata belonging to one input, including steers within another turn.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct UserInputMetadata {
@@ -94,11 +94,12 @@ pub(crate) struct TurnInputQueue {
/// Session-scoped pending input storage and active-turn mailbox delivery coordination.
pub(crate) struct InputQueue {
activity_tx: watch::Sender<InputQueueActivity>,
controller: Option<(ThreadId, Arc<dyn AgentControl>, watch::Receiver<bool>)>,
mailbox_pending_mails: Mutex<VecDeque<PendingMailboxCommunication>>,
}
struct PendingMailboxCommunication {
communication: InterAgentCommunication,
pub(crate) struct PendingMailboxCommunication {
pub(crate) communication: InterAgentCommunication,
start_options: TurnStartOptions,
_diagnostics_guard: GaugeGuard,
}
@@ -108,10 +109,44 @@ impl InputQueue {
let (activity_tx, _) = watch::channel(InputQueueActivity::Mailbox);
Self {
activity_tx,
controller: None,
mailbox_pending_mails: Mutex::new(VecDeque::new()),
}
}
pub(crate) fn with_controller(thread_id: ThreadId, control: Arc<dyn AgentControl>) -> Self {
let updates = control.watch_mailbox(thread_id);
Self {
controller: Some((thread_id, control, updates)),
..Self::new()
}
}
pub(crate) fn mailbox_updates(&self) -> Option<watch::Receiver<bool>> {
self.controller
.as_ref()
.map(|(_, _, updates)| updates.clone())
}
pub(crate) fn notify_mailbox(&self) {
self.activity_tx.send_replace(InputQueueActivity::Mailbox);
}
fn read_mailbox(&self, pending: &mut VecDeque<PendingMailboxCommunication>) {
if let Some((thread_id, control, _)) = &self.controller {
pending.extend(
control
.take_mailbox(*thread_id)
.into_iter()
.map(|communication| PendingMailboxCommunication {
communication,
start_options: TurnStartOptions::default(),
_diagnostics_guard: PENDING_MAILBOX_MESSAGES.track(),
}),
);
}
}
pub(crate) async fn subscribe_activity(
&self,
turn_state: Option<&Mutex<TurnState>>,
@@ -172,19 +207,23 @@ impl InputQueue {
communication: InterAgentCommunication,
start_options: TurnStartOptions,
) {
self.mailbox_pending_mails
.lock()
.await
.push_back(PendingMailboxCommunication {
communication,
start_options,
_diagnostics_guard: PENDING_MAILBOX_MESSAGES.track(),
});
let mut pending = self.mailbox_pending_mails.lock().await;
// Mail retained while unloaded precedes new submissions to the loaded session.
self.read_mailbox(&mut pending);
pending.push_back(PendingMailboxCommunication {
communication,
start_options,
_diagnostics_guard: PENDING_MAILBOX_MESSAGES.track(),
});
self.activity_tx.send_replace(InputQueueActivity::Mailbox);
}
pub(crate) async fn has_pending_mailbox_items(&self) -> bool {
!self.mailbox_pending_mails.lock().await.is_empty()
|| self
.controller
.as_ref()
.is_some_and(|(_, _, updates)| *updates.borrow())
}
pub(crate) async fn has_trigger_turn_mailbox_items(&self) -> bool {
@@ -195,13 +234,14 @@ impl InputQueue {
.any(|mail| mail.communication.trigger_turn)
}
pub(crate) async fn drain_mailbox(&self) -> Vec<PendingMailboxCommunication> {
let mut pending = self.mailbox_pending_mails.lock().await;
self.read_mailbox(&mut pending);
pending.drain(..).collect()
}
pub(crate) async fn drain_mailbox_input_items(&self) -> (Vec<TurnInput>, TurnStartOptions) {
let pending_mails = self
.mailbox_pending_mails
.lock()
.await
.drain(..)
.collect::<Vec<_>>();
let pending_mails = self.drain_mailbox().await;
// A later follow-up supersedes the earlier choice, including an omitted choice.
let mut start_options = pending_mails
.iter()