Add experimental thread queue APIs to app server (#38456)

## What changed

- Add experimental `thread/queue/add`, `list`, `update`, `delete`, `reorder`, and `start` requests for persistent queued user submissions.
- Automatically dispatch queued submissions in FIFO order after completed or failed turns, while leaving the queue paused after interruption.
- Emit `thread/queue/changed` notifications and expose paginated queue contents with stable submission and client message IDs.
- Export the new protocol types through the generated JSON Schema and TypeScript bindings.

## Testing

- Add app-server integration coverage for capability gating, CRUD, pagination, capacity limits, notifications, automatic dispatch, cold-thread resume, interruption, and explicit queue starts.

GitOrigin-RevId: 89caf5577192199bda77cf6c372a59d26cdd910e
This commit is contained in:
efrazer-oai authored and copyberry committed 2026-08-13 23:38:02 +00:00
1 parent 4d9f3021c8
commit 9341b38310
35 files changed
+1952 -105

No files matched your search

+40 -10
View File
@@ -6,6 +6,7 @@ use codex_analytics::AnalyticsEventsClient;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadGoal;
use codex_app_server_protocol::ThreadGoalUpdatedNotification;
use codex_app_server_protocol::ThreadQueueChangedNotification;
use codex_app_server_protocol::WarningNotification;
use codex_core::NewThread;
use codex_core::StartThreadOptions;
@@ -26,8 +27,8 @@ use codex_protocol::ThreadId;
use codex_protocol::error::CodexErr;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_queue_extension::QueuedItemService;
use codex_rollout::state_db::StateDbHandle;
use codex_thread_store::QueueStore;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::ThreadScopedOutgoingMessageSender;
@@ -45,8 +46,8 @@ pub(crate) struct ThreadExtensionDependencies {
pub(crate) executor_skill_provider: Arc<dyn codex_skills_extension::SkillProvider>,
pub(crate) git_attribution_base_url: String,
pub(crate) http_client_factory: HttpClientFactory,
/// Process-scoped persistence backend for queued user messages.
pub(crate) queue_store: Option<Arc<dyn QueueStore>>,
/// Process-scoped queue shared by idle dispatch and app-server requests.
pub(crate) queue_service: Option<Arc<QueuedItemService>>,
}
pub(crate) fn thread_extensions<S>(
@@ -67,15 +68,10 @@ where
executor_skill_provider,
git_attribution_base_url,
http_client_factory,
queue_store,
queue_service,
} = dependencies;
let mut builder = ExtensionRegistryBuilder::<Config>::with_event_sink(Arc::clone(&event_sink));
if let Some(queue) = queue_store {
let queue_service = Arc::new(codex_queue_extension::QueuedItemService::new(
queue,
thread_manager.clone(),
event_sink,
));
if let Some(queue_service) = queue_service {
codex_queue_extension::install(&mut builder, queue_service);
}
if let Some(state_db) = state_db {
@@ -171,6 +167,40 @@ const EXTENSION_WARNING_SUBSCRIBER_TIMEOUT: Duration = Duration::from_secs(10);
impl ExtensionEventSink for AppServerExtensionEventSink {
fn emit(&self, event: Event) {
match event.msg {
EventMsg::ThreadQueueChanged(queue_event) => {
let thread_id = queue_event.thread_id;
if let Some(listener_command_tx) = self
.thread_state_manager
.current_listener_command_tx(thread_id)
{
let command = ThreadListenerCommand::EmitThreadQueueChanged;
if listener_command_tx.send(command).is_ok() {
return;
}
tracing::warn!(
"failed to enqueue extension queue update for {thread_id}: listener command channel is closed"
);
}
let outgoing = Arc::clone(&self.outgoing);
let thread_state_manager = self.thread_state_manager.clone();
tokio::spawn(async move {
let subscribed_connection_ids = thread_state_manager
.subscribed_connection_ids(thread_id)
.await;
let outgoing = ThreadScopedOutgoingMessageSender::new(
outgoing,
subscribed_connection_ids,
thread_id,
);
outgoing
.send_server_notification(ServerNotification::ThreadQueueChanged(
ThreadQueueChangedNotification {
thread_id: thread_id.to_string(),
},
))
.await;
});
}
EventMsg::ThreadGoalUpdated(thread_goal_event) => {
let thread_id = thread_goal_event.thread_id;
let turn_id = thread_goal_event.turn_id;