Codex Handbook
app-server/src/thread_state.rs 557 lines
use crate::outgoing_message::ConnectionId;use crate::outgoing_message::ConnectionRequestId;use codex_app_server_protocol::RequestId;use codex_app_server_protocol::ThreadGoal;use codex_app_server_protocol::ThreadHistoryBuilder;use codex_app_server_protocol::ThreadSettings;use codex_app_server_protocol::Turn;use codex_app_server_protocol::TurnError;use codex_core::CodexThread;use codex_core::ThreadConfigSnapshot;use codex_file_watcher::WatchRegistration;use codex_protocol::ThreadId;use codex_protocol::protocol::EventMsg;use codex_protocol::protocol::RolloutItem;use codex_rollout::state_db::StateDbHandle;use codex_utils_absolute_path::AbsolutePathBuf;use std::collections::HashMap;use std::collections::HashSet;use std::sync::Arc;use std::sync::Mutex as StdMutex;use std::sync::Weak;use tokio::sync::Mutex;use tokio::sync::mpsc;use tokio::sync::oneshot;use tokio::sync::watch;use tracing::error;type PendingInterruptQueue = Vec<ConnectionRequestId>;pub(crate) struct PendingThreadResumeRequest {    pub(crate) request_id: ConnectionRequestId,    pub(crate) history_items: Vec<RolloutItem>,    pub(crate) config_snapshot: ThreadConfigSnapshot,    pub(crate) instruction_sources: Vec<AbsolutePathBuf>,    pub(crate) thread_summary: codex_app_server_protocol::Thread,    pub(crate) emit_thread_goal_update: bool,    pub(crate) thread_goal_state_db: Option<StateDbHandle>,    pub(crate) include_turns: bool,    pub(crate) initial_turns_page:        Option<codex_app_server_protocol::ThreadResumeInitialTurnsPageParams>,    pub(crate) redact_resume_payloads: bool,}// ThreadListenerCommand is used to perform operations in the context of the thread listener, for serialization purposes.pub(crate) enum ThreadListenerCommand {    // SendThreadResumeResponse is used to resume an already running thread by sending the thread's history to the client and atomically subscribing for new updates.    SendThreadResumeResponse(Box<PendingThreadResumeRequest>),    // EmitThreadGoalUpdated is used to order goal updates with running-thread resume responses and goal clears.    EmitThreadGoalUpdated {        turn_id: Option<String>,        goal: ThreadGoal,    },    // EmitThreadGoalCleared is used to order app-server goal clears with running-thread resume responses.    EmitThreadGoalCleared,    // EmitThreadGoalSnapshot is used to read and emit the latest goal state in the listener order.    EmitThreadGoalSnapshot {        state_db: StateDbHandle,    },    // ResolveServerRequest is used to notify the client that the request has been resolved.    // It is executed in the thread listener's context to ensure that the resolved notification is ordered with regard to the request itself.    ResolveServerRequest {        request_id: RequestId,        completion_tx: oneshot::Sender<()>,    },}/// Per-conversation accumulation of the latest states e.g. error message while a turn runs.#[derive(Default, Clone)]pub(crate) struct TurnSummary {    pub(crate) started_at: Option<i64>,    pub(crate) command_execution_started: HashSet<String>,    pub(crate) last_error: Option<TurnError>,}#[derive(Default)]pub(crate) struct ThreadState {    pub(crate) pending_interrupts: PendingInterruptQueue,    pub(crate) pending_rollbacks: Option<ConnectionRequestId>,    pub(crate) turn_summary: TurnSummary,    pub(crate) last_terminal_turn_id: Option<String>,    pub(crate) cancel_tx: Option<oneshot::Sender<()>>,    pub(crate) experimental_raw_events: bool,    pub(crate) listener_generation: u64,    last_thread_settings: Option<ThreadSettings>,    listener_command_tx: Option<mpsc::UnboundedSender<ThreadListenerCommand>>,    current_turn_history: ThreadHistoryBuilder,    listener_thread: Option<Weak<CodexThread>>,    watch_registration: WatchRegistration,}impl ThreadState {    pub(crate) fn listener_matches(&self, conversation: &Arc<CodexThread>) -> bool {        self.listener_thread            .as_ref()            .and_then(Weak::upgrade)            .is_some_and(|existing| Arc::ptr_eq(&existing, conversation))    }    pub(crate) fn set_listener(        &mut self,        cancel_tx: oneshot::Sender<()>,        conversation: &Arc<CodexThread>,        watch_registration: WatchRegistration,        thread_settings_baseline: ThreadSettings,    ) -> (mpsc::UnboundedReceiver<ThreadListenerCommand>, u64) {        if let Some(previous) = self.cancel_tx.replace(cancel_tx) {            let _ = previous.send(());        }        self.listener_generation = self.listener_generation.wrapping_add(1);        self.last_thread_settings = Some(thread_settings_baseline);        let (listener_command_tx, listener_command_rx) = mpsc::unbounded_channel();        self.listener_command_tx = Some(listener_command_tx);        self.listener_thread = Some(Arc::downgrade(conversation));        self.watch_registration = watch_registration;        (listener_command_rx, self.listener_generation)    }    pub(crate) fn clear_listener(&mut self) {        if let Some(cancel_tx) = self.cancel_tx.take() {            let _ = cancel_tx.send(());        }        self.listener_command_tx = None;        self.current_turn_history.reset();        self.listener_thread = None;        self.watch_registration = WatchRegistration::default();    }    pub(crate) fn set_experimental_raw_events(&mut self, enabled: bool) {        self.experimental_raw_events = enabled;    }    pub(crate) fn listener_command_tx(        &self,    ) -> Option<mpsc::UnboundedSender<ThreadListenerCommand>> {        self.listener_command_tx.clone()    }    pub(crate) fn active_turn_snapshot(&self) -> Option<Turn> {        self.current_turn_history.active_turn_snapshot()    }    pub(crate) fn track_current_turn_event(&mut self, event_turn_id: &str, event: &EventMsg) {        if let EventMsg::TurnStarted(payload) = event {            self.turn_summary.started_at = payload.started_at;        }        self.current_turn_history.handle_event(event);        if matches!(event, EventMsg::TurnAborted(_) | EventMsg::TurnComplete(_))            && !self.current_turn_history.has_active_turn()        {            self.last_terminal_turn_id = Some(event_turn_id.to_string());            self.current_turn_history.reset();        }    }    pub(crate) fn note_thread_settings(&mut self, thread_settings: ThreadSettings) -> bool {        let changed = self.last_thread_settings.as_ref() != Some(&thread_settings);        self.last_thread_settings = Some(thread_settings);        changed    }}pub(crate) async fn resolve_server_request_on_thread_listener(    thread_state: &Arc<Mutex<ThreadState>>,    request_id: RequestId,) {    let (completion_tx, completion_rx) = oneshot::channel();    let listener_command_tx = {        let state = thread_state.lock().await;        state.listener_command_tx()    };    let Some(listener_command_tx) = listener_command_tx else {        error!("failed to remove pending client request: thread listener is not running");        return;    };    if listener_command_tx        .send(ThreadListenerCommand::ResolveServerRequest {            request_id,            completion_tx,        })        .is_err()    {        error!(            "failed to remove pending client request: thread listener command channel is closed"        );        return;    }    if let Err(err) = completion_rx.await {        error!("failed to remove pending client request: {err}");    }}#[cfg(test)]mod tests {    use super::*;    use codex_app_server_protocol::ApprovalsReviewer;    use codex_app_server_protocol::AskForApproval;    use codex_app_server_protocol::SandboxPolicy;    use codex_protocol::config_types::CollaborationMode;    use codex_protocol::config_types::ModeKind;    use codex_protocol::config_types::Settings;    use pretty_assertions::assert_eq;    #[test]    fn note_thread_settings_reports_only_effective_changes() {        let mut state = ThreadState::default();        let initial = thread_settings("mock-model");        let updated = thread_settings("mock-model-2");        let results = vec![            state.note_thread_settings(initial.clone()),            state.note_thread_settings(initial),            state.note_thread_settings(updated.clone()),            state.note_thread_settings(updated),        ];        assert_eq!(results, vec![true, false, true, false]);    }    fn thread_settings(model: &str) -> ThreadSettings {        ThreadSettings {            cwd: AbsolutePathBuf::from_absolute_path("/tmp").expect("absolute path"),            approval_policy: AskForApproval::OnRequest,            approvals_reviewer: ApprovalsReviewer::User,            sandbox_policy: SandboxPolicy::ReadOnly {                network_access: false,            },            active_permission_profile: None,            model: model.to_string(),            model_provider: "mock_provider".to_string(),            service_tier: None,            effort: None,            summary: None,            collaboration_mode: CollaborationMode {                mode: ModeKind::Default,                settings: Settings {                    model: model.to_string(),                    reasoning_effort: None,                    developer_instructions: None,                },            },            personality: None,        }    }}struct ThreadEntry {    state: Arc<Mutex<ThreadState>>,    connection_ids: HashSet<ConnectionId>,    has_connections_watcher: watch::Sender<bool>,}impl Default for ThreadEntry {    fn default() -> Self {        Self {            state: Arc::new(Mutex::new(ThreadState::default())),            connection_ids: HashSet::new(),            has_connections_watcher: watch::channel(false).0,        }    }}impl ThreadEntry {    fn update_has_connections(&self) {        let _ = self.has_connections_watcher.send_if_modified(|current| {            let prev = *current;            *current = !self.connection_ids.is_empty();            prev != *current        });    }}#[derive(Default)]struct ThreadStateManagerInner {    live_connections: HashMap<ConnectionId, ConnectionCapabilities>,    threads: HashMap<ThreadId, ThreadEntry>,    thread_ids_by_connection: HashMap<ConnectionId, HashSet<ThreadId>>,}#[derive(Clone, Copy, Default)]pub(crate) struct ConnectionCapabilities {    pub(crate) request_attestation: bool,}#[derive(Clone, Default)]pub(crate) struct ThreadStateManager {    state: Arc<Mutex<ThreadStateManagerInner>>,    // Extension event sinks are synchronous, so they need an await-free way to    // enqueue work on the active per-thread listener.    listener_commands:        Arc<StdMutex<HashMap<ThreadId, mpsc::UnboundedSender<ThreadListenerCommand>>>>,}impl ThreadStateManager {    pub(crate) fn new() -> Self {        Self::default()    }    pub(crate) async fn connection_initialized(        &self,        connection_id: ConnectionId,        capabilities: ConnectionCapabilities,    ) {        self.state            .lock()            .await            .live_connections            .insert(connection_id, capabilities);    }    pub(crate) async fn first_attestation_capable_connection_for_thread(        &self,        thread_id: ThreadId,    ) -> Option<ConnectionId> {        let state = self.state.lock().await;        state            .threads            .get(&thread_id)?            .connection_ids            .iter()            .filter_map(|connection_id| {                state                    .live_connections                    .get(connection_id)?                    .request_attestation                    .then_some(*connection_id)            })            .min_by_key(|connection_id| connection_id.0)    }    pub(crate) async fn subscribed_connection_ids(&self, thread_id: ThreadId) -> Vec<ConnectionId> {        let state = self.state.lock().await;        state            .threads            .get(&thread_id)            .map(|thread_entry| thread_entry.connection_ids.iter().copied().collect())            .unwrap_or_default()    }    pub(crate) async fn thread_state(&self, thread_id: ThreadId) -> Arc<Mutex<ThreadState>> {        let mut state = self.state.lock().await;        state.threads.entry(thread_id).or_default().state.clone()    }    pub(crate) fn current_listener_command_tx(        &self,        thread_id: ThreadId,    ) -> Option<mpsc::UnboundedSender<ThreadListenerCommand>> {        self.listener_commands            .lock()            .unwrap_or_else(std::sync::PoisonError::into_inner)            .get(&thread_id)            .cloned()    }    pub(crate) fn register_listener_command_tx(        &self,        thread_id: ThreadId,        tx: mpsc::UnboundedSender<ThreadListenerCommand>,    ) {        self.listener_commands            .lock()            .unwrap_or_else(std::sync::PoisonError::into_inner)            .insert(thread_id, tx);    }    pub(crate) fn unregister_listener_command_tx(&self, thread_id: ThreadId) {        self.listener_commands            .lock()            .unwrap_or_else(std::sync::PoisonError::into_inner)            .remove(&thread_id);    }    pub(crate) async fn remove_thread_state(&self, thread_id: ThreadId) {        let thread_state = {            let mut state = self.state.lock().await;            let thread_state = state                .threads                .remove(&thread_id)                .map(|thread_entry| thread_entry.state);            state.thread_ids_by_connection.retain(|_, thread_ids| {                thread_ids.remove(&thread_id);                !thread_ids.is_empty()            });            thread_state        };        self.unregister_listener_command_tx(thread_id);        if let Some(thread_state) = thread_state {            let mut thread_state = thread_state.lock().await;            tracing::debug!(                thread_id = %thread_id,                listener_generation = thread_state.listener_generation,                had_listener = thread_state.cancel_tx.is_some(),                had_active_turn = thread_state.active_turn_snapshot().is_some(),                "clearing thread listener during thread-state teardown"            );            thread_state.clear_listener();        }    }    pub(crate) async fn clear_all_listeners(&self) {        let thread_states = {            let state = self.state.lock().await;            state                .threads                .iter()                .map(|(thread_id, thread_entry)| (*thread_id, thread_entry.state.clone()))                .collect::<Vec<_>>()        };        for (thread_id, thread_state) in thread_states {            self.unregister_listener_command_tx(thread_id);            let mut thread_state = thread_state.lock().await;            tracing::debug!(                thread_id = %thread_id,                listener_generation = thread_state.listener_generation,                had_listener = thread_state.cancel_tx.is_some(),                had_active_turn = thread_state.active_turn_snapshot().is_some(),                "clearing thread listener during app-server shutdown"            );            thread_state.clear_listener();        }    }    pub(crate) async fn unsubscribe_connection_from_thread(        &self,        thread_id: ThreadId,        connection_id: ConnectionId,    ) -> bool {        {            let mut state = self.state.lock().await;            if !state.threads.contains_key(&thread_id) {                return false;            }            if !state                .thread_ids_by_connection                .get(&connection_id)                .is_some_and(|thread_ids| thread_ids.contains(&thread_id))            {                return false;            }            if let Some(thread_ids) = state.thread_ids_by_connection.get_mut(&connection_id) {                thread_ids.remove(&thread_id);                if thread_ids.is_empty() {                    state.thread_ids_by_connection.remove(&connection_id);                }            }            if let Some(thread_entry) = state.threads.get_mut(&thread_id) {                thread_entry.connection_ids.remove(&connection_id);                thread_entry.update_has_connections();            }        };        true    }    #[cfg(test)]    pub(crate) async fn has_subscribers(&self, thread_id: ThreadId) -> bool {        self.state            .lock()            .await            .threads            .get(&thread_id)            .is_some_and(|thread_entry| !thread_entry.connection_ids.is_empty())    }    pub(crate) async fn try_ensure_connection_subscribed(        &self,        thread_id: ThreadId,        connection_id: ConnectionId,        experimental_raw_events: bool,    ) -> Option<Arc<Mutex<ThreadState>>> {        let thread_state = {            let mut state = self.state.lock().await;            if !state.live_connections.contains_key(&connection_id) {                return None;            }            state                .thread_ids_by_connection                .entry(connection_id)                .or_default()                .insert(thread_id);            let thread_entry = state.threads.entry(thread_id).or_default();            thread_entry.connection_ids.insert(connection_id);            thread_entry.update_has_connections();            thread_entry.state.clone()        };        {            let mut thread_state_guard = thread_state.lock().await;            if experimental_raw_events {                thread_state_guard.set_experimental_raw_events(/*enabled*/ true);            }        }        Some(thread_state)    }    pub(crate) async fn try_add_connection_to_thread(        &self,        thread_id: ThreadId,        connection_id: ConnectionId,    ) -> bool {        let mut state = self.state.lock().await;        if !state.live_connections.contains_key(&connection_id) {            return false;        }        state            .thread_ids_by_connection            .entry(connection_id)            .or_default()            .insert(thread_id);        let thread_entry = state.threads.entry(thread_id).or_default();        thread_entry.connection_ids.insert(connection_id);        thread_entry.update_has_connections();        true    }    pub(crate) async fn remove_connection(&self, connection_id: ConnectionId) -> Vec<ThreadId> {        {            let mut state = self.state.lock().await;            state.live_connections.remove(&connection_id);            let thread_ids = state                .thread_ids_by_connection                .remove(&connection_id)                .unwrap_or_default();            for thread_id in &thread_ids {                if let Some(thread_entry) = state.threads.get_mut(thread_id) {                    thread_entry.connection_ids.remove(&connection_id);                    thread_entry.update_has_connections();                }            }            thread_ids                .into_iter()                .filter(|thread_id| {                    state                        .threads                        .get(thread_id)                        .is_some_and(|thread_entry| thread_entry.connection_ids.is_empty())                })                .collect::<Vec<_>>()        }    }    pub(crate) async fn subscribe_to_has_connections(        &self,        thread_id: ThreadId,    ) -> Option<watch::Receiver<bool>> {        let state = self.state.lock().await;        state            .threads            .get(&thread_id)            .map(|thread_entry| thread_entry.has_connections_watcher.subscribe())    }}