Add durable user-message queue dispatch (#37204)

## What changed

- Add a storage-neutral queue extension for listing, adding, editing, reordering, deleting, and explicitly starting queued user messages.
- Dispatch queued messages in FIFO order when a thread becomes idle after a completed turn, while leaving them queued after interruptions or failures.
- Wait for user input to be persisted before removing its queue item, and report hook rejection, task termination, and persistence failures distinctly.
- Snapshot local image and audio attachments into validated data URLs before storing them, enforce input limits, and emit `ThreadQueueChanged` events after mutations.

## Testing

- Cover queue mutations, automatic and explicit dispatch, interruption and failure behavior, hook rejection, invalid records, input limits, and local attachment snapshotting.

GitOrigin-RevId: 14f39d7c8cbaccb767a6da84b82cced0edfcb4b9
This commit is contained in:
efrazer-oai authored and copyberry committed 2026-08-06 03:11:42 +00:00
1 parent 70b4653232
commit bc8b25ea02
42 files changed
+2114 -140

No files matched your search

+53 -32
View File
@@ -8,6 +8,7 @@ use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use codex_extension_api::ThreadIdleCause;
use futures::future::BoxFuture;
use tokio::select;
use tokio::sync::Notify;
@@ -22,13 +23,12 @@ use tracing::trace_span;
use tracing::warn;
use crate::codex_thread::BackgroundTerminalInfo;
use crate::codex_thread::TryStartTurnIfIdleRejectionReason;
use crate::config::Config;
use crate::context::ContextualUserFragment;
use crate::hook_runtime::inspect_pending_input;
use crate::hook_runtime::record_additional_contexts;
use crate::hook_runtime::record_pending_input;
use crate::session::TurnInput;
use crate::session::session::Session;
use crate::session::turn::run_hooks_and_record_inputs;
use crate::session::turn_context::TurnContext;
use crate::state::ActiveTurn;
use crate::state::RunningTask;
@@ -281,8 +281,14 @@ impl Session {
) {
self.abort_all_tasks(TurnAbortReason::Replaced).await;
self.clear_connector_selection().await;
self.start_task(turn_context, input, task, MailboxParentProvenance::Ignore)
.await;
self.start_task(
turn_context,
input,
task,
/*input_persisted*/ None,
MailboxParentProvenance::Ignore,
)
.await;
}
pub(crate) async fn start_task<T: SessionTask>(
@@ -290,6 +296,9 @@ impl Session {
turn_context: Arc<TurnContext>,
input: Vec<TurnInput>,
task: T,
input_persisted: Option<
tokio::sync::oneshot::Sender<Result<(), TryStartTurnIfIdleRejectionReason>>,
>,
mailbox_parent_provenance: MailboxParentProvenance,
) {
let task: Arc<dyn AnySessionTask> = Arc::new(task);
@@ -408,6 +417,7 @@ impl Session {
handle: AbortOnDropHandle::new(handle),
kind: task_kind,
task,
input_persisted,
cancellation_token,
turn_context: Arc::clone(&turn_context),
_agent_execution_guard: agent_execution_guard,
@@ -471,6 +481,7 @@ impl Session {
turn_context,
Vec::new(),
RegularTask::new(),
/*input_persisted*/ None,
MailboxParentProvenance::Attribute,
)
.await;
@@ -580,12 +591,24 @@ impl Session {
let turn_state = {
let mut active = self.active_turn.lock().await;
active.as_mut().and_then(|active_turn| {
let task = active_turn.task.take()?;
let mut task = active_turn.task.take()?;
let task_ended_before_persistence =
if let Some(sender) = task.input_persisted.take() {
let _ = sender.send(Err(
TryStartTurnIfIdleRejectionReason::TaskEndedBeforePersistence,
));
true
} else {
false
};
task.handle.detach();
Some(Arc::clone(&active_turn.turn_state))
Some((
Arc::clone(&active_turn.turn_state),
task_ended_before_persistence,
))
})
};
let Some(turn_state) = turn_state else {
let Some((turn_state, mut task_ended_before_persistence)) = turn_state else {
return;
};
let pending_input = self
@@ -600,28 +623,10 @@ impl Session {
ts.token_usage_at_turn_start.clone(),
)
};
if !pending_input.is_empty() {
for pending_input_item in pending_input {
let hook_outcome =
inspect_pending_input(self, &turn_context, &pending_input_item).await;
if hook_outcome.should_stop {
record_additional_contexts(
self,
&turn_context,
hook_outcome.additional_contexts,
)
.await;
} else {
record_pending_input(
self,
&turn_context,
pending_input_item,
hook_outcome.additional_contexts,
)
.await;
}
}
}
run_hooks_and_record_inputs(self, &turn_context, &pending_input).await;
task_ended_before_persistence |= self
.pending_user_message_admissions
.complete_task_end(&turn_context.sub_id);
// Emit token usage metrics.
{
// TODO(jif): drop this
@@ -764,6 +769,15 @@ impl Session {
turn_id: turn_context.sub_id.clone(),
profile,
});
let idle_cause = if matches!(abort_reason.as_ref(), Some(TurnAbortReason::Interrupted)) {
ThreadIdleCause::Interrupted
} else if task_ended_before_persistence
|| (abort_reason.is_none() && turn_context.terminal_error.lock().await.is_some())
{
ThreadIdleCause::Failed
} else {
ThreadIdleCause::Completed
};
let event = if let Some(reason) = abort_reason {
self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref())
.await;
@@ -812,7 +826,7 @@ impl Session {
}
};
if cleared_active_turn {
self.emit_thread_idle_lifecycle_if_idle().await;
self.emit_thread_idle_lifecycle_if_idle(idle_cause).await;
}
// Regular items were flushed before this terminal event was appended; buffering
// thread writers may not flush it without another explicit barrier.
@@ -847,12 +861,19 @@ impl Session {
.await
}
async fn handle_task_abort(self: &Arc<Self>, task: RunningTask, reason: TurnAbortReason) {
async fn handle_task_abort(self: &Arc<Self>, mut task: RunningTask, reason: TurnAbortReason) {
let sub_id = task.turn_context.sub_id.clone();
if task.cancellation_token.is_cancelled() {
return;
}
if let Some(sender) = task.input_persisted.take() {
let _ = sender.send(Err(
TryStartTurnIfIdleRejectionReason::TaskEndedBeforePersistence,
));
}
self.pending_user_message_admissions
.complete_task_end(&sub_id);
trace!(task_kind = ?task.kind, sub_id, "aborting running task");
task.cancellation_token.cancel();
task.turn_context