mirror of
https://github.com/openai/codex.git
synced 2026-11-09 20:50:33 +00:00
Preserve runtime workspace roots across thread resume (#43848)
## Why Resuming a thread should retain its selected workspace folders, including additional roots and explicit empty selections. Resume overrides also need to survive a subsequent resume when no turn has run. ## What changed - Persist `runtime_workspace_roots` in startup metadata and thread settings snapshots, separately from explicit environment selections and permission-profile roots. - Restore roots from the latest snapshot owned by the resumed thread, falling back to owned startup metadata only when no snapshot exists. Honor explicit `runtimeWorkspaceRoots` overrides, retarget the old `cwd` root when `cwd` changes, deduplicate roots, and validate restored paths for the current host. - Checkpoint effective settings on resume and restored settings after revert. Reload resume configuration if saved workspace roots change during loading. - Normalize Windows rollout path spellings when matching thread search results, preserving selection of the correct rollout after revert, including compressed rollouts. ## Testing Add regression coverage for workspace restoration, empty and explicit overrides, foreign paths, compaction and revert, resume checkpoints without recency changes, concurrent settings persistence, and rollout search path matching. GitOrigin-RevId: d98d9d34dd63934d441120916c61c12b69e7f062
This commit is contained in:
@@ -766,6 +766,7 @@ impl Session {
|
||||
windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop,
|
||||
use_legacy_landlock: config.features.use_legacy_landlock(),
|
||||
legacy_fallback_cwd: config.cwd.clone(),
|
||||
runtime_workspace_roots: config.workspace_roots.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
thread_name: None,
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
@@ -1472,8 +1473,14 @@ impl Session {
|
||||
self.state.lock().await.latest_token_usage_record =
|
||||
Self::last_token_usage_record_from_rollout(&rollout_items);
|
||||
|
||||
// Checkpoint effective settings even when no turn follows the resume.
|
||||
self.persist_rollout_items(&[RolloutItem::EventMsg(
|
||||
thread_settings::applied_event(self).await,
|
||||
)])
|
||||
.await;
|
||||
|
||||
// Defer seeding the session's initial context until the first turn starts so
|
||||
// turn/start overrides can be merged before we write to the rollout.
|
||||
// turn/start overrides can be merged before we write model-visible context.
|
||||
if !is_subagent {
|
||||
let _ = self.flush_rollout().await;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ use codex_protocol::protocol::ThreadSource;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelections;
|
||||
use codex_skills::SkillError;
|
||||
use codex_utils_git_discovery::GitRootDiscovery;
|
||||
use codex_utils_path::replace_path_and_deduplicate;
|
||||
use std::sync::OnceLock;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
@@ -108,6 +109,8 @@ pub(crate) struct SessionConfiguration {
|
||||
|
||||
/// Legacy thread cwd used when a turn does not select an environment.
|
||||
pub(super) legacy_fallback_cwd: AbsolutePathBuf,
|
||||
/// Top-level runtime workspace roots, independent of explicit environment selections.
|
||||
pub(super) runtime_workspace_roots: Vec<AbsolutePathBuf>,
|
||||
/// Directory containing all Codex state for this session.
|
||||
pub(super) codex_home: AbsolutePathBuf,
|
||||
/// Optional user-facing name for the thread, updated during the session.
|
||||
@@ -293,6 +296,7 @@ impl SessionConfiguration {
|
||||
permission_profile: self.materialized_permission_profile(environment_selections),
|
||||
active_permission_profile: self.active_permission_profile(),
|
||||
cwd: self.legacy_fallback_cwd.clone(),
|
||||
runtime_workspace_roots: Some(self.runtime_workspace_roots.clone()),
|
||||
reasoning_effort: self.step_settings.collaboration_mode.reasoning_effort(),
|
||||
reasoning_summary: self.step_settings.reasoning_summary,
|
||||
personality: self.step_settings.personality,
|
||||
@@ -310,6 +314,7 @@ impl SessionConfiguration {
|
||||
self.legacy_fallback_cwd.clone(),
|
||||
environment_selections,
|
||||
)),
|
||||
runtime_workspace_roots: Some(self.runtime_workspace_roots.clone()),
|
||||
profile_workspace_roots: Some(
|
||||
self.permission_profile_state
|
||||
.profile_workspace_roots()
|
||||
@@ -388,6 +393,15 @@ impl SessionConfiguration {
|
||||
next_configuration.legacy_fallback_cwd = environments.legacy_fallback_cwd.clone();
|
||||
}
|
||||
let cwd_changed = next_configuration.legacy_fallback_cwd != current_cwd;
|
||||
if let Some(runtime_workspace_roots) = &updates.runtime_workspace_roots {
|
||||
next_configuration.runtime_workspace_roots = runtime_workspace_roots.clone();
|
||||
} else if cwd_changed {
|
||||
next_configuration.runtime_workspace_roots = replace_path_and_deduplicate(
|
||||
next_configuration.runtime_workspace_roots,
|
||||
current_cwd.as_path(),
|
||||
next_configuration.legacy_fallback_cwd.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(permission_profile) = updates.permission_profile.clone() {
|
||||
let active_permission_profile =
|
||||
@@ -549,6 +563,7 @@ pub(crate) struct SessionSettingsCommit {
|
||||
pub(crate) struct SessionSettingsUpdate {
|
||||
pub(crate) step_settings: StepSettingsUpdate,
|
||||
pub(crate) environments: Option<TurnEnvironmentSelections>,
|
||||
pub(crate) runtime_workspace_roots: Option<Vec<AbsolutePathBuf>>,
|
||||
pub(crate) profile_workspace_roots: Option<Vec<AbsolutePathBuf>>,
|
||||
pub(crate) sandbox_policy: Option<SandboxPolicy>,
|
||||
pub(crate) permission_profile: Option<PermissionProfile>,
|
||||
@@ -882,6 +897,7 @@ impl Session {
|
||||
initial_window_id: initial_auto_compact_window_ids
|
||||
.window_id
|
||||
.to_string(),
|
||||
runtime_workspace_roots: Some(config.workspace_roots.clone()),
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: Some(config.cwd.to_path_buf()),
|
||||
model_provider: config.model_provider_id.clone(),
|
||||
|
||||
@@ -4486,6 +4486,7 @@ async fn set_rate_limits_retains_previous_credits() {
|
||||
windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop,
|
||||
use_legacy_landlock: config.features.use_legacy_landlock(),
|
||||
legacy_fallback_cwd: config.cwd.clone(),
|
||||
runtime_workspace_roots: config.workspace_roots.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
thread_name: None,
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
@@ -4605,6 +4606,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
|
||||
windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop,
|
||||
use_legacy_landlock: config.features.use_legacy_landlock(),
|
||||
legacy_fallback_cwd: config.cwd.clone(),
|
||||
runtime_workspace_roots: config.workspace_roots.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
thread_name: None,
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
@@ -4956,6 +4958,7 @@ async fn open_thread_persistence(session: &mut Session) -> PathBuf {
|
||||
subagent_history_start_ordinal: None,
|
||||
history_base: None,
|
||||
initial_window_id: Uuid::now_v7().to_string(),
|
||||
runtime_workspace_roots: None,
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: Some(config.cwd.to_path_buf()),
|
||||
model_provider: config.model_provider_id.clone(),
|
||||
@@ -5249,6 +5252,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
|
||||
windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop,
|
||||
use_legacy_landlock: config.features.use_legacy_landlock(),
|
||||
legacy_fallback_cwd: config.cwd.clone(),
|
||||
runtime_workspace_roots: config.workspace_roots.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
thread_name: None,
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
@@ -5907,8 +5911,8 @@ async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_up
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compaction_checkpoint_waits_for_accepted_settings_persistence() {
|
||||
let (mut session, _turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx(
|
||||
async fn settings_checkpoint_waits_for_accepted_settings_persistence() {
|
||||
let (mut session, _turn_context, rx) = make_session_and_context_with_auth_and_config_and_rx(
|
||||
CodexAuth::from_api_key("Test API Key"),
|
||||
Vec::new(),
|
||||
|config| {
|
||||
@@ -5957,13 +5961,17 @@ async fn compaction_checkpoint_waits_for_accepted_settings_persistence() {
|
||||
},
|
||||
),
|
||||
));
|
||||
let mut settings_checkpoint = Box::pin(tokio::task::unconstrained(
|
||||
session.checkpoint_thread_settings(),
|
||||
));
|
||||
assert!(futures::poll!(checkpoint.as_mut()).is_pending());
|
||||
assert!(futures::poll!(settings_checkpoint.as_mut()).is_pending());
|
||||
assert_eq!(
|
||||
session.clone_history().await.annotated_items(),
|
||||
history_before.annotated_items()
|
||||
);
|
||||
|
||||
// Direct runtime restoration may overlap postcommit work. The checkpoint must wait
|
||||
// Direct runtime restoration may overlap postcommit work. Both checkpoints must wait
|
||||
// for the accepted event, then capture current settings rather than its older commit.
|
||||
let restored = session
|
||||
.update_settings(SessionSettingsUpdate {
|
||||
@@ -5980,8 +5988,18 @@ async fn compaction_checkpoint_waits_for_accepted_settings_persistence() {
|
||||
drop(refresh_guard);
|
||||
update.await.expect("accepted settings update");
|
||||
checkpoint.await;
|
||||
settings_checkpoint
|
||||
.await
|
||||
.expect("checkpoint current settings");
|
||||
|
||||
let live_snapshots = std::iter::from_fn(|| rx.try_recv().ok())
|
||||
.filter_map(|event| match event.msg {
|
||||
EventMsg::ThreadSettingsApplied(event) => Some(event.thread_settings),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(live_snapshots, vec![committed.clone()]);
|
||||
|
||||
session.flush_rollout().await.expect("flush checkpoint");
|
||||
let (items, _, _) = RolloutRecorder::load_rollout_items(&rollout_path)
|
||||
.await
|
||||
.expect("read persisted settings");
|
||||
@@ -5998,6 +6016,7 @@ async fn compaction_checkpoint_waits_for_accepted_settings_persistence() {
|
||||
snapshots,
|
||||
vec![
|
||||
(Some(session.thread_id), committed),
|
||||
(Some(session.thread_id), restored.clone()),
|
||||
(Some(session.thread_id), restored),
|
||||
]
|
||||
);
|
||||
@@ -6304,6 +6323,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() {
|
||||
windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop,
|
||||
use_legacy_landlock: config.features.use_legacy_landlock(),
|
||||
legacy_fallback_cwd: config.cwd.clone(),
|
||||
runtime_workspace_roots: config.workspace_roots.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
thread_name: None,
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
@@ -6458,6 +6478,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop,
|
||||
use_legacy_landlock: config.features.use_legacy_landlock(),
|
||||
legacy_fallback_cwd: config.cwd.clone(),
|
||||
runtime_workspace_roots: config.workspace_roots.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
thread_name: None,
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
@@ -6760,6 +6781,7 @@ async fn make_session_with_config_and_rx(
|
||||
windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop,
|
||||
use_legacy_landlock: config.features.use_legacy_landlock(),
|
||||
legacy_fallback_cwd: config.cwd.clone(),
|
||||
runtime_workspace_roots: config.workspace_roots.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
thread_name: None,
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
@@ -6887,6 +6909,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx(
|
||||
windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop,
|
||||
use_legacy_landlock: config.features.use_legacy_landlock(),
|
||||
legacy_fallback_cwd: config.cwd.clone(),
|
||||
runtime_workspace_roots: config.workspace_roots.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
thread_name: None,
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
@@ -8222,6 +8245,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() {
|
||||
subagent_history_start_ordinal: None,
|
||||
history_base: None,
|
||||
initial_window_id: Uuid::now_v7().to_string(),
|
||||
runtime_workspace_roots: None,
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: Some(config.cwd.to_path_buf()),
|
||||
model_provider: config.model_provider_id.clone(),
|
||||
@@ -8333,6 +8357,7 @@ async fn submission_loop_channel_close_runs_full_thread_teardown() {
|
||||
subagent_history_start_ordinal: None,
|
||||
history_base: None,
|
||||
initial_window_id: Uuid::now_v7().to_string(),
|
||||
runtime_workspace_roots: None,
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: Some(config.cwd.to_path_buf()),
|
||||
model_provider: config.model_provider_id.clone(),
|
||||
@@ -8761,6 +8786,7 @@ where
|
||||
windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop,
|
||||
use_legacy_landlock: config.features.use_legacy_landlock(),
|
||||
legacy_fallback_cwd: config.cwd.clone(),
|
||||
runtime_workspace_roots: config.workspace_roots.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
thread_name: None,
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
@@ -10999,6 +11025,7 @@ async fn attach_in_memory_thread_store(
|
||||
subagent_history_start_ordinal: None,
|
||||
history_base: None,
|
||||
initial_window_id: Uuid::now_v7().to_string(),
|
||||
runtime_workspace_roots: None,
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: Some(config.cwd.to_path_buf()),
|
||||
model_provider: config.model_provider_id.clone(),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Handles persistent thread-settings updates and serializes their persistence
|
||||
//! with compaction checkpoints.
|
||||
//! with checkpoints written directly to storage.
|
||||
|
||||
use super::session::Session;
|
||||
use super::session::SessionSettingsUpdate;
|
||||
use super::step_settings::StepSettingsUpdate;
|
||||
use crate::config::ConstraintResult;
|
||||
use codex_history::RolloutItem;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::Event;
|
||||
@@ -12,9 +13,24 @@ use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ThreadSettingsAppliedEvent;
|
||||
use codex_protocol::protocol::ThreadSettingsOverrides;
|
||||
use codex_protocol::protocol::ThreadSettingsSnapshot;
|
||||
use codex_thread_store::ThreadStoreResult;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::SemaphorePermit;
|
||||
|
||||
impl Session {
|
||||
/// Captures and flushes current settings under the shared persistence permit.
|
||||
pub(crate) async fn checkpoint_thread_settings(&self) -> ThreadStoreResult<()> {
|
||||
let _settings_guard = acquire_persistence_lock(self).await;
|
||||
if let Some(live_thread) = self.live_thread() {
|
||||
live_thread
|
||||
.append_items(&[RolloutItem::EventMsg(applied_event(self).await)])
|
||||
.await?;
|
||||
live_thread.flush().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies standalone thread settings and reports invalid overrides through the
|
||||
/// normal event stream.
|
||||
pub(super) async fn update(
|
||||
@@ -41,6 +57,7 @@ pub(super) async fn update(
|
||||
pub(super) fn prepare_update(overrides: ThreadSettingsOverrides) -> SessionSettingsUpdate {
|
||||
let ThreadSettingsOverrides {
|
||||
environments,
|
||||
runtime_workspace_roots,
|
||||
profile_workspace_roots,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
@@ -67,6 +84,7 @@ pub(super) fn prepare_update(overrides: ThreadSettingsOverrides) -> SessionSetti
|
||||
approvals_reviewer,
|
||||
},
|
||||
environments,
|
||||
runtime_workspace_roots,
|
||||
profile_workspace_roots,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
@@ -115,7 +133,7 @@ pub(super) async fn emit_applied(
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Builds a current thread-owned snapshot for fork and compaction persistence.
|
||||
/// Builds a current thread-owned snapshot for storage checkpoints.
|
||||
pub(super) async fn applied_event(session: &Session) -> EventMsg {
|
||||
EventMsg::ThreadSettingsApplied(ThreadSettingsAppliedEvent {
|
||||
thread_id: Some(session.thread_id()),
|
||||
|
||||
Reference in New Issue
Block a user