Merge branch 'main' into codex/stateful-apply-patch-streaming

This commit is contained in:
Akshay Nathan
2026-05-04 15:18:11 -07:00
147 changed files with 3745 additions and 980 deletions
+7
View File
@@ -29,6 +29,7 @@ pub use codex_app_server::in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY;
pub use codex_app_server::in_process::InProcessServerEvent;
use codex_app_server::in_process::InProcessStartArgs;
use codex_app_server::in_process::LogDbLayer;
pub use codex_app_server::in_process::StateDbHandle;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientNotification;
use codex_app_server_protocol::ClientRequest;
@@ -343,6 +344,8 @@ pub struct InProcessClientStartArgs {
pub feedback: CodexFeedback,
/// SQLite tracing layer used to flush recently emitted logs before feedback upload.
pub log_db: Option<LogDbLayer>,
/// Process-wide SQLite state handle shared with the embedded app-server.
pub state_db: Option<StateDbHandle>,
/// Environment manager used by core execution and filesystem operations.
pub environment_manager: Arc<EnvironmentManager>,
/// Startup warnings emitted after initialize succeeds.
@@ -404,6 +407,7 @@ impl InProcessClientStartArgs {
thread_config_loader,
feedback: self.feedback,
log_db: self.log_db,
state_db: self.state_db,
environment_manager: self.environment_manager,
config_warnings: self.config_warnings,
session_source: self.session_source,
@@ -983,6 +987,7 @@ mod tests {
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
config_warnings: Vec::new(),
session_source,
@@ -2057,6 +2062,7 @@ mod tests {
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
environment_manager: environment_manager.clone(),
config_warnings: Vec::new(),
session_source: SessionSource::Exec,
@@ -2096,6 +2102,7 @@ mod tests {
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
config_warnings: Vec::new(),
session_source: SessionSource::Exec,
+32 -7
View File
@@ -86,6 +86,7 @@ use codex_exec_server::EnvironmentManager;
use codex_feedback::CodexFeedback;
use codex_login::AuthManager;
use codex_protocol::protocol::SessionSource;
pub use codex_rollout::StateDbHandle;
pub use codex_state::log_db::LogDbLayer;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
@@ -126,6 +127,8 @@ pub struct InProcessStartArgs {
pub feedback: CodexFeedback,
/// SQLite tracing layer used to flush recently emitted logs before feedback upload.
pub log_db: Option<LogDbLayer>,
/// Process-wide SQLite state handle shared with embedded app-server consumers.
pub state_db: Option<StateDbHandle>,
/// Environment manager used by core execution and filesystem operations.
pub environment_manager: Arc<EnvironmentManager>,
/// Startup warnings emitted after initialize succeeds.
@@ -251,6 +254,8 @@ pub struct InProcessClientHandle {
client: InProcessClientSender,
event_rx: mpsc::Receiver<InProcessServerEvent>,
runtime_handle: tokio::task::JoinHandle<()>,
#[cfg(test)]
_test_codex_home: Option<tempfile::TempDir>,
}
impl InProcessClientHandle {
@@ -418,6 +423,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
environment_manager: args.environment_manager,
feedback: args.feedback,
log_db: args.log_db,
state_db: args.state_db,
config_warnings: args.config_warnings,
session_source: args.session_source,
auth_manager,
@@ -717,6 +723,8 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
client: InProcessClientSender { client_tx },
event_rx,
runtime_handle,
#[cfg(test)]
_test_codex_home: None,
}
}
@@ -738,13 +746,22 @@ mod tests {
use codex_app_server_protocol::TurnStatus;
use codex_core::config::ConfigBuilder;
use pretty_assertions::assert_eq;
use std::path::Path;
use tempfile::TempDir;
async fn build_test_config() -> Config {
match ConfigBuilder::default().build().await {
async fn build_test_config(codex_home: &Path) -> Config {
match ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.build()
.await
{
Ok(config) => config,
Err(_) => Config::load_default_with_cli_overrides(Vec::new())
.await
.expect("default config should load"),
Err(_) => Config::load_default_with_cli_overrides_for_codex_home(
codex_home.to_path_buf(),
Vec::new(),
)
.await
.expect("default config should load"),
}
}
@@ -752,15 +769,21 @@ mod tests {
session_source: SessionSource,
channel_capacity: usize,
) -> InProcessClientHandle {
let codex_home = TempDir::new().expect("temp dir");
let config = Arc::new(build_test_config(codex_home.path()).await);
let state_db = codex_rollout::state_db::try_init(config.as_ref())
.await
.expect("state db should initialize for in-process test");
let args = InProcessStartArgs {
arg0_paths: Arg0DispatchPaths::default(),
config: Arc::new(build_test_config().await),
config,
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
state_db: Some(state_db),
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
config_warnings: Vec::new(),
session_source,
@@ -775,7 +798,9 @@ mod tests {
},
channel_capacity,
};
start(args).await.expect("in-process runtime should start")
let mut client = start(args).await.expect("in-process runtime should start");
client._test_codex_home = Some(codex_home);
client
}
async fn start_test_client(session_source: SessionSource) -> InProcessClientHandle {
+57 -32
View File
@@ -54,6 +54,7 @@ use codex_exec_server::EnvironmentManager;
use codex_exec_server::ExecServerRuntimePaths;
use codex_feedback::CodexFeedback;
use codex_protocol::protocol::SessionSource;
use codex_rollout::state_db as rollout_state_db;
use codex_state::log_db;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
@@ -453,23 +454,6 @@ pub async fn run_main_with_transport_options(
.await
{
Ok(config) => {
let effective_toml = config.config_layer_stack.effective_config();
match effective_toml.try_into() {
Ok(config_toml) => {
if let Err(err) = codex_core::personality_migration::maybe_migrate_personality(
&config.codex_home,
&config_toml,
)
.await
{
warn!(error = %err, "Failed to run personality migration");
}
}
Err(err) => {
warn!(error = %err, "Failed to deserialize config for personality migration");
}
}
let discovered_thread_config_loader = configured_thread_config_loader(&config);
config_manager
.replace_thread_config_loader(Arc::clone(&discovered_thread_config_loader));
@@ -483,23 +467,70 @@ pub async fn run_main_with_transport_options(
}
};
let mut config_warnings = Vec::new();
let config = match config_manager
let (mut config, should_run_personality_migration) = match config_manager
.load_latest_config(/*fallback_cwd*/ None)
.await
{
Ok(config) => config,
Ok(config) => (config, true),
Err(err) => {
let message = config_warning_from_error("Invalid configuration; using defaults.", &err);
config_warnings.push(message);
config_manager.load_default_config().await.map_err(|e| {
std::io::Error::new(
ErrorKind::InvalidData,
format!("error loading default config after config error: {e}"),
)
})?
(
config_manager.load_default_config().await.map_err(|e| {
std::io::Error::new(
ErrorKind::InvalidData,
format!("error loading default config after config error: {e}"),
)
})?,
false,
)
}
};
let state_db_result = rollout_state_db::try_init(&config).await;
let state_db_init_error = state_db_result.as_ref().err().map(ToString::to_string);
let state_db = state_db_result.ok();
if should_run_personality_migration {
let effective_toml = config.config_layer_stack.effective_config();
match effective_toml.try_into() {
Ok(config_toml) => {
match codex_core::personality_migration::maybe_migrate_personality(
&config.codex_home,
&config_toml,
state_db.clone(),
)
.await
{
Ok(codex_core::personality_migration::PersonalityMigrationStatus::Applied) => {
config = config_manager
.load_latest_config(/*fallback_cwd*/ None)
.await
.map_err(|err| {
std::io::Error::new(
ErrorKind::InvalidData,
format!(
"error reloading config after personality migration: {err}"
),
)
})?;
}
Ok(
codex_core::personality_migration::PersonalityMigrationStatus::SkippedMarker
| codex_core::personality_migration::PersonalityMigrationStatus::SkippedExplicitPersonality
| codex_core::personality_migration::PersonalityMigrationStatus::SkippedNoSessions,
) => {}
Err(err) => {
warn!(error = %err, "Failed to run personality migration");
}
}
}
Err(err) => {
warn!(error = %err, "Failed to deserialize config for personality migration");
}
}
}
if let Ok(Some(err)) = check_execpolicy_for_warnings(&config.config_layer_stack).await {
let (path, range) = exec_policy_warning_location(&err);
let message = ConfigWarningNotification {
@@ -567,13 +598,6 @@ pub async fn run_main_with_transport_options(
let feedback_layer = feedback.logger_layer();
let feedback_metadata_layer = feedback.metadata_layer();
let state_db_result = codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.model_provider_id.clone(),
)
.await;
let state_db_init_error = state_db_result.as_ref().err().map(ToString::to_string);
let state_db = state_db_result.ok();
let log_db = state_db.clone().map(log_db::start);
let log_db_layer = log_db
.clone()
@@ -745,6 +769,7 @@ pub async fn run_main_with_transport_options(
environment_manager,
feedback: feedback.clone(),
log_db,
state_db: state_db.clone(),
config_warnings,
session_source,
auth_manager,
+10 -6
View File
@@ -72,6 +72,7 @@ use codex_login::auth::ExternalAuthTokens;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::W3cTraceContext;
use codex_rollout::StateDbHandle;
use codex_state::log_db::LogDbLayer;
use tokio::sync::Mutex;
use tokio::sync::Semaphore;
@@ -251,6 +252,7 @@ pub(crate) struct MessageProcessorArgs {
pub(crate) environment_manager: Arc<EnvironmentManager>,
pub(crate) feedback: CodexFeedback,
pub(crate) log_db: Option<LogDbLayer>,
pub(crate) state_db: Option<StateDbHandle>,
pub(crate) config_warnings: Vec<ConfigWarningNotification>,
pub(crate) session_source: SessionSource,
pub(crate) auth_manager: Arc<AuthManager>,
@@ -272,6 +274,7 @@ impl MessageProcessor {
environment_manager,
feedback,
log_db,
state_db,
config_warnings,
session_source,
auth_manager,
@@ -285,7 +288,7 @@ impl MessageProcessor {
// The thread store is intentionally process-scoped. Config reloads can
// affect per-thread behavior, but they must not move newly started,
// resumed, or forked threads to a different persistence backend/root.
let thread_store = thread_store_from_config(config.as_ref());
let thread_store = thread_store_from_config(config.as_ref(), state_db.clone());
let thread_manager = Arc::new(ThreadManager::new(
config.as_ref(),
auth_manager.clone(),
@@ -293,6 +296,7 @@ impl MessageProcessor {
environment_manager,
Some(analytics_events_client.clone()),
Arc::clone(&thread_store),
state_db.clone(),
));
thread_manager
.plugins_manager()
@@ -337,6 +341,7 @@ impl MessageProcessor {
Arc::clone(&config),
feedback,
log_db,
state_db.clone(),
);
let git_processor = GitRequestProcessor::new();
let initialize_processor = InitializeRequestProcessor::new(
@@ -371,6 +376,7 @@ impl MessageProcessor {
outgoing.clone(),
Arc::clone(&config),
thread_state_manager.clone(),
state_db.clone(),
);
let thread_processor = ThreadRequestProcessor::new(
auth_manager.clone(),
@@ -386,6 +392,7 @@ impl MessageProcessor {
thread_watch_manager.clone(),
Arc::clone(&thread_list_state_permit),
thread_goal_processor.clone(),
state_db.clone(),
);
let turn_processor = TurnRequestProcessor::new(
auth_manager.clone(),
@@ -399,6 +406,7 @@ impl MessageProcessor {
thread_state_manager,
thread_watch_manager,
thread_list_state_permit,
state_db.clone(),
);
if matches!(plugin_startup_tasks, crate::PluginStartupTasks::Start) {
// Keep plugin startup warmups aligned at app-server startup.
@@ -429,11 +437,7 @@ impl MessageProcessor {
arg0_paths,
config.codex_home.to_path_buf(),
);
let device_key_processor = DeviceKeyRequestProcessor::new(
outgoing.clone(),
config.sqlite_home.clone(),
config.model_provider_id.clone(),
);
let device_key_processor = DeviceKeyRequestProcessor::new(outgoing.clone(), state_db);
let fs_processor = FsRequestProcessor::new(
thread_manager
.environment_manager()
@@ -290,6 +290,7 @@ async fn build_test_processor(
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
config_warnings: Vec::new(),
session_source: SessionSource::VSCode,
auth_manager,
@@ -369,7 +369,6 @@ use codex_rmcp_client::perform_oauth_login_return_url;
use codex_rollout::EventPersistenceMode;
use codex_rollout::is_persisted_rollout_item;
use codex_rollout::state_db::StateDbHandle;
use codex_rollout::state_db::get_state_db;
use codex_rollout::state_db::reconcile_rollout;
use codex_state::StateRuntime;
use codex_state::ThreadMetadata;
@@ -1,6 +1,5 @@
use std::fmt;
use std::future::Future;
use std::path::PathBuf;
use std::sync::Arc;
use crate::error_code::internal_error;
@@ -36,7 +35,6 @@ use codex_device_key::RemoteControlClientEnrollmentAudience;
use codex_device_key::RemoteControlClientEnrollmentSignPayload;
use codex_state::DeviceKeyBindingRecord;
use codex_state::StateRuntime;
use tokio::sync::OnceCell;
#[derive(Clone)]
pub(crate) struct DeviceKeyRequestProcessor {
@@ -47,15 +45,11 @@ pub(crate) struct DeviceKeyRequestProcessor {
impl DeviceKeyRequestProcessor {
pub(crate) fn new(
outgoing: Arc<OutgoingMessageSender>,
sqlite_home: PathBuf,
default_provider: String,
state_db: Option<Arc<StateRuntime>>,
) -> Self {
Self {
outgoing,
store: DeviceKeyStore::new(Arc::new(StateDeviceKeyBindingStore::new(
sqlite_home,
default_provider,
))),
store: DeviceKeyStore::new(Arc::new(StateDeviceKeyBindingStore::new(state_db))),
}
}
@@ -176,39 +170,25 @@ async fn sign_device_key(
}
struct StateDeviceKeyBindingStore {
sqlite_home: PathBuf,
default_provider: String,
state_db: OnceCell<Arc<StateRuntime>>,
state_db: Option<Arc<StateRuntime>>,
}
impl StateDeviceKeyBindingStore {
fn new(sqlite_home: PathBuf, default_provider: String) -> Self {
Self {
sqlite_home,
default_provider,
state_db: OnceCell::new(),
}
fn new(state_db: Option<Arc<StateRuntime>>) -> Self {
Self { state_db }
}
async fn state_db(&self) -> Result<Arc<StateRuntime>, DeviceKeyError> {
let sqlite_home = self.sqlite_home.clone();
let default_provider = self.default_provider.clone();
self.state_db
.get_or_try_init(|| async move {
StateRuntime::init(sqlite_home, default_provider)
.await
.map_err(|err| DeviceKeyError::Platform(err.to_string()))
})
.await
.cloned()
.clone()
.ok_or_else(|| DeviceKeyError::Platform("sqlite state db unavailable".to_string()))
}
}
impl fmt::Debug for StateDeviceKeyBindingStore {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StateDeviceKeyBindingStore")
.field("sqlite_home", &self.sqlite_home)
.field("default_provider", &self.default_provider)
.field("has_state_db", &self.state_db.is_some())
.finish_non_exhaustive()
}
}
@@ -7,6 +7,7 @@ pub(crate) struct FeedbackRequestProcessor {
config: Arc<Config>,
feedback: CodexFeedback,
log_db: Option<LogDbLayer>,
state_db: Option<StateDbHandle>,
}
impl FeedbackRequestProcessor {
@@ -16,6 +17,7 @@ impl FeedbackRequestProcessor {
config: Arc<Config>,
feedback: CodexFeedback,
log_db: Option<LogDbLayer>,
state_db: Option<StateDbHandle>,
) -> Self {
Self {
auth_manager,
@@ -23,6 +25,7 @@ impl FeedbackRequestProcessor {
config,
feedback,
log_db,
state_db,
}
}
@@ -75,7 +78,7 @@ impl FeedbackRequestProcessor {
if let Some(log_db) = self.log_db.as_ref() {
log_db.flush().await;
}
let state_db_ctx = get_state_db(&self.config).await;
let state_db_ctx = self.state_db.clone();
let feedback_thread_ids = match conversation_id {
Some(conversation_id) => match self
.thread_manager
@@ -7,6 +7,7 @@ pub(crate) struct ThreadGoalRequestProcessor {
outgoing: Arc<OutgoingMessageSender>,
config: Arc<Config>,
thread_state_manager: ThreadStateManager,
state_db: Option<StateDbHandle>,
}
impl ThreadGoalRequestProcessor {
@@ -15,12 +16,14 @@ impl ThreadGoalRequestProcessor {
outgoing: Arc<OutgoingMessageSender>,
config: Arc<Config>,
thread_state_manager: ThreadStateManager,
state_db: Option<StateDbHandle>,
) -> Self {
Self {
thread_manager,
outgoing,
config,
thread_state_manager,
state_db,
}
}
@@ -78,7 +81,7 @@ impl ThreadGoalRequestProcessor {
if let Some(state_db) = thread.state_db() {
Some(state_db)
} else {
open_state_db_for_direct_thread_lookup(&self.config).await
self.state_db.clone()
}
} else {
None
@@ -104,12 +107,16 @@ impl ThreadGoalRequestProcessor {
"ephemeral thread does not support goals: {thread_id}"
))
})?,
None => find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string())
.await
.map_err(|err| {
internal_error(format!("failed to locate thread id {thread_id}: {err}"))
})?
.ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?,
None => find_thread_path_by_id_str(
&self.config.codex_home,
&thread_id.to_string(),
self.state_db.as_deref(),
)
.await
.map_err(|err| {
internal_error(format!("failed to locate thread id {thread_id}: {err}"))
})?
.ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?,
};
reconcile_rollout(
Some(&state_db),
@@ -247,12 +254,16 @@ impl ThreadGoalRequestProcessor {
"ephemeral thread does not support goals: {thread_id}"
))
})?,
None => find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string())
.await
.map_err(|err| {
internal_error(format!("failed to locate thread id {thread_id}: {err}"))
})?
.ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?,
None => find_thread_path_by_id_str(
&self.config.codex_home,
&thread_id.to_string(),
self.state_db.as_deref(),
)
.await
.map_err(|err| {
internal_error(format!("failed to locate thread id {thread_id}: {err}"))
})?
.ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?,
};
reconcile_rollout(
Some(&state_db),
@@ -307,16 +318,20 @@ impl ThreadGoalRequestProcessor {
return Ok(state_db);
}
} else {
find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string())
.await
.map_err(|err| {
internal_error(format!("failed to locate thread id {thread_id}: {err}"))
})?
.ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?;
find_thread_path_by_id_str(
&self.config.codex_home,
&thread_id.to_string(),
self.state_db.as_deref(),
)
.await
.map_err(|err| {
internal_error(format!("failed to locate thread id {thread_id}: {err}"))
})?
.ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?;
}
open_state_db_for_direct_thread_lookup(&self.config)
.await
self.state_db
.clone()
.ok_or_else(|| internal_error("sqlite state db unavailable for thread goals"))
}
@@ -259,6 +259,7 @@ pub(crate) struct ThreadRequestProcessor {
pub(super) thread_watch_manager: ThreadWatchManager,
pub(super) thread_list_state_permit: Arc<Semaphore>,
pub(super) thread_goal_processor: ThreadGoalRequestProcessor,
pub(super) state_db: Option<StateDbHandle>,
pub(super) background_tasks: TaskTracker,
}
@@ -278,6 +279,7 @@ impl ThreadRequestProcessor {
thread_watch_manager: ThreadWatchManager,
thread_list_state_permit: Arc<Semaphore>,
thread_goal_processor: ThreadGoalRequestProcessor,
state_db: Option<StateDbHandle>,
) -> Self {
Self {
auth_manager,
@@ -293,6 +295,7 @@ impl ThreadRequestProcessor {
thread_watch_manager,
thread_list_state_permit,
thread_goal_processor,
state_db,
background_tasks: TaskTracker::new(),
}
}
@@ -1170,7 +1173,7 @@ impl ThreadRequestProcessor {
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
let mut thread_ids = vec![thread_id];
if let Some(state_db_ctx) = get_state_db(&self.config).await {
if let Some(state_db_ctx) = self.state_db.as_ref() {
let descendants = state_db_ctx
.list_thread_spawn_descendants(thread_id)
.await
@@ -1391,14 +1394,10 @@ impl ThreadRequestProcessor {
}
async fn memory_reset_response_inner(&self) -> Result<MemoryResetResponse, JSONRPCErrorError> {
let state_db = StateRuntime::init(
self.config.sqlite_home.clone(),
self.config.model_provider_id.clone(),
)
.await
.map_err(|err| {
internal_error(format!("failed to open state db for memory reset: {err}"))
})?;
let state_db = self
.state_db
.clone()
.ok_or_else(|| internal_error("sqlite state db unavailable for memory reset"))?;
state_db.clear_memory_data().await.map_err(|err| {
internal_error(format!("failed to clear memory rows in state db: {err}"))
@@ -1445,7 +1444,7 @@ impl ThreadRequestProcessor {
let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok();
let mut state_db_ctx = loaded_thread.as_ref().and_then(|thread| thread.state_db());
if state_db_ctx.is_none() {
state_db_ctx = get_state_db(&self.config).await;
state_db_ctx = self.state_db.clone();
}
let Some(state_db_ctx) = state_db_ctx else {
return Err(internal_error(format!(
@@ -1583,33 +1582,37 @@ impl ThreadRequestProcessor {
return Ok(());
}
let rollout_path =
match find_thread_path_by_id_str(&self.config.codex_home, &thread_uuid.to_string())
.await
let rollout_path = match find_thread_path_by_id_str(
&self.config.codex_home,
&thread_uuid.to_string(),
self.state_db.as_deref(),
)
.await
{
Ok(Some(path)) => path,
Ok(None) => match find_archived_thread_path_by_id_str(
&self.config.codex_home,
&thread_uuid.to_string(),
self.state_db.as_deref(),
)
.await
{
Ok(Some(path)) => path,
Ok(None) => match find_archived_thread_path_by_id_str(
&self.config.codex_home,
&thread_uuid.to_string(),
)
.await
{
Ok(Some(path)) => path,
Ok(None) => {
return Err(invalid_request(format!("thread not found: {thread_uuid}")));
}
Err(err) => {
return Err(internal_error(format!(
"failed to locate archived thread id {thread_uuid}: {err}"
)));
}
},
Ok(None) => {
return Err(invalid_request(format!("thread not found: {thread_uuid}")));
}
Err(err) => {
return Err(internal_error(format!(
"failed to locate thread id {thread_uuid}: {err}"
"failed to locate archived thread id {thread_uuid}: {err}"
)));
}
};
},
Err(err) => {
return Err(internal_error(format!(
"failed to locate thread id {thread_uuid}: {err}"
)));
}
};
reconcile_rollout(
Some(state_db_ctx),
@@ -2555,7 +2558,7 @@ impl ThreadRequestProcessor {
let InitialHistory::Resumed(resumed_history) = thread_history else {
return None;
};
let state_db_ctx = get_state_db(&self.config).await?;
let state_db_ctx = self.state_db.clone()?;
let persisted_metadata = state_db_ctx
.get_thread(resumed_history.conversation_id)
.await
@@ -2922,7 +2925,9 @@ impl ThreadRequestProcessor {
}
async fn attach_thread_name(&self, thread_id: ThreadId, thread: &mut Thread) {
if let Some(title) = title_from_state_db(&self.config, thread_id).await {
if let Some(title) =
title_from_state_db(&self.config, self.state_db.as_ref(), thread_id).await
{
set_thread_name_from_title(thread, title);
}
}
@@ -3683,8 +3688,12 @@ async fn read_summary_from_state_db_context_by_thread_id(
Some(summary_from_thread_metadata(&metadata))
}
async fn title_from_state_db(config: &Config, thread_id: ThreadId) -> Option<String> {
if let Some(state_db_ctx) = open_state_db_for_direct_thread_lookup(config).await
async fn title_from_state_db(
config: &Config,
state_db_ctx: Option<&StateDbHandle>,
thread_id: ThreadId,
) -> Option<String> {
if let Some(state_db_ctx) = state_db_ctx
&& let Some(metadata) = state_db_ctx.get_thread(thread_id).await.ok().flatten()
&& let Some(title) = distinct_title(&metadata)
{
@@ -1,13 +1,5 @@
use super::*;
pub(super) async fn open_state_db_for_direct_thread_lookup(
config: &Config,
) -> Option<StateDbHandle> {
StateRuntime::init(config.sqlite_home.clone(), config.model_provider_id.clone())
.await
.ok()
}
pub(crate) async fn read_summary_from_rollout(
path: &Path,
fallback_provider: &str,
@@ -13,6 +13,7 @@ pub(crate) struct TurnRequestProcessor {
thread_state_manager: ThreadStateManager,
thread_watch_manager: ThreadWatchManager,
thread_list_state_permit: Arc<Semaphore>,
state_db: Option<StateDbHandle>,
}
impl TurnRequestProcessor {
@@ -29,6 +30,7 @@ impl TurnRequestProcessor {
thread_state_manager: ThreadStateManager,
thread_watch_manager: ThreadWatchManager,
thread_list_state_permit: Arc<Semaphore>,
state_db: Option<StateDbHandle>,
) -> Self {
Self {
auth_manager,
@@ -42,6 +44,7 @@ impl TurnRequestProcessor {
thread_state_manager,
thread_watch_manager,
thread_list_state_permit,
state_db,
}
}
@@ -891,16 +894,20 @@ impl TurnRequestProcessor {
let rollout_path = if let Some(path) = parent_thread.rollout_path() {
path
} else {
find_thread_path_by_id_str(&self.config.codex_home, &parent_thread_id.to_string())
.await
.map_err(|err| {
internal_error(format!(
"failed to locate thread id {parent_thread_id}: {err}"
))
})?
.ok_or_else(|| {
invalid_request(format!("no rollout found for thread id {parent_thread_id}"))
})?
find_thread_path_by_id_str(
&self.config.codex_home,
&parent_thread_id.to_string(),
self.state_db.as_deref(),
)
.await
.map_err(|err| {
internal_error(format!(
"failed to locate thread id {parent_thread_id}: {err}"
))
})?
.ok_or_else(|| {
invalid_request(format!("no rollout found for thread id {parent_thread_id}"))
})?
};
let mut config = self.config.as_ref().clone();
@@ -204,6 +204,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> {
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
config_warnings: Vec::new(),
session_source: SessionSource::Cli,
@@ -80,6 +80,7 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste
thread_config_loader: Arc::new(NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
config_warnings: Vec::new(),
session_source: SessionSource::Cli,
@@ -63,7 +63,7 @@ async fn thread_archive_requires_materialized_rollout() -> Result<()> {
rollout_path.display()
);
assert!(
find_thread_path_by_id_str(codex_home.path(), &thread.id)
find_thread_path_by_id_str(codex_home.path(), &thread.id, /*state_db_ctx*/ None)
.await?
.is_none(),
"thread id should not be discoverable before rollout materialization"
@@ -118,9 +118,10 @@ async fn thread_archive_requires_materialized_rollout() -> Result<()> {
rollout_path.display()
);
let discovered_path = find_thread_path_by_id_str(codex_home.path(), &thread.id)
.await?
.expect("expected rollout path for thread id to exist after materialization");
let discovered_path =
find_thread_path_by_id_str(codex_home.path(), &thread.id, /*state_db_ctx*/ None)
.await?
.expect("expected rollout path for thread id to exist after materialization");
assert_paths_match_on_disk(&discovered_path, &rollout_path)?;
let archive_id = mcp
@@ -252,15 +253,23 @@ async fn thread_archive_archives_spawned_descendants() -> Result<()> {
for thread_id in [parent_thread_id, child_thread_id, grandchild_thread_id] {
assert!(
find_thread_path_by_id_str(codex_home.path(), &thread_id.to_string())
.await?
.is_none(),
find_thread_path_by_id_str(
codex_home.path(),
&thread_id.to_string(),
/*state_db_ctx*/ None,
)
.await?
.is_none(),
"expected active rollout for {thread_id} to be archived"
);
assert!(
find_archived_thread_path_by_id_str(codex_home.path(), &thread_id.to_string())
.await?
.is_some(),
find_archived_thread_path_by_id_str(
codex_home.path(),
&thread_id.to_string(),
/*state_db_ctx*/ None,
)
.await?
.is_some(),
"expected archived rollout for {thread_id} to exist"
);
}
@@ -322,9 +331,10 @@ async fn thread_archive_succeeds_when_descendant_archive_fails() -> Result<()> {
)
.await?;
let child_rollout_path = find_thread_path_by_id_str(codex_home.path(), &child_id)
.await?
.expect("child rollout path");
let child_rollout_path =
find_thread_path_by_id_str(codex_home.path(), &child_id, /*state_db_ctx*/ None)
.await?
.expect("child rollout path");
let archived_child_path = codex_home
.path()
.join(ARCHIVED_SESSIONS_SUBDIR)
@@ -381,15 +391,23 @@ async fn thread_archive_succeeds_when_descendant_archive_fails() -> Result<()> {
);
for thread_id in [parent_thread_id, grandchild_thread_id] {
assert!(
find_thread_path_by_id_str(codex_home.path(), &thread_id.to_string())
.await?
.is_none(),
find_thread_path_by_id_str(
codex_home.path(),
&thread_id.to_string(),
/*state_db_ctx*/ None,
)
.await?
.is_none(),
"expected active rollout for {thread_id} to be archived"
);
assert!(
find_archived_thread_path_by_id_str(codex_home.path(), &thread_id.to_string())
.await?
.is_some(),
find_archived_thread_path_by_id_str(
codex_home.path(),
&thread_id.to_string(),
/*state_db_ctx*/ None,
)
.await?
.is_some(),
"expected archived rollout for {thread_id} to exist"
);
}
@@ -455,15 +473,19 @@ async fn thread_archive_succeeds_when_spawned_descendant_is_missing() -> Result<
assert_eq!(archived_notification.thread_id, parent_id);
assert!(
find_thread_path_by_id_str(codex_home.path(), &parent_id)
find_thread_path_by_id_str(codex_home.path(), &parent_id, /*state_db_ctx*/ None)
.await?
.is_none(),
"parent should be archived even when a descendant is missing"
);
assert!(
find_archived_thread_path_by_id_str(codex_home.path(), &parent_id)
.await?
.is_some(),
find_archived_thread_path_by_id_str(
codex_home.path(),
&parent_id,
/*state_db_ctx*/ None,
)
.await?
.is_some(),
"parent should be moved into archived sessions"
);
@@ -614,6 +614,7 @@ sqlite = true
generate_memories: false,
};
let repaired_page = codex_core::RolloutRecorder::list_threads(
Some(state_db.clone()),
&rollout_config,
/*page_size*/ 10,
/*cursor*/ None,
@@ -211,9 +211,10 @@ async fn thread_name_update_rollout_count(
codex_home: &Path,
conversation_id: &str,
) -> Result<usize> {
let rollout_path = find_thread_path_by_id_str(codex_home, conversation_id)
.await?
.context("rollout path")?;
let rollout_path =
find_thread_path_by_id_str(codex_home, conversation_id, /*state_db_ctx*/ None)
.await?
.context("rollout path")?;
let contents = tokio::fs::read_to_string(rollout_path).await?;
Ok(contents
.lines()
@@ -300,6 +300,7 @@ async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result<
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
config_warnings: Vec::new(),
session_source: SessionSource::Cli.into(),
@@ -363,6 +364,7 @@ async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_pa
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
config_warnings: Vec::new(),
session_source: SessionSource::Cli.into(),
@@ -447,6 +449,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()>
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
config_warnings: Vec::new(),
session_source: SessionSource::Cli.into(),
@@ -75,9 +75,10 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result
)
.await??;
let found_rollout_path = find_thread_path_by_id_str(codex_home.path(), &thread.id)
.await?
.expect("expected rollout path for thread id to exist");
let found_rollout_path =
find_thread_path_by_id_str(codex_home.path(), &thread.id, /*state_db_ctx*/ None)
.await?
.expect("expected rollout path for thread id to exist");
assert_paths_match_on_disk(&found_rollout_path, &rollout_path)?;
let archive_id = mcp
@@ -92,9 +93,13 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result
.await??;
let _: ThreadArchiveResponse = to_response::<ThreadArchiveResponse>(archive_resp)?;
let archived_path = find_archived_thread_path_by_id_str(codex_home.path(), &thread.id)
.await?
.expect("expected archived rollout path for thread id to exist");
let archived_path = find_archived_thread_path_by_id_str(
codex_home.path(),
&thread.id,
/*state_db_ctx*/ None,
)
.await?
.expect("expected archived rollout path for thread id to exist");
let archived_path_display = archived_path.display();
assert!(
archived_path.exists(),
+21 -47
View File
@@ -20,7 +20,7 @@ use crate::MaybeApplyPatchVerified;
use crate::parser::Hunk;
use crate::parser::ParseError;
use crate::parser::ParsePatchMode;
use crate::parser::parse_patch_with_mode;
use crate::parser::parse_patch;
use crate::unified_diff_from_chunks;
use std::str::Utf8Error;
use tree_sitter::LanguageError;
@@ -103,16 +103,11 @@ fn extract_apply_patch_from_shell(
}
// TODO: make private once we remove tests in lib.rs
#[cfg_attr(not(test), allow(dead_code))]
pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
maybe_parse_apply_patch_with_mode(argv, ParsePatchMode::Legacy)
}
pub fn maybe_parse_apply_patch_with_mode(argv: &[String], mode: ParsePatchMode) -> MaybeApplyPatch {
pub fn maybe_parse_apply_patch(argv: &[String], mode: ParsePatchMode) -> MaybeApplyPatch {
match argv {
// Direct invocation: apply_patch <patch>
[cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => {
match parse_patch_with_mode(body, mode) {
match parse_patch(body, mode) {
Ok(source) => MaybeApplyPatch::Body(source),
Err(e) => MaybeApplyPatch::PatchParseError(e),
}
@@ -120,7 +115,7 @@ pub fn maybe_parse_apply_patch_with_mode(argv: &[String], mode: ParsePatchMode)
// Shell heredoc form: (optional `cd <path> &&`) apply_patch <<'EOF' ...
_ => match parse_shell_script(argv) {
Some((shell, script)) => match extract_apply_patch_from_shell(shell, script) {
Ok((body, workdir)) => match parse_patch_with_mode(&body, mode) {
Ok((body, workdir)) => match parse_patch(&body, mode) {
Ok(mut source) => {
source.workdir = workdir;
MaybeApplyPatch::Body(source)
@@ -140,15 +135,6 @@ pub fn maybe_parse_apply_patch_with_mode(argv: &[String], mode: ParsePatchMode)
/// cwd must be an absolute path so that we can resolve relative paths in the
/// patch.
pub async fn maybe_parse_apply_patch_verified(
argv: &[String],
cwd: &AbsolutePathBuf,
fs: &dyn ExecutorFileSystem,
sandbox: Option<&codex_exec_server::FileSystemSandboxContext>,
) -> MaybeApplyPatchVerified {
maybe_parse_apply_patch_verified_with_mode(argv, ParsePatchMode::Legacy, cwd, fs, sandbox).await
}
pub async fn maybe_parse_apply_patch_verified_with_mode(
argv: &[String],
mode: ParsePatchMode,
cwd: &AbsolutePathBuf,
@@ -158,17 +144,17 @@ pub async fn maybe_parse_apply_patch_verified_with_mode(
// Detect a raw patch body passed directly as the command or as the body of a shell
// script. In these cases, report an explicit error rather than applying the patch.
if let [body] = argv
&& parse_patch_with_mode(body, mode).is_ok()
&& parse_patch(body, mode).is_ok()
{
return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation);
}
if let Some((_, script)) = parse_shell_script(argv)
&& parse_patch_with_mode(script, mode).is_ok()
&& parse_patch(script, mode).is_ok()
{
return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation);
}
match maybe_parse_apply_patch_with_mode(argv, mode) {
match maybe_parse_apply_patch(argv, mode) {
MaybeApplyPatch::Body(ApplyPatchArgs {
patch,
hunks,
@@ -179,15 +165,13 @@ pub async fn maybe_parse_apply_patch_verified_with_mode(
.map(|dir| cwd.join(Path::new(dir)))
.unwrap_or_else(|| cwd.clone());
let mut changes = HashMap::new();
for hunk in &hunks {
for hunk in hunks {
let path = hunk.resolve_path(&effective_cwd);
match hunk {
Hunk::AddFile { contents, .. } => {
changes.insert(
path.into_path_buf(),
ApplyPatchFileChange::Add {
content: contents.clone(),
},
ApplyPatchFileChange::Add { content: contents },
);
}
Hunk::DeleteFile { .. } => {
@@ -223,9 +207,7 @@ pub async fn maybe_parse_apply_patch_verified_with_mode(
path.into_path_buf(),
ApplyPatchFileChange::Update {
unified_diff,
move_path: move_path
.as_ref()
.map(|p| effective_cwd.join(p).into_path_buf()),
move_path: move_path.map(|p| effective_cwd.join(p).into_path_buf()),
new_content: contents,
},
);
@@ -234,7 +216,6 @@ pub async fn maybe_parse_apply_patch_verified_with_mode(
}
MaybeApplyPatchVerified::Body(ApplyPatchAction {
changes,
hunks,
patch,
cwd: effective_cwd,
})
@@ -400,7 +381,6 @@ fn extract_apply_patch_from_bash(
#[cfg(test)]
mod tests {
use super::*;
use crate::UpdateFileChunk;
use crate::parse_patch;
use crate::unified_diff_from_chunks;
use assert_matches::assert_matches;
@@ -462,7 +442,7 @@ mod tests {
}
fn assert_match_args(args: Vec<String>, expected_workdir: Option<&str>) {
match maybe_parse_apply_patch(&args) {
match maybe_parse_apply_patch(&args, ParsePatchMode::Legacy) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
assert_eq!(workdir.as_deref(), expected_workdir);
assert_eq!(hunks, expected_single_add());
@@ -479,7 +459,7 @@ mod tests {
fn assert_not_match(script: &str) {
let args = args_bash(script);
assert_matches!(
maybe_parse_apply_patch(&args),
maybe_parse_apply_patch(&args, ParsePatchMode::Legacy),
MaybeApplyPatch::NotApplyPatch
);
}
@@ -492,6 +472,7 @@ mod tests {
assert_matches!(
maybe_parse_apply_patch_verified(
&args,
ParsePatchMode::Legacy,
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
LOCAL_FS.as_ref(),
/*sandbox*/ None,
@@ -509,6 +490,7 @@ mod tests {
assert_matches!(
maybe_parse_apply_patch_verified(
&args,
ParsePatchMode::Legacy,
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
LOCAL_FS.as_ref(),
/*sandbox*/ None,
@@ -529,7 +511,7 @@ mod tests {
"#,
]);
match maybe_parse_apply_patch(&args) {
match maybe_parse_apply_patch(&args, ParsePatchMode::Legacy) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
assert_eq!(
hunks,
@@ -554,7 +536,7 @@ mod tests {
"#,
]);
match maybe_parse_apply_patch(&args) {
match maybe_parse_apply_patch(&args, ParsePatchMode::Legacy) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
assert_eq!(
hunks,
@@ -593,7 +575,7 @@ mod tests {
PATCH"#,
]);
match maybe_parse_apply_patch(&args) {
match maybe_parse_apply_patch(&args, ParsePatchMode::Legacy) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
assert_eq!(workdir, None);
assert_eq!(
@@ -714,7 +696,7 @@ PATCH"#,
path.display()
));
let patch = parse_patch(&patch).unwrap();
let patch = parse_patch(&patch, ParsePatchMode::Legacy).unwrap();
let chunks = match patch.hunks.as_slice() {
[Hunk::UpdateFile { chunks, .. }] => chunks,
_ => panic!("Expected a single UpdateFile hunk"),
@@ -753,7 +735,7 @@ PATCH"#,
path.display()
));
let patch = parse_patch(&patch).unwrap();
let patch = parse_patch(&patch, ParsePatchMode::Legacy).unwrap();
let chunks = match patch.hunks.as_slice() {
[Hunk::UpdateFile { chunks, .. }] => chunks,
_ => panic!("Expected a single UpdateFile hunk"),
@@ -798,6 +780,7 @@ PATCH"#,
let result = maybe_parse_apply_patch_verified(
&argv,
ParsePatchMode::Legacy,
&AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(),
LOCAL_FS.as_ref(),
/*sandbox*/ None,
@@ -821,16 +804,6 @@ PATCH"#,
new_content: "updated session directory content\n".to_string(),
},
)]),
hunks: vec![Hunk::UpdateFile {
path: PathBuf::from(relative_path),
move_path: None,
chunks: vec![UpdateFileChunk {
change_context: None,
old_lines: vec!["session directory content".to_string()],
new_lines: vec!["updated session directory content".to_string()],
is_end_of_file: false,
}],
}],
patch: argv[1].clone(),
cwd: AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(),
})
@@ -862,6 +835,7 @@ PATCH"#,
let result = maybe_parse_apply_patch_verified(
&argv,
ParsePatchMode::Legacy,
&AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(),
LOCAL_FS.as_ref(),
/*sandbox*/ None,
+18 -17
View File
@@ -27,7 +27,6 @@ pub use streaming_parser::StreamingPatchParser;
use thiserror::Error;
pub use invocation::maybe_parse_apply_patch_verified;
pub use invocation::maybe_parse_apply_patch_verified_with_mode;
pub use standalone_executable::main;
use crate::invocation::ExtractHeredocError;
@@ -137,7 +136,6 @@ pub enum MaybeApplyPatchVerified {
#[derive(Debug, PartialEq)]
pub struct ApplyPatchAction {
changes: HashMap<PathBuf, ApplyPatchFileChange>,
hunks: Vec<Hunk>,
/// The raw patch argument that can be used to apply the patch. i.e., if the
/// original arg was parsed in "lenient" mode with a
@@ -158,10 +156,6 @@ impl ApplyPatchAction {
&self.changes
}
pub fn hunks(&self) -> &[Hunk] {
&self.hunks
}
/// Should be used exclusively for testing. (Not worth the overhead of
/// creating a feature flag for this.)
pub fn new_add_for_test(path: &AbsolutePathBuf, content: String) -> Self {
@@ -177,15 +171,10 @@ impl ApplyPatchAction {
+ {content}
*** End Patch"#,
);
let hunk = Hunk::AddFile {
path: path.to_path_buf(),
contents: content.clone(),
};
let changes = HashMap::from([(path.to_path_buf(), ApplyPatchFileChange::Add { content })]);
#[expect(clippy::expect_used)]
Self {
changes,
hunks: vec![hunk],
cwd: path.parent().expect("path should have parent"),
patch,
}
@@ -195,13 +184,14 @@ impl ApplyPatchAction {
/// Applies the patch and prints the result to stdout/stderr.
pub async fn apply_patch(
patch: &str,
mode: ParsePatchMode,
cwd: &AbsolutePathBuf,
stdout: &mut impl std::io::Write,
stderr: &mut impl std::io::Write,
fs: &dyn ExecutorFileSystem,
sandbox: Option<&FileSystemSandboxContext>,
) -> Result<(), ApplyPatchError> {
let hunks = match parse_patch(patch) {
let hunks = match parser::parse_patch(patch, mode) {
Ok(source) => source.hunks,
Err(e) => {
match &e {
@@ -652,6 +642,7 @@ mod tests {
let mut stderr = Vec::new();
apply_patch(
&patch,
ParsePatchMode::Legacy,
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
&mut stdout,
&mut stderr,
@@ -712,6 +703,7 @@ mod tests {
apply_patch(
&patch,
ParsePatchMode::Legacy,
&cwd,
&mut stdout,
&mut stderr,
@@ -755,6 +747,7 @@ mod tests {
let mut stderr = Vec::new();
apply_patch(
&patch,
ParsePatchMode::Legacy,
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
&mut stdout,
&mut stderr,
@@ -791,6 +784,7 @@ mod tests {
let mut stderr = Vec::new();
apply_patch(
&patch,
ParsePatchMode::Legacy,
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
&mut stdout,
&mut stderr,
@@ -831,6 +825,7 @@ mod tests {
let mut stderr = Vec::new();
apply_patch(
&patch,
ParsePatchMode::Legacy,
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
&mut stdout,
&mut stderr,
@@ -880,6 +875,7 @@ mod tests {
let mut stderr = Vec::new();
apply_patch(
&patch,
ParsePatchMode::Legacy,
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
&mut stdout,
&mut stderr,
@@ -938,6 +934,7 @@ mod tests {
let mut stderr = Vec::new();
apply_patch(
&patch,
ParsePatchMode::Legacy,
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
&mut stdout,
&mut stderr,
@@ -982,6 +979,7 @@ mod tests {
let mut stderr = Vec::new();
apply_patch(
&patch,
ParsePatchMode::Legacy,
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
&mut stdout,
&mut stderr,
@@ -1025,6 +1023,7 @@ mod tests {
let mut stderr = Vec::new();
apply_patch(
&patch,
ParsePatchMode::Legacy,
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
&mut stdout,
&mut stderr,
@@ -1069,7 +1068,7 @@ mod tests {
+QUX"#,
path.display()
));
let patch = parse_patch(&patch).unwrap();
let patch = parse_patch(&patch, ParsePatchMode::Legacy).unwrap();
let update_file_chunks = match patch.hunks.as_slice() {
[Hunk::UpdateFile { chunks, .. }] => chunks,
@@ -1116,7 +1115,7 @@ mod tests {
path.display()
));
let patch = parse_patch(&patch).unwrap();
let patch = parse_patch(&patch, ParsePatchMode::Legacy).unwrap();
let chunks = match patch.hunks.as_slice() {
[Hunk::UpdateFile { chunks, .. }] => chunks,
_ => panic!("Expected a single UpdateFile hunk"),
@@ -1157,7 +1156,7 @@ mod tests {
path.display()
));
let patch = parse_patch(&patch).unwrap();
let patch = parse_patch(&patch, ParsePatchMode::Legacy).unwrap();
let chunks = match patch.hunks.as_slice() {
[Hunk::UpdateFile { chunks, .. }] => chunks,
_ => panic!("Expected a single UpdateFile hunk"),
@@ -1196,7 +1195,7 @@ mod tests {
path.display()
));
let patch = parse_patch(&patch).unwrap();
let patch = parse_patch(&patch, ParsePatchMode::Legacy).unwrap();
let chunks = match patch.hunks.as_slice() {
[Hunk::UpdateFile { chunks, .. }] => chunks,
_ => panic!("Expected a single UpdateFile hunk"),
@@ -1246,7 +1245,7 @@ mod tests {
let patch = wrap_patch(&patch_body);
// Extract chunks then build the unified diff.
let parsed = parse_patch(&patch).unwrap();
let parsed = parse_patch(&patch, ParsePatchMode::Legacy).unwrap();
let chunks = match parsed.hunks.as_slice() {
[Hunk::UpdateFile { chunks, .. }] => chunks,
_ => panic!("Expected a single UpdateFile hunk"),
@@ -1281,6 +1280,7 @@ mod tests {
let mut stderr = Vec::new();
apply_patch(
&patch,
ParsePatchMode::Legacy,
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
&mut stdout,
&mut stderr,
@@ -1321,6 +1321,7 @@ g
let mut stderr = Vec::new();
let result = apply_patch(
&patch,
ParsePatchMode::Legacy,
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
&mut stdout,
&mut stderr,
+16 -9
View File
@@ -129,16 +129,17 @@ pub enum ParsePatchMode {
Streaming,
}
pub fn parse_patch(patch: &str) -> Result<ApplyPatchArgs, ParseError> {
parse_patch_with_mode(patch, ParsePatchMode::Legacy)
}
pub fn parse_patch_with_mode(
patch: &str,
mode: ParsePatchMode,
) -> Result<ApplyPatchArgs, ParseError> {
pub fn parse_patch(patch: &str, mode: ParsePatchMode) -> Result<ApplyPatchArgs, ParseError> {
if mode == ParsePatchMode::Streaming {
return crate::streaming_parser::parse_patch(patch);
let patch = normalize_patch_text(patch)?;
let mut parser = crate::streaming_parser::StreamingPatchParser::default();
parser.push_delta(&patch)?;
let hunks = parser.finish()?;
return Ok(ApplyPatchArgs {
hunks,
patch,
workdir: None,
});
}
let mode = if PARSE_IN_STRICT_MODE {
@@ -149,6 +150,12 @@ pub fn parse_patch_with_mode(
parse_patch_text(patch, mode)
}
fn normalize_patch_text(patch: &str) -> Result<String, ParseError> {
let lines: Vec<&str> = patch.trim().lines().collect();
let (patch_lines, _) = check_patch_boundaries_lenient(&lines)?;
Ok(patch_lines.join("\n"))
}
enum ParseMode {
/// Parse the patch text argument as is.
Strict,
@@ -67,6 +67,7 @@ pub fn run_main() -> i32 {
};
match runtime.block_on(crate::apply_patch(
&patch_arg,
crate::ParsePatchMode::Legacy,
&cwd,
&mut stdout,
&mut stderr,
@@ -1,6 +1,5 @@
use std::path::PathBuf;
use crate::ApplyPatchArgs;
use crate::parser::ADD_FILE_MARKER;
use crate::parser::BEGIN_PATCH_MARKER;
use crate::parser::CHANGE_CONTEXT_MARKER;
@@ -17,67 +16,6 @@ use crate::parser::UpdateFileChunk;
use Hunk::*;
use ParseError::*;
pub fn parse_patch(patch: &str) -> Result<ApplyPatchArgs, ParseError> {
let patch = normalize_patch_text(patch)?;
let mut parser = StreamingPatchParser::default();
parser.push_delta(&patch)?;
let hunks = parser.finish()?;
Ok(ApplyPatchArgs {
patch,
hunks,
workdir: None,
})
}
fn normalize_patch_text(patch: &str) -> Result<String, ParseError> {
let lines: Vec<&str> = patch.trim().lines().collect();
let patch_lines = match check_patch_boundaries(&lines) {
Ok(lines) => lines,
Err(original_parse_error) => match lines.as_slice() {
[first, .., last]
if matches!(*first, "<<EOF" | "<<'EOF'" | "<<\"EOF\"")
&& last.ends_with("EOF")
&& lines.len() >= 4 =>
{
let inner_lines = &lines[1..lines.len() - 1];
check_patch_boundaries(inner_lines)?
}
_ => return Err(original_parse_error),
},
};
Ok(patch_lines.join("\n"))
}
fn check_patch_boundaries<'a>(lines: &'a [&'a str]) -> Result<&'a [&'a str], ParseError> {
let (first_line, last_line) = match lines {
[] => (None, None),
[first] => (Some(first), Some(first)),
[first, .., last] => (Some(first), Some(last)),
};
check_start_and_end_lines(first_line, last_line)?;
Ok(lines)
}
fn check_start_and_end_lines(
first_line: Option<&&str>,
last_line: Option<&&str>,
) -> Result<(), ParseError> {
let first_line = first_line.map(|line| line.trim());
let last_line = last_line.map(|line| line.trim());
match (first_line, last_line) {
(Some(first), Some(last)) if first == BEGIN_PATCH_MARKER && last == END_PATCH_MARKER => {
Ok(())
}
(Some(first), _) if first != BEGIN_PATCH_MARKER => Err(InvalidPatchError(String::from(
"The first line of the patch must be '*** Begin Patch'",
))),
_ => Err(InvalidPatchError(String::from(
"The last line of the patch must be '*** End Patch'",
))),
}
}
#[derive(Debug, Default, Clone)]
pub struct StreamingPatchParser {
line_buffer: String,
@@ -428,36 +366,6 @@ mod tests {
use std::path::PathBuf;
use super::*;
use crate::parser;
#[test]
fn test_parse_patch_matches_legacy_parser_for_complete_input() {
let patch = "\
*** Begin Patch
*** Add File: added.txt
+hello
*** Update File: changed.txt
@@
-old
+new
*** Delete File: removed.txt
*** End Patch";
assert_eq!(parse_patch(patch), parser::parse_patch(patch));
}
#[test]
fn test_parse_patch_supports_lenient_heredoc_wrapper() {
let patch = "\
<<'EOF'
*** Begin Patch
*** Add File: added.txt
+hello
*** End Patch
EOF";
assert_eq!(parse_patch(patch), parser::parse_patch(patch));
}
#[test]
fn test_streaming_patch_parser_streams_complete_lines_before_end_patch() {
+1
View File
@@ -116,6 +116,7 @@ pub fn arg0_dispatch() -> Option<Arg0PathEntryGuard> {
};
match runtime.block_on(codex_apply_patch::apply_patch(
&patch_arg,
codex_apply_patch::ParsePatchMode::Legacy,
&cwd,
&mut stdout,
&mut stderr,
+2 -2
View File
@@ -446,7 +446,7 @@ struct AppServerCommand {
#[derive(Debug, Parser)]
struct ExecServerCommand {
/// Transport endpoint URL. Supported values: `ws://IP:PORT` (default).
/// Transport endpoint URL. Supported values: `ws://IP:PORT` (default), `stdio`, `stdio://`.
#[arg(
long = "listen",
value_name = "URL",
@@ -1388,7 +1388,7 @@ async fn run_debug_prompt_input_command(
});
}
let prompt_input = codex_core::build_prompt_input(config, input).await?;
let prompt_input = codex_core::build_prompt_input(config, input, /*state_db*/ None).await?;
println!("{}", serde_json::to_string_pretty(&prompt_input)?);
Ok(())
+4
View File
@@ -104,6 +104,8 @@ pub struct TuiGlobalKeymap {
pub toggle_shortcuts: Option<KeybindingsSpec>,
/// Toggle Vim mode for the composer input.
pub toggle_vim_mode: Option<KeybindingsSpec>,
/// Toggle Fast mode.
pub toggle_fast_mode: Option<KeybindingsSpec>,
}
/// Chat context keybindings.
@@ -169,6 +171,8 @@ pub struct TuiEditorKeymap {
pub delete_forward_word: Option<KeybindingsSpec>,
/// Kill text from cursor to line start.
pub kill_line_start: Option<KeybindingsSpec>,
/// Kill the current line.
pub kill_whole_line: Option<KeybindingsSpec>,
/// Kill text from cursor to line end.
pub kill_line_end: Option<KeybindingsSpec>,
/// Yank the kill buffer.
+2
View File
@@ -27,6 +27,7 @@ pub use codex_core::ForkSnapshot;
pub use codex_core::McpManager;
pub use codex_core::NewThread;
pub use codex_core::StartThreadOptions;
pub use codex_core::StateDbHandle;
pub use codex_core::ThreadManager;
pub use codex_core::ThreadShutdownReport;
pub use codex_core::config::Config;
@@ -37,6 +38,7 @@ pub use codex_core::config::Permissions;
pub use codex_core::config::TerminalResizeReflowConfig;
pub use codex_core::config::ThreadStoreConfig;
pub use codex_core::config::find_codex_home;
pub use codex_core::init_state_db;
pub use codex_core::skills::SkillsManager;
pub use codex_core::thread_store_from_config;
pub use codex_exec_server::EnvironmentManager;
+20
View File
@@ -2444,6 +2444,7 @@
"insert_newline": null,
"kill_line_end": null,
"kill_line_start": null,
"kill_whole_line": null,
"move_down": null,
"move_left": null,
"move_line_end": null,
@@ -2461,6 +2462,7 @@
"open_transcript": null,
"queue": null,
"submit": null,
"toggle_fast_mode": null,
"toggle_shortcuts": null,
"toggle_vim_mode": null
},
@@ -2814,6 +2816,14 @@
],
"description": "Kill text from cursor to line start."
},
"kill_whole_line": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Kill the current line."
},
"move_down": {
"allOf": [
{
@@ -2941,6 +2951,14 @@
],
"description": "Submit the current composer draft."
},
"toggle_fast_mode": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Toggle Fast mode."
},
"toggle_shortcuts": {
"allOf": [
{
@@ -3021,6 +3039,7 @@
"insert_newline": null,
"kill_line_end": null,
"kill_line_start": null,
"kill_whole_line": null,
"move_down": null,
"move_left": null,
"move_line_end": null,
@@ -3045,6 +3064,7 @@
"open_transcript": null,
"queue": null,
"submit": null,
"toggle_fast_mode": null,
"toggle_shortcuts": null,
"toggle_vim_mode": null
}
+2 -2
View File
@@ -29,7 +29,6 @@ use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::user_input::UserInput;
use codex_rollout::state_db;
use codex_state::DirectionalThreadSpawnEdgeStatus;
use codex_thread_store::ReadThreadParams;
use serde::Serialize;
@@ -526,6 +525,7 @@ impl AgentControl {
let _ = config.features.disable(Feature::Collab);
}
let state = self.upgrade()?;
let state_db_ctx = state.state_db();
let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?;
let (session_source, agent_metadata) = match session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
@@ -536,7 +536,7 @@ impl AgentControl {
agent_nickname: _,
}) => {
let (resumed_agent_nickname, resumed_agent_role) =
if let Some(state_db_ctx) = state_db::get_state_db(&config).await {
if let Some(state_db_ctx) = state_db_ctx.as_ref() {
match state_db_ctx.get_thread(thread_id).await {
Ok(Some(metadata)) => (metadata.agent_nickname, metadata.agent_role),
Ok(None) | Err(_) => (None, None),
+15 -3
View File
@@ -1,5 +1,6 @@
use super::*;
use crate::CodexThread;
use crate::StateDbHandle;
use crate::ThreadManager;
use crate::agent::agent_status_from_event;
use crate::config::AgentRoleConfig;
@@ -7,6 +8,7 @@ use crate::config::Config;
use crate::config::ConfigBuilder;
use crate::context::ContextualUserFragment;
use crate::context::SubagentNotification;
use crate::init_state_db;
use assert_matches::assert_matches;
use codex_features::Feature;
use codex_login::CodexAuth;
@@ -84,6 +86,7 @@ fn spawn_agent_call(call_id: &str) -> ResponseItem {
struct AgentControlHarness {
_home: TempDir,
config: Config,
state_db: Option<StateDbHandle>,
manager: ThreadManager,
control: AgentControl,
}
@@ -91,16 +94,19 @@ struct AgentControlHarness {
impl AgentControlHarness {
async fn new() -> Self {
let (home, config) = test_config().await;
let manager = ThreadManager::with_models_provider_and_home_for_tests(
let state_db = init_state_db(&config).await;
let manager = ThreadManager::with_models_provider_home_and_state_for_tests(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
state_db.clone(),
);
let control = manager.agent_control();
Self {
_home: home,
config,
state_db,
manager,
control,
}
@@ -1537,16 +1543,19 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() {
.features
.enable(Feature::Sqlite)
.expect("test config should allow sqlite");
let manager = ThreadManager::with_models_provider_and_home_for_tests(
let state_db = init_state_db(&config).await;
let manager = ThreadManager::with_models_provider_home_and_state_for_tests(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
state_db.clone(),
);
let control = manager.agent_control();
let harness = AgentControlHarness {
_home: home,
config,
state_db,
manager,
control,
};
@@ -1695,7 +1704,10 @@ async fn resume_agent_from_rollout_reads_archived_rollout_path() {
.shutdown_live_agent(child_thread_id)
.await
.expect("child shutdown should succeed");
let store = LocalThreadStore::new(LocalThreadStoreConfig::from_config(&harness.config));
let store = LocalThreadStore::new(
LocalThreadStoreConfig::from_config(&harness.config),
harness.state_db.clone(),
);
store
.archive_thread(ArchiveThreadParams {
thread_id: child_thread_id,
+1 -4
View File
@@ -30,7 +30,6 @@ use tokio::time::timeout;
use tokio_util::sync::CancellationToken;
use crate::config::Config;
use crate::environment_selection::ResolvedTurnEnvironments;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::new_guardian_review_id;
use crate::guardian::routes_approval_to_guardian;
@@ -94,9 +93,7 @@ pub(crate) async fn run_codex_thread_interactive(
inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)),
parent_rollout_thread_trace: codex_rollout_trace::ThreadTraceContext::disabled(),
parent_trace: None,
environment_selections: ResolvedTurnEnvironments {
turn_environments: parent_ctx.environments.clone(),
},
environment_selections: parent_ctx.environments.clone(),
analytics_events_client: Some(parent_session.services.analytics_events_client.clone()),
thread_store: Arc::clone(&parent_session.services.thread_store),
}))
@@ -176,7 +176,9 @@ impl EnvironmentContext {
pub(crate) fn from_turn_context(turn_context: &TurnContext) -> Self {
Self::new(
EnvironmentContextEnvironment::from_turn_environments(&turn_context.environments),
EnvironmentContextEnvironment::from_turn_environments(
&turn_context.environments.turn_environments,
),
turn_context.current_date.clone(),
turn_context.timezone.clone(),
Self::network_from_turn_context(turn_context),
+5 -5
View File
@@ -24,7 +24,7 @@ pub(crate) fn default_thread_environment_selections(
.collect()
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Default)]
pub(crate) struct ResolvedTurnEnvironments {
pub(crate) turn_environments: Vec<TurnEnvironment>,
}
@@ -37,17 +37,17 @@ impl ResolvedTurnEnvironments {
.collect()
}
pub(crate) fn primary_turn_environment(&self) -> Option<&TurnEnvironment> {
pub(crate) fn primary(&self) -> Option<&TurnEnvironment> {
self.turn_environments.first()
}
pub(crate) fn primary_environment(&self) -> Option<Arc<codex_exec_server::Environment>> {
self.primary_turn_environment()
self.primary()
.map(|environment| Arc::clone(&environment.environment))
}
pub(crate) fn primary_filesystem(&self) -> Option<Arc<dyn ExecutorFileSystem>> {
self.primary_turn_environment()
self.primary()
.map(|environment| environment.environment.get_filesystem())
}
}
@@ -171,7 +171,7 @@ mod tests {
assert_eq!(
resolved
.primary_turn_environment()
.primary()
.expect("primary environment")
.environment_id,
"local"
+1 -1
View File
@@ -145,7 +145,7 @@ pub(crate) mod shell_snapshot;
pub mod spawn;
pub(crate) mod state_db_bridge;
pub use state_db_bridge::StateDbHandle;
pub use state_db_bridge::get_state_db;
pub use state_db_bridge::init_state_db;
mod thread_rollout_truncation;
mod tools;
pub(crate) mod turn_diff_tracker;
+16 -7
View File
@@ -1,6 +1,7 @@
use crate::config::edit::ConfigEditsBuilder;
use codex_config::config_toml::ConfigToml;
use codex_protocol::config_types::Personality;
use codex_rollout::state_db::StateDbHandle;
use codex_thread_store::ListThreadsParams;
use codex_thread_store::LocalThreadStore;
use codex_thread_store::LocalThreadStoreConfig;
@@ -24,6 +25,7 @@ pub enum PersonalityMigrationStatus {
pub async fn maybe_migrate_personality(
codex_home: &Path,
config_toml: &ConfigToml,
state_db: Option<StateDbHandle>,
) -> io::Result<PersonalityMigrationStatus> {
let marker_path = codex_home.join(PERSONALITY_MIGRATION_FILENAME);
if tokio::fs::try_exists(&marker_path).await? {
@@ -43,7 +45,7 @@ pub async fn maybe_migrate_personality(
.or_else(|| config_toml.model_provider.clone())
.unwrap_or_else(|| "openai".to_string());
if !has_recorded_sessions(codex_home, model_provider_id.as_str()).await? {
if !has_recorded_sessions(codex_home, model_provider_id.as_str(), state_db).await? {
create_marker(&marker_path).await?;
return Ok(PersonalityMigrationStatus::SkippedNoSessions);
}
@@ -60,12 +62,19 @@ pub async fn maybe_migrate_personality(
Ok(PersonalityMigrationStatus::Applied)
}
async fn has_recorded_sessions(codex_home: &Path, default_provider: &str) -> io::Result<bool> {
let store = LocalThreadStore::new(LocalThreadStoreConfig {
codex_home: codex_home.to_path_buf(),
sqlite_home: codex_home.to_path_buf(),
default_model_provider_id: default_provider.to_string(),
});
async fn has_recorded_sessions(
codex_home: &Path,
default_provider: &str,
state_db: Option<StateDbHandle>,
) -> io::Result<bool> {
let store = LocalThreadStore::new(
LocalThreadStoreConfig {
codex_home: codex_home.to_path_buf(),
sqlite_home: codex_home.to_path_buf(),
default_model_provider_id: default_provider.to_string(),
},
state_db,
);
if has_threads(&store, /*archived*/ false).await? {
return Ok(true);
}
@@ -87,7 +87,7 @@ async fn applies_when_sessions_exist_and_no_personality() -> io::Result<()> {
write_session_with_user_event(temp.path()).await?;
let config_toml = ConfigToml::default();
let status = maybe_migrate_personality(temp.path(), &config_toml).await?;
let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?;
assert_eq!(status, PersonalityMigrationStatus::Applied);
assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists());
@@ -103,7 +103,7 @@ async fn applies_when_only_archived_sessions_exist_and_no_personality() -> io::R
write_archived_session_with_user_event(temp.path()).await?;
let config_toml = ConfigToml::default();
let status = maybe_migrate_personality(temp.path(), &config_toml).await?;
let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?;
assert_eq!(status, PersonalityMigrationStatus::Applied);
assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists());
@@ -119,7 +119,7 @@ async fn skips_when_marker_exists() -> io::Result<()> {
create_marker(&temp.path().join(PERSONALITY_MIGRATION_FILENAME)).await?;
let config_toml = ConfigToml::default();
let status = maybe_migrate_personality(temp.path(), &config_toml).await?;
let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?;
assert_eq!(status, PersonalityMigrationStatus::SkippedMarker);
assert!(!temp.path().join("config.toml").exists());
@@ -136,7 +136,7 @@ async fn skips_when_personality_explicit() -> io::Result<()> {
.map_err(|err| io::Error::other(format!("failed to write config: {err}")))?;
let config_toml = read_config_toml(temp.path()).await?;
let status = maybe_migrate_personality(temp.path(), &config_toml).await?;
let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?;
assert_eq!(
status,
@@ -153,7 +153,7 @@ async fn skips_when_personality_explicit() -> io::Result<()> {
async fn skips_when_no_sessions() -> io::Result<()> {
let temp = TempDir::new()?;
let config_toml = ConfigToml::default();
let status = maybe_migrate_personality(temp.path(), &config_toml).await?;
let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?;
assert_eq!(status, PersonalityMigrationStatus::SkippedNoSessions);
assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists());
+5 -1
View File
@@ -16,6 +16,7 @@ use crate::config::Config;
use crate::session::session::Session;
use crate::session::turn::build_prompt;
use crate::session::turn::built_tools;
use crate::state_db_bridge::StateDbHandle;
use crate::thread_manager::ThreadManager;
use crate::thread_manager::thread_store_from_config;
@@ -24,6 +25,7 @@ use crate::thread_manager::thread_store_from_config;
pub async fn build_prompt_input(
mut config: Config,
input: Vec<UserInput>,
state_db: Option<StateDbHandle>,
) -> CodexResult<Vec<ResponseItem>> {
config.ephemeral = true;
@@ -35,13 +37,15 @@ pub async fn build_prompt_input(
config.codex_linux_sandbox_exe.clone(),
)?;
let thread_store = thread_store_from_config(&config, state_db.clone());
let thread_manager = ThreadManager::new(
&config,
Arc::clone(&auth_manager),
SessionSource::Exec,
Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
thread_store,
state_db.clone(),
);
let thread = thread_manager.start_thread(config).await?;
+1 -1
View File
@@ -221,7 +221,7 @@ impl Session {
let mcp_servers = with_codex_apps_mcp(mcp_servers, auth.as_ref(), &mcp_config);
let auth_statuses =
compute_auth_statuses(mcp_servers.iter(), store_mode, auth.as_ref()).await;
let mcp_runtime_environment = match turn_context.primary_environment() {
let mcp_runtime_environment = match turn_context.environments.primary() {
Some(turn_environment) => McpRuntimeEnvironment::new(
Arc::clone(&turn_environment.environment),
turn_environment.cwd.to_path_buf(),
+11 -1
View File
@@ -355,6 +355,7 @@ use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::user_input::UserInput;
use codex_tools::ToolEnvironmentMode;
use codex_tools::ToolsConfig;
use codex_tools::ToolsConfigParams;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -555,7 +556,15 @@ impl Codex {
};
match thread_id {
Some(thread_id) => {
let state_db_ctx = state_db::get_state_db(&config).await;
let state_db_ctx = if config.ephemeral {
None
} else if let Some(local_store) =
thread_store.as_any().downcast_ref::<LocalThreadStore>()
{
local_store.state_db().await
} else {
None
};
state_db::get_dynamic_tools(state_db_ctx.as_deref(), thread_id, "codex_spawn")
.await
}
@@ -1297,6 +1306,7 @@ impl Session {
self.services.user_shell.as_ref().clone(),
self.services.shell_snapshot_tx.clone(),
self.services.session_telemetry.clone(),
self.services.state_db.clone(),
);
}
+1 -1
View File
@@ -52,7 +52,7 @@ pub(super) async fn spawn_review_thread(
)
.with_web_search_config(/*web_search_config*/ None)
.with_allow_login_shell(config.permissions.allow_login_shell)
.with_has_environment(parent_turn_context.tools_config.has_environment)
.with_environment_mode(parent_turn_context.tools_config.environment_mode)
.with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled)
.with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone())
.with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata)
+2 -1
View File
@@ -726,6 +726,7 @@ impl Session {
session_configuration.cwd.clone(),
&mut default_shell,
session_telemetry.clone(),
state_db_ctx.clone(),
)
}
} else {
@@ -968,7 +969,7 @@ impl Session {
"unknown stored MCP environment id",
))
})?
.primary_turn_environment()
.primary()
.cloned();
let mcp_runtime_environment = match turn_environment {
Some(turn_environment) => McpRuntimeEnvironment::new(
+101 -45
View File
@@ -2944,13 +2944,15 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
fn turn_environments_for_tests(
environment: &Arc<codex_exec_server::Environment>,
cwd: &codex_utils_absolute_path::AbsolutePathBuf,
) -> Vec<TurnEnvironment> {
vec![TurnEnvironment {
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
environment: Arc::clone(environment),
cwd: cwd.clone(),
shell: "bash".to_string(),
}]
) -> crate::environment_selection::ResolvedTurnEnvironments {
crate::environment_selection::ResolvedTurnEnvironments {
turn_environments: vec![TurnEnvironment {
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
environment: Arc::clone(environment),
cwd: cwd.clone(),
shell: "bash".to_string(),
}],
}
}
#[tokio::test]
@@ -3399,7 +3401,7 @@ async fn absolute_cwd_update_with_turn_environment_is_allowed() {
assert_eq!(turn_context.cwd, absolute_cwd);
assert_eq!(turn_context.config.cwd, absolute_cwd);
assert_eq!(turn_context.environments.len(), 1);
assert_eq!(turn_context.environments.turn_environments.len(), 1);
}
#[tokio::test]
@@ -3490,6 +3492,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
/*analytics_events_client*/ None,
Arc::new(codex_thread_store::LocalThreadStore::new(
codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()),
/*state_db*/ None,
)),
codex_rollout_trace::ThreadTraceContext::disabled(),
)
@@ -3637,6 +3640,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
live_thread: None,
thread_store: Arc::new(codex_thread_store::LocalThreadStore::new(
codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()),
/*state_db*/ None,
)),
model_client: ModelClient::new(
Some(auth_manager.clone()),
@@ -3808,6 +3812,7 @@ async fn make_session_with_config_and_rx(
/*analytics_events_client*/ None,
Arc::new(codex_thread_store::LocalThreadStore::new(
codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()),
/*state_db*/ None,
)),
codex_rollout_trace::ThreadTraceContext::disabled(),
)
@@ -4418,15 +4423,16 @@ async fn turn_environments_set_primary_environment() {
.expect("turn should start");
let turn_environments = &turn_context.environments;
assert_eq!(turn_environments.len(), 1);
assert_eq!(turn_environments.turn_environments.len(), 1);
let turn_environment = turn_context
.primary_environment()
.environments
.primary()
.expect("primary environment should be set");
assert!(std::sync::Arc::ptr_eq(
&turn_environment.environment,
&turn_environments[0].environment
&turn_environments.turn_environments[0].environment
));
assert!(!turn_context.environments.is_empty());
assert!(!turn_context.environments.turn_environments.is_empty());
assert_eq!(turn_context.cwd.as_path(), selected_cwd.as_path());
assert_eq!(turn_context.config.cwd.as_path(), selected_cwd.as_path());
}
@@ -4449,13 +4455,14 @@ async fn default_turn_overlays_session_cwd_onto_stored_thread_environments() {
let turn_context = session.new_default_turn().await;
let turn_environments = &turn_context.environments;
assert_eq!(turn_environments.len(), 1);
assert_eq!(turn_environments.turn_environments.len(), 1);
let turn_environment = turn_context
.primary_environment()
.environments
.primary()
.expect("primary environment should be set");
assert!(std::sync::Arc::ptr_eq(
&turn_environment.environment,
&turn_environments[0].environment
&turn_environments.turn_environments[0].environment
));
assert_eq!(turn_context.cwd, session_cwd);
assert_eq!(turn_context.config.cwd, session_cwd);
@@ -4473,28 +4480,32 @@ async fn default_turn_honors_empty_stored_thread_environments() {
let turn_context = session.new_default_turn().await;
assert!(turn_context.primary_environment().is_none());
assert!(turn_context.environments.is_empty());
assert!(turn_context.environments.primary().is_none());
assert!(turn_context.environments.turn_environments.is_empty());
assert_eq!(turn_context.cwd, session_cwd);
assert_eq!(turn_context.config.cwd, session_cwd);
assert_eq!(turn_context.environments.len(), 0);
assert_eq!(turn_context.environments.turn_environments.len(), 0);
}
#[tokio::test]
async fn primary_environment_uses_first_turn_environment() {
let (_session, mut turn_context) = make_session_and_context().await;
let first_environment = turn_context.environments[0].clone();
let first_environment = turn_context.environments.turn_environments[0].clone();
let second_cwd = turn_context.cwd.join("second");
turn_context.environments.push(TurnEnvironment {
environment_id: "second".to_string(),
environment: Arc::clone(&first_environment.environment),
cwd: second_cwd.clone(),
shell: first_environment.shell.clone(),
});
turn_context
.environments
.turn_environments
.push(TurnEnvironment {
environment_id: "second".to_string(),
environment: Arc::clone(&first_environment.environment),
cwd: second_cwd.clone(),
shell: first_environment.shell.clone(),
});
assert_eq!(
turn_context
.primary_environment()
.environments
.primary()
.expect("primary environment")
.environment_id,
first_environment.environment_id
@@ -4502,14 +4513,18 @@ async fn primary_environment_uses_first_turn_environment() {
assert_eq!(
turn_context
.environments
.turn_environments
.iter()
.find(|environment| environment.environment_id == "second")
.expect("second environment")
.cwd,
second_cwd
);
assert_eq!(turn_context.environments.len(), 2);
assert_eq!(turn_context.environments[1].cwd, second_cwd);
assert_eq!(turn_context.environments.turn_environments.len(), 2);
assert_eq!(
turn_context.environments.turn_environments[1].cwd,
second_cwd
);
}
#[tokio::test]
@@ -4527,8 +4542,8 @@ async fn empty_turn_environments_clear_primary_environment() {
.await
.expect("turn should start");
assert!(turn_context.primary_environment().is_none());
assert!(turn_context.environments.is_empty());
assert!(turn_context.environments.primary().is_none());
assert!(turn_context.environments.turn_environments.is_empty());
assert_eq!(turn_context.cwd, session.get_config().await.cwd);
assert_eq!(turn_context.config.cwd, session.get_config().await.cwd);
}
@@ -4986,10 +5001,44 @@ async fn make_session_and_context_with_auth_and_config_and_rx<F>(
where
F: FnOnce(&mut Config),
{
let (tx_event, rx_event) = async_channel::unbounded();
let codex_home = tempfile::tempdir().expect("create temp dir");
let mut config = build_test_config(codex_home.path()).await;
make_session_and_context_with_auth_config_home_and_rx(
auth,
dynamic_tools,
codex_home.path(),
configure_config,
)
.await
}
async fn make_session_and_context_with_auth_config_home_and_rx<F>(
auth: CodexAuth,
dynamic_tools: Vec<DynamicToolSpec>,
codex_home: &Path,
configure_config: F,
) -> (
Arc<Session>,
Arc<TurnContext>,
async_channel::Receiver<Event>,
)
where
F: FnOnce(&mut Config),
{
let (tx_event, rx_event) = async_channel::unbounded();
let mut config = build_test_config(codex_home).await;
configure_config(&mut config);
let state_db = if config.features.enabled(Feature::Goals) {
Some(
codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.model_provider_id.clone(),
)
.await
.expect("goal tests should initialize sqlite state db"),
)
} else {
None
};
let config = Arc::new(config);
let conversation_id = ThreadId::default();
let auth_manager = AuthManager::from_auth_for_testing(auth);
@@ -5115,10 +5164,11 @@ where
agent_control,
network_proxy: None,
network_approval: Arc::clone(&network_approval),
state_db: None,
state_db: state_db.clone(),
live_thread: None,
thread_store: Arc::new(codex_thread_store::LocalThreadStore::new(
codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()),
state_db,
)),
model_client: ModelClient::new(
Some(Arc::clone(&auth_manager)),
@@ -5213,10 +5263,13 @@ async fn make_goal_session_and_context_with_rx() -> (
Arc<Session>,
Arc<TurnContext>,
async_channel::Receiver<Event>,
tempfile::TempDir,
) {
let (session, turn_context, rx) = make_session_and_context_with_auth_and_config_and_rx(
let codex_home = tempfile::tempdir().expect("create temp dir");
let (session, turn_context, rx) = make_session_and_context_with_auth_config_home_and_rx(
CodexAuth::from_api_key("Test API Key"),
Vec::new(),
codex_home.path(),
|config| {
config
.features
@@ -5226,14 +5279,14 @@ async fn make_goal_session_and_context_with_rx() -> (
)
.await;
upsert_goal_test_thread(session.as_ref()).await;
(session, turn_context, rx)
(session, turn_context, rx, codex_home)
}
async fn upsert_goal_test_thread(session: &Session) {
let config = session.get_config().await;
let state_db = goal_test_state_db(session)
.await
.expect("goal test state db should initialize");
let state_db = session
.state_db()
.expect("goal test session should have a state db");
let mut builder = codex_state::ThreadMetadataBuilder::new(
session.conversation_id,
config
@@ -7050,7 +7103,7 @@ async fn abort_empty_active_turn_preserves_pending_input() {
#[tokio::test]
async fn interrupt_accounts_active_goal_before_pausing() -> anyhow::Result<()> {
let (sess, tc, _rx) = make_goal_session_and_context_with_rx().await;
let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
sess.set_thread_goal(
tc.as_ref(),
SetGoalRequest {
@@ -7304,6 +7357,9 @@ fn post_goal_token_usage() -> TokenUsage {
}
async fn goal_test_state_db(sess: &Session) -> anyhow::Result<crate::StateDbHandle> {
if let Some(state_db) = sess.state_db() {
return Ok(state_db);
}
let config = sess.get_config().await;
codex_state::StateRuntime::init(config.sqlite_home.clone(), config.model_provider_id.clone())
.await
@@ -7311,7 +7367,7 @@ async fn goal_test_state_db(sess: &Session) -> anyhow::Result<crate::StateDbHand
#[tokio::test]
async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyhow::Result<()> {
let (sess, tc, rx) = make_goal_session_and_context_with_rx().await;
let (sess, tc, rx, _codex_home) = make_goal_session_and_context_with_rx().await;
sess.set_thread_goal(
tc.as_ref(),
SetGoalRequest {
@@ -7411,7 +7467,7 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn external_goal_mutation_accounts_active_turn_before_status_change() -> anyhow::Result<()> {
let (sess, tc, _rx) = make_goal_session_and_context_with_rx().await;
let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
sess.set_thread_goal(
tc.as_ref(),
SetGoalRequest {
@@ -7473,7 +7529,7 @@ async fn external_goal_mutation_accounts_active_turn_before_status_change() -> a
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn external_active_goal_set_marks_current_turn_for_accounting() -> anyhow::Result<()> {
let (sess, tc, _rx) = make_goal_session_and_context_with_rx().await;
let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
sess.spawn_task(
Arc::clone(&tc),
Vec::new(),
@@ -8137,7 +8193,7 @@ async fn sample_rollout(
#[tokio::test]
async fn create_goal_tool_rejects_existing_goal() {
let (session, turn_context, _rx) = make_goal_session_and_context_with_rx().await;
let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let handler = GoalHandler;
@@ -8199,7 +8255,7 @@ async fn create_goal_tool_rejects_existing_goal() {
#[tokio::test]
async fn update_goal_tool_rejects_pausing_goal() {
let (session, turn_context, _rx) = make_goal_session_and_context_with_rx().await;
let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let handler = GoalHandler;
@@ -8259,7 +8315,7 @@ async fn update_goal_tool_rejects_pausing_goal() {
#[tokio::test]
async fn update_goal_tool_marks_goal_complete() {
let (session, turn_context, _rx) = make_goal_session_and_context_with_rx().await;
let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let handler = GoalHandler;
@@ -731,6 +731,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
let skills_watcher = Arc::new(SkillsWatcher::noop());
let thread_store = Arc::new(codex_thread_store::LocalThreadStore::new(
codex_thread_store::LocalThreadStoreConfig::from_config(&config),
/*state_db*/ None,
));
let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs {
+11 -13
View File
@@ -1,5 +1,6 @@
use super::*;
use crate::config::GhostSnapshotConfig;
use crate::environment_selection::ResolvedTurnEnvironments;
use codex_model_provider::SharedModelProvider;
use codex_model_provider::create_model_provider;
use codex_protocol::models::AdditionalPermissionProfile;
@@ -60,7 +61,7 @@ pub(crate) struct TurnContext {
pub(crate) reasoning_effort: Option<ReasoningEffortConfig>,
pub(crate) reasoning_summary: ReasoningSummaryConfig,
pub(crate) session_source: SessionSource,
pub(crate) environments: Vec<TurnEnvironment>,
pub(crate) environments: ResolvedTurnEnvironments,
/// The session's absolute working directory. All relative paths provided
/// by the model as well as sandbox policies are resolved against this path
/// instead of `std::env::current_dir()`.
@@ -106,10 +107,6 @@ impl TurnContext {
self.permission_profile.network_sandbox_policy()
}
pub(crate) fn primary_environment(&self) -> Option<&TurnEnvironment> {
self.environments.first()
}
pub(crate) fn sandbox_policy(&self) -> SandboxPolicy {
let file_system_sandbox_policy = self.file_system_sandbox_policy();
let network_sandbox_policy = self.network_sandbox_policy();
@@ -198,7 +195,7 @@ impl TurnContext {
.with_unified_exec_shell_mode(self.tools_config.unified_exec_shell_mode.clone())
.with_web_search_config(self.tools_config.web_search_config.clone())
.with_allow_login_shell(self.tools_config.allow_login_shell)
.with_has_environment(self.tools_config.has_environment)
.with_environment_mode(self.tools_config.environment_mode)
.with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled)
.with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone())
.with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata)
@@ -435,7 +432,7 @@ impl Session {
model_info: ModelInfo,
models_manager: &SharedModelsManager,
network: Option<NetworkProxy>,
environments: Vec<TurnEnvironment>,
environments: ResolvedTurnEnvironments,
cwd: AbsolutePathBuf,
sub_id: String,
skills_outcome: Arc<SkillLoadOutcome>,
@@ -476,7 +473,9 @@ impl Session {
)
.with_web_search_config(per_turn_config.web_search_config.clone())
.with_allow_login_shell(per_turn_config.permissions.allow_login_shell)
.with_has_environment(!environments.is_empty())
.with_environment_mode(ToolEnvironmentMode::from_count(
environments.turn_environments.len(),
))
.with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled)
.with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone())
.with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata)
@@ -647,12 +646,11 @@ impl Session {
fn resolve_turn_environments(
&self,
environments: &[TurnEnvironmentSelection],
) -> CodexResult<Vec<TurnEnvironment>> {
) -> CodexResult<ResolvedTurnEnvironments> {
crate::environment_selection::resolve_environment_selections(
self.services.environment_manager.as_ref(),
environments,
)
.map(|resolved| resolved.turn_environments)
}
async fn new_turn_from_configuration(
@@ -660,9 +658,9 @@ impl Session {
sub_id: String,
session_configuration: SessionConfiguration,
final_output_json_schema: Option<Option<Value>>,
turn_environments: Vec<TurnEnvironment>,
turn_environments: ResolvedTurnEnvironments,
) -> Arc<TurnContext> {
let primary_turn_environment = turn_environments.first();
let primary_turn_environment = turn_environments.primary();
let cwd = primary_turn_environment
.map(|turn_environment| turn_environment.cwd.clone())
.unwrap_or_else(|| session_configuration.cwd.clone());
@@ -769,7 +767,7 @@ impl Session {
Ok(turn_environments) => turn_environments,
Err(err) => {
warn!("failed to resolve stored session environments: {err}");
Vec::new()
ResolvedTurnEnvironments::default()
}
};
+22 -6
View File
@@ -5,6 +5,7 @@ use std::sync::Arc;
use std::time::Duration;
use std::time::SystemTime;
use crate::StateDbHandle;
use crate::rollout::list::find_thread_path_by_id_str;
use crate::shell::Shell;
use crate::shell::ShellType;
@@ -41,6 +42,7 @@ impl ShellSnapshot {
session_cwd: AbsolutePathBuf,
shell: &mut Shell,
session_telemetry: SessionTelemetry,
state_db: Option<StateDbHandle>,
) -> watch::Sender<Option<Arc<ShellSnapshot>>> {
let (shell_snapshot_tx, shell_snapshot_rx) = watch::channel(None);
shell.shell_snapshot = shell_snapshot_rx;
@@ -52,6 +54,7 @@ impl ShellSnapshot {
shell.clone(),
shell_snapshot_tx.clone(),
session_telemetry,
state_db,
);
shell_snapshot_tx
@@ -64,6 +67,7 @@ impl ShellSnapshot {
shell: Shell,
shell_snapshot_tx: watch::Sender<Option<Arc<ShellSnapshot>>>,
session_telemetry: SessionTelemetry,
state_db: Option<StateDbHandle>,
) {
Self::spawn_snapshot_task(
codex_home,
@@ -72,6 +76,7 @@ impl ShellSnapshot {
shell,
shell_snapshot_tx,
session_telemetry,
state_db,
);
}
@@ -82,15 +87,21 @@ impl ShellSnapshot {
snapshot_shell: Shell,
shell_snapshot_tx: watch::Sender<Option<Arc<ShellSnapshot>>>,
session_telemetry: SessionTelemetry,
state_db: Option<StateDbHandle>,
) {
let snapshot_span = info_span!("shell_snapshot", thread_id = %session_id);
tokio::spawn(
async move {
let timer = session_telemetry.start_timer("codex.shell_snapshot.duration_ms", &[]);
let snapshot =
ShellSnapshot::try_new(&codex_home, session_id, &session_cwd, &snapshot_shell)
.await
.map(Arc::new);
let snapshot = ShellSnapshot::try_new(
&codex_home,
session_id,
&session_cwd,
&snapshot_shell,
state_db,
)
.await
.map(Arc::new);
let success = snapshot.is_ok();
let success_tag = if success { "true" } else { "false" };
let _ = timer.map(|timer| timer.record(&[("success", success_tag)]));
@@ -110,6 +121,7 @@ impl ShellSnapshot {
session_id: ThreadId,
session_cwd: &AbsolutePathBuf,
shell: &Shell,
state_db: Option<StateDbHandle>,
) -> std::result::Result<Self, &'static str> {
// File to store the snapshot
let extension = match shell.shell_type {
@@ -131,7 +143,9 @@ impl ShellSnapshot {
let codex_home = codex_home.clone();
let cleanup_session_id = session_id;
tokio::spawn(async move {
if let Err(err) = cleanup_stale_snapshots(&codex_home, cleanup_session_id).await {
if let Err(err) =
cleanup_stale_snapshots(&codex_home, cleanup_session_id, state_db).await
{
tracing::warn!("Failed to clean up shell snapshots: {err:?}");
}
});
@@ -486,6 +500,7 @@ $envVars | ForEach-Object {
pub async fn cleanup_stale_snapshots(
codex_home: &AbsolutePathBuf,
active_session_id: ThreadId,
state_db: Option<StateDbHandle>,
) -> Result<()> {
let snapshot_dir = codex_home.join(SNAPSHOT_DIR);
@@ -515,7 +530,8 @@ pub async fn cleanup_stale_snapshots(
continue;
}
let rollout_path = find_thread_path_by_id_str(codex_home, session_id).await?;
let rollout_path =
find_thread_path_by_id_str(codex_home, session_id, state_db.as_deref()).await?;
let Some(rollout_path) = rollout_path else {
remove_snapshot_file(&path).await;
continue;
+22 -11
View File
@@ -202,6 +202,7 @@ async fn try_new_creates_and_deletes_snapshot_file() -> Result<()> {
ThreadId::new(),
&dir.path().abs(),
&shell,
/*state_db*/ None,
)
.await
.expect("snapshot should be created");
@@ -227,14 +228,24 @@ async fn try_new_uses_distinct_generation_paths() -> Result<()> {
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
};
let initial_snapshot =
ShellSnapshot::try_new(&dir.path().abs(), session_id, &dir.path().abs(), &shell)
.await
.expect("initial snapshot should be created");
let refreshed_snapshot =
ShellSnapshot::try_new(&dir.path().abs(), session_id, &dir.path().abs(), &shell)
.await
.expect("refreshed snapshot should be created");
let initial_snapshot = ShellSnapshot::try_new(
&dir.path().abs(),
session_id,
&dir.path().abs(),
&shell,
/*state_db*/ None,
)
.await
.expect("initial snapshot should be created");
let refreshed_snapshot = ShellSnapshot::try_new(
&dir.path().abs(),
session_id,
&dir.path().abs(),
&shell,
/*state_db*/ None,
)
.await
.expect("refreshed snapshot should be created");
let initial_path = initial_snapshot.path.clone();
let refreshed_path = refreshed_snapshot.path.clone();
@@ -428,7 +439,7 @@ async fn cleanup_stale_snapshots_removes_orphans_and_keeps_live() -> Result<()>
fs::write(&orphan_snapshot, "orphan").await?;
fs::write(&invalid_snapshot, "invalid").await?;
cleanup_stale_snapshots(&codex_home, ThreadId::new()).await?;
cleanup_stale_snapshots(&codex_home, ThreadId::new(), /*state_db*/ None).await?;
assert_eq!(live_snapshot.exists(), true);
assert_eq!(orphan_snapshot.exists(), false);
@@ -451,7 +462,7 @@ async fn cleanup_stale_snapshots_removes_stale_rollouts() -> Result<()> {
set_file_mtime(&rollout_path, SNAPSHOT_RETENTION + Duration::from_secs(60))?;
cleanup_stale_snapshots(&codex_home, ThreadId::new()).await?;
cleanup_stale_snapshots(&codex_home, ThreadId::new(), /*state_db*/ None).await?;
assert_eq!(stale_snapshot.exists(), false);
Ok(())
@@ -472,7 +483,7 @@ async fn cleanup_stale_snapshots_skips_active_session() -> Result<()> {
set_file_mtime(&rollout_path, SNAPSHOT_RETENTION + Duration::from_secs(60))?;
cleanup_stale_snapshots(&codex_home, active_session).await?;
cleanup_stale_snapshots(&codex_home, active_session, /*state_db*/ None).await?;
assert_eq!(active_snapshot.exists(), true);
Ok(())
+2 -2
View File
@@ -3,6 +3,6 @@ pub use codex_rollout::state_db::StateDbHandle;
use crate::config::Config;
pub async fn get_state_db(config: &Config) -> Option<StateDbHandle> {
rollout_state_db::get_state_db(config).await
pub async fn init_state_db(config: &Config) -> Option<StateDbHandle> {
rollout_state_db::init(config).await
}
+7 -4
View File
@@ -138,8 +138,11 @@ pub(crate) async fn record_completed_response_item(
.await;
}
mark_thread_memory_mode_polluted_if_external_context(sess, turn_context, item).await;
let has_memory_citation =
record_stage1_output_usage_and_detect_memory_citation(turn_context, item).await;
let has_memory_citation = record_stage1_output_usage_and_detect_memory_citation(
sess.services.state_db.as_ref(),
item,
)
.await;
if has_memory_citation {
sess.record_memory_citation_for_turn(&turn_context.sub_id)
.await;
@@ -174,7 +177,7 @@ pub(crate) async fn mark_thread_memory_mode_polluted_if_external_context(
}
async fn record_stage1_output_usage_and_detect_memory_citation(
turn_context: &TurnContext,
state_db_ctx: Option<&state_db::StateDbHandle>,
item: &ResponseItem,
) -> bool {
let Some(raw_text) = raw_assistant_output_text_from_item(item) else {
@@ -190,7 +193,7 @@ async fn record_stage1_output_usage_and_detect_memory_citation(
return true;
}
if let Some(db) = state_db::get_state_db(turn_context.config.as_ref()).await {
if let Some(db) = state_db_ctx {
let _ = db.record_stage1_output_usage(&thread_ids).await;
}
true
+16
View File
@@ -73,6 +73,22 @@ pub fn thread_manager_with_models_provider_and_home(
)
}
pub fn thread_manager_with_models_provider_home_and_state(
auth: CodexAuth,
provider: ModelProviderInfo,
codex_home: PathBuf,
environment_manager: Arc<EnvironmentManager>,
state_db: Option<crate::StateDbHandle>,
) -> ThreadManager {
ThreadManager::with_models_provider_home_and_state_for_tests(
auth,
provider,
codex_home,
environment_manager,
state_db,
)
}
pub async fn start_thread_with_user_shell_override(
thread_manager: &ThreadManager,
config: Config,
+36 -5
View File
@@ -50,6 +50,7 @@ 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_state::DirectionalThreadSpawnEdgeStatus;
use codex_thread_store::InMemoryThreadStore;
use codex_thread_store::LocalThreadStore;
@@ -248,6 +249,7 @@ pub(crate) struct ThreadManagerState {
thread_store: Arc<dyn ThreadStore>,
session_source: SessionSource,
analytics_events_client: Option<AnalyticsEventsClient>,
state_db: Option<StateDbHandle>,
// Captures submitted ops for testing purpose when test mode is enabled.
ops_log: Option<SharedCapturedOps>,
}
@@ -263,10 +265,14 @@ pub fn build_models_manager(
)
}
pub fn thread_store_from_config(config: &Config) -> Arc<dyn ThreadStore> {
pub fn thread_store_from_config(
config: &Config,
state_db: Option<StateDbHandle>,
) -> Arc<dyn ThreadStore> {
match &config.experimental_thread_store {
ThreadStoreConfig::Local => Arc::new(LocalThreadStore::new(
LocalThreadStoreConfig::from_config(config),
state_db,
)),
ThreadStoreConfig::Remote { endpoint } => Arc::new(RemoteThreadStore::new(endpoint)),
ThreadStoreConfig::InMemory { id } => InMemoryThreadStore::for_id(id),
@@ -281,6 +287,7 @@ impl ThreadManager {
environment_manager: Arc<EnvironmentManager>,
analytics_events_client: Option<AnalyticsEventsClient>,
thread_store: Arc<dyn ThreadStore>,
state_db: Option<StateDbHandle>,
) -> Self {
let codex_home = config.codex_home.clone();
let restriction_product = session_source.restriction_product();
@@ -310,6 +317,7 @@ impl ThreadManager {
auth_manager,
session_source,
analytics_events_client,
state_db,
ops_log: should_use_test_thread_manager_behavior()
.then(|| Arc::new(std::sync::Mutex::new(Vec::new()))),
}),
@@ -347,6 +355,22 @@ impl ThreadManager {
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);
@@ -369,12 +393,14 @@ impl ThreadManager {
let skills_watcher = build_skills_watcher(Arc::clone(&skills_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 {
let thread_store: Arc<dyn ThreadStore> = Arc::new(LocalThreadStore::new(
LocalThreadStoreConfig {
codex_home: codex_home.clone(),
sqlite_home: codex_home.clone(),
default_model_provider_id: OPENAI_PROVIDER_ID.to_string(),
}));
},
state_db.clone(),
));
Self {
state: Arc::new(ThreadManagerState {
threads: Arc::new(RwLock::new(HashMap::new())),
@@ -390,6 +416,7 @@ impl ThreadManager {
auth_manager,
session_source: SessionSource::Exec,
analytics_events_client: None,
state_db,
ops_log: should_use_test_thread_manager_behavior()
.then(|| Arc::new(std::sync::Mutex::new(Vec::new()))),
}),
@@ -837,6 +864,10 @@ impl ThreadManager {
}
impl ThreadManagerState {
pub(crate) fn state_db(&self) -> Option<StateDbHandle> {
self.state_db.clone()
}
pub(crate) async fn list_thread_ids(&self) -> Vec<ThreadId> {
self.threads
.read()
@@ -1100,7 +1131,7 @@ impl ThreadManagerState {
}
let environment_selections =
resolve_environment_selections(self.environment_manager.as_ref(), &environments)?;
let watch_registration = match environment_selections.primary_turn_environment() {
let watch_registration = match environment_selections.primary() {
Some(turn_environment) if !turn_environment.environment.is_remote() => {
self.skills_watcher
.register_config(
+39 -14
View File
@@ -1,5 +1,6 @@
use super::*;
use crate::config::test_config;
use crate::init_state_db;
use crate::rollout::RolloutRecorder;
use crate::session::session::SessionSettingsUpdate;
use crate::session::tests::make_session_and_context;
@@ -389,7 +390,8 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
thread_store_from_config(&config, /*state_db*/ None),
/*state_db*/ None,
);
let selected_cwd =
AbsolutePathBuf::try_from(config.cwd.as_path().join("selected")).expect("absolute path");
@@ -444,9 +446,15 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
.new_turn_with_sub_id("resume-turn".to_string(), SessionSettingsUpdate::default())
.await
.expect("build resumed turn context");
assert_eq!(resumed_turn.environments.len(), 1);
assert_eq!(resumed_turn.environments[0].cwd, default_cwd);
assert_ne!(resumed_turn.environments[0].cwd, selected_cwd);
assert_eq!(resumed_turn.environments.turn_environments.len(), 1);
assert_eq!(
resumed_turn.environments.turn_environments[0].cwd,
default_cwd
);
assert_ne!(
resumed_turn.environments.turn_environments[0].cwd,
selected_cwd
);
let forked = manager
.fork_thread(
@@ -465,9 +473,15 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
.new_turn_with_sub_id("fork-turn".to_string(), SessionSettingsUpdate::default())
.await
.expect("build forked turn context");
assert_eq!(forked_turn.environments.len(), 1);
assert_eq!(forked_turn.environments[0].cwd, default_cwd);
assert_ne!(forked_turn.environments[0].cwd, selected_cwd);
assert_eq!(forked_turn.environments.turn_environments.len(), 1);
assert_eq!(
forked_turn.environments.turn_environments[0].cwd,
default_cwd
);
assert_ne!(
forked_turn.environments.turn_environments[0].cwd,
selected_cwd
);
}
#[tokio::test]
@@ -486,7 +500,8 @@ async fn resume_active_thread_from_rollout_returns_running_thread() {
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
thread_store_from_config(&config, /*state_db*/ None),
/*state_db*/ None,
);
let source = manager
@@ -539,7 +554,8 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() {
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
thread_store_from_config(&config, /*state_db*/ None),
/*state_db*/ None,
);
let source = manager
@@ -602,7 +618,8 @@ async fn new_uses_active_provider_for_model_refresh() {
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
thread_store_from_config(&config, /*state_db*/ None),
/*state_db*/ None,
);
let _ = manager.list_models(RefreshStrategy::Online).await;
@@ -807,13 +824,15 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let state_db = init_state_db(&config).await;
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
thread_store_from_config(&config, state_db.clone()),
state_db.clone(),
);
let source = manager
@@ -909,13 +928,15 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let state_db = init_state_db(&config).await;
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
thread_store_from_config(&config, state_db.clone()),
state_db.clone(),
);
let source = manager
@@ -1000,13 +1021,15 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let state_db = init_state_db(&config).await;
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
thread_store_from_config(&config, state_db.clone()),
state_db.clone(),
);
let source = manager
@@ -1136,13 +1159,15 @@ async fn resumed_thread_keeps_paused_goal_paused() -> anyhow::Result<()> {
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let state_db = init_state_db(&config).await;
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
thread_store_from_config(&config, state_db.clone()),
state_db.clone(),
);
let source = manager
@@ -6,7 +6,6 @@ use crate::config::Config;
use crate::function_tool::FunctionCallError;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::session::turn_context::TurnEnvironment;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
@@ -541,12 +540,7 @@ async fn run_agent_job_loop(
"agent_job:{job_id}"
)))),
SpawnAgentOptions {
environments: Some(
turn.environments
.iter()
.map(TurnEnvironment::selection)
.collect(),
),
environments: Some(turn.environments.to_selections()),
..Default::default()
},
)
+17 -14
View File
@@ -256,14 +256,6 @@ fn apply_patch_payload_command(payload: &ToolPayload) -> Option<String> {
}
}
fn apply_patch_mode(turn: &TurnContext) -> ParsePatchMode {
if turn.features.enabled(Feature::ApplyPatchStreamingParser) {
ParsePatchMode::Streaming
} else {
ParsePatchMode::Legacy
}
}
async fn effective_patch_permissions(
session: &Session,
turn: &TurnContext,
@@ -372,7 +364,7 @@ impl ToolHandler for ApplyPatchHandler {
// Avoid building temporary ExecParams/command vectors; derive directly from inputs.
let cwd = turn.cwd.clone();
let command = vec!["apply_patch".to_string(), patch_input.clone()];
let Some(turn_environment) = turn.primary_environment() else {
let Some(turn_environment) = turn.environments.primary() else {
return Err(FunctionCallError::RespondToModel(
"apply_patch is unavailable in this session".to_string(),
));
@@ -382,8 +374,12 @@ impl ToolHandler for ApplyPatchHandler {
.environment
.is_remote()
.then(|| turn.file_system_sandbox_context(/*additional_permissions*/ None));
let parse_mode = apply_patch_mode(turn.as_ref());
match codex_apply_patch::maybe_parse_apply_patch_verified_with_mode(
let parse_mode = if turn.features.enabled(Feature::ApplyPatchStreamingParser) {
ParsePatchMode::Streaming
} else {
ParsePatchMode::Legacy
};
match codex_apply_patch::maybe_parse_apply_patch_verified(
&command,
parse_mode,
&cwd,
@@ -416,6 +412,7 @@ impl ToolHandler for ApplyPatchHandler {
let req = ApplyPatchRequest {
action: apply.action,
parse_mode,
file_paths,
changes,
exec_approval_requirement: apply.exec_approval_requirement,
@@ -486,11 +483,16 @@ pub(crate) async fn intercept_apply_patch(
tool_name: &str,
) -> Result<Option<FunctionToolOutput>, FunctionCallError> {
let sandbox = turn
.primary_environment()
.environments
.primary()
.filter(|env| env.environment.is_remote())
.map(|_| turn.file_system_sandbox_context(/*additional_permissions*/ None));
let parse_mode = apply_patch_mode(turn.as_ref());
match codex_apply_patch::maybe_parse_apply_patch_verified_with_mode(
let parse_mode = if turn.features.enabled(Feature::ApplyPatchStreamingParser) {
ParsePatchMode::Streaming
} else {
ParsePatchMode::Legacy
};
match codex_apply_patch::maybe_parse_apply_patch_verified(
command,
parse_mode,
cwd,
@@ -530,6 +532,7 @@ pub(crate) async fn intercept_apply_patch(
let req = ApplyPatchRequest {
action: apply.action,
parse_mode,
file_paths: approval_keys,
changes,
exec_approval_requirement: apply.exec_approval_requirement,
@@ -99,18 +99,6 @@ async fn post_tool_use_payload_uses_patch_input_and_tool_output() {
);
}
#[tokio::test]
async fn apply_patch_mode_follows_streaming_parser_feature() {
let (_, mut turn) = make_session_and_context().await;
assert_eq!(apply_patch_mode(&turn), ParsePatchMode::Legacy);
turn.features
.enable(Feature::ApplyPatchStreamingParser)
.expect("enable feature");
assert_eq!(apply_patch_mode(&turn), ParsePatchMode::Streaming);
}
#[test]
fn diff_consumer_does_not_stream_json_tool_call_arguments() {
let mut consumer = ApplyPatchArgumentDiffConsumer::default();
@@ -234,6 +222,7 @@ async fn approval_keys_include_move_destination() {
let argv = vec!["apply_patch".to_string(), patch.to_string()];
let action = match codex_apply_patch::maybe_parse_apply_patch_verified(
&argv,
codex_apply_patch::ParsePatchMode::Legacy,
&cwd,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
@@ -6,7 +6,6 @@ use crate::agent::exceeds_thread_spawn_depth_limit;
use crate::agent::next_thread_spawn_depth;
use crate::agent::role::DEFAULT_ROLE_NAME;
use crate::agent::role::apply_role_to_config;
use crate::session::turn_context::TurnEnvironment;
pub(crate) struct Handler;
@@ -83,29 +82,22 @@ impl ToolHandler for Handler {
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
apply_spawn_agent_overrides(&mut config, child_depth);
let result = Box::pin(
session.services.agent_control.spawn_agent_with_metadata(
config,
input_items,
Some(thread_spawn_source(
session.conversation_id,
&turn.session_source,
child_depth,
role_name,
/*task_name*/ None,
)?),
SpawnAgentOptions {
fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()),
fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory),
environments: Some(
turn.environments
.iter()
.map(TurnEnvironment::selection)
.collect(),
),
},
),
)
let result = Box::pin(session.services.agent_control.spawn_agent_with_metadata(
config,
input_items,
Some(thread_spawn_source(
session.conversation_id,
&turn.session_source,
child_depth,
role_name,
/*task_name*/ None,
)?),
SpawnAgentOptions {
fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()),
fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory),
environments: Some(turn.environments.to_selections()),
},
))
.await
.map_err(collab_spawn_error);
let (new_thread_id, new_agent_metadata, status) = match &result {
@@ -3,8 +3,10 @@ use crate::ThreadManager;
use crate::config::AgentRoleConfig;
use crate::config::DEFAULT_AGENT_MAX_DEPTH;
use crate::function_tool::FunctionCallError;
use crate::init_state_db;
use crate::session::tests::make_session_and_context;
use crate::session_prefix::format_subagent_notification_message;
use crate::thread_manager::thread_store_from_config;
use crate::tools::context::ToolOutput;
use crate::tools::handlers::multi_agents_v2::CloseAgentHandler as CloseAgentHandlerV2;
use crate::tools::handlers::multi_agents_v2::FollowupTaskHandler as FollowupTaskHandlerV2;
@@ -3149,13 +3151,22 @@ async fn close_agent_submits_shutdown_and_returns_previous_status() {
#[tokio::test]
async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtrees_closed() {
let (_session, turn) = make_session_and_context().await;
let manager = thread_manager();
let mut config = turn.config.as_ref().clone();
config.agent_max_depth = 3;
config
.features
.enable(Feature::Sqlite)
.expect("test config should allow sqlite");
let state_db = init_state_db(&config).await;
let manager = ThreadManager::new(
&config,
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
thread_store_from_config(&config, state_db.clone()),
state_db.clone(),
);
let parent = manager
.start_thread(config.clone())
@@ -5,7 +5,6 @@ use crate::agent::control::render_input_preview;
use crate::agent::next_thread_spawn_depth;
use crate::agent::role::DEFAULT_ROLE_NAME;
use crate::agent::role::apply_role_to_config;
use crate::session::turn_context::TurnEnvironment;
use codex_protocol::AgentPath;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::Op;
@@ -118,12 +117,7 @@ impl ToolHandler for Handler {
SpawnAgentOptions {
fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()),
fork_mode,
environments: Some(
turn.environments
.iter()
.map(TurnEnvironment::selection)
.collect(),
),
environments: Some(turn.environments.to_selections()),
},
)
.await
+1 -1
View File
@@ -412,7 +412,7 @@ impl ShellHandler {
} = args;
let mut exec_params = exec_params;
let Some(turn_environment) = turn.primary_environment() else {
let Some(turn_environment) = turn.environments.primary() else {
return Err(FunctionCallError::RespondToModel(
"shell is unavailable in this session".to_string(),
));
@@ -196,7 +196,7 @@ impl ToolHandler for UnifiedExecHandler {
}
};
let Some(turn_environment) = turn.primary_environment() else {
let Some(turn_environment) = turn.environments.primary() else {
return Err(FunctionCallError::RespondToModel(
"unified exec is unavailable in this session".to_string(),
));
@@ -88,7 +88,7 @@ impl ToolHandler for ViewImageHandler {
};
let abs_path = turn.resolve_path(Some(args.path));
let Some(environment) = turn.primary_environment() else {
let Some(environment) = turn.environments.primary() else {
return Err(FunctionCallError::RespondToModel(
"view_image is unavailable in this session".to_string(),
));
@@ -18,6 +18,7 @@ use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::with_cached_approval;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::ParsePatchMode;
use codex_exec_server::FileSystemSandboxContext;
use codex_protocol::error::CodexErr;
use codex_protocol::error::SandboxErr;
@@ -38,6 +39,7 @@ use std::time::Instant;
#[derive(Debug)]
pub struct ApplyPatchRequest {
pub action: ApplyPatchAction,
pub parse_mode: ParsePatchMode,
pub file_paths: Vec<AbsolutePathBuf>,
pub changes: std::collections::HashMap<PathBuf, FileChange>,
pub exec_approval_requirement: ExecApprovalRequirement,
@@ -191,7 +193,7 @@ impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
) -> Result<ExecToolCallOutput, ToolError> {
let turn_environment = ctx.turn.primary_environment().ok_or_else(|| {
let turn_environment = ctx.turn.environments.primary().ok_or_else(|| {
ToolError::Rejected("apply_patch is unavailable in this session".to_string())
})?;
let started_at = Instant::now();
@@ -199,8 +201,9 @@ impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
let sandbox = Self::file_system_sandbox_context_for_attempt(req, attempt);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let result = codex_apply_patch::apply_hunks(
req.action.hunks(),
let result = codex_apply_patch::apply_patch(
&req.action.patch,
req.parse_mode,
&req.action.cwd,
&mut stdout,
&mut stderr,
@@ -50,6 +50,7 @@ fn guardian_review_request_includes_patch_context() {
let expected_patch = action.patch.clone();
let request = ApplyPatchRequest {
action,
parse_mode: ParsePatchMode::Legacy,
file_paths: vec![path.clone()],
changes: HashMap::from([(
path.to_path_buf(),
@@ -88,6 +89,7 @@ fn permission_request_payload_uses_apply_patch_hook_name_and_aliases() {
let expected_patch = action.patch.clone();
let req = ApplyPatchRequest {
action,
parse_mode: ParsePatchMode::Legacy,
file_paths: vec![path],
changes: HashMap::new(),
exec_approval_requirement: ExecApprovalRequirement::NeedsApproval {
@@ -127,6 +129,7 @@ fn file_system_sandbox_context_uses_active_attempt() {
};
let req = ApplyPatchRequest {
action: ApplyPatchAction::new_add_for_test(&path, "hello".to_string()),
parse_mode: ParsePatchMode::Legacy,
file_paths: vec![path.clone()],
changes: HashMap::new(),
exec_approval_requirement: ExecApprovalRequirement::Skip {
@@ -184,6 +187,7 @@ fn no_sandbox_attempt_has_no_file_system_context() {
.abs();
let req = ApplyPatchRequest {
action: ApplyPatchAction::new_add_for_test(&path, "hello".to_string()),
parse_mode: ParsePatchMode::Legacy,
file_paths: vec![path.clone()],
changes: HashMap::new(),
exec_approval_requirement: ExecApprovalRequirement::Skip {
@@ -254,7 +254,8 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
}
let environment_is_remote = ctx
.turn
.primary_environment()
.environments
.primary()
.is_some_and(|turn_environment| turn_environment.environment.is_remote());
let command = if environment_is_remote {
base_command.to_vec()
@@ -292,7 +293,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
.await?
{
Some(prepared) => {
let Some(turn_environment) = ctx.turn.primary_environment() else {
let Some(turn_environment) = ctx.turn.environments.primary() else {
return Err(ToolError::Rejected(
"exec_command is unavailable in this session".to_string(),
));
@@ -337,7 +338,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
.env_for(command, options, managed_network)
.map_err(|err| ToolError::Codex(err.into()))?;
exec_env.exec_server_env_config = req.exec_server_env_config.clone();
let Some(turn_environment) = ctx.turn.primary_environment() else {
let Some(turn_environment) = ctx.turn.environments.primary() else {
return Err(ToolError::Rejected(
"exec_command is unavailable in this session".to_string(),
));
+6 -3
View File
@@ -96,7 +96,8 @@ async fn exec_command_with_tty(
&request,
tty,
Box::new(NoopSpawnLifecycle),
turn.primary_environment()
turn.environments
.primary()
.expect("turn environment")
.environment
.as_ref(),
@@ -594,7 +595,8 @@ async fn remote_exec_server_rejects_inherited_fd_launches() -> anyhow::Result<()
let remote_test_env = remote_test_env().await?;
let (_, mut turn) = make_session_and_context().await;
turn.environments[0].environment = Arc::new(remote_test_env.environment().clone());
turn.environments.turn_environments[0].environment =
Arc::new(remote_test_env.environment().clone());
let request = test_exec_request(
&turn,
@@ -612,7 +614,8 @@ async fn remote_exec_server_rejects_inherited_fd_launches() -> anyhow::Result<()
Box::new(TestSpawnLifecycle {
inherited_fds: vec![42],
}),
turn.primary_environment()
turn.environments
.primary()
.expect("turn environment")
.environment
.as_ref(),
+5 -2
View File
@@ -423,6 +423,7 @@ impl TestCodexBuilder {
environment_manager: Arc<codex_exec_server::EnvironmentManager>,
) -> anyhow::Result<TestCodex> {
let auth = self.auth.clone();
let state_db = codex_core::init_state_db(&config).await;
let thread_manager = if config.model_catalog.is_some() {
ThreadManager::new(
&config,
@@ -430,14 +431,16 @@ impl TestCodexBuilder {
SessionSource::Exec,
Arc::clone(&environment_manager),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
thread_store_from_config(&config, state_db.clone()),
state_db.clone(),
)
} else {
codex_core::test_support::thread_manager_with_models_provider_and_home(
codex_core::test_support::thread_manager_with_models_provider_home_and_state(
auth.clone(),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
Arc::clone(&environment_manager),
state_db.clone(),
)
};
let thread_manager = Arc::new(thread_manager);
@@ -219,6 +219,51 @@ D delete.txt
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_cli_uses_streaming_parser_when_feature_enabled() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = apply_patch_harness_with(|builder| {
builder.with_model("gpt-5.4").with_config(|config| {
config
.features
.enable(Feature::ApplyPatchStreamingParser)
.expect("enable apply_patch streaming parser");
config.suppress_unstable_features_warning = true;
})
})
.await?;
let patch = "*** Begin Patch\n*** Add File: streaming-parser.txt\n+enabled\n*** End Patch";
let call_id = "apply-streaming-parser";
mount_apply_patch(
&harness,
call_id,
patch,
"done",
ApplyPatchModelOutput::Function,
)
.await;
harness
.submit("please apply streaming parser patch")
.await?;
let out = harness
.apply_patch_output(call_id, ApplyPatchModelOutput::Function)
.await;
assert_regex_match(
r"(?s)^Exit code: 0.*Success\. Updated the following files:\nA streaming-parser\.txt\n?$",
&out,
);
assert_eq!(
harness.read_file_text("streaming-parser.txt").await?,
"enabled\n"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
+2 -1
View File
@@ -1112,7 +1112,8 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
thread_store_from_config(&config, /*state_db*/ None),
/*state_db*/ None,
);
let NewThread { thread: codex, .. } = thread_manager
.start_thread(config.clone())
@@ -141,7 +141,8 @@ async fn migration_marker_exists_no_sessions_no_change() -> io::Result<()> {
let marker_path = temp.path().join(PERSONALITY_MIGRATION_FILENAME);
tokio::fs::write(&marker_path, "v1\n").await?;
let status = maybe_migrate_personality(temp.path(), &ConfigToml::default()).await?;
let status =
maybe_migrate_personality(temp.path(), &ConfigToml::default(), /*state_db*/ None).await?;
assert_eq!(status, PersonalityMigrationStatus::SkippedMarker);
assert_eq!(
@@ -155,7 +156,8 @@ async fn migration_marker_exists_no_sessions_no_change() -> io::Result<()> {
async fn no_marker_no_sessions_no_change() -> io::Result<()> {
let temp = TempDir::new()?;
let status = maybe_migrate_personality(temp.path(), &ConfigToml::default()).await?;
let status =
maybe_migrate_personality(temp.path(), &ConfigToml::default(), /*state_db*/ None).await?;
assert_eq!(status, PersonalityMigrationStatus::SkippedNoSessions);
assert_eq!(
@@ -174,7 +176,8 @@ async fn no_marker_sessions_sets_personality() -> io::Result<()> {
let temp = TempDir::new()?;
write_session_with_user_event(temp.path()).await?;
let status = maybe_migrate_personality(temp.path(), &ConfigToml::default()).await?;
let status =
maybe_migrate_personality(temp.path(), &ConfigToml::default(), /*state_db*/ None).await?;
assert_eq!(status, PersonalityMigrationStatus::Applied);
assert_eq!(
@@ -194,7 +197,7 @@ async fn no_marker_sessions_preserves_existing_config_fields() -> io::Result<()>
tokio::fs::write(temp.path().join("config.toml"), "model = \"gpt-5.4\"\n").await?;
let config_toml = read_config_toml(temp.path()).await?;
let status = maybe_migrate_personality(temp.path(), &config_toml).await?;
let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?;
assert_eq!(status, PersonalityMigrationStatus::Applied);
let persisted = read_config_toml(temp.path()).await?;
@@ -208,7 +211,8 @@ async fn no_marker_meta_only_rollout_is_treated_as_no_sessions() -> io::Result<(
let temp = TempDir::new()?;
write_session_with_meta_only(temp.path()).await?;
let status = maybe_migrate_personality(temp.path(), &ConfigToml::default()).await?;
let status =
maybe_migrate_personality(temp.path(), &ConfigToml::default(), /*state_db*/ None).await?;
assert_eq!(status, PersonalityMigrationStatus::SkippedNoSessions);
assert_eq!(
@@ -228,7 +232,7 @@ async fn no_marker_explicit_global_personality_skips_migration() -> io::Result<(
write_session_with_user_event(temp.path()).await?;
let config_toml = parse_config_toml("personality = \"friendly\"\n")?;
let status = maybe_migrate_personality(temp.path(), &config_toml).await?;
let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?;
assert_eq!(
status,
@@ -258,7 +262,7 @@ personality = "friendly"
"#,
)?;
let status = maybe_migrate_personality(temp.path(), &config_toml).await?;
let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?;
assert_eq!(
status,
@@ -281,7 +285,7 @@ async fn marker_short_circuits_invalid_profile_resolution() -> io::Result<()> {
tokio::fs::write(temp.path().join(PERSONALITY_MIGRATION_FILENAME), "v1\n").await?;
let config_toml = parse_config_toml("profile = \"missing\"\n")?;
let status = maybe_migrate_personality(temp.path(), &config_toml).await?;
let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?;
assert_eq!(status, PersonalityMigrationStatus::SkippedMarker);
Ok(())
@@ -292,7 +296,7 @@ async fn invalid_selected_profile_returns_error_and_does_not_write_marker() -> i
let temp = TempDir::new()?;
let config_toml = parse_config_toml("profile = \"missing\"\n")?;
let err = maybe_migrate_personality(temp.path(), &config_toml)
let err = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None)
.await
.expect_err("missing profile should fail");
@@ -309,8 +313,10 @@ async fn applied_migration_is_idempotent_on_second_run() -> io::Result<()> {
let temp = TempDir::new()?;
write_session_with_user_event(temp.path()).await?;
let first_status = maybe_migrate_personality(temp.path(), &ConfigToml::default()).await?;
let second_status = maybe_migrate_personality(temp.path(), &ConfigToml::default()).await?;
let first_status =
maybe_migrate_personality(temp.path(), &ConfigToml::default(), /*state_db*/ None).await?;
let second_status =
maybe_migrate_personality(temp.path(), &ConfigToml::default(), /*state_db*/ None).await?;
assert_eq!(first_status, PersonalityMigrationStatus::Applied);
assert_eq!(second_status, PersonalityMigrationStatus::SkippedMarker);
@@ -324,7 +330,8 @@ async fn no_marker_archived_sessions_sets_personality() -> io::Result<()> {
let temp = TempDir::new()?;
write_archived_session_with_user_event(temp.path()).await?;
let status = maybe_migrate_personality(temp.path(), &ConfigToml::default()).await?;
let status =
maybe_migrate_personality(temp.path(), &ConfigToml::default(), /*state_db*/ None).await?;
assert_eq!(status, PersonalityMigrationStatus::Applied);
assert_eq!(
@@ -29,6 +29,7 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> {
text: "hello from debug prompt".to_string(),
text_elements: Vec::new(),
}],
/*state_db*/ None,
)
.await?;
+40 -22
View File
@@ -14,6 +14,7 @@ use codex_core::find_thread_path_by_id_str;
use codex_protocol::ThreadId;
use codex_protocol::models::BaseInstructions;
use codex_protocol::protocol::SessionSource;
use codex_rollout::StateDbHandle;
use codex_state::StateRuntime;
use codex_state::ThreadMetadataBuilder;
use pretty_assertions::assert_eq;
@@ -27,7 +28,13 @@ fn write_minimal_rollout_with_id_in_subdir(codex_home: &Path, subdir: &str, id:
std::fs::create_dir_all(&sessions).unwrap();
let file = sessions.join(format!("rollout-2024-01-01T00-00-00-{id}.jsonl"));
let mut f = std::fs::File::create(&file).unwrap();
write_minimal_rollout_with_id_at_path(&file, id);
file
}
fn write_minimal_rollout_with_id_at_path(file: &Path, id: Uuid) {
let mut f = std::fs::File::create(file).unwrap();
// Minimal first line: session_meta with the id so content search can find it
writeln!(
f,
@@ -46,8 +53,6 @@ fn write_minimal_rollout_with_id_in_subdir(codex_home: &Path, subdir: &str, id:
})
)
.unwrap();
file
}
/// Create sessions/YYYY/MM/DD and write a minimal rollout file containing the
@@ -56,7 +61,11 @@ fn write_minimal_rollout_with_id(codex_home: &Path, id: Uuid) -> PathBuf {
write_minimal_rollout_with_id_in_subdir(codex_home, "sessions", id)
}
async fn upsert_thread_metadata(codex_home: &Path, thread_id: ThreadId, rollout_path: PathBuf) {
async fn upsert_thread_metadata(
codex_home: &Path,
thread_id: ThreadId,
rollout_path: PathBuf,
) -> StateDbHandle {
let runtime = StateRuntime::init(codex_home.to_path_buf(), "test-provider".to_string())
.await
.unwrap();
@@ -73,6 +82,7 @@ async fn upsert_thread_metadata(codex_home: &Path, thread_id: ThreadId, rollout_
builder.cwd = codex_home.to_path_buf();
let metadata = builder.build("test-provider");
runtime.upsert_thread(&metadata).await.unwrap();
runtime
}
#[tokio::test]
@@ -81,9 +91,10 @@ async fn find_locates_rollout_file_by_id() {
let id = Uuid::new_v4();
let expected = write_minimal_rollout_with_id(home.path(), id);
let found = find_thread_path_by_id_str(home.path(), &id.to_string())
.await
.unwrap();
let found =
find_thread_path_by_id_str(home.path(), &id.to_string(), /*state_db_ctx*/ None)
.await
.unwrap();
assert_eq!(found.unwrap(), expected);
}
@@ -97,9 +108,10 @@ async fn find_handles_gitignore_covering_codex_home_directory() {
let id = Uuid::new_v4();
let expected = write_minimal_rollout_with_id(&codex_home, id);
let found = find_thread_path_by_id_str(&codex_home, &id.to_string())
.await
.unwrap();
let found =
find_thread_path_by_id_str(&codex_home, &id.to_string(), /*state_db_ctx*/ None)
.await
.unwrap();
assert_eq!(found, Some(expected));
}
@@ -113,11 +125,11 @@ async fn find_prefers_sqlite_path_by_id() {
"sessions/2030/12/30/rollout-2030-12-30T00-00-00-{id}.jsonl"
));
std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
std::fs::write(&db_path, "").unwrap();
write_minimal_rollout_with_id_at_path(&db_path, id);
write_minimal_rollout_with_id(home.path(), id);
upsert_thread_metadata(home.path(), thread_id, db_path.clone()).await;
let state_db = upsert_thread_metadata(home.path(), thread_id, db_path.clone()).await;
let found = find_thread_path_by_id_str(home.path(), &id.to_string())
let found = find_thread_path_by_id_str(home.path(), &id.to_string(), Some(&state_db))
.await
.unwrap();
@@ -134,9 +146,9 @@ async fn find_falls_back_to_filesystem_when_sqlite_has_no_match() {
let unrelated_path = home
.path()
.join("sessions/2030/12/30/rollout-2030-12-30T00-00-00-unrelated.jsonl");
upsert_thread_metadata(home.path(), unrelated_thread_id, unrelated_path).await;
let state_db = upsert_thread_metadata(home.path(), unrelated_thread_id, unrelated_path).await;
let found = find_thread_path_by_id_str(home.path(), &id.to_string())
let found = find_thread_path_by_id_str(home.path(), &id.to_string(), Some(&state_db))
.await
.unwrap();
@@ -150,9 +162,10 @@ async fn find_ignores_granular_gitignore_rules() {
let expected = write_minimal_rollout_with_id(home.path(), id);
std::fs::write(home.path().join("sessions/.gitignore"), "*.jsonl\n").unwrap();
let found = find_thread_path_by_id_str(home.path(), &id.to_string())
.await
.unwrap();
let found =
find_thread_path_by_id_str(home.path(), &id.to_string(), /*state_db_ctx*/ None)
.await
.unwrap();
assert_eq!(found, Some(expected));
}
@@ -197,7 +210,8 @@ async fn find_locates_rollout_file_written_by_recorder() -> std::io::Result<()>
),
)?;
let found = find_thread_meta_by_name_str(home.path(), thread_name).await?;
let found =
find_thread_meta_by_name_str(home.path(), thread_name, /*state_db_ctx*/ None).await?;
let (path, session_meta) = found.expect("expected rollout path to be found");
assert_eq!(session_meta.meta.id, thread_id);
@@ -214,9 +228,13 @@ async fn find_archived_locates_rollout_file_by_id() {
let id = Uuid::new_v4();
let expected = write_minimal_rollout_with_id_in_subdir(home.path(), "archived_sessions", id);
let found = find_archived_thread_path_by_id_str(home.path(), &id.to_string())
.await
.unwrap();
let found = find_archived_thread_path_by_id_str(
home.path(),
&id.to_string(),
/*state_db_ctx*/ None,
)
.await
.unwrap();
assert_eq!(found, Some(expected));
}
+2 -1
View File
@@ -246,7 +246,8 @@ async fn list_skills_skips_cwd_roots_when_environment_disabled() -> Result<()> {
)?,
)),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
thread_store_from_config(&config, /*state_db*/ None),
/*state_db*/ None,
);
let new_thread = thread_manager.start_thread(config.clone()).await?;
let cwd = config.cwd.to_path_buf();
-6
View File
@@ -8,13 +8,9 @@ use tokio::sync::watch;
use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::tungstenite::Message;
#[cfg(test)]
use tokio::io::AsyncBufReadExt;
#[cfg(test)]
use tokio::io::AsyncWriteExt;
#[cfg(test)]
use tokio::io::BufReader;
#[cfg(test)]
use tokio::io::BufWriter;
pub(crate) const CHANNEL_CAPACITY: usize = 128;
@@ -34,7 +30,6 @@ pub(crate) struct JsonRpcConnection {
}
impl JsonRpcConnection {
#[cfg(test)]
pub(crate) fn from_stdio<R, W>(reader: R, writer: W, connection_label: String) -> Self
where
R: AsyncRead + Unpin + Send + 'static,
@@ -298,7 +293,6 @@ async fn send_malformed_message(
.await;
}
#[cfg(test)]
async fn write_jsonrpc_line_message<W>(
writer: &mut BufWriter<W>,
message: &JSONRPCMessage,
+54 -7
View File
@@ -1,5 +1,8 @@
use std::io::Write as _;
use std::net::SocketAddr;
use tokio::io;
use tokio::io::AsyncRead;
use tokio::io::AsyncWrite;
use tokio::net::TcpListener;
use tokio_tungstenite::accept_async;
use tracing::warn;
@@ -10,6 +13,12 @@ use crate::server::processor::ConnectionProcessor;
pub const DEFAULT_LISTEN_URL: &str = "ws://127.0.0.1:0";
#[derive(Debug, Clone, Eq, PartialEq)]
pub(crate) enum ExecServerListenTransport {
WebSocket(SocketAddr),
Stdio,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum ExecServerListenUrlParseError {
UnsupportedListenUrl(String),
@@ -21,7 +30,7 @@ impl std::fmt::Display for ExecServerListenUrlParseError {
match self {
ExecServerListenUrlParseError::UnsupportedListenUrl(listen_url) => write!(
f,
"unsupported --listen URL `{listen_url}`; expected `ws://IP:PORT`"
"unsupported --listen URL `{listen_url}`; expected `ws://IP:PORT` or `stdio`"
),
ExecServerListenUrlParseError::InvalidWebSocketListenUrl(listen_url) => write!(
f,
@@ -35,11 +44,18 @@ impl std::error::Error for ExecServerListenUrlParseError {}
pub(crate) fn parse_listen_url(
listen_url: &str,
) -> Result<SocketAddr, ExecServerListenUrlParseError> {
) -> Result<ExecServerListenTransport, ExecServerListenUrlParseError> {
if matches!(listen_url, "stdio" | "stdio://") {
return Ok(ExecServerListenTransport::Stdio);
}
if let Some(socket_addr) = listen_url.strip_prefix("ws://") {
return socket_addr.parse::<SocketAddr>().map_err(|_| {
ExecServerListenUrlParseError::InvalidWebSocketListenUrl(listen_url.to_string())
});
return socket_addr
.parse::<SocketAddr>()
.map(ExecServerListenTransport::WebSocket)
.map_err(|_| {
ExecServerListenUrlParseError::InvalidWebSocketListenUrl(listen_url.to_string())
});
}
Err(ExecServerListenUrlParseError::UnsupportedListenUrl(
@@ -51,8 +67,39 @@ pub(crate) async fn run_transport(
listen_url: &str,
runtime_paths: ExecServerRuntimePaths,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let bind_address = parse_listen_url(listen_url)?;
run_websocket_listener(bind_address, runtime_paths).await
match parse_listen_url(listen_url)? {
ExecServerListenTransport::WebSocket(bind_address) => {
run_websocket_listener(bind_address, runtime_paths).await
}
ExecServerListenTransport::Stdio => run_stdio_connection(runtime_paths).await,
}
}
async fn run_stdio_connection(
runtime_paths: ExecServerRuntimePaths,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
run_stdio_connection_with_io(io::stdin(), io::stdout(), runtime_paths).await
}
async fn run_stdio_connection_with_io<R, W>(
reader: R,
writer: W,
runtime_paths: ExecServerRuntimePaths,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
let processor = ConnectionProcessor::new(runtime_paths);
tracing::info!("codex-exec-server listening on stdio");
processor
.run_connection(JsonRpcConnection::from_stdio(
reader,
writer,
"exec-server stdio".to_string(),
))
.await;
Ok(())
}
async fn run_websocket_listener(
@@ -1,31 +1,127 @@
use std::net::SocketAddr;
use std::time::Duration;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCRequest;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use pretty_assertions::assert_eq;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
use tokio::io::BufReader;
use tokio::io::duplex;
use tokio::time::timeout;
use super::DEFAULT_LISTEN_URL;
use super::ExecServerListenTransport;
use super::parse_listen_url;
use super::run_stdio_connection_with_io;
use crate::ExecServerRuntimePaths;
use crate::protocol::INITIALIZE_METHOD;
use crate::protocol::INITIALIZED_METHOD;
use crate::protocol::InitializeParams;
use crate::protocol::InitializeResponse;
#[test]
fn parse_listen_url_accepts_default_websocket_url() {
let bind_address =
parse_listen_url(DEFAULT_LISTEN_URL).expect("default listen URL should parse");
let transport = parse_listen_url(DEFAULT_LISTEN_URL).expect("default listen URL should parse");
assert_eq!(
bind_address,
"127.0.0.1:0"
.parse::<SocketAddr>()
.expect("valid socket address")
transport,
ExecServerListenTransport::WebSocket(
"127.0.0.1:0"
.parse::<SocketAddr>()
.expect("valid socket address")
)
);
}
#[test]
fn parse_listen_url_accepts_stdio() {
let transport = parse_listen_url("stdio").expect("stdio listen URL should parse");
assert_eq!(transport, ExecServerListenTransport::Stdio);
}
#[test]
fn parse_listen_url_accepts_stdio_url() {
let transport = parse_listen_url("stdio://").expect("stdio listen URL should parse");
assert_eq!(transport, ExecServerListenTransport::Stdio);
}
#[tokio::test]
async fn stdio_listen_transport_serves_initialize() {
let transport = parse_listen_url("stdio").expect("stdio listen URL should parse");
let ExecServerListenTransport::Stdio = transport else {
panic!("expected stdio listen transport, got {transport:?}");
};
let (mut client_writer, server_reader) = duplex(1 << 20);
let (server_writer, client_reader) = duplex(1 << 20);
let server_task = tokio::spawn(run_stdio_connection_with_io(
server_reader,
server_writer,
test_runtime_paths(),
));
let mut client_lines = BufReader::new(client_reader).lines();
let initialize = JSONRPCMessage::Request(JSONRPCRequest {
id: RequestId::Integer(1),
method: INITIALIZE_METHOD.to_string(),
params: Some(
serde_json::to_value(InitializeParams {
client_name: "exec-server-transport-test".to_string(),
resume_session_id: None,
})
.expect("initialize params should serialize"),
),
trace: None,
});
write_jsonrpc_line(&mut client_writer, &initialize).await;
let response = timeout(Duration::from_secs(1), client_lines.next_line())
.await
.expect("initialize response should arrive")
.expect("initialize response read should succeed")
.expect("initialize response should be present");
let response: JSONRPCMessage =
serde_json::from_str(&response).expect("initialize response should parse");
let JSONRPCMessage::Response(JSONRPCResponse { id, result }) = response else {
panic!("expected initialize response, got {response:?}");
};
assert_eq!(id, RequestId::Integer(1));
let initialize_response: InitializeResponse =
serde_json::from_value(result).expect("initialize response should decode");
assert!(
!initialize_response.session_id.is_empty(),
"initialize should return a session id"
);
let initialized = JSONRPCMessage::Notification(JSONRPCNotification {
method: INITIALIZED_METHOD.to_string(),
params: Some(serde_json::to_value(()).expect("initialized params should serialize")),
});
write_jsonrpc_line(&mut client_writer, &initialized).await;
drop(client_writer);
drop(client_lines);
timeout(Duration::from_secs(1), server_task)
.await
.expect("stdio transport should finish after client disconnect")
.expect("stdio transport task should join")
.expect("stdio transport should not fail");
}
#[test]
fn parse_listen_url_accepts_websocket_url() {
let bind_address =
let transport =
parse_listen_url("ws://127.0.0.1:1234").expect("websocket listen URL should parse");
assert_eq!(
bind_address,
"127.0.0.1:1234"
.parse::<SocketAddr>()
.expect("valid socket address")
transport,
ExecServerListenTransport::WebSocket(
"127.0.0.1:1234"
.parse::<SocketAddr>()
.expect("valid socket address")
)
);
}
@@ -45,6 +141,26 @@ fn parse_listen_url_rejects_unsupported_url() {
parse_listen_url("http://127.0.0.1:1234").expect_err("unsupported scheme should fail");
assert_eq!(
err.to_string(),
"unsupported --listen URL `http://127.0.0.1:1234`; expected `ws://IP:PORT`"
"unsupported --listen URL `http://127.0.0.1:1234`; expected `ws://IP:PORT` or `stdio`"
);
}
async fn write_jsonrpc_line(writer: &mut tokio::io::DuplexStream, message: &JSONRPCMessage) {
let encoded = serde_json::to_vec(message).expect("JSON-RPC message should serialize");
writer
.write_all(&encoded)
.await
.expect("JSON-RPC message should write");
writer
.write_all(b"\n")
.await
.expect("JSON-RPC newline should write");
}
fn test_runtime_paths() -> ExecServerRuntimePaths {
ExecServerRuntimePaths::new(
std::env::current_exe().expect("current exe"),
/*codex_linux_sandbox_exe*/ None,
)
.expect("runtime paths")
}
+13 -3
View File
@@ -57,6 +57,7 @@ use codex_cloud_requirements::cloud_requirements_loader_for_storage;
use codex_config::ConfigLoadError;
use codex_config::LoaderOverrides;
use codex_config::format_config_error_with_source;
use codex_core::StateDbHandle;
use codex_core::check_execpolicy_for_warnings;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
@@ -194,6 +195,7 @@ impl RequestIdSequencer {
struct ExecRunArgs {
in_process_start_args: InProcessClientStartArgs,
state_db: Option<StateDbHandle>,
command: Option<ExecCommand>,
config: Config,
dangerously_bypass_approvals_and_sandbox: bool,
@@ -503,6 +505,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
arg0_paths.codex_self_exe.clone(),
arg0_paths.codex_linux_sandbox_exe.clone(),
)?;
let state_db = codex_core::init_state_db(&config).await;
let in_process_start_args = InProcessClientStartArgs {
arg0_paths,
config: std::sync::Arc::new(config.clone()),
@@ -511,6 +514,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
cloud_requirements: run_cloud_requirements,
feedback: CodexFeedback::new(),
log_db: None,
state_db: state_db.clone(),
environment_manager: std::sync::Arc::new(
EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await,
),
@@ -525,6 +529,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
};
run_exec_session(ExecRunArgs {
in_process_start_args,
state_db,
command,
config,
dangerously_bypass_approvals_and_sandbox,
@@ -546,6 +551,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
let ExecRunArgs {
in_process_start_args,
state_db,
command,
config,
dangerously_bypass_approvals_and_sandbox,
@@ -672,7 +678,9 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
let (primary_thread_id, fallback_session_configured) = if let Some(ExecCommand::Resume(args)) =
command.as_ref()
{
if let Some(thread_id) = resolve_resume_thread_id(&client, &config, args).await? {
if let Some(thread_id) =
resolve_resume_thread_id(&client, &config, state_db.as_ref(), args).await?
{
let response: ThreadResumeResponse = send_request_with_response(
&client,
ClientRequest::ThreadResume {
@@ -1318,6 +1326,7 @@ fn cwds_match(current_cwd: &Path, session_cwd: &Path) -> bool {
async fn resolve_resume_thread_id(
client: &InProcessAppServerClient,
config: &Config,
state_db: Option<&StateDbHandle>,
args: &crate::cli::ResumeArgs,
) -> anyhow::Result<Option<String>> {
let model_providers = resume_lookup_model_providers(config, args);
@@ -1365,7 +1374,7 @@ async fn resolve_resume_thread_id(
if Uuid::parse_str(session_id).is_ok() {
return Ok(Some(session_id.to_string()));
}
if let Some(state_db) = codex_core::get_state_db(config).await {
if let Some(state_db) = state_db {
let cwd = (!args.all).then_some(config.cwd.as_path());
let resolved = state_db
.find_thread_by_exact_title(
@@ -1380,7 +1389,8 @@ async fn resolve_resume_thread_id(
return Ok(Some(thread.id.to_string()));
}
if let Some((_, session_meta)) =
find_thread_meta_by_name_str(&config.codex_home, session_id).await?
find_thread_meta_by_name_str(&config.codex_home, session_id, Some(state_db.as_ref()))
.await?
&& (args.all || cwds_match(config.cwd.as_path(), &session_meta.meta.cwd))
{
return Ok(Some(session_meta.meta.id.to_string()));
+2
View File
@@ -83,6 +83,7 @@ pub async fn run_main(
std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}"))
})?;
set_default_client_residency_requirement(config.enforce_residency.value());
let state_db = codex_core::init_state_db(&config).await;
let otel = codex_core::otel_init::build_provider(
&config,
@@ -144,6 +145,7 @@ pub async fn run_main(
arg0_paths,
Arc::new(config),
environment_manager,
state_db,
)
.await;
async move {
+4 -1
View File
@@ -2,6 +2,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use codex_arg0::Arg0DispatchPaths;
use codex_core::StateDbHandle;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::thread_store_from_config;
@@ -53,6 +54,7 @@ impl MessageProcessor {
arg0_paths: Arg0DispatchPaths,
config: Arc<Config>,
environment_manager: Arc<EnvironmentManager>,
state_db: Option<StateDbHandle>,
) -> Self {
let outgoing = Arc::new(outgoing);
let auth_manager = AuthManager::shared_from_config(
@@ -66,7 +68,8 @@ impl MessageProcessor {
SessionSource::Mcp,
environment_manager,
/*analytics_events_client*/ None,
thread_store_from_config(config.as_ref()),
thread_store_from_config(config.as_ref(), state_db.clone()),
state_db.clone(),
));
Self {
outgoing,
+41 -15
View File
@@ -1239,6 +1239,7 @@ async fn find_thread_path_by_id_str_in_subdir(
codex_home: &Path,
subdir: &str,
id_str: &str,
state_db_ctx: Option<&codex_state::StateRuntime>,
) -> io::Result<Option<PathBuf>> {
// Validate UUID format early.
if Uuid::parse_str(id_str).is_err() {
@@ -1253,8 +1254,8 @@ async fn find_thread_path_by_id_str_in_subdir(
_ => None,
};
let thread_id = ThreadId::from_string(id_str).ok();
let state_db_ctx = state_db::open_if_present(codex_home, "").await;
if let Some(state_db_ctx) = state_db_ctx.as_deref()
let mut unverified_db_path = None;
if let Some(state_db_ctx) = state_db_ctx
&& let Some(thread_id) = thread_id
&& let Some(db_path) = state_db::find_rollout_path_by_id(
Some(state_db_ctx),
@@ -1265,21 +1266,43 @@ async fn find_thread_path_by_id_str_in_subdir(
.await
{
if tokio::fs::try_exists(&db_path).await.unwrap_or(false) {
return Ok(Some(db_path));
match read_session_meta_line(&db_path).await {
Ok(meta_line) if meta_line.meta.id == thread_id => {
return Ok(Some(db_path));
}
Ok(meta_line) => {
tracing::error!(
"state db returned rollout path for thread {id_str} but file belongs to thread {}: {}",
meta_line.meta.id,
db_path.display()
);
tracing::warn!(
"state db discrepancy during find_thread_path_by_id_str_in_subdir: mismatched_db_path"
);
}
Err(err) => {
tracing::debug!(
"state db returned rollout path for thread {id_str} that could not be verified: {}: {err}",
db_path.display()
);
unverified_db_path = Some(db_path);
}
}
} else {
tracing::error!(
"state db returned stale rollout path for thread {id_str}: {}",
db_path.display()
);
tracing::warn!(
"state db discrepancy during find_thread_path_by_id_str_in_subdir: stale_db_path"
);
}
tracing::error!(
"state db returned stale rollout path for thread {id_str}: {}",
db_path.display()
);
tracing::warn!(
"state db discrepancy during find_thread_path_by_id_str_in_subdir: stale_db_path"
);
}
let mut root = codex_home.to_path_buf();
root.push(subdir);
if !root.exists() {
return Ok(None);
return Ok(unverified_db_path);
}
// This is safe because we know the values are valid.
#[allow(clippy::unwrap_used)]
@@ -1301,7 +1324,7 @@ async fn find_thread_path_by_id_str_in_subdir(
"state db discrepancy during find_thread_path_by_id_str_in_subdir: falling_back"
);
state_db::read_repair_rollout_path(
state_db_ctx.as_deref(),
state_db_ctx,
thread_id,
archived_only,
found_path.as_path(),
@@ -1309,7 +1332,7 @@ async fn find_thread_path_by_id_str_in_subdir(
.await;
}
Ok(found)
Ok(found.or(unverified_db_path))
}
/// Locate a recorded thread rollout file by its UUID string using the existing
@@ -1318,16 +1341,19 @@ async fn find_thread_path_by_id_str_in_subdir(
pub async fn find_thread_path_by_id_str(
codex_home: &Path,
id_str: &str,
state_db_ctx: Option<&codex_state::StateRuntime>,
) -> io::Result<Option<PathBuf>> {
find_thread_path_by_id_str_in_subdir(codex_home, SESSIONS_SUBDIR, id_str).await
find_thread_path_by_id_str_in_subdir(codex_home, SESSIONS_SUBDIR, id_str, state_db_ctx).await
}
/// Locate an archived thread rollout file by its UUID string.
pub async fn find_archived_thread_path_by_id_str(
codex_home: &Path,
id_str: &str,
state_db_ctx: Option<&codex_state::StateRuntime>,
) -> io::Result<Option<PathBuf>> {
find_thread_path_by_id_str_in_subdir(codex_home, ARCHIVED_SESSIONS_SUBDIR, id_str).await
find_thread_path_by_id_str_in_subdir(codex_home, ARCHIVED_SESSIONS_SUBDIR, id_str, state_db_ctx)
.await
}
/// Extract the `YYYY/MM/DD` directory components from a rollout filename.
+16 -1
View File
@@ -136,6 +136,21 @@ pub(crate) async fn backfill_sessions(
runtime: &codex_state::StateRuntime,
codex_home: &Path,
default_provider: &str,
) {
backfill_sessions_with_lease(
runtime,
codex_home,
default_provider,
BACKFILL_LEASE_SECONDS,
)
.await;
}
pub(crate) async fn backfill_sessions_with_lease(
runtime: &codex_state::StateRuntime,
codex_home: &Path,
default_provider: &str,
backfill_lease_seconds: i64,
) {
let metric_client = codex_otel::global();
let timer = metric_client
@@ -154,7 +169,7 @@ pub(crate) async fn backfill_sessions(
if backfill_state.status == BackfillStatus::Complete {
return;
}
let claimed = match runtime.try_claim_backfill(BACKFILL_LEASE_SECONDS).await {
let claimed = match runtime.try_claim_backfill(backfill_lease_seconds).await {
Ok(claimed) => claimed,
Err(err) => {
warn!(
+2 -5
View File
@@ -102,10 +102,12 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option<EventPersistenceMode> {
| EventMsg::ContextCompacted(_)
| EventMsg::EnteredReviewMode(_)
| EventMsg::ExitedReviewMode(_)
| EventMsg::McpToolCallEnd(_)
| EventMsg::ThreadRolledBack(_)
| EventMsg::TurnAborted(_)
| EventMsg::TurnStarted(_)
| EventMsg::TurnComplete(_)
| EventMsg::WebSearchEnd(_)
| EventMsg::ImageGenerationEnd(_) => Some(EventPersistenceMode::Limited),
EventMsg::ItemCompleted(event) => {
// Plan items are derived from streaming tags and are not part of the
@@ -117,14 +119,9 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option<EventPersistenceMode> {
None
}
}
EventMsg::McpToolCallEnd(event) if event.mcp_app_resource_uri.is_some() => {
Some(EventPersistenceMode::Limited)
}
EventMsg::Error(_)
| EventMsg::GuardianAssessment(_)
| EventMsg::WebSearchEnd(_)
| EventMsg::ExecCommandEnd(_)
| EventMsg::McpToolCallEnd(_)
| EventMsg::ViewImageToolCall(_)
| EventMsg::CollabAgentSpawnEnd(_)
| EventMsg::CollabAgentInteractionEnd(_)
+10 -8
View File
@@ -79,7 +79,6 @@ pub struct RolloutRecorder {
tx: Sender<RolloutCmd>,
writer_task: Arc<RolloutWriterTask>,
pub(crate) rollout_path: PathBuf,
state_db: Option<StateDbHandle>,
event_persistence_mode: EventPersistenceMode,
}
@@ -230,6 +229,7 @@ impl RolloutRecorder {
/// List threads (rollout files) under the provided Codex home directory.
#[allow(clippy::too_many_arguments)]
pub async fn list_threads(
state_db_ctx: Option<StateDbHandle>,
config: &impl RolloutConfigView,
page_size: usize,
cursor: Option<&Cursor>,
@@ -242,6 +242,7 @@ impl RolloutRecorder {
search_term: Option<&str>,
) -> std::io::Result<ThreadsPage> {
Self::list_threads_with_db_fallback(
state_db_ctx,
config,
page_size,
cursor,
@@ -260,6 +261,7 @@ impl RolloutRecorder {
#[allow(clippy::too_many_arguments)]
pub async fn list_threads_from_state_db(
state_db_ctx: Option<StateDbHandle>,
config: &impl RolloutConfigView,
page_size: usize,
cursor: Option<&Cursor>,
@@ -272,6 +274,7 @@ impl RolloutRecorder {
search_term: Option<&str>,
) -> std::io::Result<ThreadsPage> {
Self::list_threads_with_db_fallback(
state_db_ctx,
config,
page_size,
cursor,
@@ -291,6 +294,7 @@ impl RolloutRecorder {
/// List archived threads (rollout files) under the archived sessions directory.
#[allow(clippy::too_many_arguments)]
pub async fn list_archived_threads(
state_db_ctx: Option<StateDbHandle>,
config: &impl RolloutConfigView,
page_size: usize,
cursor: Option<&Cursor>,
@@ -303,6 +307,7 @@ impl RolloutRecorder {
search_term: Option<&str>,
) -> std::io::Result<ThreadsPage> {
Self::list_threads_with_db_fallback(
state_db_ctx,
config,
page_size,
cursor,
@@ -321,6 +326,7 @@ impl RolloutRecorder {
#[allow(clippy::too_many_arguments)]
pub async fn list_archived_threads_from_state_db(
state_db_ctx: Option<StateDbHandle>,
config: &impl RolloutConfigView,
page_size: usize,
cursor: Option<&Cursor>,
@@ -333,6 +339,7 @@ impl RolloutRecorder {
search_term: Option<&str>,
) -> std::io::Result<ThreadsPage> {
Self::list_threads_with_db_fallback(
state_db_ctx,
config,
page_size,
cursor,
@@ -351,6 +358,7 @@ impl RolloutRecorder {
#[allow(clippy::too_many_arguments)]
async fn list_threads_with_db_fallback(
state_db_ctx: Option<StateDbHandle>,
config: &impl RolloutConfigView,
page_size: usize,
cursor: Option<&Cursor>,
@@ -365,7 +373,6 @@ impl RolloutRecorder {
search_term: Option<&str>,
) -> std::io::Result<ThreadsPage> {
let codex_home = config.codex_home();
let state_db_ctx = state_db::get_state_db(config).await;
let archived = match archive_filter {
ThreadListArchiveFilter::Active => false,
ThreadListArchiveFilter::Archived => true,
@@ -575,6 +582,7 @@ impl RolloutRecorder {
/// Find the newest recorded thread path, optionally filtering to a matching cwd.
#[allow(clippy::too_many_arguments)]
pub async fn find_latest_thread_path(
state_db_ctx: Option<StateDbHandle>,
config: &impl RolloutConfigView,
page_size: usize,
cursor: Option<&Cursor>,
@@ -585,7 +593,6 @@ impl RolloutRecorder {
filter_cwd: Option<&Path>,
) -> std::io::Result<Option<PathBuf>> {
let codex_home = config.codex_home();
let state_db_ctx = state_db::get_state_db(config).await;
let cwd_filter = filter_cwd.map(Path::to_path_buf);
if state_db_ctx.is_some() {
let mut db_cursor = cursor.cloned();
@@ -770,7 +777,6 @@ impl RolloutRecorder {
tx,
writer_task,
rollout_path,
state_db: state_db_ctx,
event_persistence_mode,
})
}
@@ -779,10 +785,6 @@ impl RolloutRecorder {
self.rollout_path.as_path()
}
pub fn state_db(&self) -> Option<StateDbHandle> {
self.state_db.clone()
}
pub async fn record_items(&self, items: &[RolloutItem]) -> std::io::Result<()> {
let mut filtered = Vec::new();
for item in items {
+89
View File
@@ -3,6 +3,7 @@
use super::*;
use crate::config::RolloutConfig;
use chrono::TimeZone;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AgentMessageEvent;
@@ -11,6 +12,9 @@ use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::TurnContextItem;
use codex_protocol::protocol::UserMessageEvent;
use pretty_assertions::assert_eq;
@@ -65,6 +69,77 @@ fn write_session_file(root: &Path, ts: &str, uuid: Uuid) -> std::io::Result<Path
Ok(path)
}
#[tokio::test]
async fn state_db_init_backfills_before_returning() -> anyhow::Result<()> {
let home = TempDir::new().expect("temp dir");
let uuid = Uuid::new_v4();
let thread_id = ThreadId::from_string(&uuid.to_string())?;
let rollout_path = home.path().join(format!(
"sessions/2026/01/27/rollout-2026-01-27T12-34-56-{uuid}.jsonl"
));
let parent = rollout_path
.parent()
.expect("rollout path should have parent");
fs::create_dir_all(parent)?;
let session_meta_line = SessionMetaLine {
meta: SessionMeta {
id: thread_id,
forked_from_id: None,
timestamp: "2026-01-27T12:34:56Z".to_string(),
cwd: home.path().to_path_buf(),
originator: "test".to_string(),
cli_version: "test".to_string(),
source: SessionSource::Cli,
agent_path: None,
agent_nickname: None,
agent_role: None,
model_provider: None,
base_instructions: None,
dynamic_tools: None,
memory_mode: None,
},
git: None,
};
let lines = [
RolloutLine {
timestamp: "2026-01-27T12:34:56Z".to_string(),
item: RolloutItem::SessionMeta(session_meta_line),
},
RolloutLine {
timestamp: "2026-01-27T12:34:57Z".to_string(),
item: RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
message: "hello from startup backfill".to_string(),
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
})),
},
];
let jsonl = lines
.iter()
.map(serde_json::to_string)
.collect::<Result<Vec<_>, _>>()?
.join("\n");
fs::write(&rollout_path, format!("{jsonl}\n"))?;
let runtime = crate::state_db::init(&test_config(home.path()))
.await
.expect("state db should initialize");
let metadata = runtime
.get_thread(thread_id)
.await?
.expect("thread should be backfilled before init returns");
assert_eq!(metadata.rollout_path, rollout_path);
assert_eq!(
runtime.get_backfill_state().await?.status,
codex_state::BackfillStatus::Complete
);
Ok(())
}
#[tokio::test]
async fn load_rollout_items_skips_legacy_ghost_snapshot_lines() -> std::io::Result<()> {
let home = TempDir::new().expect("temp dir");
@@ -526,6 +601,7 @@ async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Re
let default_provider = config.model_provider_id.clone();
let page1 = RolloutRecorder::list_threads(
/*state_db_ctx*/ None,
&config,
/*page_size*/ 1,
/*cursor*/ None,
@@ -543,6 +619,7 @@ async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Re
let cursor = page1.next_cursor.clone().expect("cursor should be present");
let page2 = RolloutRecorder::list_threads(
/*state_db_ctx*/ None,
&config,
/*page_size*/ 1,
Some(&cursor),
@@ -602,6 +679,7 @@ async fn list_threads_db_enabled_drops_missing_rollout_paths() -> std::io::Resul
let default_provider = config.model_provider_id.clone();
let page = RolloutRecorder::list_threads(
Some(runtime.clone()),
&config,
/*page_size*/ 10,
/*cursor*/ None,
@@ -666,6 +744,7 @@ async fn list_threads_db_enabled_repairs_stale_rollout_paths() -> std::io::Resul
let default_provider = config.model_provider_id.clone();
let page = RolloutRecorder::list_threads(
Some(runtime.clone()),
&config,
/*page_size*/ 1,
/*cursor*/ None,
@@ -738,6 +817,7 @@ async fn list_threads_state_db_only_skips_jsonl_repair_scan() -> std::io::Result
let cwd_filters = [home.path().to_path_buf()];
let state_db_only_page = RolloutRecorder::list_threads_from_state_db(
Some(runtime.clone()),
&config,
/*page_size*/ 10,
/*cursor*/ None,
@@ -753,6 +833,7 @@ async fn list_threads_state_db_only_skips_jsonl_repair_scan() -> std::io::Result
assert_eq!(state_db_only_page.items.len(), 0);
let repaired_page = RolloutRecorder::list_threads(
Some(runtime.clone()),
&config,
/*page_size*/ 10,
/*cursor*/ None,
@@ -768,6 +849,7 @@ async fn list_threads_state_db_only_skips_jsonl_repair_scan() -> std::io::Result
assert_eq!(repaired_page.items.len(), 1);
let repaired_state_db_only_page = RolloutRecorder::list_threads_from_state_db(
Some(runtime.clone()),
&config,
/*page_size*/ 10,
/*cursor*/ None,
@@ -825,6 +907,7 @@ async fn list_threads_default_filter_returns_filesystem_scan_results() -> std::i
let cwd_filters = [stale_cwd];
let state_db_only_page = RolloutRecorder::list_threads_from_state_db(
Some(runtime.clone()),
&config,
/*page_size*/ 10,
/*cursor*/ None,
@@ -840,6 +923,7 @@ async fn list_threads_default_filter_returns_filesystem_scan_results() -> std::i
assert_eq!(state_db_only_page.items.len(), 1);
let scanned_page = RolloutRecorder::list_threads(
Some(runtime.clone()),
&config,
/*page_size*/ 10,
/*cursor*/ None,
@@ -855,6 +939,7 @@ async fn list_threads_default_filter_returns_filesystem_scan_results() -> std::i
assert_eq!(scanned_page.items.len(), 0);
let repaired_state_db_only_page = RolloutRecorder::list_threads_from_state_db(
Some(runtime.clone()),
&config,
/*page_size*/ 10,
/*cursor*/ None,
@@ -913,6 +998,7 @@ async fn list_threads_metadata_filter_overlays_state_db_list_metadata() -> std::
.expect("state db upsert should succeed");
let page = RolloutRecorder::list_threads(
Some(runtime.clone()),
&config,
/*page_size*/ 10,
/*cursor*/ None,
@@ -1039,6 +1125,7 @@ async fn list_threads_search_repairs_stale_state_db_hits_before_returning() -> s
.expect("state db upsert should succeed");
let stale_state_db_only_page = RolloutRecorder::list_threads_from_state_db(
Some(runtime.clone()),
&config,
/*page_size*/ 10,
/*cursor*/ None,
@@ -1054,6 +1141,7 @@ async fn list_threads_search_repairs_stale_state_db_hits_before_returning() -> s
assert_eq!(stale_state_db_only_page.items.len(), 1);
let scanned_page = RolloutRecorder::list_threads(
Some(runtime.clone()),
&config,
/*page_size*/ 10,
/*cursor*/ None,
@@ -1069,6 +1157,7 @@ async fn list_threads_search_repairs_stale_state_db_hits_before_returning() -> s
assert_eq!(scanned_page.items.len(), 0);
let repaired_state_db_only_page = RolloutRecorder::list_threads_from_state_db(
Some(runtime.clone()),
&config,
/*page_size*/ 10,
/*cursor*/ None,
+7 -2
View File
@@ -117,6 +117,7 @@ pub async fn find_thread_names_by_ids(
pub async fn find_thread_meta_by_name_str(
codex_home: &Path,
name: &str,
state_db_ctx: Option<&codex_state::StateRuntime>,
) -> std::io::Result<Option<(PathBuf, SessionMetaLine)>> {
if name.trim().is_empty() {
return Ok(None);
@@ -135,8 +136,12 @@ pub async fn find_thread_meta_by_name_str(
while let Some(thread_id) = rx.recv().await {
// Keep walking until a matching id resolves to a loadable rollout so an unsaved or partial
// rename cannot shadow an older persisted session with the same name.
if let Some(path) =
super::list::find_thread_path_by_id_str(codex_home, &thread_id.to_string()).await?
if let Some(path) = super::list::find_thread_path_by_id_str(
codex_home,
&thread_id.to_string(),
state_db_ctx,
)
.await?
&& let Ok(session_meta) = super::list::read_session_meta_line(&path).await
{
drop(rx);
+3 -3
View File
@@ -99,7 +99,7 @@ async fn find_thread_meta_by_name_str_skips_newest_entry_without_rollout() -> st
];
write_index(&path, &lines)?;
let found = find_thread_meta_by_name_str(temp.path(), "same").await?;
let found = find_thread_meta_by_name_str(temp.path(), "same", /*state_db_ctx*/ None).await?;
assert_eq!(
found.map(|(path, session_meta)| (path, session_meta.meta.id)),
@@ -136,7 +136,7 @@ async fn find_thread_meta_by_name_str_skips_partial_rollout() -> std::io::Result
];
write_index(&path, &lines)?;
let found = find_thread_meta_by_name_str(temp.path(), "same").await?;
let found = find_thread_meta_by_name_str(temp.path(), "same", /*state_db_ctx*/ None).await?;
assert_eq!(found.map(|(path, _)| path), Some(saved_rollout_path));
Ok(())
@@ -174,7 +174,7 @@ async fn find_thread_meta_by_name_str_ignores_historical_name_after_rename() ->
];
write_index(&path, &lines)?;
let found = find_thread_meta_by_name_str(temp.path(), "same").await?;
let found = find_thread_meta_by_name_str(temp.path(), "same", /*state_db_ctx*/ None).await?;
assert_eq!(found.map(|(path, _)| path), Some(current_rollout_path));
Ok(())
+145 -47
View File
@@ -17,15 +17,52 @@ use serde_json::Value;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use tracing::info;
use tracing::warn;
/// Core-facing handle to the SQLite-backed state runtime.
pub type StateDbHandle = Arc<codex_state::StateRuntime>;
/// Initialize the state runtime for thread state persistence and backfill checks.
#[cfg(not(test))]
const STARTUP_BACKFILL_POLL_INTERVAL: Duration = Duration::from_secs(1);
#[cfg(test)]
const STARTUP_BACKFILL_POLL_INTERVAL: Duration = Duration::from_millis(10);
#[cfg(not(test))]
const STARTUP_BACKFILL_WAIT_TIMEOUT: Duration = Duration::from_secs(30);
#[cfg(test)]
const STARTUP_BACKFILL_WAIT_TIMEOUT: Duration = Duration::from_secs(2);
/// Initialize the state runtime for thread state persistence.
///
/// This is the process entry point for local state: it opens the SQLite-backed
/// runtime, applies rollout metadata backfills as needed, and returns the
/// initialized handle.
pub async fn init(config: &impl RolloutConfigView) -> Option<StateDbHandle> {
let config = RolloutConfig::from_view(config);
init_with_roots(
match try_init_with_roots(
config.codex_home,
config.sqlite_home,
config.model_provider_id,
)
.await
{
Ok(runtime) => Some(runtime),
Err(err) => {
emit_startup_warning(&format!("failed to initialize state runtime: {err}"));
None
}
}
}
/// Initialize the state runtime and return any initialization error to the caller.
///
/// Prefer [`init`] unless the caller needs to surface the exact failure after
/// tracing or UI setup has completed.
pub async fn try_init(config: &impl RolloutConfigView) -> anyhow::Result<StateDbHandle> {
let config = RolloutConfig::from_view(config);
try_init_with_roots(
config.codex_home,
config.sqlite_home,
config.model_provider_id,
@@ -33,52 +70,128 @@ pub async fn init(config: &impl RolloutConfigView) -> Option<StateDbHandle> {
.await
}
/// Initialize the state runtime for a local thread store.
pub async fn init_with_roots(
async fn try_init_with_roots(
codex_home: PathBuf,
sqlite_home: PathBuf,
default_model_provider_id: String,
) -> Option<StateDbHandle> {
let runtime = match codex_state::StateRuntime::init(
sqlite_home.clone(),
default_model_provider_id.clone(),
) -> anyhow::Result<StateDbHandle> {
try_init_with_roots_inner(
codex_home,
sqlite_home,
default_model_provider_id,
/*backfill_lease_seconds*/ None,
)
.await
{
Ok(runtime) => runtime,
Err(err) => {
warn!(
"failed to initialize state runtime at {}: {err}",
sqlite_home.display()
);
return None;
}
};
let backfill_state = match runtime.get_backfill_state().await {
Ok(state) => state,
Err(err) => {
warn!(
}
#[cfg(test)]
async fn try_init_with_roots_and_backfill_lease(
codex_home: PathBuf,
sqlite_home: PathBuf,
default_model_provider_id: String,
backfill_lease_seconds: i64,
) -> anyhow::Result<StateDbHandle> {
try_init_with_roots_inner(
codex_home,
sqlite_home,
default_model_provider_id,
Some(backfill_lease_seconds),
)
.await
}
async fn try_init_with_roots_inner(
codex_home: PathBuf,
sqlite_home: PathBuf,
default_model_provider_id: String,
backfill_lease_seconds: Option<i64>,
) -> anyhow::Result<StateDbHandle> {
let runtime =
codex_state::StateRuntime::init(sqlite_home.clone(), default_model_provider_id.clone())
.await
.map_err(|err| {
anyhow::anyhow!(
"failed to initialize state runtime at {}: {err}",
sqlite_home.display()
)
})?;
let wait_started = Instant::now();
let mut reported_wait = false;
loop {
let backfill_state = runtime.get_backfill_state().await.map_err(|err| {
anyhow::anyhow!(
"failed to read backfill state at {}: {err}",
codex_home.display()
);
return None;
)
})?;
if backfill_state.status == codex_state::BackfillStatus::Complete {
return Ok(runtime);
}
};
if backfill_state.status != codex_state::BackfillStatus::Complete {
let runtime_for_backfill = runtime.clone();
tokio::spawn(async move {
if let Some(backfill_lease_seconds) = backfill_lease_seconds {
metadata::backfill_sessions_with_lease(
runtime.as_ref(),
codex_home.as_path(),
default_model_provider_id.as_str(),
backfill_lease_seconds,
)
.await;
} else {
metadata::backfill_sessions(
runtime_for_backfill.as_ref(),
runtime.as_ref(),
codex_home.as_path(),
default_model_provider_id.as_str(),
)
.await;
});
}
let backfill_state = runtime.get_backfill_state().await.map_err(|err| {
anyhow::anyhow!(
"failed to read backfill state at {} after startup backfill: {err}",
codex_home.display()
)
})?;
if backfill_state.status == codex_state::BackfillStatus::Complete {
return Ok(runtime);
}
if wait_started.elapsed() >= STARTUP_BACKFILL_WAIT_TIMEOUT {
return Err(anyhow::anyhow!(
"timed out waiting for state db backfill at {} after {:?} (status: {})",
codex_home.display(),
STARTUP_BACKFILL_WAIT_TIMEOUT,
backfill_state.status.as_str()
));
}
let message = format!(
"state db backfill is {} at {}; waiting up to {:?} before retrying startup initialization",
backfill_state.status.as_str(),
codex_home.display(),
STARTUP_BACKFILL_WAIT_TIMEOUT,
);
if reported_wait {
info!("{message}");
} else {
emit_startup_warning(&message);
reported_wait = true;
}
tokio::time::sleep(STARTUP_BACKFILL_POLL_INTERVAL).await;
}
Some(runtime)
}
/// Get the DB if the feature is enabled and the DB exists.
fn emit_startup_warning(message: &str) {
warn!("{message}");
if !tracing::dispatcher::has_been_set() {
#[allow(clippy::print_stderr)]
{
eprintln!("{message}");
}
}
}
/// Open the DB if it exists and its startup backfill has already completed.
///
/// Unlike [`init`], this helper does not run rollout backfill. It is for
/// optional local reads from non-owning contexts such as remote app-server mode.
pub async fn get_state_db(config: &impl RolloutConfigView) -> Option<StateDbHandle> {
let state_path = codex_state::state_db_path(config.sqlite_home());
if !tokio::fs::try_exists(&state_path).await.unwrap_or(false) {
@@ -93,21 +206,6 @@ pub async fn get_state_db(config: &impl RolloutConfigView) -> Option<StateDbHand
require_backfill_complete(runtime, config.sqlite_home()).await
}
/// Open the state runtime when the SQLite file exists, without feature gating.
///
/// This is used for parity checks during the SQLite migration phase.
pub async fn open_if_present(codex_home: &Path, default_provider: &str) -> Option<StateDbHandle> {
let db_path = codex_state::state_db_path(codex_home);
if !tokio::fs::try_exists(&db_path).await.unwrap_or(false) {
return None;
}
let runtime =
codex_state::StateRuntime::init(codex_home.to_path_buf(), default_provider.to_string())
.await
.ok()?;
require_backfill_complete(runtime, codex_home).await
}
async fn require_backfill_complete(
runtime: StateDbHandle,
codex_home: &Path,
+62
View File
@@ -7,6 +7,7 @@ use chrono::NaiveDateTime;
use chrono::Timelike;
use chrono::Utc;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
#[test]
fn cursor_to_anchor_normalizes_timestamp_format() {
@@ -22,3 +23,64 @@ fn cursor_to_anchor_normalizes_timestamp_format() {
assert_eq!(anchor.ts, expected_ts);
}
#[tokio::test]
async fn try_init_waits_for_concurrent_startup_backfill() -> anyhow::Result<()> {
let home = TempDir::new().expect("temp dir");
let runtime =
codex_state::StateRuntime::init(home.path().to_path_buf(), "test-provider".to_string())
.await?;
let claimed = runtime.try_claim_backfill(/*lease_seconds*/ 60).await?;
assert!(claimed);
let runtime_for_completion = runtime.clone();
let complete_backfill = tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
runtime_for_completion
.mark_backfill_complete(/*last_watermark*/ None)
.await
});
let initialized = try_init_with_roots_and_backfill_lease(
home.path().to_path_buf(),
home.path().to_path_buf(),
"test-provider".to_string(),
/*backfill_lease_seconds*/ 60,
)
.await?;
complete_backfill.await??;
assert_eq!(
initialized.get_backfill_state().await?.status,
codex_state::BackfillStatus::Complete
);
Ok(())
}
#[tokio::test]
async fn try_init_times_out_waiting_for_stuck_startup_backfill() -> anyhow::Result<()> {
let home = TempDir::new().expect("temp dir");
let runtime =
codex_state::StateRuntime::init(home.path().to_path_buf(), "test-provider".to_string())
.await?;
let claimed = runtime.try_claim_backfill(/*lease_seconds*/ 60).await?;
assert!(claimed);
let result = try_init_with_roots_and_backfill_lease(
home.path().to_path_buf(),
home.path().to_path_buf(),
"test-provider".to_string(),
/*backfill_lease_seconds*/ 60,
)
.await;
let err = match result {
Ok(_) => panic!("state db init should not wait forever for incomplete backfill"),
Err(err) => err,
};
assert!(
err.to_string()
.contains("timed out waiting for state db backfill"),
"unexpected error: {err}"
);
Ok(())
}
+75 -6
View File
@@ -58,7 +58,7 @@ async fn insert_state_db_thread(
thread_id: ThreadId,
rollout_path: &Path,
archived: bool,
) {
) -> crate::state_db::StateDbHandle {
let runtime = codex_state::StateRuntime::init(home.to_path_buf(), TEST_PROVIDER.to_string())
.await
.expect("state db should initialize");
@@ -87,6 +87,7 @@ async fn insert_state_db_thread(
.upsert_thread(&metadata)
.await
.expect("state db upsert should succeed");
runtime
}
// TODO(jif) fix
@@ -236,7 +237,7 @@ async fn find_thread_path_falls_back_when_db_path_is_stale() {
let stale_db_path = home.join(format!(
"sessions/2099/01/01/rollout-2099-01-01T00-00-00-{uuid}.jsonl"
));
insert_state_db_thread(
let runtime = insert_state_db_thread(
home,
thread_id,
stale_db_path.as_path(),
@@ -244,7 +245,52 @@ async fn find_thread_path_falls_back_when_db_path_is_stale() {
)
.await;
let found = find_thread_path_by_id_str(home, &uuid.to_string())
let found = find_thread_path_by_id_str(home, &uuid.to_string(), Some(runtime.as_ref()))
.await
.expect("lookup should succeed");
assert_eq!(found, Some(fs_rollout_path.clone()));
assert_state_db_rollout_path(home, thread_id, Some(fs_rollout_path.as_path())).await;
}
#[tokio::test]
async fn find_thread_path_falls_back_when_db_path_points_to_another_thread() {
let temp = TempDir::new().unwrap();
let home = temp.path();
let uuid = Uuid::from_u128(304);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let ts = "2025-01-03T13-00-00";
write_session_file(
home,
ts,
uuid,
/*num_records*/ 1,
Some(SessionSource::Cli),
)
.unwrap();
let fs_rollout_path = home.join(format!("sessions/2025/01/03/rollout-{ts}-{uuid}.jsonl"));
let other_uuid = Uuid::from_u128(1304);
let other_ts = "2025-01-04T13-00-00";
write_session_file(
home,
other_ts,
other_uuid,
/*num_records*/ 1,
Some(SessionSource::Cli),
)
.unwrap();
let stale_db_path = home.join(format!(
"sessions/2025/01/04/rollout-{other_ts}-{other_uuid}.jsonl"
));
let runtime = insert_state_db_thread(
home,
thread_id,
stale_db_path.as_path(),
/*archived*/ false,
)
.await;
let found = find_thread_path_by_id_str(home, &uuid.to_string(), Some(runtime.as_ref()))
.await
.expect("lookup should succeed");
assert_eq!(found, Some(fs_rollout_path.clone()));
@@ -269,21 +315,44 @@ async fn find_thread_path_repairs_missing_db_row_after_filesystem_fallback() {
let fs_rollout_path = home.join(format!("sessions/2025/01/03/rollout-{ts}-{uuid}.jsonl"));
// Create an empty state DB so lookup takes the DB-first path and then falls back to files.
let _runtime = codex_state::StateRuntime::init(home.to_path_buf(), TEST_PROVIDER.to_string())
let runtime = codex_state::StateRuntime::init(home.to_path_buf(), TEST_PROVIDER.to_string())
.await
.expect("state db should initialize");
_runtime
runtime
.mark_backfill_complete(/*last_watermark*/ None)
.await
.expect("backfill should be complete");
let found = find_thread_path_by_id_str(home, &uuid.to_string())
let found = find_thread_path_by_id_str(home, &uuid.to_string(), Some(runtime.as_ref()))
.await
.expect("lookup should succeed");
assert_eq!(found, Some(fs_rollout_path.clone()));
assert_state_db_rollout_path(home, thread_id, Some(fs_rollout_path.as_path())).await;
}
#[tokio::test]
async fn find_thread_path_accepts_existing_state_db_path_without_canonical_filename() {
let temp = TempDir::new().unwrap();
let home = temp.path();
let uuid = Uuid::from_u128(305);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let db_rollout_path = home.join("sessions/2025/01/03/custom-rollout-name.jsonl");
fs::create_dir_all(db_rollout_path.parent().expect("rollout parent")).unwrap();
fs::write(&db_rollout_path, "").unwrap();
let runtime = insert_state_db_thread(
home,
thread_id,
db_rollout_path.as_path(),
/*archived*/ false,
)
.await;
let found = find_thread_path_by_id_str(home, &uuid.to_string(), Some(runtime.as_ref()))
.await
.expect("lookup should succeed");
assert_eq!(found, Some(db_rollout_path));
}
#[test]
fn rollout_date_parts_extracts_directory_components() {
let file_name = OsStr::new("rollout-2025-03-01T09-00-00-123.jsonl");
+4 -1
View File
@@ -54,6 +54,7 @@ use codex_core_api::WebSearchMode;
use codex_core_api::arg0_dispatch_or_else;
use codex_core_api::built_in_model_providers;
use codex_core_api::find_codex_home;
use codex_core_api::init_state_db;
use codex_core_api::item_event_to_server_notification;
use codex_core_api::set_default_originator;
use codex_core_api::thread_store_from_config;
@@ -102,6 +103,7 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
};
let config = new_config(args.model, arg0_paths)?;
let state_db = init_state_db(&config).await;
let auth_manager =
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await;
@@ -109,7 +111,7 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
config.codex_self_exe.clone(),
config.codex_linux_sandbox_exe.clone(),
)?;
let thread_store = thread_store_from_config(&config);
let thread_store = thread_store_from_config(&config, state_db.clone());
let environment_manager =
Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await);
let thread_manager = ThreadManager::new(
@@ -119,6 +121,7 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
environment_manager,
/*analytics_events_client*/ None,
Arc::clone(&thread_store),
state_db,
);
let NewThread {
@@ -13,15 +13,19 @@ pub(super) async fn archive_thread(
params: ArchiveThreadParams,
) -> ThreadStoreResult<()> {
let thread_id = params.thread_id;
let rollout_path =
find_thread_path_by_id_str(store.config.codex_home.as_path(), &thread_id.to_string())
.await
.map_err(|err| ThreadStoreError::InvalidRequest {
message: format!("failed to locate thread id {thread_id}: {err}"),
})?
.ok_or_else(|| ThreadStoreError::InvalidRequest {
message: format!("no rollout found for thread id {thread_id}"),
})?;
let state_db_ctx = store.state_db().await;
let rollout_path = find_thread_path_by_id_str(
store.config.codex_home.as_path(),
&thread_id.to_string(),
state_db_ctx.as_deref(),
)
.await
.map_err(|err| ThreadStoreError::InvalidRequest {
message: format!("failed to locate thread id {thread_id}: {err}"),
})?
.ok_or_else(|| ThreadStoreError::InvalidRequest {
message: format!("no rollout found for thread id {thread_id}"),
})?;
let canonical_rollout_path = scoped_rollout_path(
store.config.codex_home.join(codex_rollout::SESSIONS_SUBDIR),
@@ -48,7 +52,7 @@ pub(super) async fn archive_thread(
}
})?;
if let Some(ctx) = store.state_db().await {
if let Some(ctx) = state_db_ctx {
let _ = ctx
.mark_archived(thread_id, archived_path.as_path(), Utc::now())
.await;
@@ -77,7 +81,7 @@ mod tests {
#[tokio::test]
async fn archive_thread_moves_rollout_to_archived_collection() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let uuid = Uuid::from_u128(201);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let active_path =
@@ -123,7 +127,6 @@ mod tests {
async fn archive_thread_updates_sqlite_metadata_when_present() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let store = LocalThreadStore::new(config.clone());
let uuid = Uuid::from_u128(202);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let active_path =
@@ -134,6 +137,7 @@ mod tests {
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
runtime
.mark_backfill_complete(/*last_watermark*/ None)
.await
@@ -39,6 +39,7 @@ pub(super) async fn list_threads(
SortDirection::Asc => codex_rollout::SortDirection::Asc,
SortDirection::Desc => codex_rollout::SortDirection::Desc,
};
let state_db = store.state_db().await;
let rollout_config = RolloutConfig {
codex_home: store.config.codex_home.clone(),
sqlite_home: store.config.sqlite_home.clone(),
@@ -47,6 +48,7 @@ pub(super) async fn list_threads(
generate_memories: false,
};
let page = list_rollout_threads(
state_db,
&rollout_config,
store.config.default_model_provider_id.as_str(),
&params,
@@ -106,6 +108,7 @@ pub(super) async fn list_threads(
}
async fn list_rollout_threads(
state_db: Option<codex_rollout::StateDbHandle>,
config: &RolloutConfig,
default_model_provider_id: &str,
params: &ListThreadsParams,
@@ -115,6 +118,7 @@ async fn list_rollout_threads(
) -> ThreadStoreResult<codex_rollout::ThreadsPage> {
let page = if params.use_state_db_only && params.archived {
RolloutRecorder::list_archived_threads_from_state_db(
state_db,
config,
params.page_size,
cursor,
@@ -129,6 +133,7 @@ async fn list_rollout_threads(
.await
} else if params.use_state_db_only {
RolloutRecorder::list_threads_from_state_db(
state_db,
config,
params.page_size,
cursor,
@@ -143,6 +148,7 @@ async fn list_rollout_threads(
.await
} else if params.archived {
RolloutRecorder::list_archived_threads(
state_db,
config,
params.page_size,
cursor,
@@ -157,6 +163,7 @@ async fn list_rollout_threads(
.await
} else {
RolloutRecorder::list_threads(
state_db,
config,
params.page_size,
cursor,
@@ -196,7 +203,7 @@ mod tests {
#[tokio::test]
async fn list_threads_uses_default_provider_when_rollout_omits_provider() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
write_session_file_with(
home.path(),
home.path().join("sessions/2025/01/03"),
@@ -231,7 +238,6 @@ mod tests {
async fn list_threads_preserves_sqlite_title_search_results() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let store = LocalThreadStore::new(config.clone());
let uuid = Uuid::from_u128(103);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let rollout_path = home.path().join("rollout-title-search.jsonl");
@@ -243,6 +249,7 @@ mod tests {
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
runtime
.mark_backfill_complete(/*last_watermark*/ None)
.await
@@ -296,7 +303,7 @@ mod tests {
#[tokio::test]
async fn list_threads_selects_active_or_archived_collection() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let active_uuid = Uuid::from_u128(105);
let archived_uuid = Uuid::from_u128(106);
write_session_file(home.path(), "2025-01-03T12-00-00", active_uuid)
@@ -365,7 +372,7 @@ mod tests {
async fn list_threads_returns_local_rollout_summary() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let store = LocalThreadStore::new(config);
let store = LocalThreadStore::new(config, /*state_db*/ None);
let uuid = Uuid::from_u128(101);
let path =
write_session_file(home.path(), "2025-01-03T12-00-00", uuid).expect("session file");
@@ -404,7 +411,7 @@ mod tests {
#[tokio::test]
async fn list_threads_rejects_invalid_cursor() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let err = store
.list_threads(ListThreadsParams {
+17 -30
View File
@@ -19,7 +19,6 @@ use std::collections::hash_map::Entry;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::sync::OnceCell;
use crate::AppendThreadItemsParams;
use crate::ArchiveThreadParams;
@@ -42,7 +41,7 @@ use crate::UpdateThreadMetadataParams;
pub struct LocalThreadStore {
pub(super) config: LocalThreadStoreConfig,
live_recorders: Arc<Mutex<HashMap<ThreadId, RolloutRecorder>>>,
state_db: Arc<OnceCell<StateDbHandle>>,
state_db: Option<StateDbHandle>,
}
/// Process-scoped configuration for local thread storage.
@@ -76,30 +75,18 @@ impl std::fmt::Debug for LocalThreadStore {
}
impl LocalThreadStore {
/// Create a local store from process-scoped local storage configuration.
pub fn new(config: LocalThreadStoreConfig) -> Self {
/// Create a local store using an already initialized state DB handle.
pub fn new(config: LocalThreadStoreConfig, state_db: Option<StateDbHandle>) -> Self {
Self {
config,
live_recorders: Arc::new(Mutex::new(HashMap::new())),
state_db: Arc::new(OnceCell::new()),
state_db,
}
}
/// Return the state DB handle used by local rollout writers.
pub async fn state_db(&self) -> Option<StateDbHandle> {
self.state_db
.get_or_try_init(|| async {
codex_rollout::state_db::init_with_roots(
self.config.codex_home.clone(),
self.config.sqlite_home.clone(),
self.config.default_model_provider_id.clone(),
)
.await
.ok_or(())
})
.await
.ok()
.cloned()
self.state_db.clone()
}
/// Read a local rollout-backed thread by path.
@@ -302,7 +289,7 @@ mod tests {
#[tokio::test]
async fn live_writer_lifecycle_writes_and_closes() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let thread_id = ThreadId::default();
store
@@ -351,7 +338,7 @@ mod tests {
#[tokio::test]
async fn create_thread_rejects_missing_cwd() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let thread_id = ThreadId::default();
let mut params = create_thread_params(thread_id);
params.metadata.cwd = None;
@@ -371,7 +358,7 @@ mod tests {
#[tokio::test]
async fn discard_thread_drops_unmaterialized_live_writer() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let thread_id = ThreadId::default();
store
@@ -410,7 +397,7 @@ mod tests {
let config = test_config(home.path());
let thread_id = ThreadId::default();
let first_store = LocalThreadStore::new(config.clone());
let first_store = LocalThreadStore::new(config.clone(), /*state_db*/ None);
first_store
.create_thread(create_thread_params(thread_id))
.await
@@ -439,7 +426,7 @@ mod tests {
.await
.expect("shutdown initial writer");
let resumed_store = LocalThreadStore::new(config);
let resumed_store = LocalThreadStore::new(config, /*state_db*/ None);
resumed_store
.resume_thread(ResumeThreadParams {
thread_id,
@@ -470,7 +457,7 @@ mod tests {
#[tokio::test]
async fn create_thread_rejects_duplicate_live_writer() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let thread_id = ThreadId::default();
store
@@ -490,7 +477,7 @@ mod tests {
#[tokio::test]
async fn resume_thread_rejects_duplicate_live_writer() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let thread_id = ThreadId::default();
store
@@ -519,7 +506,7 @@ mod tests {
#[tokio::test]
async fn resume_thread_rejects_missing_cwd() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let uuid = uuid::Uuid::from_u128(407);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let rollout_path =
@@ -548,7 +535,7 @@ mod tests {
async fn load_history_uses_live_writer_rollout_path() {
let home = TempDir::new().expect("temp dir");
let external_home = TempDir::new().expect("external temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let uuid = uuid::Uuid::from_u128(404);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let rollout_path = write_session_file(external_home.path(), "2025-01-04T10-00-00", uuid)
@@ -597,7 +584,7 @@ mod tests {
async fn read_thread_uses_live_writer_rollout_path_for_external_resume() {
let home = TempDir::new().expect("temp dir");
let external_home = TempDir::new().expect("external temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let uuid = uuid::Uuid::from_u128(406);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let rollout_path = write_session_file(external_home.path(), "2025-01-04T11-00-00", uuid)
@@ -636,7 +623,7 @@ mod tests {
#[tokio::test]
async fn load_history_uses_live_writer_rollout_path_for_archived_source() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let uuid = uuid::Uuid::from_u128(405);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let rollout_path = write_archived_session_file(home.path(), "2025-01-04T10-30-00", uuid)
@@ -704,7 +691,7 @@ mod tests {
#[tokio::test]
async fn read_thread_by_rollout_path_includes_history() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let thread_id = ThreadId::default();
store
+44 -40
View File
@@ -10,7 +10,6 @@ use codex_rollout::find_thread_name_by_id;
use codex_rollout::find_thread_path_by_id_str;
use codex_rollout::read_session_meta_line;
use codex_rollout::read_thread_item_from_rollout;
use codex_state::StateRuntime;
use codex_state::ThreadMetadata;
use super::LocalThreadStore;
@@ -172,16 +171,22 @@ async fn resolve_rollout_path(
return Ok(Some(path));
}
let state_db_ctx = store.state_db().await;
if include_archived {
match find_thread_path_by_id_str(store.config.codex_home.as_path(), &thread_id.to_string())
.await
.map_err(|err| ThreadStoreError::InvalidRequest {
message: format!("failed to locate thread id {thread_id}: {err}"),
})? {
match find_thread_path_by_id_str(
store.config.codex_home.as_path(),
&thread_id.to_string(),
state_db_ctx.as_deref(),
)
.await
.map_err(|err| ThreadStoreError::InvalidRequest {
message: format!("failed to locate thread id {thread_id}: {err}"),
})? {
Some(path) => Ok(Some(path)),
None => find_archived_thread_path_by_id_str(
store.config.codex_home.as_path(),
&thread_id.to_string(),
state_db_ctx.as_deref(),
)
.await
.map_err(|err| ThreadStoreError::InvalidRequest {
@@ -189,11 +194,15 @@ async fn resolve_rollout_path(
}),
}
} else {
find_thread_path_by_id_str(store.config.codex_home.as_path(), &thread_id.to_string())
.await
.map_err(|err| ThreadStoreError::InvalidRequest {
message: format!("failed to locate thread id {thread_id}: {err}"),
})
find_thread_path_by_id_str(
store.config.codex_home.as_path(),
&thread_id.to_string(),
state_db_ctx.as_deref(),
)
.await
.map_err(|err| ThreadStoreError::InvalidRequest {
message: format!("failed to locate thread id {thread_id}: {err}"),
})
}
}
@@ -246,12 +255,7 @@ async fn read_sqlite_metadata(
store: &LocalThreadStore,
thread_id: codex_protocol::ThreadId,
) -> Option<ThreadMetadata> {
let runtime = StateRuntime::init(
store.config.sqlite_home.clone(),
store.config.default_model_provider_id.clone(),
)
.await
.ok()?;
let runtime = store.state_db().await?;
runtime.get_thread(thread_id).await.ok().flatten()
}
@@ -411,7 +415,7 @@ mod tests {
#[tokio::test]
async fn read_thread_returns_active_rollout_summary() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let uuid = Uuid::from_u128(205);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let active_path =
@@ -439,7 +443,7 @@ mod tests {
#[tokio::test]
async fn read_thread_returns_rollout_path_summary() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let uuid = Uuid::from_u128(211);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let active_path =
@@ -470,7 +474,6 @@ mod tests {
async fn read_thread_by_rollout_path_prefers_sqlite_git_info() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let store = LocalThreadStore::new(config.clone());
let uuid = Uuid::from_u128(223);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let active_path =
@@ -481,6 +484,7 @@ mod tests {
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
let mut builder = ThreadMetadataBuilder::new(
thread_id,
active_path.clone(),
@@ -518,7 +522,7 @@ mod tests {
#[tokio::test]
async fn read_thread_returns_archived_rollout_when_requested() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let uuid = Uuid::from_u128(207);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let archived_path = write_archived_session_file(home.path(), "2025-01-03T12-00-00", uuid)
@@ -559,7 +563,7 @@ mod tests {
#[tokio::test]
async fn read_thread_prefers_active_rollout_over_archived() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let uuid = Uuid::from_u128(208);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let active_path =
@@ -584,7 +588,7 @@ mod tests {
#[tokio::test]
async fn read_thread_returns_forked_from_id() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let uuid = Uuid::from_u128(209);
let parent_uuid = Uuid::from_u128(210);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
@@ -617,7 +621,6 @@ mod tests {
async fn read_thread_applies_sqlite_thread_name() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let store = LocalThreadStore::new(config.clone());
let uuid = Uuid::from_u128(212);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let rollout_path =
@@ -628,6 +631,7 @@ mod tests {
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
let mut builder =
ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli);
builder.model_provider = Some(config.default_model_provider_id.clone());
@@ -657,7 +661,13 @@ mod tests {
async fn read_thread_preserves_rollout_cwd_when_sqlite_metadata_exists() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let store = LocalThreadStore::new(config.clone());
let runtime = codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
let uuid = Uuid::from_u128(224);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let day_dir = home.path().join("sessions/2025/01/03");
@@ -690,12 +700,6 @@ mod tests {
});
writeln!(file, "{user_event}").expect("write user event");
let runtime = codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let mut builder = ThreadMetadataBuilder::new(
thread_id,
rollout_path.clone(),
@@ -732,7 +736,7 @@ mod tests {
#[tokio::test]
async fn read_thread_uses_legacy_thread_name_when_sqlite_title_is_missing() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let uuid = Uuid::from_u128(213);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
write_session_file(home.path(), "2025-01-03T12-00-00", uuid).expect("session file");
@@ -756,7 +760,6 @@ mod tests {
async fn read_thread_uses_sqlite_metadata_for_rollout_without_user_preview() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let store = LocalThreadStore::new(config.clone());
let uuid = Uuid::from_u128(217);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let day_dir = home.path().join("sessions/2025/01/03");
@@ -784,6 +787,7 @@ mod tests {
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
let mut builder = ThreadMetadataBuilder::new(
thread_id,
rollout_path.clone(),
@@ -826,7 +830,6 @@ mod tests {
let home = TempDir::new().expect("temp dir");
let external = TempDir::new().expect("external temp dir");
let config = test_config(home.path());
let store = LocalThreadStore::new(config.clone());
let uuid = Uuid::from_u128(220);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let rollout_path =
@@ -838,6 +841,7 @@ mod tests {
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
let mut builder = ThreadMetadataBuilder::new(
thread_id,
stale_path.clone(),
@@ -875,7 +879,6 @@ mod tests {
let home = TempDir::new().expect("temp dir");
let external = TempDir::new().expect("external temp dir");
let config = test_config(home.path());
let store = LocalThreadStore::new(config.clone());
let uuid = Uuid::from_u128(221);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let rollout_path =
@@ -889,6 +892,7 @@ mod tests {
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
let mut builder =
ThreadMetadataBuilder::new(thread_id, stale_path, Utc::now(), SessionSource::Cli);
builder.model_provider = Some("wrong-sqlite-provider".to_string());
@@ -920,7 +924,7 @@ mod tests {
#[tokio::test]
async fn read_thread_uses_session_meta_for_rollout_without_user_preview_or_sqlite_metadata() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let uuid = Uuid::from_u128(218);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let day_dir = home.path().join("sessions/2025/01/03");
@@ -975,7 +979,6 @@ mod tests {
let home = TempDir::new().expect("temp dir");
let external = TempDir::new().expect("external temp dir");
let config = test_config(home.path());
let store = LocalThreadStore::new(config.clone());
let uuid = Uuid::from_u128(214);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let rollout_path = external
@@ -987,6 +990,7 @@ mod tests {
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
let mut builder = ThreadMetadataBuilder::new(
thread_id,
rollout_path.clone(),
@@ -1033,7 +1037,6 @@ mod tests {
let home = TempDir::new().expect("temp dir");
let external = TempDir::new().expect("external temp dir");
let config = test_config(home.path());
let store = LocalThreadStore::new(config.clone());
let uuid = Uuid::from_u128(216);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let rollout_path = external
@@ -1047,6 +1050,7 @@ mod tests {
.expect("state db should initialize");
let mut builder =
ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli);
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
builder.archived_at = Some(Utc::now());
let mut metadata = builder.build(config.default_model_provider_id.as_str());
metadata.first_user_message = Some("Archived SQLite preview".to_string());
@@ -1089,7 +1093,6 @@ mod tests {
async fn read_thread_sqlite_fallback_loads_archived_history() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let store = LocalThreadStore::new(config.clone());
let uuid = Uuid::from_u128(219);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let archived_path = write_archived_session_file(home.path(), "2025-01-03T12-00-00", uuid)
@@ -1100,6 +1103,7 @@ mod tests {
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
let mut builder = ThreadMetadataBuilder::new(
thread_id,
archived_path.clone(),
@@ -1135,7 +1139,7 @@ mod tests {
#[tokio::test]
async fn read_thread_fails_without_rollout() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let uuid = Uuid::from_u128(206);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
@@ -17,9 +17,11 @@ pub(super) async fn unarchive_thread(
params: ArchiveThreadParams,
) -> ThreadStoreResult<StoredThread> {
let thread_id = params.thread_id;
let state_db_ctx = store.state_db().await;
let archived_path = find_archived_thread_path_by_id_str(
store.config.codex_home.as_path(),
&thread_id.to_string(),
state_db_ctx.as_deref(),
)
.await
.map_err(|err| ThreadStoreError::InvalidRequest {
@@ -71,7 +73,7 @@ pub(super) async fn unarchive_thread(
message: format!("failed to update unarchived thread timestamp: {err}"),
})?;
if let Some(ctx) = store.state_db().await {
if let Some(ctx) = state_db_ctx {
let _ = ctx
.mark_unarchived(thread_id, restored_path.as_path())
.await;
@@ -116,7 +118,7 @@ mod tests {
#[tokio::test]
async fn unarchive_thread_restores_rollout_and_returns_updated_thread() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let uuid = Uuid::from_u128(203);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let archived_path = write_archived_session_file(home.path(), "2025-01-03T13-00-00", uuid)
@@ -147,7 +149,6 @@ mod tests {
async fn unarchive_thread_updates_sqlite_metadata_when_present() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let store = LocalThreadStore::new(config.clone());
let uuid = Uuid::from_u128(204);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let archived_path = write_archived_session_file(home.path(), "2025-01-03T13-00-00", uuid)
@@ -158,6 +159,7 @@ mod tests {
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
runtime
.mark_backfill_complete(/*last_watermark*/ None)
.await
@@ -157,12 +157,16 @@ async fn resolve_rollout_path(
return Ok(ResolvedRolloutPath { path, archived });
}
let active_path =
find_thread_path_by_id_str(store.config.codex_home.as_path(), &thread_id.to_string())
.await
.map_err(|err| ThreadStoreError::InvalidRequest {
message: format!("failed to locate thread id {thread_id}: {err}"),
})?;
let state_db_ctx = store.state_db().await;
let active_path = find_thread_path_by_id_str(
store.config.codex_home.as_path(),
&thread_id.to_string(),
state_db_ctx.as_deref(),
)
.await
.map_err(|err| ThreadStoreError::InvalidRequest {
message: format!("failed to locate thread id {thread_id}: {err}"),
})?;
if let Some(path) = active_path {
return Ok(ResolvedRolloutPath {
path,
@@ -174,18 +178,22 @@ async fn resolve_rollout_path(
message: format!("thread not found: {thread_id}"),
});
}
find_archived_thread_path_by_id_str(store.config.codex_home.as_path(), &thread_id.to_string())
.await
.map_err(|err| ThreadStoreError::InvalidRequest {
message: format!("failed to locate archived thread id {thread_id}: {err}"),
})?
.map(|path| ResolvedRolloutPath {
path,
archived: true,
})
.ok_or_else(|| ThreadStoreError::InvalidRequest {
message: format!("thread not found: {thread_id}"),
})
find_archived_thread_path_by_id_str(
store.config.codex_home.as_path(),
&thread_id.to_string(),
state_db_ctx.as_deref(),
)
.await
.map_err(|err| ThreadStoreError::InvalidRequest {
message: format!("failed to locate archived thread id {thread_id}: {err}"),
})?
.map(|path| ResolvedRolloutPath {
path,
archived: true,
})
.ok_or_else(|| ThreadStoreError::InvalidRequest {
message: format!("thread not found: {thread_id}"),
})
}
fn rollout_path_is_archived(store: &LocalThreadStore, path: &std::path::Path) -> bool {
@@ -213,7 +221,7 @@ mod tests {
#[tokio::test]
async fn update_thread_metadata_sets_name_on_active_rollout_and_indexes_name() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let uuid = Uuid::from_u128(301);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let path =
@@ -248,7 +256,6 @@ mod tests {
async fn update_thread_metadata_sets_memory_mode_on_active_rollout() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let store = LocalThreadStore::new(config.clone());
let uuid = Uuid::from_u128(302);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let path =
@@ -259,6 +266,7 @@ mod tests {
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
let thread = store
.update_thread_metadata(UpdateThreadMetadataParams {
@@ -288,7 +296,7 @@ mod tests {
async fn update_thread_metadata_uses_live_rollout_path_for_external_resume() {
let home = TempDir::new().expect("temp dir");
let external_home = TempDir::new().expect("external temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let uuid = Uuid::from_u128(307);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let path = write_session_file(external_home.path(), "2025-01-03T14-45-00", uuid)
@@ -328,7 +336,7 @@ mod tests {
#[tokio::test]
async fn update_thread_metadata_rejects_mismatched_session_meta_id() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let filename_uuid = Uuid::from_u128(303);
let metadata_uuid = Uuid::from_u128(304);
let thread_id = ThreadId::from_string(&filename_uuid.to_string()).expect("valid thread id");
@@ -360,7 +368,7 @@ mod tests {
#[tokio::test]
async fn update_thread_metadata_rejects_multi_field_patch_without_partial_write() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()));
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let uuid = Uuid::from_u128(305);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let path =
@@ -395,7 +403,6 @@ mod tests {
async fn update_thread_metadata_keeps_archived_thread_archived_in_sqlite() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let store = LocalThreadStore::new(config.clone());
let uuid = Uuid::from_u128(306);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let archived_path = write_archived_session_file(home.path(), "2025-01-03T16-00-00", uuid)
@@ -406,6 +413,7 @@ mod tests {
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
runtime
.mark_backfill_complete(/*last_watermark*/ None)
.await
@@ -458,7 +466,6 @@ mod tests {
async fn update_thread_metadata_keeps_live_archived_thread_archived_in_sqlite() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let store = LocalThreadStore::new(config.clone());
let uuid = Uuid::from_u128(308);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let archived_path = write_archived_session_file(home.path(), "2025-01-03T16-30-00", uuid)
@@ -469,6 +476,7 @@ mod tests {
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
runtime
.mark_backfill_complete(/*last_watermark*/ None)
.await
+1
View File
@@ -109,6 +109,7 @@ pub use responses_api::mcp_tool_to_deferred_responses_api_tool;
pub use responses_api::mcp_tool_to_responses_api_tool;
pub use responses_api::tool_definition_to_responses_api_tool;
pub use tool_config::ShellCommandBackendConfig;
pub use tool_config::ToolEnvironmentMode;
pub use tool_config::ToolUserShellType;
pub use tool_config::ToolsConfig;
pub use tool_config::ToolsConfigParams;
+25 -4
View File
@@ -88,7 +88,7 @@ pub struct ToolsConfig {
pub shell_type: ConfigShellToolType,
pub shell_command_backend: ShellCommandBackendConfig,
pub unified_exec_shell_mode: UnifiedExecShellMode,
pub has_environment: bool,
pub environment_mode: ToolEnvironmentMode,
pub allow_login_shell: bool,
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
pub web_search_mode: Option<WebSearchMode>,
@@ -129,6 +129,27 @@ pub struct ToolsConfigParams<'a> {
pub windows_sandbox_level: WindowsSandboxLevel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolEnvironmentMode {
None,
Single,
Multiple,
}
impl ToolEnvironmentMode {
pub fn from_count(count: usize) -> Self {
match count {
0 => Self::None,
1 => Self::Single,
_ => Self::Multiple,
}
}
pub fn has_environment(self) -> bool {
!matches!(self, Self::None)
}
}
impl ToolsConfig {
pub fn new(params: &ToolsConfigParams<'_>) -> Self {
let ToolsConfigParams {
@@ -205,7 +226,7 @@ impl ToolsConfig {
shell_type,
shell_command_backend,
unified_exec_shell_mode: UnifiedExecShellMode::Direct,
has_environment: true,
environment_mode: ToolEnvironmentMode::Single,
allow_login_shell: true,
apply_patch_tool_type,
web_search_mode: *web_search_mode,
@@ -306,8 +327,8 @@ impl ToolsConfig {
self
}
pub fn with_has_environment(mut self, has_environment: bool) -> Self {
self.has_environment = has_environment;
pub fn with_environment_mode(mut self, environment_mode: ToolEnvironmentMode) -> Self {
self.environment_mode = environment_mode;
self
}
+7 -5
View File
@@ -135,7 +135,7 @@ pub fn build_tool_registry_plan(
);
}
if config.has_environment {
if config.environment_mode.has_environment() {
match &config.shell_type {
ConfigShellToolType::Default => {
plan.push_spec(
@@ -184,7 +184,9 @@ pub fn build_tool_registry_plan(
}
}
if config.has_environment && config.shell_type != ConfigShellToolType::Disabled {
if config.environment_mode.has_environment()
&& config.shell_type != ConfigShellToolType::Disabled
{
plan.register_handler("shell", ToolHandlerKind::Shell);
plan.register_handler("container.exec", ToolHandlerKind::Shell);
plan.register_handler("local_shell", ToolHandlerKind::Shell);
@@ -324,7 +326,7 @@ pub fn build_tool_registry_plan(
);
}
if config.has_environment
if config.environment_mode.has_environment()
&& let Some(apply_patch_tool_type) = &config.apply_patch_tool_type
{
match apply_patch_tool_type {
@@ -346,7 +348,7 @@ pub fn build_tool_registry_plan(
plan.register_handler("apply_patch", ToolHandlerKind::ApplyPatch);
}
if config.has_environment
if config.environment_mode.has_environment()
&& config
.experimental_supported_tools
.iter()
@@ -393,7 +395,7 @@ pub fn build_tool_registry_plan(
);
}
if config.has_environment {
if config.environment_mode.has_environment() {
plan.push_spec(
create_view_image_tool(ViewImageToolOptions {
can_request_original_image_detail: config.can_request_original_image_detail,
@@ -11,6 +11,7 @@ use crate::ResponsesApiNamespaceTool;
use crate::ResponsesApiTool;
use crate::ResponsesApiWebSearchFilters;
use crate::ResponsesApiWebSearchUserLocation;
use crate::ToolEnvironmentMode;
use crate::ToolHandlerSpec;
use crate::ToolName;
use crate::ToolNamespace;
@@ -544,7 +545,7 @@ fn disabled_environment_omits_environment_backed_tools() {
permission_profile: &PermissionProfile::Disabled,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
})
.with_has_environment(/*has_environment*/ false);
.with_environment_mode(ToolEnvironmentMode::None);
tools_config
.experimental_supported_tools
.push("list_dir".to_string());
+15
View File
@@ -76,6 +76,8 @@ use crate::tui;
use crate::tui::TuiEvent;
use crate::update_action::UpdateAction;
use crate::version::CODEX_CLI_VERSION;
use crate::workspace_command::AppServerWorkspaceCommandRunner;
use crate::workspace_command::WorkspaceCommandRunner;
use codex_ansi_escape::ansi_escape_line;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::TypedRequestError;
@@ -144,6 +146,7 @@ use codex_protocol::openai_models::ModelUpgrade;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
#[cfg(target_os = "windows")]
use codex_protocol::permissions::FileSystemSandboxKind;
use codex_rollout::StateDbHandle;
use codex_terminal_detection::user_agent;
use codex_utils_absolute_path::AbsolutePathBuf;
use color_eyre::eyre::Result;
@@ -431,8 +434,10 @@ pub(crate) struct App {
pub(crate) session_telemetry: SessionTelemetry,
pub(crate) app_event_tx: AppEventSender,
pub(crate) chat_widget: ChatWidget,
workspace_command_runner: Option<WorkspaceCommandRunner>,
/// Config is stored here so we can recreate ChatWidgets as needed.
pub(crate) config: Config,
pub(crate) state_db: Option<StateDbHandle>,
pub(crate) active_profile: Option<String>,
cli_kv_overrides: Vec<(String, TomlValue)>,
harness_overrides: ConfigOverrides,
@@ -572,6 +577,7 @@ impl App {
config: cfg,
frame_requester: tui.frame_requester(),
app_event_tx: self.app_event_tx.clone(),
workspace_command_runner: self.workspace_command_runner.clone(),
initial_user_message,
enhanced_keys_supported: self.enhanced_keys_supported,
has_chatgpt_account: self.chat_widget.has_chatgpt_account(),
@@ -609,6 +615,7 @@ impl App {
should_prompt_windows_sandbox_nux_at_startup: bool,
remote_app_server_url: Option<String>,
remote_app_server_auth_token: Option<String>,
state_db: Option<StateDbHandle>,
environment_manager: Arc<EnvironmentManager>,
) -> Result<AppExitInfo> {
use tokio_stream::StreamExt;
@@ -709,6 +716,9 @@ impl App {
let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false));
let terminal_title_invalid_items_warned = Arc::new(AtomicBool::new(false));
let workspace_command_runner: WorkspaceCommandRunner = Arc::new(
AppServerWorkspaceCommandRunner::new(app_server.request_handle()),
);
let runtime_model_provider_base_url =
resolve_runtime_model_provider_base_url(&config.model_provider).await;
@@ -731,6 +741,7 @@ impl App {
config: config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
workspace_command_runner: Some(workspace_command_runner.clone()),
initial_user_message: crate::chatwidget::create_initial_user_message(
initial_prompt.clone(),
initial_images.clone(),
@@ -766,6 +777,7 @@ impl App {
config: config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
workspace_command_runner: Some(workspace_command_runner.clone()),
initial_user_message: crate::chatwidget::create_initial_user_message(
initial_prompt.clone(),
initial_images.clone(),
@@ -806,6 +818,7 @@ impl App {
config: config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
workspace_command_runner: Some(workspace_command_runner.clone()),
initial_user_message: crate::chatwidget::create_initial_user_message(
initial_prompt.clone(),
initial_images.clone(),
@@ -853,7 +866,9 @@ See the Codex keymap documentation for supported actions and examples."
session_telemetry: session_telemetry.clone(),
app_event_tx,
chat_widget,
workspace_command_runner: Some(workspace_command_runner),
config,
state_db,
active_profile,
cli_kv_overrides,
harness_overrides,

Some files were not shown because too many files have changed in this diff Show More