| use super::*; |
| use crate::extensions::send_thread_warning; |
| use codex_app_server_protocol::ThreadQueueChangedNotification; |
| use codex_extension_api::ThreadIdleCause; |
| use codex_protocol::config_types::MultiAgentMode; |
|
|
| #[derive(Clone)] |
| pub(super) struct ListenerTaskContext { |
| pub(super) thread_manager: Arc<ThreadManager>, |
| pub(super) thread_state_manager: ThreadStateManager, |
| pub(super) outgoing: Arc<OutgoingMessageSender>, |
| pub(super) pending_thread_unloads: Arc<Mutex<HashSet<ThreadId>>>, |
| pub(super) thread_watch_manager: ThreadWatchManager, |
| pub(super) codex_home: PathBuf, |
| pub(super) thread_unload_delay: Duration, |
| pub(super) skills_watcher: Arc<SkillsWatcher>, |
| pub(super) turn_cost_worker: Option<crate::turn_cost_worker::TurnCostWorkerHandle>, |
| } |
|
|
| struct UnloadingState { |
| delay: Duration, |
| has_subscribers_rx: watch::Receiver<bool>, |
| has_subscribers: (bool, Instant), |
| thread_status_rx: watch::Receiver<ThreadStatus>, |
| is_active: (bool, Instant), |
| } |
|
|
| impl UnloadingState { |
| async fn new( |
| listener_task_context: &ListenerTaskContext, |
| thread_id: ThreadId, |
| delay: Duration, |
| ) -> Option<Self> { |
| let has_subscribers_rx = listener_task_context |
| .thread_state_manager |
| .subscribe_to_has_connections(thread_id) |
| .await?; |
| let thread_status_rx = listener_task_context |
| .thread_watch_manager |
| .subscribe(thread_id) |
| .await?; |
| let has_subscribers = (*has_subscribers_rx.borrow(), Instant::now()); |
| let is_active = ( |
| matches!(*thread_status_rx.borrow(), ThreadStatus::Active { .. }), |
| Instant::now(), |
| ); |
| Some(Self { |
| delay, |
| has_subscribers_rx, |
| has_subscribers, |
| thread_status_rx, |
| is_active, |
| }) |
| } |
|
|
| fn unloading_target(&self) -> Option<Instant> { |
| match (self.has_subscribers, self.is_active) { |
| ((false, has_no_subscribers_since), (false, is_inactive_since)) => { |
| std::cmp::max(has_no_subscribers_since, is_inactive_since).checked_add(self.delay) |
| } |
| _ => None, |
| } |
| } |
|
|
| fn sync_receiver_values(&mut self) { |
| let has_subscribers = *self.has_subscribers_rx.borrow(); |
| if self.has_subscribers.0 != has_subscribers { |
| self.has_subscribers = (has_subscribers, Instant::now()); |
| } |
|
|
| let is_active = matches!(*self.thread_status_rx.borrow(), ThreadStatus::Active { .. }); |
| if self.is_active.0 != is_active { |
| self.is_active = (is_active, Instant::now()); |
| } |
| } |
|
|
| fn should_unload_now(&mut self) -> bool { |
| self.sync_receiver_values(); |
| self.unloading_target() |
| .is_some_and(|target| target <= Instant::now()) |
| } |
|
|
| fn note_thread_activity_observed(&mut self) { |
| if !self.is_active.0 { |
| self.is_active = (false, Instant::now()); |
| } |
| } |
|
|
| async fn wait_for_unloading_trigger(&mut self) -> bool { |
| loop { |
| self.sync_receiver_values(); |
| let unloading_target = self.unloading_target(); |
| if let Some(target) = unloading_target |
| && target <= Instant::now() |
| { |
| return true; |
| } |
| let unloading_sleep = async { |
| if let Some(target) = unloading_target { |
| tokio::time::sleep_until(target.into()).await; |
| } else { |
| futures::future::pending::<()>().await; |
| } |
| }; |
| tokio::select! { |
| _ = unloading_sleep => return true, |
| changed = self.has_subscribers_rx.changed() => { |
| if changed.is_err() { |
| return false; |
| } |
| self.sync_receiver_values(); |
| }, |
| changed = self.thread_status_rx.changed() => { |
| if changed.is_err() { |
| return false; |
| } |
| self.sync_receiver_values(); |
| }, |
| } |
| } |
| } |
| } |
|
|
| pub(super) enum ThreadShutdownResult { |
| Complete, |
| SubmitFailed, |
| TimedOut, |
| } |
|
|
| pub(super) enum EnsureConversationListenerResult { |
| Attached, |
| ConnectionClosed, |
| } |
|
|
| #[expect( |
| clippy::await_holding_invalid_type, |
| reason = "listener subscription must be serialized against pending unloads" |
| )] |
| pub(super) async fn ensure_conversation_listener( |
| listener_task_context: ListenerTaskContext, |
| conversation_id: ThreadId, |
| connection_id: ConnectionId, |
| raw_events_enabled: bool, |
| ) -> Result<EnsureConversationListenerResult, JSONRPCErrorError> { |
| let conversation = match listener_task_context |
| .thread_manager |
| .get_thread(conversation_id) |
| .await |
| { |
| Ok(conv) => conv, |
| Err(_) => { |
| return Err(invalid_request(format!( |
| "thread not found: {conversation_id}" |
| ))); |
| } |
| }; |
| let thread_state = { |
| let pending_thread_unloads = listener_task_context.pending_thread_unloads.lock().await; |
| if pending_thread_unloads.contains(&conversation_id) { |
| return Err(invalid_request(format!( |
| "thread {conversation_id} is closing; retry after the thread is closed" |
| ))); |
| } |
| let Some(thread_state) = listener_task_context |
| .thread_state_manager |
| .try_ensure_connection_subscribed(conversation_id, connection_id, raw_events_enabled) |
| .await |
| else { |
| return Ok(EnsureConversationListenerResult::ConnectionClosed); |
| }; |
| thread_state |
| }; |
| if let Err(error) = ensure_listener_task_running( |
| listener_task_context.clone(), |
| conversation_id, |
| conversation, |
| thread_state, |
| ) |
| .await |
| { |
| let _ = listener_task_context |
| .thread_state_manager |
| .unsubscribe_connection_from_thread(conversation_id, connection_id) |
| .await; |
| return Err(error); |
| } |
| Ok(EnsureConversationListenerResult::Attached) |
| } |
|
|
| pub(super) fn log_listener_attach_result( |
| result: Result<EnsureConversationListenerResult, JSONRPCErrorError>, |
| thread_id: ThreadId, |
| connection_id: ConnectionId, |
| thread_kind: &'static str, |
| ) { |
| match result { |
| Ok(EnsureConversationListenerResult::Attached) => {} |
| Ok(EnsureConversationListenerResult::ConnectionClosed) => { |
| tracing::debug!( |
| thread_id = %thread_id, |
| connection_id = ?connection_id, |
| "skipping auto-attach for closed connection" |
| ); |
| } |
| Err(err) => { |
| tracing::warn!( |
| "failed to attach listener for {thread_kind} {thread_id}: {message}", |
| message = err.message |
| ); |
| } |
| } |
| } |
|
|
| pub(super) async fn ensure_listener_task_running( |
| listener_task_context: ListenerTaskContext, |
| conversation_id: ThreadId, |
| conversation: Arc<CodexThread>, |
| thread_state: Arc<Mutex<ThreadState>>, |
| ) -> Result<(), JSONRPCErrorError> { |
| let (cancel_tx, mut cancel_rx) = oneshot::channel(); |
| let Some(mut unloading_state) = UnloadingState::new( |
| &listener_task_context, |
| conversation_id, |
| listener_task_context.thread_unload_delay, |
| ) |
| .await |
| else { |
| return Err(invalid_request(format!( |
| "thread {conversation_id} is closing; retry after the thread is closed" |
| ))); |
| }; |
| let config = conversation.config().await; |
| let environments = conversation.environment_selections().await; |
| let watch_registration = listener_task_context |
| .skills_watcher |
| .register_thread_config( |
| config.as_ref(), |
| listener_task_context.thread_manager.as_ref(), |
| &environments, |
| ) |
| .await; |
| let config_snapshot = conversation.config_snapshot().await; |
| let thread_settings_baseline = thread_settings_from_config_snapshot(&config_snapshot); |
| let (mut listener_command_rx, listener_generation) = { |
| let mut thread_state = thread_state.lock().await; |
| if thread_state.listener_matches(&conversation) { |
| return Ok(()); |
| } |
| let (listener_command_rx, listener_generation) = thread_state.set_listener( |
| cancel_tx, |
| &conversation, |
| watch_registration, |
| thread_settings_baseline, |
| ); |
| let Some(listener_command_tx) = thread_state.listener_command_tx() else { |
| tracing::warn!( |
| "thread listener command sender missing immediately after listener registration" |
| ); |
| return Ok(()); |
| }; |
| listener_task_context |
| .thread_state_manager |
| .register_listener_command_tx(conversation_id, listener_command_tx); |
| (listener_command_rx, listener_generation) |
| }; |
| let ListenerTaskContext { |
| outgoing, |
| thread_manager, |
| thread_state_manager, |
| pending_thread_unloads, |
| thread_watch_manager, |
| codex_home, |
| turn_cost_worker, |
| .. |
| } = listener_task_context; |
| let outgoing_for_task = Arc::clone(&outgoing); |
| tokio::spawn(async move { |
| loop { |
| tokio::select! { |
| biased; |
| _ = &mut cancel_rx => { |
| |
| break; |
| } |
| listener_command = listener_command_rx.recv() => { |
| let Some(listener_command) = listener_command else { |
| break; |
| }; |
| handle_thread_listener_command( |
| conversation_id, |
| &conversation, |
| codex_home.as_path(), |
| &thread_state_manager, |
| &thread_state, |
| &thread_watch_manager, |
| &outgoing_for_task, |
| &pending_thread_unloads, |
| listener_command, |
| ) |
| .await; |
| } |
| event = conversation.next_event() => { |
| let event = match event { |
| Ok(event) => event, |
| Err(err) => { |
| tracing::warn!("thread.next_event() failed with: {err}"); |
| break; |
| } |
| }; |
|
|
| if let Some(worker) = &turn_cost_worker { |
| worker.observe_event( |
| conversation_id, |
| config.as_ref(), |
| &event, |
| || conversation.session_telemetry(), |
| ); |
| } |
|
|
| |
| |
| |
| let raw_events_enabled = { |
| let mut thread_state = thread_state.lock().await; |
| thread_state.track_current_turn_event(&event.id, &event.msg); |
| thread_state.experimental_raw_events |
| }; |
| if matches!( |
| &event.msg, |
| EventMsg::RawResponseItem(_) | EventMsg::RawResponseCompleted(_) |
| ) && !raw_events_enabled |
| { |
| continue; |
| } |
| let subscribed_connection_ids = thread_state_manager |
| .subscribed_connection_ids(conversation_id) |
| .await; |
| let thread_outgoing = ThreadScopedOutgoingMessageSender::new( |
| outgoing_for_task.clone(), |
| subscribed_connection_ids, |
| conversation_id, |
| ); |
|
|
| apply_bespoke_event_handling( |
| event.clone(), |
| conversation_id, |
| conversation.clone(), |
| thread_manager.clone(), |
| thread_outgoing, |
| thread_state.clone(), |
| thread_watch_manager.clone(), |
| ) |
| .await; |
| if matches!(event.msg, EventMsg::ShutdownComplete) |
| && let Some(completion_tx) = thread_state |
| .lock() |
| .await |
| .take_shutdown_drain_waiter() |
| { |
| let _ = completion_tx.send(()); |
| } |
| } |
| unloading_watchers_open = unloading_state.wait_for_unloading_trigger() => { |
| if !unloading_watchers_open { |
| break; |
| } |
| if !unloading_state.should_unload_now() { |
| continue; |
| } |
| if matches!(conversation.agent_status().await, AgentStatus::Running) { |
| unloading_state.note_thread_activity_observed(); |
| continue; |
| } |
| { |
| let mut pending_thread_unloads = pending_thread_unloads.lock().await; |
| if pending_thread_unloads.contains(&conversation_id) { |
| continue; |
| } |
| if !unloading_state.should_unload_now() { |
| continue; |
| } |
| pending_thread_unloads.insert(conversation_id); |
| } |
| unload_thread_without_subscribers( |
| thread_manager.clone(), |
| outgoing_for_task.clone(), |
| pending_thread_unloads.clone(), |
| thread_state_manager.clone(), |
| thread_watch_manager.clone(), |
| conversation_id, |
| conversation.clone(), |
| ) |
| .await; |
| break; |
| } |
| } |
| } |
|
|
| let mut thread_state = thread_state.lock().await; |
| if thread_state.listener_generation == listener_generation { |
| thread_state_manager.unregister_listener_command_tx(conversation_id); |
| thread_state.clear_listener(); |
| } |
| }); |
| Ok(()) |
| } |
|
|
| pub(super) async fn wait_for_thread_shutdown(thread: &Arc<CodexThread>) -> ThreadShutdownResult { |
| match tokio::time::timeout(Duration::from_secs(10), thread.shutdown_and_wait()).await { |
| Ok(Ok(())) => ThreadShutdownResult::Complete, |
| Ok(Err(_)) => ThreadShutdownResult::SubmitFailed, |
| Err(_) => ThreadShutdownResult::TimedOut, |
| } |
| } |
|
|
| pub(super) async fn unload_thread_without_subscribers( |
| thread_manager: Arc<ThreadManager>, |
| outgoing: Arc<OutgoingMessageSender>, |
| pending_thread_unloads: Arc<Mutex<HashSet<ThreadId>>>, |
| thread_state_manager: ThreadStateManager, |
| thread_watch_manager: ThreadWatchManager, |
| thread_id: ThreadId, |
| thread: Arc<CodexThread>, |
| ) { |
| info!("thread {thread_id} has no subscribers and is idle; shutting down"); |
|
|
| |
| |
| outgoing |
| .cancel_requests_for_thread(thread_id, None) |
| .await; |
| thread_state_manager.remove_thread_state(thread_id).await; |
|
|
| tokio::spawn(async move { |
| match wait_for_thread_shutdown(&thread).await { |
| ThreadShutdownResult::Complete => { |
| |
| |
| if thread_manager |
| .remove_thread_if_matches(&thread_id, &thread) |
| .await |
| .is_none() |
| { |
| info!("thread {thread_id} was replaced or removed before teardown finalized"); |
| pending_thread_unloads.lock().await.remove(&thread_id); |
| return; |
| } |
| thread_watch_manager |
| .remove_thread(&thread_id.to_string()) |
| .await; |
| let notification = ThreadClosedNotification { |
| thread_id: thread_id.to_string(), |
| }; |
| outgoing |
| .send_server_notification(ServerNotification::ThreadClosed(notification)) |
| .await; |
| pending_thread_unloads.lock().await.remove(&thread_id); |
| } |
| ThreadShutdownResult::SubmitFailed => { |
| pending_thread_unloads.lock().await.remove(&thread_id); |
| warn!("failed to submit Shutdown to thread {thread_id}"); |
| } |
| ThreadShutdownResult::TimedOut => { |
| pending_thread_unloads.lock().await.remove(&thread_id); |
| warn!("thread {thread_id} shutdown timed out; leaving thread loaded"); |
| } |
| } |
| }); |
| } |
|
|
| #[allow(clippy::too_many_arguments)] |
| pub(super) async fn handle_thread_listener_command( |
| conversation_id: ThreadId, |
| conversation: &Arc<CodexThread>, |
| codex_home: &Path, |
| thread_state_manager: &ThreadStateManager, |
| thread_state: &Arc<Mutex<ThreadState>>, |
| thread_watch_manager: &ThreadWatchManager, |
| outgoing: &Arc<OutgoingMessageSender>, |
| pending_thread_unloads: &Arc<Mutex<HashSet<ThreadId>>>, |
| listener_command: ThreadListenerCommand, |
| ) { |
| match listener_command { |
| ThreadListenerCommand::SendThreadResumeResponse { |
| request: resume_request, |
| completion_tx, |
| } => { |
| handle_pending_thread_resume_request( |
| conversation_id, |
| conversation, |
| codex_home, |
| thread_state_manager, |
| thread_state, |
| thread_watch_manager, |
| outgoing, |
| pending_thread_unloads, |
| *resume_request, |
| ) |
| .await; |
| let _ = completion_tx.send(()); |
| } |
| ThreadListenerCommand::EmitThreadGoalUpdated { turn_id, goal } => { |
| outgoing |
| .send_server_notification(ServerNotification::ThreadGoalUpdated( |
| ThreadGoalUpdatedNotification { |
| thread_id: conversation_id.to_string(), |
| turn_id, |
| goal, |
| }, |
| )) |
| .await; |
| } |
| ThreadListenerCommand::EmitThreadQueueChanged => { |
| let subscribed_connection_ids = thread_state_manager |
| .subscribed_connection_ids(conversation_id) |
| .await; |
| let outgoing = ThreadScopedOutgoingMessageSender::new( |
| Arc::clone(outgoing), |
| subscribed_connection_ids, |
| conversation_id, |
| ); |
| outgoing |
| .send_server_notification(ServerNotification::ThreadQueueChanged( |
| ThreadQueueChangedNotification { |
| thread_id: conversation_id.to_string(), |
| }, |
| )) |
| .await; |
| } |
| ThreadListenerCommand::EmitWarning { message } => { |
| send_thread_warning(outgoing, thread_state_manager, conversation_id, message).await; |
| } |
| ThreadListenerCommand::EmitThreadGoalCleared => { |
| outgoing |
| .send_server_notification(ServerNotification::ThreadGoalCleared( |
| ThreadGoalClearedNotification { |
| thread_id: conversation_id.to_string(), |
| }, |
| )) |
| .await; |
| } |
| ThreadListenerCommand::EmitThreadGoalSnapshot { state_db } => { |
| send_thread_goal_snapshot_notification(outgoing, conversation_id, &state_db).await; |
| } |
| ThreadListenerCommand::ResolveServerRequest { |
| request_id, |
| completion_tx, |
| } => { |
| resolve_pending_server_request( |
| conversation_id, |
| thread_state_manager, |
| outgoing, |
| request_id, |
| ) |
| .await; |
| let _ = completion_tx.send(()); |
| } |
| } |
| } |
|
|
| #[allow(clippy::too_many_arguments)] |
| #[expect( |
| clippy::await_holding_invalid_type, |
| reason = "running-thread resume subscription must be serialized against pending unloads" |
| )] |
| pub(super) async fn handle_pending_thread_resume_request( |
| conversation_id: ThreadId, |
| conversation: &Arc<CodexThread>, |
| _codex_home: &Path, |
| thread_state_manager: &ThreadStateManager, |
| thread_state: &Arc<Mutex<ThreadState>>, |
| thread_watch_manager: &ThreadWatchManager, |
| outgoing: &Arc<OutgoingMessageSender>, |
| pending_thread_unloads: &Arc<Mutex<HashSet<ThreadId>>>, |
| mut pending: crate::thread_state::PendingThreadResumeRequest, |
| ) { |
| let active_turn = { |
| let state = thread_state.lock().await; |
| state.active_turn_snapshot() |
| }; |
| tracing::debug!( |
| thread_id = %conversation_id, |
| request_id = ?pending.request_id, |
| active_turn_present = active_turn.is_some(), |
| active_turn_id = ?active_turn.as_ref().map(|turn| turn.id.as_str()), |
| active_turn_status = ?active_turn.as_ref().map(|turn| &turn.status), |
| "composing running thread resume response" |
| ); |
| let has_live_in_progress_turn = |
| matches!(conversation.agent_status().await, AgentStatus::Running) |
| || active_turn |
| .as_ref() |
| .is_some_and(|turn| matches!(turn.status, TurnStatus::InProgress)); |
|
|
| let request_id = pending.request_id; |
| let connection_id = request_id.connection_id; |
| let mut thread = pending.thread_summary; |
| if pending.include_turns { |
| if let Some(turns) = pending.paginated_turns.take() { |
| thread.turns = turns; |
| } else { |
| populate_thread_turns_from_history( |
| &mut thread, |
| &pending.history_items, |
| None, |
| ); |
| } |
| if let Some(active_turn) = active_turn.as_ref() { |
| merge_turn_history_with_active_turn(&mut thread.turns, active_turn.clone()); |
| } |
| } |
|
|
| let thread_status = thread_watch_manager |
| .loaded_status_for_thread(&thread.id) |
| .await; |
|
|
| set_thread_status_and_interrupt_stale_turns( |
| &mut thread, |
| thread_status.clone(), |
| has_live_in_progress_turn, |
| ); |
| let mut initial_turns_page = if let Some(mut page) = pending.paginated_initial_turns_page.take() |
| { |
| if let (Some(active_turn), Some(params)) = |
| (active_turn, pending.initial_turns_page.as_ref()) |
| { |
| let sort_direction = params.sort_direction.unwrap_or(SortDirection::Desc); |
| let active_turn_is_in_page = page.data.iter().any(|turn| turn.id == active_turn.id); |
| if matches!(sort_direction, SortDirection::Desc) |
| && !active_turn_is_in_page |
| && let Some(page_with_active_slot) = |
| pending.paginated_initial_turns_page_with_active_slot.take() |
| { |
| page = page_with_active_slot; |
| } |
| merge_active_turn_into_page(&mut page, active_turn, params); |
| } |
| super::thread_processor::normalize_thread_turns_status( |
| &mut page.data, |
| thread_status, |
| has_live_in_progress_turn, |
| ); |
| Some(page) |
| } else if let Some(params) = pending.initial_turns_page.as_ref() { |
| match super::thread_processor::build_thread_resume_initial_turns_page( |
| &pending.history_items, |
| thread.status.clone(), |
| has_live_in_progress_turn, |
| active_turn, |
| params, |
| ) { |
| Ok(page) => Some(page), |
| Err(error) => { |
| outgoing.send_error(request_id, error).await; |
| return; |
| } |
| } |
| } else { |
| None |
| }; |
| let token_usage_turn_id = pending.cold_resume_token_usage_turn_id.or_else(|| { |
| pending |
| .include_turns |
| .then(|| restored_token_usage_turn_id(&pending.history_items, thread.turns.as_slice())) |
| }); |
| if pending.initial_turns_page.is_none() { |
| initial_turns_page = None; |
| } |
| if pending.redact_resume_payloads { |
| redact_thread_resume_payloads(&mut thread.turns); |
| if let Some(initial_turns_page) = initial_turns_page.as_mut() { |
| redact_thread_resume_payloads(&mut initial_turns_page.data); |
| } |
| } |
|
|
| { |
| let pending_thread_unloads = pending_thread_unloads.lock().await; |
| if pending_thread_unloads.contains(&conversation_id) { |
| drop(pending_thread_unloads); |
| outgoing |
| .send_error( |
| request_id, |
| invalid_request(format!( |
| "thread {conversation_id} is closing; retry thread/resume after the thread is closed" |
| )), |
| ) |
| .await; |
| return; |
| } |
| if !thread_state_manager |
| .try_add_connection_to_thread(conversation_id, connection_id) |
| .await |
| { |
| tracing::debug!( |
| thread_id = %conversation_id, |
| connection_id = ?connection_id, |
| "skipping running thread resume for closed connection" |
| ); |
| return; |
| } |
| } |
|
|
| let (turns_backwards_cursor, items_backwards_cursor) = if let Some(thread_store) = |
| pending.resume_cursor_store.as_ref() |
| { |
| match super::thread_processor::ThreadRequestProcessor::paginated_resume_backwards_cursors( |
| thread_store.as_ref(), |
| conversation_id, |
| ) |
| .await |
| { |
| Ok(cursors) => cursors, |
| Err(error) => { |
| outgoing.send_error(request_id, error).await; |
| return; |
| } |
| } |
| } else { |
| (None, None) |
| }; |
|
|
| let config_snapshot = pending.config_snapshot; |
| let sandbox = config_snapshot.sandbox_policy().into(); |
| let cwd = config_snapshot.cwd().clone(); |
| let ThreadConfigSnapshot { |
| model, |
| disabled_plugin_ids, |
| model_provider_id, |
| service_tier, |
| approval_policy, |
| approvals_reviewer, |
| active_permission_profile, |
| workspace_roots, |
| reasoning_effort, |
| collaboration_mode, |
| originator, |
| .. |
| } = config_snapshot; |
| let instruction_sources = pending.instruction_sources; |
| let active_permission_profile = |
| thread_response_active_permission_profile(active_permission_profile); |
| let session_id = conversation.session_configured().session_id.to_string(); |
| thread.session_id = session_id; |
|
|
| let response = ThreadResumeResponse { |
| thread, |
| disabled_plugin_ids, |
| model, |
| model_provider: model_provider_id, |
| service_tier, |
| cwd, |
| runtime_workspace_roots: workspace_roots, |
| instruction_sources, |
| approval_policy: approval_policy.into(), |
| approvals_reviewer: approvals_reviewer.into(), |
| sandbox, |
| active_permission_profile, |
| reasoning_effort, |
| collaboration_mode: Some(collaboration_mode), |
| multi_agent_mode: MultiAgentMode::ExplicitRequestOnly, |
| initial_turns_page, |
| turns_backwards_cursor, |
| items_backwards_cursor, |
| }; |
| outgoing |
| .send_response_with_thread_originator(request_id, response, originator) |
| .await; |
| |
| |
| if let Some(token_usage_turn_id) = token_usage_turn_id { |
| |
| |
| send_thread_token_usage_update_to_connection( |
| outgoing, |
| connection_id, |
| conversation_id, |
| conversation.as_ref(), |
| token_usage_turn_id, |
| ) |
| .await; |
| } |
| if pending.emit_thread_goal_update { |
| if let Some(state_db) = pending.thread_goal_state_db { |
| send_thread_goal_snapshot_notification(outgoing, conversation_id, &state_db).await; |
| } else { |
| tracing::warn!( |
| thread_id = %conversation_id, |
| "state db unavailable when reading thread goal for running thread resume" |
| ); |
| } |
| } |
| outgoing |
| .replay_requests_to_connection_for_thread(connection_id, conversation_id) |
| .await; |
| |
| |
| conversation |
| .emit_thread_idle_lifecycle_if_idle(ThreadIdleCause::Completed) |
| .await; |
| } |
|
|
| pub(super) async fn send_thread_goal_snapshot_notification( |
| outgoing: &Arc<OutgoingMessageSender>, |
| thread_id: ThreadId, |
| state_db: &StateDbHandle, |
| ) { |
| match state_db.thread_goals().get_thread_goal(thread_id).await { |
| Ok(Some(goal)) => { |
| outgoing |
| .send_server_notification(ServerNotification::ThreadGoalUpdated( |
| ThreadGoalUpdatedNotification { |
| thread_id: thread_id.to_string(), |
| turn_id: None, |
| goal: api_thread_goal_from_state(goal), |
| }, |
| )) |
| .await; |
| } |
| Ok(None) => { |
| outgoing |
| .send_server_notification(ServerNotification::ThreadGoalCleared( |
| ThreadGoalClearedNotification { |
| thread_id: thread_id.to_string(), |
| }, |
| )) |
| .await; |
| } |
| Err(err) => { |
| tracing::warn!( |
| thread_id = %thread_id, |
| "failed to read thread goal for resume snapshot: {err}" |
| ); |
| } |
| } |
| } |
|
|
| pub(crate) fn populate_thread_turns_from_history( |
| thread: &mut Thread, |
| items: &[RolloutItem], |
| active_turn: Option<&Turn>, |
| ) { |
| let mut turns = build_legacy_api_turns_from_rollout_items(items); |
| if let Some(active_turn) = active_turn { |
| merge_turn_history_with_active_turn(&mut turns, active_turn.clone()); |
| } |
| thread.turns = turns; |
| } |
|
|
| pub(super) async fn resolve_pending_server_request( |
| conversation_id: ThreadId, |
| thread_state_manager: &ThreadStateManager, |
| outgoing: &Arc<OutgoingMessageSender>, |
| request_id: RequestId, |
| ) { |
| let thread_id = conversation_id.to_string(); |
| let subscribed_connection_ids = thread_state_manager |
| .subscribed_connection_ids(conversation_id) |
| .await; |
| let outgoing = ThreadScopedOutgoingMessageSender::new( |
| outgoing.clone(), |
| subscribed_connection_ids, |
| conversation_id, |
| ); |
| outgoing |
| .send_server_notification(ServerNotification::ServerRequestResolved( |
| ServerRequestResolvedNotification { |
| thread_id, |
| request_id, |
| }, |
| )) |
| .await; |
| } |
|
|
| pub(super) fn merge_turn_history_with_active_turn(turns: &mut Vec<Turn>, active_turn: Turn) { |
| turns.retain(|turn| turn.id != active_turn.id); |
| turns.push(active_turn); |
| } |
|
|
| fn merge_active_turn_into_page( |
| page: &mut codex_app_server_protocol::TurnsPage, |
| mut active_turn: Turn, |
| params: &codex_app_server_protocol::ThreadResumeInitialTurnsPageParams, |
| ) { |
| super::thread_processor::apply_thread_turns_items_view( |
| std::slice::from_mut(&mut active_turn), |
| params.items_view.unwrap_or(TurnItemsView::Summary), |
| ); |
| let sort_direction = params.sort_direction.unwrap_or(SortDirection::Desc); |
| let page_size = super::thread_processor::thread_turns_page_size(params.limit); |
| let active_turn_is_in_page = page.data.iter().any(|turn| turn.id == active_turn.id); |
| page.data.retain(|turn| turn.id != active_turn.id); |
| match sort_direction { |
| SortDirection::Asc |
| if active_turn_is_in_page |
| || (page.data.len() < page_size && page.next_cursor.is_none()) => |
| { |
| page.data.push(active_turn); |
| } |
| SortDirection::Asc => {} |
| SortDirection::Desc => page.data.insert(0, active_turn), |
| } |
| } |
|
|
| pub(super) fn set_thread_status_and_interrupt_stale_turns( |
| thread: &mut Thread, |
| loaded_status: ThreadStatus, |
| has_live_in_progress_turn: bool, |
| ) { |
| let status = resolve_thread_status(loaded_status, has_live_in_progress_turn); |
| if !matches!(status, ThreadStatus::Active { .. }) { |
| for turn in &mut thread.turns { |
| if matches!(turn.status, TurnStatus::InProgress) { |
| turn.status = TurnStatus::Interrupted; |
| } |
| } |
| } |
| thread.status = status; |
| } |
|
|