Enforce single-writer ownership for all thread histories (#36389)

## Why

Legacy thread histories did not use the cross-process writer ownership guard already applied to paginated histories.

## What changed

- Acquire and retain a writer lock whenever a legacy or paginated thread is created or resumed.
- Apply the same ownership checks when archiving or deleting either history mode.
- Release ownership when initialization fails or the active thread shuts down, allowing another process to resume it.

## Testing

- Cover competing create and resume operations for both history modes.
- Cover archive and deletion conflicts for legacy and paginated descendants.

GitOrigin-RevId: 1be79b01966ad2634ed79eb96c0aa0db8eafe2fa
This commit is contained in:
Owen Lin authored and copyberry committed 2026-07-31 20:49:08 +00:00
1 parent 1bef168976
commit dc60dadce6
16 files changed
+240 -366

No files matched your search

+15
View File
@@ -549,6 +549,21 @@ impl TestCodexBuilder {
.await
}
pub async fn restart(
&mut self,
server: &MockServer,
previous: &TestCodex,
) -> Result<TestCodex> {
let rollout_path = previous
.session_configured
.rollout_path
.clone()
.context("rollout path")?;
previous.codex.shutdown_and_wait().await?;
self.resume(server, Arc::clone(&previous.home), rollout_path)
.await
}
async fn build_with_home_and_base_url(
&mut self,
base_url: String,
@@ -944,12 +944,6 @@ async fn resume_replays_collaboration_instructions() -> Result<()> {
let mut builder = test_codex();
let initial = builder.build(&server).await?;
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
let home = initial.home.clone();
let collab_text = "resume instructions";
core_test_support::submit_thread_settings(
@@ -976,7 +970,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> {
.await?;
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let resumed = builder.resume(&server, home, rollout_path).await?;
let resumed = builder.restart(&server, &initial).await?;
resumed
.codex
.submit(Op::UserInput {
+1 -10
View File
@@ -2041,12 +2041,6 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() {
let _ = config.features.disable(Feature::RemoteCompactionV2);
});
let initial = builder.build(&server).await.unwrap();
let home = initial.home.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
// A single over-limit completion should not auto-compact until the next user message.
mount_sse_once(
@@ -2069,10 +2063,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() {
config.model_auto_compact_token_limit = Some(limit);
let _ = config.features.disable(Feature::RemoteCompactionV2);
});
let resumed = resume_builder
.resume(&server, home, rollout_path)
.await
.unwrap();
let resumed = resume_builder.restart(&server, &initial).await.unwrap();
let follow_up_user = "AFTER_RESUME_USER";
let sse_follow_up = sse(vec![
+2 -14
View File
@@ -2005,12 +2005,6 @@ async fn resumed_thread_runs_resume_then_compact_session_start_hooks() -> Result
trust_discovered_hooks(config);
});
let initial = builder.build(&server).await?;
let home = initial.home.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.context("rollout path")?;
initial.submit_turn("hello before resume").await?;
assert_eq!(responses_mock.requests().len(), 1);
@@ -2019,7 +2013,7 @@ async fn resumed_thread_runs_resume_then_compact_session_start_hooks() -> Result
config.model_auto_compact_token_limit = Some(limit);
trust_discovered_hooks(config);
});
let resumed = resume_builder.resume(&server, home, rollout_path).await?;
let resumed = resume_builder.restart(&server, &initial).await?;
resumed.submit_turn("hello after resume").await?;
let requests = responses_mock.requests();
@@ -2129,12 +2123,6 @@ async fn resumed_thread_keeps_stop_continuation_prompt_in_history() -> Result<()
})
.with_config(trust_discovered_hooks);
let initial = initial_builder.build(&server).await?;
let home = initial.home.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
initial.submit_turn("tell me something").await?;
@@ -2151,7 +2139,7 @@ async fn resumed_thread_keeps_stop_continuation_prompt_in_history() -> Result<()
.await;
let mut resume_builder = test_codex().with_config(trust_discovered_hooks);
let resumed = resume_builder.resume(&server, home, rollout_path).await?;
let resumed = resume_builder.restart(&server, &initial).await?;
resumed.submit_turn("and now continue").await?;
@@ -549,7 +549,6 @@ async fn deferred_tool_world_state_survives_resume_without_duplicate_updates() -
initial.codex.ensure_rollout_materialized().await;
initial.codex.flush_rollout().await?;
let home = initial.home.clone();
let rollout_path = initial
.session_configured
.rollout_path
@@ -573,11 +572,10 @@ async fn deferred_tool_world_state_survives_resume_without_duplicate_updates() -
"mcp__codex_apps__calendar": "Plan events and manage your calendar."
})
);
drop(initial);
let mut resume_builder = search_capable_apps_builder(apps_server.chatgpt_base_url)
.with_config(enable_deferred_tool_world_state_without_agents);
let resumed = resume_builder.resume(&server, home, rollout_path).await?;
let resumed = resume_builder.restart(&server, &initial).await?;
drop(initial);
wait_for_mcp_server(&resumed.codex, CODEX_APPS_MCP_SERVER_NAME).await?;
resumed
.submit_turn("inspect unchanged deferred tools after resume")
@@ -424,12 +424,6 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul
});
let initial = initial_builder.build(&server).await?;
let codex = Arc::clone(&initial.codex);
let home = initial.home.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
let initial_mock = mount_sse_once(
&server,
@@ -475,7 +469,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul
.expect("test config should allow feature update");
config.personality = Some(Personality::Pragmatic);
});
let resumed = resume_builder.resume(&server, home, rollout_path).await?;
let resumed = resume_builder.restart(&server, &initial).await?;
let resume_override_cwd = resumed.cwd_path().join(PRETURN_CONTEXT_DIFF_CWD);
fs::create_dir_all(&resume_override_cwd)?;
let resume_override_cwd = resume_override_cwd.abs();
@@ -543,12 +537,6 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() -
});
let initial = initial_builder.build(&server).await?;
let codex = Arc::clone(&initial.codex);
let home = initial.home.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
let initial_mock = mount_sse_once(
&server,
@@ -589,7 +577,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() -
.with_config(|config| {
config.model = Some("gpt-5.4".to_string());
});
let resumed = resume_builder.resume(&server, home, rollout_path).await?;
let resumed = resume_builder.restart(&server, &initial).await?;
let resume_override_cwd = resumed.cwd_path().join(PRETURN_CONTEXT_DIFF_CWD);
fs::create_dir_all(&resume_override_cwd)?;
let resume_override_cwd = resume_override_cwd.abs();
+4 -16
View File
@@ -225,21 +225,15 @@ async fn changing_configured_mode_hint_to_empty_emits_no_update() -> Result<()>
.with_config(configure_custom_mode_hint)
.build(&server)
.await?;
let home = initial.home.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
submit_turn(&initial.codex, "before resume", /*effort*/ None).await?;
drop(initial);
let mut resume_builder = test_codex().with_config(|config| {
configure_multi_agent_v2(config);
config.multi_agent_v2.multi_agent_mode_hint_text = Some(String::new());
});
let resumed = resume_builder.resume(&server, home, rollout_path).await?;
let resumed = resume_builder.restart(&server, &initial).await?;
drop(initial);
submit_turn(&resumed.codex, "after resume", /*effort*/ None).await?;
let requests = responses.requests();
@@ -362,20 +356,14 @@ async fn leaving_ultra_after_cold_resume_emits_explicit_mode() -> Result<()> {
.with_config(configure_ultra)
.build(&server)
.await?;
let home = initial.home.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
submit_turn(&initial.codex, "before resume", /*effort*/ None).await?;
drop(initial);
let mut resume_builder = test_codex()
.with_model_info_override("gpt-5.4", add_ultra_reasoning)
.with_config(configure_ultra);
let resumed = resume_builder.resume(&server, home, rollout_path).await?;
let resumed = resume_builder.restart(&server, &initial).await?;
drop(initial);
submit_turn(&resumed.codex, "after resume", Some(ReasoningEffort::High)).await?;
let requests = responses.requests();
@@ -245,12 +245,6 @@ async fn cold_root_resume_restores_agent_identity_and_role_on_followup() -> Resu
});
let initial = initial_builder.build_with_auto_env(&server).await?;
let root_thread_id = initial.session_configured.thread_id;
let home = initial.home.clone();
let rollout_path = initial
.codex
.rollout_path()
.expect("root rollout path")
.to_path_buf();
let mut op = vec![UserInput::Text {
text: INITIAL_PROMPT.to_string(),
text_elements: Vec::new(),
@@ -382,9 +376,10 @@ async fn cold_root_resume_restores_agent_identity_and_role_on_followup() -> Resu
sibling_thread.flush_rollout().await?;
worker_thread.flush_rollout().await?;
initial.codex.flush_rollout().await?;
sibling_thread.shutdown_and_wait().await?;
worker_thread.shutdown_and_wait().await?;
drop(sibling_thread);
drop(worker_thread);
drop(initial);
let followup_args = serde_json::to_string(&json!({
"target": "worker",
@@ -437,7 +432,8 @@ async fn cold_root_resume_restores_agent_identity_and_role_on_followup() -> Resu
let mut resume_builder = test_codex().with_config(move |config| {
configure_multi_agent_v2_with_role(config, &resumed_model_provider_base_url);
});
let resumed = resume_builder.resume(&server, home, rollout_path).await?;
let resumed = resume_builder.restart(&server, &initial).await?;
drop(initial);
assert_eq!(
resumed.thread_manager.list_thread_ids().await,
vec![root_thread_id]
@@ -631,12 +631,6 @@ async fn resume_replays_permissions_messages() -> Result<()> {
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
});
let initial = builder.build(&server).await?;
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
let home = initial.home.clone();
initial
.codex
@@ -677,7 +671,7 @@ async fn resume_replays_permissions_messages() -> Result<()> {
.await?;
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let resumed = builder.resume(&server, home, rollout_path).await?;
let resumed = builder.restart(&server, &initial).await?;
resumed
.codex
.submit(Op::UserInput {
@@ -736,7 +730,6 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
.rollout_path
.clone()
.expect("rollout path");
let home = initial.home.clone();
initial
.codex
@@ -783,7 +776,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
builder = builder.with_config(|config| {
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
});
let resumed = builder.resume(&server, home, rollout_path.clone()).await?;
let resumed = builder.restart(&server, &initial).await?;
resumed
.codex
.submit(Op::UserInput {
+4 -108
View File
@@ -13,49 +13,10 @@ use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::TestCodexBuilder;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tempfile::TempDir;
use wiremock::MockServer;
async fn resume_until_initial_messages(
builder: &mut TestCodexBuilder,
server: &MockServer,
home: Arc<TempDir>,
rollout_path: PathBuf,
predicate: impl Fn(&[EventMsg]) -> bool,
) -> Result<TestCodex> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
let poll_interval = Duration::from_millis(10);
let mut last_initial_messages = "<missing initial messages>".to_string();
loop {
let resumed = builder
.resume(server, Arc::clone(&home), rollout_path.clone())
.await?;
if let Some(initial_messages) = resumed.session_configured.initial_messages.as_ref() {
if predicate(initial_messages) {
return Ok(resumed);
}
last_initial_messages = format!("{initial_messages:#?}");
}
if tokio::time::Instant::now() >= deadline {
panic!(
"timed out waiting for rollout resume messages to stabilize: {last_initial_messages}"
);
}
drop(resumed);
tokio::time::sleep(poll_interval).await;
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> {
@@ -65,12 +26,6 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> {
let mut builder = test_codex();
let initial = builder.build(&server).await?;
let codex = Arc::clone(&initial.codex);
let home = initial.home.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
let initial_sse = sse(vec![
ev_response_created("resp-initial"),
@@ -98,26 +53,7 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> {
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let resumed = resume_until_initial_messages(
&mut builder,
&server,
home,
rollout_path,
|initial_messages| {
matches!(
initial_messages,
[
EventMsg::TurnStarted(_),
EventMsg::UserMessage(_),
EventMsg::AgentMessage(_),
EventMsg::TokenCount(_),
EventMsg::TurnComplete(_),
]
)
},
)
.await?;
let resumed = builder.restart(&server, &initial).await?;
let initial_messages = resumed
.session_configured
.initial_messages
@@ -155,12 +91,6 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()>
});
let initial = builder.build(&server).await?;
let codex = Arc::clone(&initial.codex);
let home = initial.home.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
let initial_sse = sse(vec![
ev_response_created("resp-initial"),
@@ -184,28 +114,7 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()>
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let resumed = resume_until_initial_messages(
&mut builder,
&server,
home,
rollout_path,
|initial_messages| {
matches!(
initial_messages,
[
EventMsg::TurnStarted(_),
EventMsg::UserMessage(_),
EventMsg::AgentReasoning(_),
EventMsg::AgentReasoningRawContent(_),
EventMsg::AgentMessage(_),
EventMsg::TokenCount(_),
EventMsg::TurnComplete(_),
]
)
},
)
.await?;
let resumed = builder.restart(&server, &initial).await?;
let initial_messages = resumed
.session_configured
.initial_messages
@@ -246,12 +155,6 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> {
});
let initial = builder.build(&server).await?;
let codex = Arc::clone(&initial.codex);
let home = initial.home.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
let initial_sse = sse(vec![
ev_response_created("resp-initial"),
@@ -273,7 +176,6 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> {
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let initial_body = initial_mock.single_request().body_json();
let initial_instructions = initial_body
.get("instructions")
@@ -301,7 +203,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> {
let mut resume_builder = test_codex().with_config(|config| {
config.model = Some("gpt-5.4".to_string());
});
let resumed = resume_builder.resume(&server, home, rollout_path).await?;
let resumed = resume_builder.restart(&server, &initial).await?;
resumed
.codex
.submit(Op::UserInput {
@@ -378,12 +280,6 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu
});
let initial = builder.build(&server).await?;
let codex = Arc::clone(&initial.codex);
let home = initial.home.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
let initial_mock = mount_sse_once(
&server,
@@ -422,7 +318,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu
let mut resume_builder = test_codex().with_config(|config| {
config.model = Some("gpt-5.5".to_string());
});
let resumed = resume_builder.resume(&server, home, rollout_path).await?;
let resumed = resume_builder.restart(&server, &initial).await?;
core_test_support::submit_thread_settings(
&resumed.codex,
codex_protocol::protocol::ThreadSettingsOverrides {
@@ -181,6 +181,7 @@ async fn resume_restores_dynamic_tools_from_rollout_with_sqlite_enabled() -> Res
})
.await;
started.thread.shutdown_and_wait().await?;
let mut resume_builder = test_codex().with_config(|config| {
config
.features