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 authored and copyberry committed 2026-09-08 16:24:38 +00:00
1 parent dd9512c000
commit 6515a72db7
46 files changed
+984 -135

No files matched your search

@@ -84,6 +84,7 @@ struct ThreadListFilters {
#[derive(PartialEq)]
struct ResumeConfigState {
history_cwd: Option<PathBuf>,
workspace_roots: Option<Vec<AbsolutePathBuf>>,
persisted_metadata: Option<ThreadMetadata>,
persisted_settings: Option<PersistedResumeSettings>,
}
@@ -2269,6 +2270,15 @@ impl ThreadRequestProcessor {
"failed to restore thread settings after revert: {err}"
))
})?;
// Replace the resume-time checkpoint written from the original config.
codex_thread
.checkpoint_thread_settings()
.await
.map_err(|err| {
internal_error(format!(
"failed to persist restored thread settings after revert: {err}"
))
})?;
Self::set_app_server_client_info(
codex_thread.as_ref(),
app_server_client_name,
@@ -3799,21 +3809,72 @@ impl ThreadRequestProcessor {
}
// Copied or referenced history can contain another thread's settings. Only snapshots
// explicitly owned by this thread can override its startup cwd.
let history_cwd = if let InitialHistory::Resumed(resumed) = &thread_history {
// explicitly owned by this thread can override its startup cwd and workspace folders.
let history_settings = if let InitialHistory::Resumed(resumed) = &thread_history {
resumed.history.iter().rev().find_map(|item| match item {
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event))
if event.thread_id == Some(resumed.conversation_id) =>
{
Some(event.thread_settings.cwd.to_path_buf())
Some(&event.thread_settings)
}
_ => None,
})
} else {
None
};
let history_cwd = history_cwd.or_else(|| thread_history.session_cwd());
let runtime_workspace_roots = runtime_workspace_roots.map(resolve_runtime_workspace_roots);
let history_cwd = history_settings
.map(|settings| settings.cwd.to_path_buf())
.or_else(|| thread_history.session_cwd());
let mut runtime_workspace_roots =
runtime_workspace_roots.map(resolve_runtime_workspace_roots);
if runtime_workspace_roots.is_none() {
// A retained owned snapshot is authoritative. Only use startup metadata when
// no such snapshot exists; missing roots do not resurrect an older selection.
let saved_workspaces: Option<(&Path, Vec<&Path>)> = match history_settings {
Some(settings) => settings.runtime_workspace_roots.as_ref().map(|roots| {
(
settings.cwd.as_path(),
roots.iter().map(AbsolutePathBuf::as_path).collect(),
)
}),
None => {
if let InitialHistory::Resumed(resumed) = &thread_history
&& let Some(RolloutItem::SessionMeta(meta)) = resumed.history.first()
&& meta.meta.id == resumed.conversation_id
&& let Some(roots) = &meta.meta.runtime_workspace_roots
{
Some((
meta.meta.cwd.as_path(),
roots.iter().map(PathBuf::as_path).collect(),
))
} else {
None
}
}
};
if let Some((saved_cwd, saved_roots)) = saved_workspaces {
let resume_cwd = resolve_request_cwd(cwd.as_ref().map(PathBuf::from))?;
// Retarget before validation so an explicit cwd can replace a
// startup cwd recorded on another operating system.
let restored_roots = path_utils::replace_path_and_deduplicate(
saved_roots,
saved_cwd,
resume_cwd.as_ref().map_or(saved_cwd, AbsolutePathBuf::as_path),
)
.into_iter()
.map(|root| {
AbsolutePathBuf::from_absolute_path_checked(root).map_err(|err| {
invalid_params(format!(
"cannot restore workspace root `{}` on this host: {err}. Pass `runtimeWorkspaceRoots` with valid local paths or an empty list.",
root.display()
))
})
})
.collect::<Result<Vec<_>, _>>()?;
// Validation can normalize distinct saved paths to the same root.
runtime_workspace_roots = Some(resolve_runtime_workspace_roots(restored_roots));
}
}
let mut typesafe_overrides = self.build_thread_config_overrides(
model,
model_provider,
@@ -3865,6 +3926,7 @@ impl ThreadRequestProcessor {
.is_some_and(|metadata| metadata.reasoning_effort.is_none());
let config_state = ResumeConfigState {
history_cwd: history_cwd.clone(),
workspace_roots: typesafe_overrides.workspace_roots.clone(),
persisted_metadata,
persisted_settings: match &thread_history {
InitialHistory::Resumed(resumed) => {