Route extension warnings to app-server threads (#35168)

## Why

Extension warnings need a stable thread target even when they are emitted outside an active turn.

## What changed

- Add `ExtensionWarning` and a dedicated `ExtensionEventSink::emit_warning` path with an explicit thread ID and optional turn ID.
- Deliver extension warnings as thread-scoped app-server `warning` notifications, preserving listener ordering and waiting briefly for a subscriber when no listener is active.
- Move skills warnings to the new path, limiting provider catalog warnings to four messages of 256 bytes each.

## Testing

- Cover warning routing, ordering, UTF-8-safe truncation, subscriber fallback, and invalid thread IDs.
- Verify skills warning bounds and app-server delivery of executor catalog budget warnings.

GitOrigin-RevId: 4dcb1ba7f17c0edb1a9d548f9568e0c734bffc15
This commit is contained in:
felixxia-oai authored and copyberry committed 2026-07-24 12:28:29 +00:00
1 parent c8957bbf0f
commit 5dd992acd3
15 files changed
+592 -65

No files matched your search

+328 -7
View File
@@ -1,10 +1,12 @@
use std::sync::Arc;
use std::sync::Weak;
use std::time::Duration;
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::WarningNotification;
use codex_core::NewThread;
use codex_core::StartThreadOptions;
use codex_core::ThreadManager;
@@ -15,6 +17,7 @@ use codex_extension_api::AgentSpawner;
use codex_extension_api::ExtensionEventSink;
use codex_extension_api::ExtensionRegistry;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::ExtensionWarning;
use codex_goal_extension::GoalService;
use codex_http_client::HttpClientFactory;
use codex_login::AuthManager;
@@ -26,6 +29,7 @@ use codex_rollout::state_db::StateDbHandle;
use codex_thread_store::ThreadStore;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::ThreadScopedOutgoingMessageSender;
use crate::thread_state::ThreadListenerCommand;
use crate::thread_state::ThreadStateManager;
@@ -122,11 +126,36 @@ pub(crate) fn app_server_extension_event_sink(
})
}
pub(crate) async fn send_thread_warning(
outgoing: &Arc<OutgoingMessageSender>,
thread_state_manager: &ThreadStateManager,
thread_id: ThreadId,
message: String,
) {
let subscribed_connection_ids = thread_state_manager
.subscribed_connection_ids(thread_id)
.await;
let thread_outgoing = ThreadScopedOutgoingMessageSender::new(
Arc::clone(outgoing),
subscribed_connection_ids,
thread_id,
);
thread_outgoing
.send_server_notification(ServerNotification::Warning(WarningNotification {
thread_id: Some(thread_id.to_string()),
message,
}))
.await;
}
struct AppServerExtensionEventSink {
outgoing: Arc<OutgoingMessageSender>,
thread_state_manager: ThreadStateManager,
}
const MAX_EXTENSION_WARNING_BYTES: usize = 256;
const EXTENSION_WARNING_SUBSCRIBER_TIMEOUT: Duration = Duration::from_secs(10);
impl ExtensionEventSink for AppServerExtensionEventSink {
fn emit(&self, event: Event) {
match event.msg {
@@ -167,6 +196,62 @@ impl ExtensionEventSink for AppServerExtensionEventSink {
}
}
}
fn emit_warning(&self, warning: ExtensionWarning) {
let ExtensionWarning {
thread_id,
turn_id: _,
message,
} = warning;
let Ok(thread_id) = ThreadId::from_string(&thread_id) else {
tracing::warn!(
%thread_id,
"dropping extension warning with invalid thread id"
);
return;
};
let mut message = message;
if message.len() > MAX_EXTENSION_WARNING_BYTES {
let mut truncate_at = MAX_EXTENSION_WARNING_BYTES;
while !message.is_char_boundary(truncate_at) {
truncate_at -= 1;
}
message.truncate(truncate_at);
}
if let Some(listener_command_tx) = self
.thread_state_manager
.current_listener_command_tx(thread_id)
{
let command = ThreadListenerCommand::EmitWarning {
message: message.clone(),
};
if listener_command_tx.send(command).is_ok() {
return;
}
tracing::warn!(
"failed to enqueue extension warning 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 {
if tokio::time::timeout(
EXTENSION_WARNING_SUBSCRIBER_TIMEOUT,
thread_state_manager.wait_for_thread_subscriber(thread_id),
)
.await
.is_err()
{
tracing::warn!(
%thread_id,
timeout_secs = EXTENSION_WARNING_SUBSCRIBER_TIMEOUT.as_secs(),
"dropping extension warning after waiting for a thread subscriber"
);
return;
}
send_thread_warning(&outgoing, &thread_state_manager, thread_id, message).await;
});
}
}
pub(crate) fn guardian_agent_spawner(
@@ -189,8 +274,6 @@ pub(crate) fn guardian_agent_spawner(
#[cfg(test)]
mod tests {
use std::time::Duration;
use codex_protocol::protocol::ThreadGoal as CoreThreadGoal;
use codex_protocol::protocol::ThreadGoalStatus;
use codex_protocol::protocol::ThreadGoalUpdatedEvent;
@@ -198,10 +281,15 @@ mod tests {
use tokio::sync::mpsc;
use tokio::time::timeout;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::OutgoingEnvelope;
use crate::outgoing_message::OutgoingMessage;
use crate::thread_state::ConnectionCapabilities;
use super::*;
#[tokio::test]
async fn app_server_event_sink_uses_listener_fifo_for_goal_updates_and_clears() {
async fn app_server_event_sink_uses_listener_fifo_for_goal_updates_warnings_and_clears() {
let (outgoing_tx, _outgoing_rx) = mpsc::channel(4);
let outgoing = Arc::new(OutgoingMessageSender::new(
outgoing_tx,
@@ -213,15 +301,19 @@ mod tests {
thread_state_manager.register_listener_command_tx(thread_id, listener_command_tx.clone());
let sink = app_server_extension_event_sink(outgoing, thread_state_manager);
for turn_id in ["turn-1", "turn-2"] {
sink.emit(thread_goal_updated_event(thread_id, turn_id));
}
sink.emit(thread_goal_updated_event(thread_id, "turn-1"));
sink.emit_warning(ExtensionWarning {
thread_id: thread_id.to_string(),
turn_id: Some("turn-warning".to_string()),
message: "catalog was shortened".to_string(),
});
sink.emit(thread_goal_updated_event(thread_id, "turn-2"));
listener_command_tx
.send(ThreadListenerCommand::EmitThreadGoalCleared)
.expect("listener command channel should be open");
let mut observed = Vec::new();
for _ in 0..3 {
for _ in 0..4 {
let command = timeout(Duration::from_secs(1), listener_command_rx.recv())
.await
.expect("timed out waiting for listener command")
@@ -230,6 +322,7 @@ mod tests {
ThreadListenerCommand::EmitThreadGoalUpdated { turn_id, .. } => {
observed.push(turn_id.expect("extension goal updates should include turn ids"));
}
ThreadListenerCommand::EmitWarning { message } => observed.push(message),
ThreadListenerCommand::EmitThreadGoalCleared => {
observed.push("cleared".to_string())
}
@@ -240,6 +333,7 @@ mod tests {
assert_eq!(
vec![
"turn-1".to_string(),
"catalog was shortened".to_string(),
"turn-2".to_string(),
"cleared".to_string()
],
@@ -247,6 +341,233 @@ mod tests {
);
}
#[tokio::test]
async fn app_server_event_sink_truncates_warning_before_listener_enqueue() {
let (outgoing_tx, _outgoing_rx) = mpsc::channel(4);
let outgoing = Arc::new(OutgoingMessageSender::new(
outgoing_tx,
AnalyticsEventsClient::disabled(),
));
let thread_state_manager = ThreadStateManager::new();
let thread_id = ThreadId::default();
let (listener_command_tx, mut listener_command_rx) = mpsc::unbounded_channel();
thread_state_manager.register_listener_command_tx(thread_id, listener_command_tx);
let sink = app_server_extension_event_sink(outgoing, thread_state_manager);
sink.emit_warning(ExtensionWarning {
thread_id: thread_id.to_string(),
turn_id: Some("turn-warning".to_string()),
message: "🙂".repeat(65),
});
let command = timeout(Duration::from_secs(1), listener_command_rx.recv())
.await
.expect("timed out waiting for listener command")
.expect("listener command channel closed unexpectedly");
let ThreadListenerCommand::EmitWarning { message } = command else {
panic!("expected warning listener command");
};
assert_eq!(message, "🙂".repeat(64));
}
#[tokio::test]
async fn app_server_event_sink_targets_subscriber_without_listener() {
let (outgoing_tx, mut outgoing_rx) = mpsc::channel(4);
let outgoing = Arc::new(OutgoingMessageSender::new(
outgoing_tx,
AnalyticsEventsClient::disabled(),
));
let thread_id = ThreadId::new();
let subscribed_connection = ConnectionId(1);
let unrelated_connection = ConnectionId(2);
let thread_state_manager = ThreadStateManager::new();
for connection_id in [subscribed_connection, unrelated_connection] {
thread_state_manager
.connection_initialized(connection_id, ConnectionCapabilities::default())
.await;
}
thread_state_manager
.try_ensure_connection_subscribed(
thread_id,
subscribed_connection,
/*experimental_raw_events*/ false,
)
.await
.expect("connection should be subscribed");
let sink = app_server_extension_event_sink(outgoing, thread_state_manager);
sink.emit_warning(ExtensionWarning {
thread_id: thread_id.to_string(),
turn_id: Some("turn-1".to_string()),
message: "catalog was shortened".to_string(),
});
let envelope = timeout(Duration::from_secs(1), outgoing_rx.recv())
.await
.expect("timed out waiting for warning notification")
.expect("outgoing channel closed unexpectedly");
let OutgoingEnvelope::ToConnection {
connection_id,
message,
write_complete_tx: _,
} = envelope
else {
panic!("expected connection-targeted warning notification");
};
assert_eq!(connection_id, subscribed_connection);
let OutgoingMessage::AppServerNotification(envelope) = message else {
panic!("expected app-server warning notification");
};
let ServerNotification::Warning(notification) = envelope.notification else {
panic!("expected warning notification");
};
assert_eq!(
notification,
WarningNotification {
thread_id: Some(thread_id.to_string()),
message: "catalog was shortened".to_string(),
}
);
assert!(outgoing_rx.try_recv().is_err());
}
#[tokio::test]
async fn app_server_event_sink_waits_for_subscriber_without_listener() {
let (outgoing_tx, mut outgoing_rx) = mpsc::channel(4);
let outgoing = Arc::new(OutgoingMessageSender::new(
outgoing_tx,
AnalyticsEventsClient::disabled(),
));
let thread_id = ThreadId::new();
let subscribed_connection = ConnectionId(1);
let thread_state_manager = ThreadStateManager::new();
thread_state_manager
.connection_initialized(subscribed_connection, ConnectionCapabilities::default())
.await;
let sink = app_server_extension_event_sink(outgoing, thread_state_manager.clone());
sink.emit_warning(ExtensionWarning {
thread_id: thread_id.to_string(),
turn_id: Some("turn-1".to_string()),
message: "catalog was shortened".to_string(),
});
tokio::task::yield_now().await;
thread_state_manager
.try_ensure_connection_subscribed(
thread_id,
subscribed_connection,
/*experimental_raw_events*/ false,
)
.await
.expect("connection should be subscribed");
let envelope = timeout(Duration::from_secs(1), outgoing_rx.recv())
.await
.expect("timed out waiting for warning notification")
.expect("outgoing channel closed unexpectedly");
let OutgoingEnvelope::ToConnection {
connection_id,
message,
write_complete_tx: _,
} = envelope
else {
panic!("expected connection-targeted warning notification");
};
assert_eq!(connection_id, subscribed_connection);
let OutgoingMessage::AppServerNotification(envelope) = message else {
panic!("expected app-server warning notification");
};
let ServerNotification::Warning(notification) = envelope.notification else {
panic!("expected warning notification");
};
assert_eq!(
notification,
WarningNotification {
thread_id: Some(thread_id.to_string()),
message: "catalog was shortened".to_string(),
}
);
}
#[tokio::test]
async fn app_server_event_sink_targets_subscriber_after_listener_closes() {
let (outgoing_tx, mut outgoing_rx) = mpsc::channel(4);
let outgoing = Arc::new(OutgoingMessageSender::new(
outgoing_tx,
AnalyticsEventsClient::disabled(),
));
let thread_id = ThreadId::new();
let subscribed_connection = ConnectionId(1);
let thread_state_manager = ThreadStateManager::new();
thread_state_manager
.connection_initialized(subscribed_connection, ConnectionCapabilities::default())
.await;
thread_state_manager
.try_ensure_connection_subscribed(
thread_id,
subscribed_connection,
/*experimental_raw_events*/ false,
)
.await
.expect("connection should be subscribed");
let (listener_command_tx, listener_command_rx) = mpsc::unbounded_channel();
drop(listener_command_rx);
thread_state_manager.register_listener_command_tx(thread_id, listener_command_tx);
let sink = app_server_extension_event_sink(outgoing, thread_state_manager);
sink.emit_warning(ExtensionWarning {
thread_id: thread_id.to_string(),
turn_id: Some("turn-1".to_string()),
message: "catalog was shortened".to_string(),
});
let envelope = timeout(Duration::from_secs(1), outgoing_rx.recv())
.await
.expect("timed out waiting for warning notification")
.expect("outgoing channel closed unexpectedly");
let OutgoingEnvelope::ToConnection {
connection_id,
message,
write_complete_tx: _,
} = envelope
else {
panic!("expected connection-targeted warning notification");
};
assert_eq!(connection_id, subscribed_connection);
let OutgoingMessage::AppServerNotification(envelope) = message else {
panic!("expected app-server warning notification");
};
let ServerNotification::Warning(notification) = envelope.notification else {
panic!("expected warning notification");
};
assert_eq!(
notification,
WarningNotification {
thread_id: Some(thread_id.to_string()),
message: "catalog was shortened".to_string(),
}
);
assert!(outgoing_rx.try_recv().is_err());
}
#[tokio::test]
async fn app_server_event_sink_drops_warning_with_invalid_thread_id() {
let (outgoing_tx, mut outgoing_rx) = mpsc::channel(4);
let outgoing = Arc::new(OutgoingMessageSender::new(
outgoing_tx,
AnalyticsEventsClient::disabled(),
));
let sink = app_server_extension_event_sink(outgoing, ThreadStateManager::new());
sink.emit_warning(ExtensionWarning {
thread_id: "not-a-thread-id".to_string(),
turn_id: Some("turn-1".to_string()),
message: "catalog was shortened".to_string(),
});
assert!(outgoing_rx.try_recv().is_err());
}
fn thread_goal_updated_event(thread_id: ThreadId, turn_id: &str) -> Event {
Event {
id: turn_id.to_string(),