Files
codex/codex-rs/core/src/thread_manager.rs
Anton Panasenko 3b4569a920 Add experimental app-server project APIs (#38940)
## What changed

- Add SQLite-backed `project/list`, `project/read`, `project/create`,
  `project/import`, `project/update`, `project/move`, and `project/delete`
  endpoints with ordered roots, metadata, manual positioning, pagination, and
  idempotent creation.
- Add project assignment to thread start, metadata updates, list filtering,
  and fork inheritance. Project deletion clears assignments without deleting
  threads.
- Emit `project/changed` and `thread/project/updated` notifications after
  committed changes, and export the new protocol schemas and TypeScript types.

## Testing

- Cover project lifecycle and ordering, idempotency, atomic imports, cursor and
  filter validation, thread assignment, deletion, and fork inheritance.

GitOrigin-RevId: 47ae621be01ab8ef70a74a629fb29a5c5709ea33
2026-08-17 04:01:37 +00:00

2211 lines
83 KiB
Rust

use crate::CodexAppsToolsCache;
use crate::agent::AgentControl;
use crate::attestation::AttestationProvider;
use crate::codex_thread::CodexThread;
use crate::config::Config;
use crate::config::ThreadStoreConfig;
use crate::current_time::TimeProvider;
use crate::environment_selection::TurnEnvironmentSnapshot;
use crate::environment_selection::default_thread_environment_selections;
use crate::mcp::McpManager;
use crate::rollout::truncation;
use crate::session::ForkPersistence;
use crate::session::GitEnrichmentPolicy;
use crate::session::INITIAL_SUBMIT_ID;
use crate::session::SessionIo;
use crate::session::SessionSpawnArgs;
use crate::session::resolve_multi_agent_version;
use crate::session::session::Session;
use crate::tasks::InterruptedTurnHistoryMarker;
use crate::tasks::interrupted_turn_history_marker;
use codex_agent_graph_store::AgentGraphStore;
use codex_agent_graph_store::LocalAgentGraphStore;
use codex_analytics::AnalyticsEventsClient;
use codex_app_server_protocol::ThreadHistoryBuilder;
use codex_app_server_protocol::TurnStatus;
use codex_code_mode::CodeModeSessionProvider;
use codex_code_mode::DisabledCodeModeSessionProvider;
use codex_code_mode::ProcessOwnedCodeModeSessionProvider;
use codex_core_plugins::PluginsManager;
use codex_exec_server::EnvironmentManager;
use codex_extension_api::ExtensionDataInit;
use codex_extension_api::ExtensionRegistry;
use codex_extension_api::LoadedUserInstructions;
use codex_extension_api::UserInstructionsProvider;
use codex_extension_api::empty_extension_registry;
use codex_features::Feature;
use codex_history::InitialHistory;
use codex_history::ResumedHistory;
use codex_history::RolloutItem;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::default_client::CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR;
use codex_login::default_client::originator;
use codex_model_provider::create_model_provider;
use codex_model_provider_info::ModelProviderInfo;
use codex_model_provider_info::OPENAI_PROVIDER_ID;
use codex_models_manager::manager::RefreshStrategy;
use codex_models_manager::manager::SharedModelsManager;
use codex_protocol::ThreadId;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::mcp::ClientMcpExtensions;
use codex_protocol::mcp::OPENAI_STANDARD_FORM_INPUT_EXTENSION_ID;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SessionConfiguredEvent;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::ThreadHistoryMode;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::protocol::W3cTraceContext;
use codex_rollout::state_db::StateDbHandle;
use codex_skills_extension::HostSkillsService;
use codex_thread_store::InMemoryThreadStore;
use codex_thread_store::LoadThreadHistoryParams;
use codex_thread_store::LocalThreadStore;
use codex_thread_store::LocalThreadStoreConfig;
use codex_thread_store::MoveThreadToSectionParams;
use codex_thread_store::PreparedFork;
use codex_thread_store::ReadThreadByRolloutPathParams;
use codex_thread_store::ReadThreadParams;
use codex_thread_store::StoredModelContext;
use codex_thread_store::StoredThread;
use codex_thread_store::ThreadMetadataPatch;
use codex_thread_store::ThreadStore;
use codex_thread_store::ThreadStoreError;
use codex_thread_store::UpdateThreadMetadataParams;
use codex_utils_absolute_path::AbsolutePathBuf;
use futures::StreamExt;
use futures::stream::FuturesUnordered;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::time::Duration;
use tokio::sync::RwLock;
use tokio::sync::broadcast;
use tracing::instrument;
use tracing::warn;
const THREAD_CREATED_CHANNEL_CAPACITY: usize = 1024;
/// Test-only override for enabling thread-manager behaviors used by integration
/// tests.
///
/// In production builds this value should remain at its default (`false`) and
/// must not be toggled.
static FORCE_TEST_THREAD_MANAGER_BEHAVIOR: AtomicBool = AtomicBool::new(false);
type CapturedOps = Vec<(ThreadId, Op)>;
type SharedCapturedOps = Arc<std::sync::Mutex<CapturedOps>>;
pub(crate) type ThreadIdGenerator = Arc<dyn Fn() -> ThreadId + Send + Sync>;
// `Op` is intentionally not `Clone`. Thread-manager tests only snapshot the
// small subset of ops they inspect.
fn capture_test_op(op: &Op) -> Option<Op> {
match op {
Op::Interrupt => Some(Op::Interrupt),
Op::InterAgentCommunication { communication } => Some(Op::InterAgentCommunication {
communication: communication.clone(),
}),
Op::Shutdown => Some(Op::Shutdown),
_ => None,
}
}
pub(crate) fn default_thread_id_generator() -> ThreadIdGenerator {
Arc::new(ThreadId::new)
}
pub(crate) fn set_thread_manager_test_mode_for_tests(enabled: bool) {
FORCE_TEST_THREAD_MANAGER_BEHAVIOR.store(enabled, Ordering::Relaxed);
}
fn should_use_test_thread_manager_behavior() -> bool {
FORCE_TEST_THREAD_MANAGER_BEHAVIOR.load(Ordering::Relaxed)
}
struct TempCodexHomeGuard {
path: PathBuf,
}
impl Drop for TempCodexHomeGuard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
/// Represents a newly created Codex thread (formerly called a conversation), including the first event
/// (which is [`EventMsg::SessionConfigured`]).
pub struct NewThread {
pub thread_id: ThreadId,
pub thread: Arc<CodexThread>,
pub session_configured: SessionConfiguredEvent,
}
// TODO(ccunningham): Add an explicit non-interrupting live-turn snapshot once
// core can represent sampling boundaries directly instead of relying on
// whichever items happened to be persisted mid-turn.
//
// Two likely future variants:
// - `TruncateToLastSamplingBoundary` for callers that want a coherent fork from
// the last stable model boundary without synthesizing an interrupt.
// - `WaitUntilNextSamplingBoundary` (or similar) for callers that prefer to
// fork after the next sampling boundary rather than interrupting immediately.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ForkSnapshot {
/// Fork a committed prefix ending strictly before the nth user message.
///
/// When `n` is within range, this cuts before that 0-based user-message
/// boundary. When `n` is out of range and the source thread is currently
/// mid-turn, this instead cuts before the active turn's opening boundary
/// so the fork drops the unfinished turn suffix. When `n` is out of range
/// and the source thread is already at a turn boundary, this returns the
/// full committed history unchanged.
TruncateBeforeNthUserMessage(usize),
/// Fork the current persisted history as if the source thread had been
/// interrupted now.
///
/// If the persisted snapshot ends mid-turn, this appends the same
/// `<turn_aborted>` marker produced by a real interrupt. If the snapshot is
/// already at a turn boundary, this returns the current persisted history
/// unchanged.
Interrupted,
}
struct ForkHistory {
snapshot: ForkSnapshot,
initial_history: InitialHistory,
persistence: ForkPersistence,
}
/// Preserve legacy `fork_thread(usize, ...)` callsites by mapping them to the
/// existing truncate-before-nth-user-message snapshot mode.
impl From<usize> for ForkSnapshot {
fn from(value: usize) -> Self {
Self::TruncateBeforeNthUserMessage(value)
}
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct ThreadShutdownReport {
pub completed: Vec<ThreadId>,
pub submit_failed: Vec<ThreadId>,
pub timed_out: Vec<ThreadId>,
}
enum ShutdownOutcome {
Complete,
SubmitFailed,
TimedOut,
}
/// [`ThreadManager`] is responsible for creating threads and maintaining
/// them in memory.
pub struct ThreadManager {
state: Arc<ThreadManagerState>,
_test_codex_home_guard: Option<TempCodexHomeGuard>,
}
pub struct StartThreadOptions {
pub config: Config,
pub allow_provider_model_fallback: bool,
pub initial_history: InitialHistory,
pub history_mode: Option<ThreadHistoryMode>,
pub session_source: Option<SessionSource>,
pub thread_source: Option<ThreadSource>,
pub dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
pub metrics_service_name: Option<String>,
pub parent_trace: Option<W3cTraceContext>,
pub environments: Option<Vec<TurnEnvironmentSelection>>,
pub thread_extension_init: ExtensionDataInit,
pub client_mcp_extensions: ClientMcpExtensions,
/// Thread ID reserved before startup so the caller can associate host-owned state with it.
pub reserved_thread_id: Option<ThreadId>,
}
impl StartThreadOptions {
pub fn new(config: Config) -> Self {
Self {
config,
allow_provider_model_fallback: false,
initial_history: InitialHistory::New,
history_mode: None,
session_source: None,
thread_source: None,
dynamic_tools: Vec::new(),
metrics_service_name: None,
parent_trace: None,
environments: None,
thread_extension_init: ExtensionDataInit::default(),
client_mcp_extensions: ClientMcpExtensions::default(),
reserved_thread_id: None,
}
}
}
struct ThreadSpawnRequest {
options: StartThreadOptions,
auth_manager: Arc<AuthManager>,
agent_control: AgentControl,
parent_thread_id: Option<ThreadId>,
forked_from_thread_id: Option<ThreadId>,
fork_persistence: ForkPersistence,
inherited_environments: Option<TurnEnvironmentSnapshot>,
inherited_exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
user_shell_override: Option<crate::shell::Shell>,
}
impl ThreadSpawnRequest {
fn new(
options: StartThreadOptions,
auth_manager: Arc<AuthManager>,
agent_control: AgentControl,
) -> Self {
Self {
options,
auth_manager,
agent_control,
parent_thread_id: None,
forked_from_thread_id: None,
fork_persistence: ForkPersistence::Copied,
inherited_environments: None,
inherited_exec_policy: None,
user_shell_override: None,
}
}
}
fn originator_from_service_name(service_name: Option<&str>) -> Option<String> {
let service_name = service_name?.trim();
for originator in [
"codex_work_desktop",
"codex_work_web",
"codex_work_mobile",
"codex_work_cca",
"chatgpt_cca",
] {
if service_name.eq_ignore_ascii_case(originator) {
return Some(originator.to_string());
}
}
None
}
fn effective_originator_value(
metrics_service_name: Option<&str>,
env_originator: Option<String>,
persisted_originator: Option<String>,
inherited_originator: Option<String>,
default_originator: String,
) -> String {
originator_from_service_name(metrics_service_name)
.or(persisted_originator)
.or(inherited_originator)
.or(env_originator)
.unwrap_or(default_originator)
}
pub(crate) struct ResumeThreadWithHistoryOptions {
pub(crate) config: Config,
pub(crate) initial_history: InitialHistory,
pub(crate) agent_control: AgentControl,
pub(crate) session_source: SessionSource,
pub(crate) parent_thread_id: Option<ThreadId>,
pub(crate) inherited_environments: Option<TurnEnvironmentSnapshot>,
pub(crate) inherited_exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
}
/// Shared, `Arc`-owned state for [`ThreadManager`]. This `Arc` is required to have a single
/// `Arc` reference that can be downgraded to by `AgentControl` while preventing every single
/// function to require an `Arc<&Self>`.
pub(crate) struct ThreadManagerState {
threads: Arc<RwLock<HashMap<ThreadId, Arc<CodexThread>>>>,
thread_created_tx: broadcast::Sender<ThreadId>,
thread_id_generator: ThreadIdGenerator,
auth_manager: Arc<AuthManager>,
models_manager: SharedModelsManager,
environment_manager: Arc<EnvironmentManager>,
starting_mcp_runtimes: std::sync::Mutex<Vec<std::sync::Weak<AtomicBool>>>,
skills_service: Arc<HostSkillsService>,
plugins_manager: Arc<PluginsManager>,
mcp_manager: Arc<McpManager>,
code_mode_session_provider: Arc<dyn CodeModeSessionProvider>,
extensions: Arc<ExtensionRegistry<Config>>,
user_instructions_provider: Arc<dyn UserInstructionsProvider>,
thread_store: Arc<dyn ThreadStore>,
agent_graph_store: Option<Arc<dyn AgentGraphStore>>,
attestation_provider: Option<Arc<dyn AttestationProvider>>,
external_time_provider: Option<Arc<dyn TimeProvider>>,
session_source: SessionSource,
installation_id: String,
analytics_events_client: Option<AnalyticsEventsClient>,
// Captures submitted ops for testing purpose when test mode is enabled.
ops_log: Option<SharedCapturedOps>,
}
pub fn build_models_manager(
config: &Config,
auth_manager: Arc<AuthManager>,
) -> SharedModelsManager {
let provider = create_model_provider(config.model_provider.clone(), Some(auth_manager));
provider.models_manager(
config.codex_home.to_path_buf(),
config.model_catalog.clone(),
)
}
pub fn thread_store_from_config(
config: &Config,
state_db: Option<StateDbHandle>,
) -> Arc<dyn ThreadStore> {
match &config.experimental_thread_store {
ThreadStoreConfig::Local => {
let compression_enabled = config
.features
.enabled(Feature::LocalThreadStoreCompression);
let background_migration_enabled = config
.features
.enabled(Feature::BackgroundPaginatedRolloutMigration);
let has_state_db = state_db.is_some();
let store = Arc::new(LocalThreadStore::new(
LocalThreadStoreConfig::from_config(config),
state_db,
));
if has_state_db && background_migration_enabled {
let startup_store = Arc::clone(&store);
let codex_home = config.codex_home.to_path_buf();
tokio::spawn(async move {
if let Err(err) = startup_store.migrate_rollouts_on_startup().await {
warn!("failed to migrate legacy rollouts on startup: {err}");
}
if compression_enabled {
codex_rollout::spawn_rollout_compression_worker(codex_home);
}
});
} else if compression_enabled {
codex_rollout::spawn_rollout_compression_worker(config.codex_home.to_path_buf());
}
store
}
ThreadStoreConfig::InMemory { id } => InMemoryThreadStore::for_id(id),
}
}
/// Construct the default SQLite-backed agent graph store when local state is available.
pub fn local_agent_graph_store_from_state_db(
state_db: Option<&StateDbHandle>,
) -> Option<Arc<dyn AgentGraphStore>> {
state_db.map(|state_db| {
Arc::new(LocalAgentGraphStore::new(Arc::clone(state_db))) as Arc<dyn AgentGraphStore>
})
}
impl ThreadManager {
#[allow(clippy::too_many_arguments)]
pub fn new(
config: &Config,
auth_manager: Arc<AuthManager>,
models_manager: SharedModelsManager,
codex_apps_tools_cache: CodexAppsToolsCache,
session_source: SessionSource,
environment_manager: Arc<EnvironmentManager>,
extensions: Arc<ExtensionRegistry<Config>>,
user_instructions_provider: Arc<dyn UserInstructionsProvider>,
analytics_events_client: Option<AnalyticsEventsClient>,
thread_store: Arc<dyn ThreadStore>,
agent_graph_store: Option<Arc<dyn AgentGraphStore>>,
installation_id: String,
attestation_provider: Option<Arc<dyn AttestationProvider>>,
external_time_provider: Option<Arc<dyn TimeProvider>>,
) -> Self {
let codex_home = config.codex_home.clone();
let restriction_product = session_source.restriction_product();
let (thread_created_tx, _) = broadcast::channel(THREAD_CREATED_CHANNEL_CAPACITY);
let skills_service = Arc::new(HostSkillsService::new_with_restriction_product(
codex_home.clone(),
config.bundled_skills_enabled(),
restriction_product,
));
let plugins_manager = Arc::new(PluginsManager::new_with_options(
codex_home.to_path_buf(),
restriction_product,
auth_manager.get_api_auth_mode(),
skills_service.clone(),
));
let mcp_manager = Arc::new(McpManager::new_with_extensions(
Arc::clone(&plugins_manager),
Arc::clone(&extensions),
codex_apps_tools_cache,
));
let code_mode_session_provider: Arc<dyn CodeModeSessionProvider> =
if config.features.enabled(Feature::CodeModeHost)
|| config.code_mode.disable_in_process_fallback
{
Arc::new(ProcessOwnedCodeModeSessionProvider::default())
} else {
Arc::new(DisabledCodeModeSessionProvider)
};
Self {
state: Arc::new(ThreadManagerState {
threads: Arc::new(RwLock::new(HashMap::new())),
thread_created_tx,
thread_id_generator: default_thread_id_generator(),
models_manager,
environment_manager,
starting_mcp_runtimes: std::sync::Mutex::new(Vec::new()),
skills_service,
plugins_manager,
mcp_manager,
code_mode_session_provider,
extensions,
user_instructions_provider,
thread_store,
agent_graph_store,
attestation_provider,
external_time_provider,
auth_manager,
session_source,
installation_id,
analytics_events_client,
ops_log: should_use_test_thread_manager_behavior()
.then(|| Arc::new(std::sync::Mutex::new(Vec::new()))),
}),
_test_codex_home_guard: None,
}
}
/// Generate every new thread identifier with the caller-provided factory.
pub fn with_thread_id_generator(
mut self,
generator: impl Fn() -> ThreadId + Send + Sync + 'static,
) -> Self {
let Some(state) = Arc::get_mut(&mut self.state) else {
unreachable!("thread ID generator must be set before thread manager is shared");
};
state.thread_id_generator = Arc::new(generator);
self
}
/// Replaces the process-wide provider before this manager is shared with threads.
pub fn with_code_mode_session_provider(
mut self,
provider: Arc<dyn CodeModeSessionProvider>,
) -> Self {
let Some(state) = Arc::get_mut(&mut self.state) else {
unreachable!("code-mode session provider must be set before thread manager is shared");
};
state.code_mode_session_provider = provider;
self
}
pub(crate) fn with_code_mode_host_program_for_tests(
mut self,
host_program: PathBuf,
_config: &Config,
) -> Self {
let Some(state) = Arc::get_mut(&mut self.state) else {
unreachable!("new thread manager state should not be shared");
};
state.code_mode_session_provider = Arc::new(
ProcessOwnedCodeModeSessionProvider::with_host_program(host_program),
);
self
}
/// Construct with a dummy AuthManager containing the provided CodexAuth.
/// Used for integration tests: should not be used by ordinary business logic.
pub(crate) fn with_models_provider_for_tests(
auth: CodexAuth,
provider: ModelProviderInfo,
) -> Self {
set_thread_manager_test_mode_for_tests(/*enabled*/ true);
let codex_home = std::env::temp_dir().join(format!(
"codex-thread-manager-test-{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&codex_home)
.unwrap_or_else(|err| panic!("temp codex home dir create failed: {err}"));
let mut manager = Self::with_models_provider_and_home_for_tests(
auth,
provider,
codex_home.clone(),
Arc::new(EnvironmentManager::default_for_tests()),
);
manager._test_codex_home_guard = Some(TempCodexHomeGuard { path: codex_home });
manager
}
/// Construct with a dummy AuthManager containing the provided CodexAuth and codex home.
/// Used for integration tests: should not be used by ordinary business logic.
pub(crate) fn with_models_provider_and_home_for_tests(
auth: CodexAuth,
provider: ModelProviderInfo,
codex_home: PathBuf,
environment_manager: Arc<EnvironmentManager>,
) -> Self {
Self::with_models_provider_home_and_state_for_tests(
auth,
provider,
codex_home,
environment_manager,
/*state_db*/ None,
)
}
pub(crate) fn with_models_provider_home_and_state_for_tests(
auth: CodexAuth,
provider: ModelProviderInfo,
codex_home: PathBuf,
environment_manager: Arc<EnvironmentManager>,
state_db: Option<StateDbHandle>,
) -> Self {
set_thread_manager_test_mode_for_tests(/*enabled*/ true);
let auth_manager = AuthManager::from_auth_for_testing(auth);
let installation_id = uuid::Uuid::new_v4().to_string();
let absolute_codex_home = match AbsolutePathBuf::from_absolute_path_checked(&codex_home) {
Ok(codex_home) => codex_home,
Err(err) => panic!("test codex_home should be absolute: {err}"),
};
let (thread_created_tx, _) = broadcast::channel(THREAD_CREATED_CHANNEL_CAPACITY);
let restriction_product = SessionSource::Exec.restriction_product();
let skills_service = Arc::new(HostSkillsService::new_with_restriction_product(
absolute_codex_home.clone(),
/*bundled_skills_enabled*/ true,
restriction_product,
));
let plugins_manager = Arc::new(PluginsManager::new_with_options(
codex_home.clone(),
restriction_product,
auth_manager.get_api_auth_mode(),
skills_service.clone(),
));
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
// This test constructor has no Config input. Tests that need a non-local
// process store should construct ThreadManager::new with an explicit store.
let thread_store: Arc<dyn ThreadStore> = Arc::new(LocalThreadStore::new(
LocalThreadStoreConfig {
codex_home: codex_home.clone(),
sqlite: codex_state::SqliteConfig::new_for_testing(absolute_codex_home),
default_model_provider_id: OPENAI_PROVIDER_ID.to_string(),
},
state_db.clone(),
));
let agent_graph_store = local_agent_graph_store_from_state_db(state_db.as_ref());
Self {
state: Arc::new(ThreadManagerState {
threads: Arc::new(RwLock::new(HashMap::new())),
thread_created_tx,
thread_id_generator: default_thread_id_generator(),
models_manager: create_model_provider(provider, Some(auth_manager.clone()))
.models_manager(codex_home, /*config_model_catalog*/ None),
environment_manager,
starting_mcp_runtimes: std::sync::Mutex::new(Vec::new()),
skills_service,
plugins_manager,
mcp_manager,
code_mode_session_provider: Arc::new(DisabledCodeModeSessionProvider),
extensions: empty_extension_registry(),
user_instructions_provider: Arc::new(
crate::test_support::EmptyUserInstructionsProvider,
),
thread_store,
agent_graph_store,
attestation_provider: None,
external_time_provider: None,
auth_manager,
session_source: SessionSource::Exec,
installation_id,
analytics_events_client: None,
ops_log: should_use_test_thread_manager_behavior()
.then(|| Arc::new(std::sync::Mutex::new(Vec::new()))),
}),
_test_codex_home_guard: None,
}
}
pub fn session_source(&self) -> SessionSource {
self.state.session_source.clone()
}
pub fn auth_manager(&self) -> Arc<AuthManager> {
self.state.auth_manager.clone()
}
pub fn skills_service(&self) -> Arc<HostSkillsService> {
self.state.skills_service.clone()
}
pub fn plugins_manager(&self) -> Arc<PluginsManager> {
self.state.plugins_manager.clone()
}
pub fn mcp_manager(&self) -> Arc<McpManager> {
self.state.mcp_manager.clone()
}
pub fn environment_manager(&self) -> Arc<EnvironmentManager> {
self.state.environment_manager.clone()
}
/// Refreshes every loaded thread and marks threads that are still being created.
pub async fn invalidate_mcp_runtimes(&self) {
self.invalidate_starting_mcp_runtimes();
let threads = self
.state
.threads
.read()
.await
.values()
.cloned()
.collect::<Vec<_>>();
for thread in threads {
thread.session.request_mcp_runtime_refresh();
}
}
/// Rebuilds loaded hook runtimes without reloading their session configurations.
pub async fn refresh_hook_runtimes(&self) {
let threads = self
.state
.threads
.read()
.await
.values()
.cloned()
.collect::<Vec<_>>();
for thread in threads {
let config = thread.session.get_config().await;
thread.session.refresh_hooks(config).await;
}
}
fn invalidate_starting_mcp_runtimes(&self) {
let mut starting = self
.state
.starting_mcp_runtimes
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
starting.retain(|runtime| {
let Some(runtime) = runtime.upgrade() else {
return false;
};
runtime.store(true, Ordering::Release);
true
});
}
pub fn default_environment_selections(
&self,
cwd: &AbsolutePathBuf,
workspace_roots: &[AbsolutePathBuf],
) -> Vec<TurnEnvironmentSelection> {
default_thread_environment_selections(
self.state.environment_manager.as_ref(),
cwd,
workspace_roots,
)
}
pub fn validate_environment_selections(
&self,
environments: &[TurnEnvironmentSelection],
) -> CodexResult<()> {
let mut environment_ids = HashSet::with_capacity(environments.len());
for environment in environments {
if !environment_ids.insert(environment.environment_id.as_str()) {
return Err(CodexErr::InvalidRequest(format!(
"duplicate turn environment id `{}`",
environment.environment_id
)));
}
self.state
.environment_manager
.get_environment(&environment.environment_id)
.ok_or_else(|| {
CodexErr::InvalidRequest(format!(
"unknown turn environment id `{}`",
environment.environment_id
))
})?;
}
Ok(())
}
pub fn get_models_manager(&self) -> SharedModelsManager {
self.state.models_manager.clone()
}
pub async fn list_models(
&self,
refresh_strategy: RefreshStrategy,
http_client_factory: codex_http_client::HttpClientFactory,
) -> Vec<ModelPreset> {
self.state
.models_manager
.list_models(refresh_strategy, http_client_factory)
.await
}
pub fn list_collaboration_modes(&self) -> Vec<CollaborationModeMask> {
self.state.models_manager.list_collaboration_modes()
}
pub async fn list_thread_ids(&self) -> Vec<ThreadId> {
self.state.list_thread_ids().await
}
pub fn subscribe_thread_created(&self) -> broadcast::Receiver<ThreadId> {
self.state.thread_created_tx.subscribe()
}
pub async fn get_thread(&self, thread_id: ThreadId) -> CodexResult<Arc<CodexThread>> {
self.state.get_thread(thread_id).await
}
/// Updates metadata for loaded and cold threads through one entrypoint.
///
/// Loaded threads route through `CodexThread`/`LiveThread`, so metadata changes stay ordered
/// with live rollout writes. Cold threads go directly to the store, which owns unloaded JSONL
/// compatibility and SQLite metadata updates. This API always returns a materialized thread;
/// if the store reports a successful no-op without one, it performs a fallback read.
pub async fn update_thread_metadata(
&self,
thread_id: ThreadId,
patch: ThreadMetadataPatch,
include_archived: bool,
) -> CodexResult<StoredThread> {
if let Ok(thread) = self.get_thread(thread_id).await {
if thread.config_snapshot().await.ephemeral {
return Err(CodexErr::InvalidRequest(format!(
"ephemeral thread does not support metadata updates: {thread_id}"
)));
}
return thread
.update_thread_metadata(patch, include_archived)
.await
.map_err(|err| thread_store_metadata_update_error(thread_id, err));
}
let updated = self
.state
.thread_store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch,
include_archived,
})
.await
.map_err(|err| match err {
ThreadStoreError::ThreadNotFound { thread_id } => {
CodexErr::ThreadNotFound(thread_id)
}
err => thread_store_metadata_update_error(thread_id, err),
})?;
match updated {
Some(thread) => Ok(thread),
None => self
.state
.thread_store
.read_thread(ReadThreadParams {
thread_id,
include_archived,
include_history: false,
})
.await
.map_err(|err| thread_store_metadata_update_error(thread_id, err)),
}
}
/// Moves a persisted thread to, within, or out of a server-ordered section.
pub async fn move_thread_to_section(
&self,
thread_id: ThreadId,
section: Option<&str>,
before_thread_id: Option<ThreadId>,
) -> CodexResult<()> {
if let Ok(thread) = self.get_thread(thread_id).await
&& thread.config_snapshot().await.ephemeral
{
return Err(CodexErr::InvalidRequest(format!(
"ephemeral thread does not support section moves: {thread_id}"
)));
}
self.state
.thread_store
.move_thread_to_section(MoveThreadToSectionParams {
thread_id,
section: section.map(ToOwned::to_owned),
before_thread_id,
})
.await
.map_err(|err| thread_store_metadata_update_error(thread_id, err))
}
/// List `thread_id` plus all known descendants in its spawn subtree.
pub async fn list_agent_subtree_thread_ids(
&self,
thread_id: ThreadId,
) -> CodexResult<Vec<ThreadId>> {
let mut subtree_thread_ids = Vec::new();
let mut seen_thread_ids = HashSet::new();
subtree_thread_ids.push(thread_id);
seen_thread_ids.insert(thread_id);
if let Some(agent_graph_store) = self.state.agent_graph_store() {
for descendant_id in agent_graph_store
.list_thread_spawn_descendants(thread_id, /*status_filter*/ None)
.await
.map_err(|err| {
CodexErr::Fatal(format!("failed to load thread-spawn descendants: {err}"))
})?
{
if seen_thread_ids.insert(descendant_id) {
subtree_thread_ids.push(descendant_id);
}
}
}
for descendant_id in self
.agent_control()
.list_live_agent_subtree_thread_ids(thread_id)
.await?
{
if seen_thread_ids.insert(descendant_id) {
subtree_thread_ids.push(descendant_id);
}
}
Ok(subtree_thread_ids)
}
pub async fn start_thread(&self, options: StartThreadOptions) -> CodexResult<NewThread> {
Box::pin(self.start_thread_inner(options, /*forked_from_thread_id*/ None)).await
}
/// Allocates a thread ID before startup so a caller can associate host-owned state with it.
pub fn reserve_thread_id(&self) -> ThreadId {
self.state.thread_id_generator.as_ref()()
}
async fn start_thread_inner(
&self,
mut options: StartThreadOptions,
forked_from_thread_id: Option<ThreadId>,
) -> CodexResult<NewThread> {
let agent_control = self.agent_control_for_config(&options.config);
let (resumed_session_source, resumed_thread_source) = options
.initial_history
.get_resumed_session_sources()
.unwrap_or_else(|| (self.state.session_source.clone(), None));
options.session_source = Some(
options
.session_source
.take()
.unwrap_or(resumed_session_source),
);
options.thread_source = options.thread_source.take().or(resumed_thread_source);
let mut request =
ThreadSpawnRequest::new(options, Arc::clone(&self.state.auth_manager), agent_control);
request.forked_from_thread_id = forked_from_thread_id;
Box::pin(self.state.spawn_thread(request)).await
}
// TODO(jif) merge with fork_agent
/// Spawn a subagent by forking persisted history from `forked_from_thread_id`.
pub async fn spawn_subagent(
&self,
forked_from_thread_id: ThreadId,
mut options: StartThreadOptions,
) -> CodexResult<NewThread> {
let fork_source = self.get_thread(forked_from_thread_id).await?;
// Persist queued rollout updates before reading the fork snapshot.
fork_source.ensure_rollout_materialized().await;
fork_source.flush_rollout().await?;
let stored_thread = fork_source
.read_thread(
/*include_archived*/ true, /*include_history*/ true,
)
.await
.map_err(|err| {
CodexErr::Fatal(format!(
"failed to read subagent fork source {forked_from_thread_id}: {err}"
))
})?;
let history = stored_thread_to_initial_history(stored_thread, fork_source.rollout_path())?;
let inherited_multi_agent_version = fork_source
.multi_agent_version()
.unwrap_or(MultiAgentVersion::V1);
options.initial_history = fork_history_from_snapshot(
ForkSnapshot::Interrupted,
history,
InterruptedTurnHistoryMarker::from_config_and_version(
&options.config,
inherited_multi_agent_version,
),
);
self.start_thread_inner(options, Some(forked_from_thread_id))
.await
}
pub async fn resume_thread_from_rollout(
&self,
config: Config,
rollout_path: PathBuf,
auth_manager: Arc<AuthManager>,
parent_trace: Option<W3cTraceContext>,
client_mcp_extensions: ClientMcpExtensions,
) -> CodexResult<NewThread> {
let initial_history = self.initial_history_from_rollout_path(rollout_path).await?;
Box::pin(self.resume_thread_with_history(
config,
initial_history,
auth_manager,
parent_trace,
client_mcp_extensions,
))
.await
}
#[instrument(level = "trace", skip_all)]
pub async fn resume_thread_with_history(
&self,
config: Config,
initial_history: InitialHistory,
auth_manager: Arc<AuthManager>,
parent_trace: Option<W3cTraceContext>,
client_mcp_extensions: ClientMcpExtensions,
) -> CodexResult<NewThread> {
let agent_control = self.agent_control_for_config(&config);
let (session_source, thread_source) = initial_history
.get_resumed_session_sources()
.unwrap_or_else(|| (self.state.session_source.clone(), None));
if let InitialHistory::Resumed(resumed) = &initial_history
&& initial_history.get_multi_agent_version() == Some(MultiAgentVersion::V2)
&& !session_source.is_non_root_agent()
{
agent_control
.restore_v2_agent_metadata(&config, resumed.conversation_id)
.await;
}
let options = StartThreadOptions {
initial_history,
session_source: Some(session_source),
thread_source,
parent_trace,
client_mcp_extensions,
..StartThreadOptions::new(config)
};
Box::pin(self.state.spawn_thread(ThreadSpawnRequest::new(
options,
auth_manager,
agent_control,
)))
.await
}
pub(crate) async fn start_thread_with_user_shell_override_for_tests(
&self,
config: Config,
user_shell_override: crate::shell::Shell,
client_mcp_extensions: ClientMcpExtensions,
) -> CodexResult<NewThread> {
let agent_control = self.agent_control_for_config(&config);
let options = StartThreadOptions {
client_mcp_extensions,
..StartThreadOptions::new(config)
};
let mut request =
ThreadSpawnRequest::new(options, Arc::clone(&self.state.auth_manager), agent_control);
request.user_shell_override = Some(user_shell_override);
Box::pin(self.state.spawn_thread(request)).await
}
pub(crate) async fn resume_thread_from_rollout_with_user_shell_override_for_tests(
&self,
config: Config,
rollout_path: PathBuf,
auth_manager: Arc<AuthManager>,
user_shell_override: crate::shell::Shell,
client_mcp_extensions: ClientMcpExtensions,
) -> CodexResult<NewThread> {
let agent_control = self.agent_control_for_config(&config);
let initial_history = self.initial_history_from_rollout_path(rollout_path).await?;
let (session_source, thread_source) = initial_history
.get_resumed_session_sources()
.unwrap_or_else(|| (self.state.session_source.clone(), None));
let options = StartThreadOptions {
initial_history,
session_source: Some(session_source),
thread_source,
client_mcp_extensions,
..StartThreadOptions::new(config)
};
let mut request = ThreadSpawnRequest::new(options, auth_manager, agent_control);
request.user_shell_override = Some(user_shell_override);
Box::pin(self.state.spawn_thread(request)).await
}
/// Removes the thread from the manager's internal map, though the thread is stored
/// as `Arc<CodexThread>`, it is possible that other references to it exist elsewhere.
/// Returns the thread if the thread was found and removed.
pub async fn remove_thread(&self, thread_id: &ThreadId) -> Option<Arc<CodexThread>> {
self.state.threads.write().await.remove(thread_id)
}
/// Removes a thread only if `thread_id` still maps to `expected`.
///
/// Delayed cleanup uses this to avoid removing a replacement runtime registered under the
/// same thread ID.
pub async fn remove_thread_if_matches(
&self,
thread_id: &ThreadId,
expected: &Arc<CodexThread>,
) -> Option<Arc<CodexThread>> {
let mut threads = self.state.threads.write().await;
if threads
.get(thread_id)
.is_some_and(|thread| Arc::ptr_eq(thread, expected))
{
threads.remove(thread_id)
} else {
None
}
}
/// Tries to shut down all tracked threads concurrently within the provided timeout.
/// Threads that complete shutdown are removed from the manager; incomplete shutdowns
/// remain tracked so callers can retry or inspect them later.
pub async fn shutdown_all_threads_bounded(&self, timeout: Duration) -> ThreadShutdownReport {
let threads = {
let threads = self.state.threads.read().await;
threads
.iter()
.map(|(thread_id, thread)| (*thread_id, Arc::clone(thread)))
.collect::<Vec<_>>()
};
let mut shutdowns = threads
.into_iter()
.map(|(thread_id, thread)| async move {
let outcome = match tokio::time::timeout(timeout, thread.shutdown_and_wait()).await
{
Ok(Ok(())) => ShutdownOutcome::Complete,
Ok(Err(_)) => ShutdownOutcome::SubmitFailed,
Err(_) => ShutdownOutcome::TimedOut,
};
(thread_id, outcome)
})
.collect::<FuturesUnordered<_>>();
let mut report = ThreadShutdownReport::default();
while let Some((thread_id, outcome)) = shutdowns.next().await {
match outcome {
ShutdownOutcome::Complete => report.completed.push(thread_id),
ShutdownOutcome::SubmitFailed => report.submit_failed.push(thread_id),
ShutdownOutcome::TimedOut => report.timed_out.push(thread_id),
}
}
let mut tracked_threads = self.state.threads.write().await;
for thread_id in &report.completed {
tracked_threads.remove(thread_id);
}
report
.completed
.sort_by_key(std::string::ToString::to_string);
report
.submit_failed
.sort_by_key(std::string::ToString::to_string);
report
.timed_out
.sort_by_key(std::string::ToString::to_string);
report
}
/// Fork an existing thread by snapshotting rollout history according to
/// `snapshot` and starting a new thread with identical configuration
/// (unless overridden by the caller's `config`). The new thread will have
/// a fresh id.
pub async fn fork_thread<S>(
&self,
snapshot: S,
config: Config,
path: PathBuf,
thread_source: Option<ThreadSource>,
parent_trace: Option<W3cTraceContext>,
) -> CodexResult<NewThread>
where
S: Into<ForkSnapshot>,
{
let snapshot = snapshot.into();
let history = self.initial_history_from_rollout_path(path).await?;
self.fork_thread_from_history(
snapshot,
config,
history,
thread_source,
parent_trace,
ClientMcpExtensions::default(),
/*reserved_thread_id*/ None,
)
.await
}
async fn initial_history_from_rollout_path(
&self,
rollout_path: PathBuf,
) -> CodexResult<InitialHistory> {
let requested_rollout_path = rollout_path.clone();
let stored_thread = self
.state
.thread_store
.read_thread_by_rollout_path(ReadThreadByRolloutPathParams {
rollout_path,
include_archived: true,
include_history: true,
})
.await
.map_err(thread_store_rollout_read_error)?;
stored_thread_to_initial_history(stored_thread, Some(requested_rollout_path))
}
/// Fork an existing thread from already-loaded store history.
#[allow(clippy::too_many_arguments)]
pub async fn fork_thread_from_history<S>(
&self,
snapshot: S,
config: Config,
history: InitialHistory,
thread_source: Option<ThreadSource>,
parent_trace: Option<W3cTraceContext>,
client_mcp_extensions: ClientMcpExtensions,
reserved_thread_id: Option<ThreadId>,
) -> CodexResult<NewThread>
where
S: Into<ForkSnapshot>,
{
self.fork_thread_with_initial_history(
config,
ForkHistory {
snapshot: snapshot.into(),
initial_history: history,
persistence: ForkPersistence::Copied,
},
thread_source,
parent_trace,
client_mcp_extensions,
reserved_thread_id,
)
.await
}
/// Fork prepared reference-backed history using the same snapshot semantics as copied forks.
pub async fn fork_prepared_thread(
&self,
config: Config,
prepared: PreparedFork,
thread_source: Option<ThreadSource>,
parent_trace: Option<W3cTraceContext>,
client_mcp_extensions: ClientMcpExtensions,
reserved_thread_id: Option<ThreadId>,
) -> CodexResult<NewThread> {
let history = InitialHistory::Resumed(ResumedHistory {
conversation_id: prepared.source_thread_id,
history: Arc::clone(&prepared.model_context),
rollout_path: None,
});
let fork_persistence = ForkPersistence::Referenced {
history_base: prepared.history_base,
inherited_item_count: prepared.model_context.len(),
};
let result = self
.fork_thread_with_initial_history(
config,
ForkHistory {
snapshot: ForkSnapshot::Interrupted,
initial_history: history,
persistence: fork_persistence,
},
thread_source,
parent_trace,
client_mcp_extensions,
reserved_thread_id,
)
.await;
drop(prepared);
result
}
async fn fork_thread_with_initial_history(
&self,
config: Config,
fork_history: ForkHistory,
thread_source: Option<ThreadSource>,
parent_trace: Option<W3cTraceContext>,
client_mcp_extensions: ClientMcpExtensions,
reserved_thread_id: Option<ThreadId>,
) -> CodexResult<NewThread> {
let ForkHistory {
snapshot,
initial_history: history,
persistence: fork_persistence,
} = fork_history;
// `forked_from_id()` describes this history's existing lineage. When
// forking a resumed thread, the child copies the resumed thread itself.
let source_thread_id = match &history {
InitialHistory::Resumed(resumed) => Some(resumed.conversation_id),
InitialHistory::Forked(_) => history.forked_from_id(),
InitialHistory::New | InitialHistory::Cleared => None,
};
let multi_agent_version = self
.state
.effective_multi_agent_version_for_spawn(
&history,
/*session_source*/ None,
/*parent_thread_id*/ None,
source_thread_id,
&config,
)
.await;
let interrupted_marker =
InterruptedTurnHistoryMarker::from_config_and_version(&config, multi_agent_version);
let history = fork_history_from_snapshot(snapshot, history, interrupted_marker);
let agent_control = self.agent_control_for_config(&config);
let options = StartThreadOptions {
initial_history: history,
thread_source,
parent_trace,
client_mcp_extensions,
reserved_thread_id,
..StartThreadOptions::new(config)
};
let mut request =
ThreadSpawnRequest::new(options, Arc::clone(&self.state.auth_manager), agent_control);
request.forked_from_thread_id = source_thread_id;
request.fork_persistence = fork_persistence;
Box::pin(self.state.spawn_thread(request)).await
}
pub(crate) fn agent_control(&self) -> AgentControl {
AgentControl::new(
Arc::downgrade(&self.state),
self.state.thread_id_generator.clone(),
/*rollout_budget*/ None,
)
}
fn agent_control_for_config(&self, config: &Config) -> AgentControl {
AgentControl::new(
Arc::downgrade(&self.state),
self.state.thread_id_generator.clone(),
config.rollout_budget.clone(),
)
}
#[cfg(test)]
pub(crate) fn captured_ops(&self) -> Vec<(ThreadId, Op)> {
self.state
.ops_log
.as_ref()
.and_then(|ops_log| {
ops_log.lock().ok().map(|log| {
log.iter()
.filter_map(|(thread_id, op)| {
capture_test_op(op).map(|op| (*thread_id, op))
})
.collect()
})
})
.unwrap_or_default()
}
}
impl ThreadManagerState {
pub(crate) fn agent_graph_store(&self) -> Option<Arc<dyn AgentGraphStore>> {
self.agent_graph_store.clone()
}
pub(crate) async fn list_thread_ids(&self) -> Vec<ThreadId> {
self.threads
.read()
.await
.iter()
.filter_map(|(thread_id, thread)| {
(!thread.session_source.is_internal()).then_some(*thread_id)
})
.collect()
}
/// List parent-child edges for currently loaded thread-spawn agents.
pub(crate) async fn list_live_thread_spawn_edges(&self) -> Vec<(ThreadId, ThreadId)> {
self.threads
.read()
.await
.iter()
.filter_map(|(thread_id, thread)| {
if thread.session_source.is_internal() {
return None;
}
match &thread.session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
..
}) => Some((*parent_thread_id, *thread_id)),
_ => None,
}
})
.collect()
}
/// Fetch a thread by ID or return ThreadNotFound.
pub(crate) async fn get_thread(&self, thread_id: ThreadId) -> CodexResult<Arc<CodexThread>> {
let threads = self.threads.read().await;
match threads.get(&thread_id) {
Some(thread) if !thread.session_source.is_internal() => Ok(thread.clone()),
Some(_) | None => Err(CodexErr::ThreadNotFound(thread_id)),
}
}
pub(crate) async fn read_stored_thread(
&self,
params: ReadThreadParams,
) -> CodexResult<StoredThread> {
let thread_id = params.thread_id;
self.thread_store
.read_thread(params)
.await
.map_err(|err| match err {
ThreadStoreError::ThreadNotFound { thread_id } => {
CodexErr::ThreadNotFound(thread_id)
}
ThreadStoreError::InvalidRequest { message } => {
if message.starts_with("no rollout found for thread id ") {
CodexErr::ThreadNotFound(thread_id)
} else {
CodexErr::Fatal(format!(
"failed to read stored thread {thread_id}: invalid thread-store request: {message}"
))
}
}
err => CodexErr::Fatal(format!("failed to read stored thread {thread_id}: {err}")),
})
}
pub(crate) async fn load_latest_model_context(
&self,
params: LoadThreadHistoryParams,
) -> CodexResult<StoredModelContext> {
let thread_id = params.thread_id;
self.thread_store
.load_latest_model_context(params)
.await
.map_err(|err| match err {
ThreadStoreError::ThreadNotFound { thread_id } => {
CodexErr::ThreadNotFound(thread_id)
}
err => CodexErr::Fatal(format!(
"failed to load model context for thread {thread_id}: {err}"
)),
})
}
/// Send an operation to a thread by ID.
pub(crate) async fn send_op(
&self,
thread_id: ThreadId,
op: Op,
parent_turn_id: Option<String>,
root_turn_id: Option<String>,
) -> CodexResult<String> {
let thread = self.get_thread(thread_id).await?;
if let Some(ops_log) = &self.ops_log
&& let Ok(mut log) = ops_log.lock()
&& let Some(captured_op) = capture_test_op(&op)
{
log.push((thread_id, captured_op));
}
thread
.io
.submit_with_trace(op, /*trace*/ None, parent_turn_id, root_turn_id)
.await
}
/// Remove a thread from the manager by ID, returning it when present.
pub(crate) async fn remove_thread(&self, thread_id: &ThreadId) -> Option<Arc<CodexThread>> {
self.threads.write().await.remove(thread_id)
}
pub(crate) async fn effective_multi_agent_version_for_spawn(
&self,
initial_history: &InitialHistory,
session_source: Option<&SessionSource>,
parent_thread_id: Option<ThreadId>,
forked_from_thread_id: Option<ThreadId>,
config: &Config,
) -> MultiAgentVersion {
if let Some(multi_agent_version) = config.multi_agent_version_override() {
return multi_agent_version;
}
self.initial_multi_agent_version_for_spawn(
initial_history,
session_source,
parent_thread_id,
forked_from_thread_id,
)
.await
.unwrap_or_else(|| config.multi_agent_version_from_features())
}
async fn initial_multi_agent_version_for_spawn(
&self,
initial_history: &InitialHistory,
session_source: Option<&SessionSource>,
parent_thread_id: Option<ThreadId>,
forked_from_thread_id: Option<ThreadId>,
) -> Option<MultiAgentVersion> {
let inherited_thread_id = match session_source {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id, ..
})) => Some(*parent_thread_id),
_ => match initial_history {
InitialHistory::Resumed(resumed) => Some(resumed.conversation_id),
InitialHistory::Forked(_) => forked_from_thread_id.or(parent_thread_id),
InitialHistory::New | InitialHistory::Cleared => parent_thread_id,
},
};
let inherited_multi_agent_version = match inherited_thread_id {
Some(thread_id) => self
.get_thread(thread_id)
.await
.ok()
.and_then(|thread| thread.multi_agent_version()),
None => None,
};
resolve_multi_agent_version(initial_history, inherited_multi_agent_version)
}
/// Resolves the provider snapshot for a newly spawned runtime.
///
/// Loads a fresh provider snapshot for:
/// - fresh root threads;
/// - cold resumes;
/// - root forks.
///
/// Uses an existing snapshot for:
/// - subagents, which inherit from their parent without invoking the
/// provider;
/// - running resumes and compaction paths, which retain the live session.
///
/// Provider warnings only apply to fresh loads. If a parent runtime is no
/// longer available, its child starts without provider instructions rather
/// than loading independently.
async fn user_instructions_for_spawn(
&self,
session_source: &SessionSource,
parent_thread_id: Option<ThreadId>,
forked_from_thread_id: Option<ThreadId>,
) -> LoadedUserInstructions {
let is_root_agent = !session_source.is_non_root_agent();
if is_root_agent {
return self
.user_instructions_provider
.load_user_instructions()
.await;
}
let inherited_thread_id = match session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id, ..
}) => Some(*parent_thread_id),
_ => parent_thread_id.or(forked_from_thread_id),
};
let instructions = match inherited_thread_id {
// The spawn path retains only thread IDs, so look up the live
// runtime again here to inherit its user instructions.
Some(thread_id) => match self.get_thread(thread_id).await {
Ok(thread) => thread.session.user_instructions().await,
Err(_) => None,
},
None => None,
};
LoadedUserInstructions {
instructions,
warnings: Vec::new(),
}
}
async fn inherited_originator_for_parent_thread(
&self,
session_source: &SessionSource,
parent_thread_id: Option<ThreadId>,
forked_from_thread_id: Option<ThreadId>,
) -> Option<String> {
let inherited_thread_id = match session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id, ..
}) => Some(*parent_thread_id),
_ => parent_thread_id.or(forked_from_thread_id),
};
let thread = self.get_thread(inherited_thread_id?).await.ok()?;
let originator = thread.config_snapshot().await.originator;
(!originator.is_empty()).then_some(originator)
}
async fn effective_originator(
&self,
initial_history: &InitialHistory,
metrics_service_name: Option<&str>,
session_source: &SessionSource,
parent_thread_id: Option<ThreadId>,
forked_from_thread_id: Option<ThreadId>,
) -> String {
let persisted_originator = initial_history.get_session_originator();
let inherited_originator = match initial_history {
InitialHistory::New | InitialHistory::Cleared => {
self.inherited_originator_for_parent_thread(
session_source,
parent_thread_id,
forked_from_thread_id,
)
.await
}
InitialHistory::Forked(_) if persisted_originator.is_none() => {
self.inherited_originator_for_parent_thread(
session_source,
parent_thread_id,
forked_from_thread_id,
)
.await
}
InitialHistory::Resumed(_) | InitialHistory::Forked(_) => None,
};
let env_originator = std::env::var(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR)
.is_ok()
.then(|| originator().value);
effective_originator_value(
metrics_service_name,
env_originator,
persisted_originator,
inherited_originator,
originator().value,
)
}
/// Spawn a new thread with no history using a provided config.
pub(crate) async fn spawn_new_thread(
&self,
config: Config,
agent_control: AgentControl,
) -> CodexResult<NewThread> {
Box::pin(self.spawn_new_thread_with_source(
config,
agent_control,
self.session_source.clone(),
/*history_mode*/ None,
/*parent_thread_id*/ None,
/*forked_from_thread_id*/ None,
/*thread_source*/ None,
/*metrics_service_name*/ None,
/*inherited_environments*/ None,
/*inherited_exec_policy*/ None,
/*environments*/ None,
))
.await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn spawn_new_thread_with_source(
&self,
config: Config,
agent_control: AgentControl,
session_source: SessionSource,
history_mode: Option<ThreadHistoryMode>,
parent_thread_id: Option<ThreadId>,
forked_from_thread_id: Option<ThreadId>,
thread_source: Option<ThreadSource>,
metrics_service_name: Option<String>,
inherited_environments: Option<TurnEnvironmentSnapshot>,
inherited_exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
environments: Option<Vec<TurnEnvironmentSelection>>,
) -> CodexResult<NewThread> {
let client_mcp_extensions = self.client_mcp_extensions_for_child(parent_thread_id).await;
let options = StartThreadOptions {
history_mode,
session_source: Some(session_source),
thread_source,
metrics_service_name,
environments,
client_mcp_extensions,
..StartThreadOptions::new(config)
};
let mut request =
ThreadSpawnRequest::new(options, Arc::clone(&self.auth_manager), agent_control);
request.parent_thread_id = parent_thread_id;
request.forked_from_thread_id = forked_from_thread_id;
request.inherited_environments = inherited_environments;
request.inherited_exec_policy = inherited_exec_policy;
Box::pin(self.spawn_thread(request)).await
}
pub(crate) async fn resume_thread_with_history_with_source(
&self,
options: ResumeThreadWithHistoryOptions,
) -> CodexResult<NewThread> {
let ResumeThreadWithHistoryOptions {
config,
initial_history,
agent_control,
session_source,
parent_thread_id,
inherited_environments,
inherited_exec_policy,
} = options;
let client_mcp_extensions = self.client_mcp_extensions_for_child(parent_thread_id).await;
let thread_source = initial_history.get_resumed_thread_source();
let environments = inherited_environments
.as_ref()
.filter(|_| initial_history.get_multi_agent_version() == Some(MultiAgentVersion::V2))
.map(TurnEnvironmentSnapshot::to_selections);
let options = StartThreadOptions {
initial_history,
session_source: Some(session_source),
thread_source,
environments,
client_mcp_extensions,
..StartThreadOptions::new(config)
};
let mut request =
ThreadSpawnRequest::new(options, Arc::clone(&self.auth_manager), agent_control);
request.parent_thread_id = parent_thread_id;
request.inherited_environments = inherited_environments;
request.inherited_exec_policy = inherited_exec_policy;
Box::pin(self.spawn_thread(request)).await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn fork_thread_with_source(
&self,
config: Config,
initial_history: InitialHistory,
history_mode: Option<ThreadHistoryMode>,
agent_control: AgentControl,
session_source: SessionSource,
thread_source: Option<ThreadSource>,
parent_thread_id: Option<ThreadId>,
forked_from_thread_id: Option<ThreadId>,
inherited_environments: Option<TurnEnvironmentSnapshot>,
inherited_exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
environments: Option<Vec<TurnEnvironmentSelection>>,
thread_extension_init: ExtensionDataInit,
) -> CodexResult<NewThread> {
let client_mcp_extensions = self.client_mcp_extensions_for_child(parent_thread_id).await;
let options = StartThreadOptions {
initial_history,
history_mode,
session_source: Some(session_source),
thread_source,
environments,
thread_extension_init,
client_mcp_extensions,
..StartThreadOptions::new(config)
};
let mut request =
ThreadSpawnRequest::new(options, Arc::clone(&self.auth_manager), agent_control);
request.parent_thread_id = parent_thread_id;
request.forked_from_thread_id = forked_from_thread_id;
request.inherited_environments = inherited_environments;
request.inherited_exec_policy = inherited_exec_policy;
Box::pin(self.spawn_thread(request)).await
}
async fn client_mcp_extensions_for_child(
&self,
parent_thread_id: Option<ThreadId>,
) -> ClientMcpExtensions {
let Some(parent_thread_id) = parent_thread_id else {
return ClientMcpExtensions::default();
};
self.get_thread(parent_thread_id)
.await
.map(|parent| parent.session.services.client_mcp_extensions.clone())
.unwrap_or_default()
}
/// Spawn a new thread with optional history and register it with the manager.
async fn spawn_thread(&self, request: ThreadSpawnRequest) -> CodexResult<NewThread> {
let ThreadSpawnRequest {
options,
auth_manager,
agent_control,
parent_thread_id,
forked_from_thread_id,
fork_persistence,
inherited_environments,
inherited_exec_policy,
user_shell_override,
} = request;
let StartThreadOptions {
config,
allow_provider_model_fallback,
initial_history,
history_mode,
session_source,
thread_source,
dynamic_tools,
metrics_service_name,
parent_trace,
environments,
thread_extension_init,
client_mcp_extensions,
reserved_thread_id,
} = options;
let session_source = session_source.unwrap_or_else(|| self.session_source.clone());
let environments = environments.unwrap_or_else(|| {
default_thread_environment_selections(
self.environment_manager.as_ref(),
&config.cwd,
&config.workspace_roots,
)
});
let is_resumed_thread = matches!(&initial_history, InitialHistory::Resumed(_));
if reserved_thread_id.is_some() && matches!(&initial_history, InitialHistory::Resumed(_)) {
return Err(CodexErr::InvalidRequest(
"reserved thread ID cannot be used when resuming a thread".to_string(),
));
}
if let InitialHistory::Resumed(resumed) = &initial_history {
let mut threads = self.threads.write().await;
if let Some(thread) = threads.get(&resumed.conversation_id).cloned() {
if thread.is_running() {
if let Some(requested_rollout_path) = resumed.rollout_path.as_deref()
&& thread.rollout_path().as_deref() != Some(requested_rollout_path)
{
return Err(CodexErr::InvalidRequest(format!(
"thread {} is already running with a different rollout path",
resumed.conversation_id
)));
}
return Ok(NewThread {
thread_id: resumed.conversation_id,
session_configured: thread.session_configured(),
thread,
});
}
threads.remove(&resumed.conversation_id);
}
}
let user_instructions = self
.user_instructions_for_spawn(&session_source, parent_thread_id, forked_from_thread_id)
.await;
let parent_rollout_thread_trace = self
.parent_rollout_thread_trace_for_source(&session_source, &initial_history)
.await;
let tracked_session_source = session_source.clone();
let multi_agent_version = self
.initial_multi_agent_version_for_spawn(
&initial_history,
Some(&session_source),
parent_thread_id,
forked_from_thread_id,
)
.await;
let originator = self
.effective_originator(
&initial_history,
metrics_service_name.as_deref(),
&session_source,
parent_thread_id,
forked_from_thread_id,
)
.await;
let source_changed_during_startup = Arc::new(AtomicBool::new(false));
{
let mut starting = self
.starting_mcp_runtimes
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
starting.retain(|runtime| runtime.strong_count() != 0);
starting.push(Arc::downgrade(&source_changed_during_startup));
}
let (session, io) = Box::pin(Session::spawn(SessionSpawnArgs {
config,
allow_provider_model_fallback,
user_instructions,
installation_id: self.installation_id.clone(),
auth_manager,
models_manager: Arc::clone(&self.models_manager),
environment_manager: Arc::clone(&self.environment_manager),
skills_service: Arc::clone(&self.skills_service),
plugins_manager: Arc::clone(&self.plugins_manager),
mcp_manager: Arc::clone(&self.mcp_manager),
code_mode_session_provider: Arc::clone(&self.code_mode_session_provider),
extensions: Arc::clone(&self.extensions),
conversation_history: initial_history,
requested_history_mode: history_mode,
fork_persistence,
session_source,
forked_from_thread_id,
parent_thread_id,
thread_source: thread_source.clone(),
originator,
agent_control,
dynamic_tools,
metrics_service_name,
inherited_environments,
inherited_exec_policy,
parent_rollout_thread_trace,
user_shell_override,
parent_trace,
environment_selections: environments,
thread_extension_init,
client_mcp_extensions,
reserved_thread_id,
analytics_events_client: self.analytics_events_client.clone(),
thread_store: Arc::clone(&self.thread_store),
attestation_provider: self.attestation_provider.clone(),
external_time_provider: self.external_time_provider.clone(),
inherited_multi_agent_version: multi_agent_version,
git_enrichment_policy: GitEnrichmentPolicy::Fresh,
windows_sandbox_proxy_settings_mode:
codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile,
}))
.await?;
// Enable Full Access form input only after session startup so a required MCP server cannot
// block startup while waiting for form input.
if session
.services
.client_mcp_extensions
.contains(OPENAI_STANDARD_FORM_INPUT_EXTENSION_ID)
&& matches!(thread_source.as_ref(), Some(ThreadSource::User))
&& !tracked_session_source.is_non_root_agent()
{
session.services.mcp_runtime.enable_full_access_form_input();
}
let new_thread = self
.finalize_thread_spawn(session, io, tracked_session_source)
.await?;
if source_changed_during_startup.load(Ordering::Acquire) {
new_thread.thread.session.request_mcp_runtime_refresh();
}
if is_resumed_thread {
new_thread.thread.emit_thread_resume_lifecycle().await;
}
Ok(new_thread)
}
async fn finalize_thread_spawn(
&self,
session: Arc<Session>,
io: SessionIo,
session_source: SessionSource,
) -> CodexResult<NewThread> {
let thread_id = session.thread_id();
let event = io.next_event().await?;
let session_configured = match event {
Event {
id,
msg: EventMsg::SessionConfigured(session_configured),
} if id == INITIAL_SUBMIT_ID => session_configured,
_ => {
return Err(CodexErr::SessionConfiguredNotFirstEvent);
}
};
{
let mut threads = self.threads.write().await;
if let std::collections::hash_map::Entry::Vacant(e) = threads.entry(thread_id) {
let thread = Arc::new(CodexThread::new(
session,
io,
session_configured.clone(),
session_configured.rollout_path.clone(),
session_source,
));
e.insert(thread.clone());
return Ok(NewThread {
thread_id,
thread,
session_configured,
});
}
}
if let Err(err) = io.shutdown_and_wait().await {
warn!("failed to shut down duplicate thread {thread_id}: {err}");
}
Err(CodexErr::InvalidRequest(format!(
"thread {thread_id} is already running"
)))
}
pub(crate) fn notify_thread_created(&self, thread_id: ThreadId) {
let _ = self.thread_created_tx.send(thread_id);
}
async fn parent_rollout_thread_trace_for_source(
&self,
session_source: &SessionSource,
initial_history: &InitialHistory,
) -> codex_rollout_trace::ThreadTraceContext {
// A fresh v2 child belongs to the same rollout tree as its parent, so
// session startup derives its child trace from the parent's thread
// context. Resumed children already have a prior `ThreadStarted` event
// for this thread id; deriving a child trace during resume would write
// that start event again and make the bundle unreplayable.
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id, ..
}) = session_source
else {
return codex_rollout_trace::ThreadTraceContext::disabled();
};
if matches!(initial_history, InitialHistory::Resumed(_)) {
return codex_rollout_trace::ThreadTraceContext::disabled();
}
// Parent lookup can fail if the parent was closed or released between
// spawn preparation and session construction. Tracing is diagnostic, so
// that race should not block child creation; the child simply starts
// without a parent rollout trace.
self.get_thread(*parent_thread_id)
.await
.ok()
.map(|thread| thread.session.services.rollout_thread_trace.clone())
.unwrap_or_else(codex_rollout_trace::ThreadTraceContext::disabled)
}
}
fn stored_thread_to_initial_history(
stored_thread: StoredThread,
rollout_path: Option<PathBuf>,
) -> CodexResult<InitialHistory> {
let thread_id = stored_thread.thread_id;
let history = stored_thread.history.ok_or_else(|| {
CodexErr::Fatal(format!(
"thread {thread_id} did not include persisted history"
))
})?;
Ok(InitialHistory::Resumed(ResumedHistory {
conversation_id: thread_id,
history: Arc::new(history.items),
rollout_path: rollout_path.or(stored_thread.rollout_path),
}))
}
fn thread_store_rollout_read_error(err: ThreadStoreError) -> CodexErr {
match err {
ThreadStoreError::ThreadNotFound { thread_id } => CodexErr::ThreadNotFound(thread_id),
ThreadStoreError::InvalidRequest { message } => CodexErr::InvalidRequest(message),
err => CodexErr::Fatal(format!("failed to read thread by rollout path: {err}")),
}
}
fn thread_store_metadata_update_error(thread_id: ThreadId, err: ThreadStoreError) -> CodexErr {
match err {
ThreadStoreError::ThreadNotFound { thread_id } => CodexErr::ThreadNotFound(thread_id),
ThreadStoreError::InvalidRequest { message } => CodexErr::InvalidRequest(message),
ThreadStoreError::Unsupported { operation } => CodexErr::UnsupportedOperation(format!(
"thread metadata update is not supported by this store: {operation}"
)),
err => CodexErr::Fatal(format!(
"failed to update thread metadata {thread_id}: {err}"
)),
}
}
/// Return a fork snapshot cut strictly before the nth user message (0-based).
///
/// Out-of-range values keep the full committed history at a turn boundary, but
/// when the source thread is currently mid-turn they fall back to cutting
/// before the active turn's opening boundary so the fork omits the unfinished
/// suffix entirely.
fn truncate_before_nth_user_message(
history: InitialHistory,
n: usize,
snapshot_state: &SnapshotTurnState,
) -> InitialHistory {
let mut items = match history {
InitialHistory::New | InitialHistory::Cleared => Vec::new(),
InitialHistory::Resumed(resumed) => Arc::unwrap_or_clone(resumed.history),
InitialHistory::Forked(items) => items,
};
let user_positions = truncation::user_message_positions_in_rollout(&items);
let rolled = if snapshot_state.ends_mid_turn && n >= user_positions.len() {
if let Some(cut_idx) = snapshot_state
.active_turn_start_index
.or_else(|| user_positions.last().copied())
{
items.truncate(cut_idx);
items
} else {
items
}
} else {
truncation::truncate_rollout_before_nth_user_message_from_start(items, n)
};
if rolled.is_empty() {
InitialHistory::New
} else {
InitialHistory::Forked(rolled)
}
}
#[derive(Debug, Eq, PartialEq)]
struct SnapshotTurnState {
ends_mid_turn: bool,
active_turn_id: Option<String>,
active_turn_started_at: Option<i64>,
active_turn_start_index: Option<usize>,
}
fn snapshot_turn_state(history: &InitialHistory) -> SnapshotTurnState {
let rollout_items = history.get_rollout_items();
let mut builder = ThreadHistoryBuilder::new();
for item in rollout_items {
builder.handle_rollout_item(item);
}
let active_turn_id = builder.active_turn_id_if_explicit();
if builder.has_active_turn() && active_turn_id.is_some() {
let active_turn_snapshot = builder.active_turn_snapshot();
if active_turn_snapshot
.as_ref()
.is_some_and(|turn| turn.status != TurnStatus::InProgress)
{
return SnapshotTurnState {
ends_mid_turn: false,
active_turn_id: None,
active_turn_started_at: None,
active_turn_start_index: None,
};
}
return SnapshotTurnState {
ends_mid_turn: true,
active_turn_id,
active_turn_started_at: active_turn_snapshot.and_then(|turn| turn.started_at),
active_turn_start_index: builder.active_turn_start_index(),
};
}
let Some(last_user_position) = truncation::user_message_positions_in_rollout(rollout_items)
.last()
.copied()
else {
return SnapshotTurnState {
ends_mid_turn: false,
active_turn_id: None,
active_turn_started_at: None,
active_turn_start_index: None,
};
};
// Synthetic fork/resume histories can contain user/assistant response items
// without explicit turn lifecycle events. If the persisted snapshot has no
// terminating boundary after its last user message, treat it as mid-turn.
SnapshotTurnState {
ends_mid_turn: !rollout_items[last_user_position + 1..].iter().any(|item| {
matches!(
item,
RolloutItem::EventMsg(EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_))
)
}),
active_turn_id: None,
active_turn_started_at: None,
active_turn_start_index: None,
}
}
fn fork_history_from_snapshot(
snapshot: ForkSnapshot,
history: InitialHistory,
interrupted_marker: InterruptedTurnHistoryMarker,
) -> InitialHistory {
let snapshot_state = snapshot_turn_state(&history);
match snapshot {
ForkSnapshot::TruncateBeforeNthUserMessage(nth_user_message) => {
truncate_before_nth_user_message(history, nth_user_message, &snapshot_state)
}
ForkSnapshot::Interrupted => {
let history = match history {
InitialHistory::New => InitialHistory::New,
InitialHistory::Cleared => InitialHistory::Cleared,
InitialHistory::Forked(history) => InitialHistory::Forked(history),
InitialHistory::Resumed(resumed) => {
InitialHistory::Forked(Arc::unwrap_or_clone(resumed.history))
}
};
if snapshot_state.ends_mid_turn {
append_interrupted_boundary(
history,
snapshot_state.active_turn_id,
snapshot_state.active_turn_started_at,
interrupted_marker,
)
} else {
history
}
}
}
}
/// Append the same persisted interrupt boundary used by the live interrupt path
/// to an existing fork snapshot after the source thread has been confirmed to
/// be mid-turn.
fn append_interrupted_boundary(
history: InitialHistory,
turn_id: Option<String>,
started_at: Option<i64>,
interrupted_marker: InterruptedTurnHistoryMarker,
) -> InitialHistory {
let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
turn_id,
reason: TurnAbortReason::Interrupted,
started_at,
completed_at: None,
duration_ms: None,
}));
match history {
InitialHistory::New | InitialHistory::Cleared => {
let mut history = Vec::new();
if let Some(marker) = interrupted_turn_history_marker(interrupted_marker) {
history.push(RolloutItem::ResponseItem(marker.into()));
}
history.push(aborted_event);
InitialHistory::Forked(history)
}
InitialHistory::Forked(mut history) => {
if let Some(marker) = interrupted_turn_history_marker(interrupted_marker) {
history.push(RolloutItem::ResponseItem(marker.into()));
}
history.push(aborted_event);
InitialHistory::Forked(history)
}
InitialHistory::Resumed(resumed) => {
let mut history = Arc::unwrap_or_clone(resumed.history);
if let Some(marker) = interrupted_turn_history_marker(interrupted_marker) {
history.push(RolloutItem::ResponseItem(marker.into()));
}
history.push(aborted_event);
InitialHistory::Forked(history)
}
}
}
#[cfg(test)]
#[path = "thread_manager_tests.rs"]
mod tests;