mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user