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:
rhan-oai
2026-09-08 16:24:38 +00:00
committed by copyberry
parent dd9512c000
commit 6515a72db7
46 changed files with 984 additions and 135 deletions
+1
View File
@@ -1211,6 +1211,7 @@ async fn spawn_agent_fork_from_paginated_parent_uses_model_context_prefix() {
permission_profile: PermissionProfile::workspace_write(),
active_permission_profile: None,
cwd: harness.config.cwd.clone(),
runtime_workspace_roots: None,
reasoning_effort: None,
reasoning_summary: None,
personality: None,
+10
View File
@@ -138,6 +138,7 @@ impl ThreadConfigSnapshot {
#[derive(Clone, Default)]
pub struct CodexThreadSettingsOverrides {
pub environments: Option<TurnEnvironmentSelections>,
pub runtime_workspace_roots: Option<Vec<AbsolutePathBuf>>,
pub profile_workspace_roots: Option<Vec<AbsolutePathBuf>>,
pub approval_policy: Option<AskForApproval>,
pub approvals_reviewer: Option<ApprovalsReviewer>,
@@ -535,9 +536,17 @@ impl CodexThread {
self.session.update_settings(updates).await.map(|_| ())
}
/// Persists current settings without emitting a live settings event.
///
/// Serializes snapshot capture and persistence with accepted settings updates.
pub async fn checkpoint_thread_settings(&self) -> ThreadStoreResult<()> {
self.session.checkpoint_thread_settings().await
}
fn thread_settings_update(overrides: CodexThreadSettingsOverrides) -> SessionSettingsUpdate {
let CodexThreadSettingsOverrides {
environments,
runtime_workspace_roots,
profile_workspace_roots,
approval_policy,
approvals_reviewer,
@@ -564,6 +573,7 @@ impl CodexThread {
approvals_reviewer,
},
environments,
runtime_workspace_roots,
profile_workspace_roots,
sandbox_policy,
permission_profile,
+8 -1
View File
@@ -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;
}
+16
View File
@@ -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(),
+31 -4
View File
@@ -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(),
+20 -2
View File
@@ -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()),