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
+7
View File
@@ -20,6 +20,7 @@ use codex_protocol::protocol::ThreadHistoryMode;
use codex_protocol::protocol::ThreadMemoryMode;
use codex_rollout::RolloutItem;
use codex_rollout::persisted_rollout_items;
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::AppendThreadItemsParams;
use crate::ArchiveThreadParams;
@@ -144,6 +145,7 @@ mod tests {
history_base: None,
subagent_history_start_ordinal: None,
initial_window_id: uuid::Uuid::now_v7().to_string(),
runtime_workspace_roots: None,
metadata: ThreadPersistenceMetadata {
cwd: None,
model_provider: "test-provider".to_string(),
@@ -437,6 +439,7 @@ mod tests {
history_base: None,
subagent_history_start_ordinal: None,
initial_window_id: uuid::Uuid::now_v7().to_string(),
runtime_workspace_roots: None,
metadata: thread_metadata(),
}
}
@@ -549,6 +552,10 @@ impl InMemoryThreadStore {
forked_from_id: params.forked_from_id,
parent_thread_id: params.parent_thread_id,
cwd: params.metadata.cwd.clone().unwrap_or_default(),
runtime_workspace_roots: params
.runtime_workspace_roots
.as_ref()
.map(|roots| roots.iter().map(AbsolutePathBuf::to_path_buf).collect()),
agent_nickname: params.source.get_nickname(),
agent_role: params.source.get_agent_role(),
agent_path: params.source.get_agent_path().map(Into::into),
@@ -6,6 +6,7 @@ use codex_protocol::protocol::ThreadMemoryMode;
use codex_rollout::RolloutConfig;
use codex_rollout::RolloutRecorder;
use codex_rollout::RolloutRecorderParams;
use codex_utils_absolute_path::AbsolutePathBuf;
pub(super) async fn create_thread(
store: &LocalThreadStore,
@@ -39,6 +40,12 @@ pub(super) async fn create_thread(
)
.with_session_id(params.session_id)
.with_selected_capability_roots(params.selected_capability_roots)
.with_runtime_workspace_roots(params.runtime_workspace_roots.map(|roots| {
roots
.into_iter()
.map(AbsolutePathBuf::into_path_buf)
.collect()
}))
.with_multi_agent_version(params.multi_agent_version)
.with_history_mode(params.history_mode)
.with_history_base(params.history_base)
+1
View File
@@ -1963,6 +1963,7 @@ mod tests {
history_base: None,
subagent_history_start_ordinal: None,
initial_window_id: uuid::Uuid::now_v7().to_string(),
runtime_workspace_roots: None,
metadata: thread_metadata(),
}
}
@@ -277,6 +277,7 @@ fn create_thread_params(thread_id: ThreadId) -> CreateThreadParams {
history_base: None,
subagent_history_start_ordinal: None,
initial_window_id: uuid::Uuid::now_v7().to_string(),
runtime_workspace_roots: None,
metadata: ThreadPersistenceMetadata {
cwd: Some(std::env::current_dir().expect("cwd")),
model_provider: "test-provider".to_string(),
@@ -170,6 +170,7 @@ async fn create_replacement_recorder(
.with_session_id(source_meta.session_id)
.with_rollout_id(rollout_id)
.with_selected_capability_roots(source_meta.selected_capability_roots)
.with_runtime_workspace_roots(source_meta.runtime_workspace_roots)
.with_multi_agent_version(source_meta.multi_agent_version)
.with_history_mode(ThreadHistoryMode::Paginated)
.with_history_base(history_base)
@@ -8,6 +8,7 @@ use codex_protocol::protocol::ThreadMemoryMode;
use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::protocol::TurnStartedEvent;
use codex_rollout::RolloutItem;
use codex_utils_absolute_path::test_support::PathExt;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
@@ -94,6 +95,10 @@ async fn revert_keeps_thread_id_and_hides_suffix_across_repeated_reverts() {
.meta;
assert_eq!(replacement_meta.id, thread_id);
assert_eq!(replacement_meta.memory_mode, None);
assert_eq!(
replacement_meta.runtime_workspace_roots,
Some(vec![home.path().join("workspace")])
);
assert_eq!(turn_ids(&store, thread_id).await, vec!["turn-1"]);
store
@@ -242,6 +247,7 @@ async fn create_paginated_thread(store: &LocalThreadStore, thread_id: ThreadId)
history_base: None,
subagent_history_start_ordinal: None,
initial_window_id: "window-1".to_string(),
runtime_workspace_roots: Some(vec![store.config.codex_home.join("workspace").abs()]),
metadata: ThreadPersistenceMetadata {
cwd: Some(std::env::current_dir().expect("cwd")),
model_provider: "test-provider".to_string(),
@@ -1,10 +1,13 @@
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use codex_install_context::InstallContext;
use codex_rollout::RolloutConfig;
use codex_rollout::first_rollout_content_match_snippet;
use codex_rollout::parse_cursor;
use codex_rollout::search_rollout_matches;
use codex_utils_absolute_path::normalize_windows_device_path;
use super::LocalThreadStore;
use super::helpers::resolve_thread_names;
@@ -106,7 +109,10 @@ pub(super) async fn search_threads(
relation_filter: None,
use_state_db_only: state_db.is_some(),
};
let mut remaining_rollouts = matching_rollouts;
let mut remaining_rollouts = matching_rollouts
.into_iter()
.map(|(path, snippet)| (rollout_search_path(&path), snippet))
.collect::<HashMap<_, _>>();
loop {
let page = list_rollout_threads(
@@ -120,7 +126,7 @@ pub(super) async fn search_threads(
)
.await?;
for item in page.items {
let logical_path = codex_rollout::plain_rollout_path(item.path.as_path());
let logical_path = rollout_search_path(item.path.as_path());
let Some(snippet) = (match remaining_rollouts.remove(logical_path.as_path()) {
Some(Some(snippet)) => Some(snippet),
Some(None) => first_rollout_content_match_snippet(item.path.as_path(), search_term)
@@ -195,6 +201,19 @@ pub(super) async fn search_threads(
Ok(ThreadSearchPage { items, next_cursor })
}
fn rollout_search_path(path: &Path) -> PathBuf {
let path = codex_rollout::plain_rollout_path(path);
// Resume can persist a Windows namespace prefix while filesystem search returns the
// ordinary spelling. Normalize both join keys without requiring the uncompressed file
// to exist, and retain the filename identifying the selected rollout after a revert.
if cfg!(windows)
&& let Some(normalized) = path.to_str().and_then(normalize_windows_device_path)
{
return PathBuf::from(normalized);
}
path
}
fn cursor_from_thread_search_item(
item: &ThreadSearchItem,
sort_key: ThreadSortKey,
@@ -1,10 +1,21 @@
use chrono::Utc;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadHistoryMode;
use codex_rollout::ThreadItem;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use uuid::Uuid;
use super::super::LocalThreadStore;
use super::super::test_support::test_config;
use super::super::test_support::write_session_file_with;
use super::ThreadSearchItem;
use super::cursor_from_thread_search_item;
use crate::SearchThreadsParams;
use crate::SortDirection;
use crate::ThreadSortKey;
use crate::ThreadStore;
#[test]
fn recency_cursor_includes_thread_id_tie_breaker() {
@@ -27,3 +38,126 @@ fn recency_cursor_includes_thread_id_tie_breaker() {
format!("\"2026-01-27T12:34:56Z|{thread_id}\"")
);
}
#[tokio::test]
async fn search_matches_selected_rollout_across_path_spellings_and_compression() {
for compressed in [false, true] {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let state_db = codex_state::StateRuntime::init(
config.sqlite.clone(),
config.default_model_provider_id.clone(),
)
.await
.expect("initialize state database");
let uuid = Uuid::new_v4();
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("thread ID should parse");
let selected_path = write_session_file_with(
home.path(),
home.path().join("sessions/2025/01/03"),
"2025-01-03T12-00-00",
uuid,
"needle in selected rollout",
Some("test-provider"),
ThreadHistoryMode::Legacy,
)
.expect("write selected rollout");
// Reverting a thread leaves its previous rollout on disk with the same thread ID.
write_session_file_with(
home.path(),
home.path().join("sessions/2025/01/03"),
"2025-01-03T11-00-00",
uuid,
"obsolete needle in previous rollout",
Some("test-provider"),
ThreadHistoryMode::Legacy,
)
.expect("write unselected rollout");
let home_paths = [home.path().to_path_buf()];
#[cfg(windows)]
let home_paths = {
let verbatim = std::fs::canonicalize(&home_paths[0]).expect("canonicalize home");
let ordinary =
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&verbatim)
.expect("normalize home")
.into_path_buf();
[ordinary, verbatim]
};
let relative_path = selected_path
.strip_prefix(home.path())
.expect("relative rollout path");
let selected_paths = home_paths.each_ref().map(|home| home.join(relative_path));
#[cfg(windows)]
assert_ne!(selected_paths[0], selected_paths[1]);
if compressed {
let contents = std::fs::read(&selected_path).expect("read selected rollout");
let contents = zstd::stream::encode_all(contents.as_slice(), /*level*/ 3)
.expect("compress selected rollout");
std::fs::write(selected_path.with_extension("jsonl.zst"), contents)
.expect("write compressed rollout");
std::fs::remove_file(&selected_path).expect("remove plain rollout");
}
let selected_paths = selected_paths
.into_iter()
.flat_map(|path| {
let compressed_path = compressed.then(|| path.with_extension("jsonl.zst"));
std::iter::once(path).chain(compressed_path)
})
.collect::<Vec<_>>();
for codex_home in home_paths {
let store = LocalThreadStore::new(
super::super::LocalThreadStoreConfig {
codex_home,
..config.clone()
},
Some(state_db.clone()),
);
for selected_path in &selected_paths {
let mut metadata = codex_state::ThreadMetadataBuilder::new(
thread_id,
selected_path.clone(),
Utc::now(),
SessionSource::Cli,
)
.build("test-provider");
metadata.first_user_message = Some("needle in selected rollout".to_string());
state_db
.upsert_thread(&metadata)
.await
.expect("select rollout in database");
for (search_term, expected) in [
("needle", vec![(thread_id, "needle in selected rollout")]),
("obsolete", Vec::new()),
] {
let page = store
.search_threads(SearchThreadsParams {
page_size: 10,
cursor: None,
sort_key: ThreadSortKey::CreatedAt,
sort_direction: SortDirection::Desc,
allowed_sources: vec![SessionSource::Cli],
archived: false,
search_term: search_term.to_string(),
})
.await
.expect("search selected rollout");
assert_eq!(
(
page.items
.iter()
.map(|item| (item.thread.thread_id, item.snippet.as_str()))
.collect::<Vec<_>>(),
page.next_cursor,
),
(expected, None),
"compressed={compressed}, selected_path={selected_path:?}",
);
}
}
}
}
}
@@ -599,6 +599,7 @@ async fn paginated_realtime_items_materialize_separately_in_rollout_order() {
history_base: None,
subagent_history_start_ordinal: None,
initial_window_id: "window-1".to_string(),
runtime_workspace_roots: None,
metadata: ThreadPersistenceMetadata {
cwd: Some(home.path().to_path_buf()),
model_provider: "test-provider".to_string(),
@@ -2586,6 +2587,7 @@ async fn create_paginated_subagent_thread(
history_base,
subagent_history_start_ordinal,
initial_window_id: "window-1".to_string(),
runtime_workspace_roots: None,
metadata: ThreadPersistenceMetadata {
cwd: Some(std::env::current_dir().expect("cwd")),
model_provider: "test-provider".to_string(),
@@ -460,6 +460,7 @@ mod tests {
history_base: None,
subagent_history_start_ordinal: None,
initial_window_id: uuid::Uuid::now_v7().to_string(),
runtime_workspace_roots: None,
metadata: ThreadPersistenceMetadata {
cwd: None,
model_provider: "test-provider".to_string(),
@@ -663,6 +664,7 @@ mod tests {
permission_profile: permission_profile.clone(),
active_permission_profile: None,
cwd: cwd.clone().try_into().expect("absolute settings cwd"),
runtime_workspace_roots: None,
reasoning_effort: Some(ReasoningEffort::Ultra),
reasoning_summary: Some(ReasoningSummary::Auto),
personality: None,
+4
View File
@@ -23,6 +23,7 @@ use codex_protocol::protocol::ThreadMemoryMode as MemoryMode;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TokenUsage;
use codex_rollout::RolloutItem;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
@@ -104,6 +105,9 @@ pub struct CreateThreadParams {
pub subagent_history_start_ordinal: Option<u64>,
/// Initial context-window identity captured when the thread was created.
pub initial_window_id: String,
/// Runtime workspace roots at creation, excluding permission-profile roots.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_workspace_roots: Option<Vec<AbsolutePathBuf>>,
/// Metadata captured for the newly created thread.
pub metadata: ThreadPersistenceMetadata,
}