Make thread store process-scoped (#19474)

- Build one app-server process ThreadStore from startup config and share
it with ThreadManager and CodexMessageProcessor.
- Remove per-thread/fork store reconstruction so effective thread config
cannot switch the persistence backend.
- Add params to ThreadStore create/resume for specifying thread
metadata, since otherwise the metadata from store creation would be used
(incorrectly).
This commit is contained in:
Tom
2026-04-30 21:24:59 -07:00
committed by GitHub
parent f50c02d7bc
commit fe05acad23
55 changed files with 1076 additions and 514 deletions
@@ -48,6 +48,7 @@ use codex_protocol::models::BaseInstructions;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionSource as ProtocolSessionSource;
use codex_protocol::protocol::ThreadMemoryMode;
use codex_protocol::protocol::UserMessageEvent;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
@@ -56,6 +57,7 @@ use codex_thread_store::CreateThreadParams;
use codex_thread_store::InMemoryThreadStore;
use codex_thread_store::ThreadEventPersistenceMode;
use codex_thread_store::ThreadMetadataPatch;
use codex_thread_store::ThreadPersistenceMetadata;
use codex_thread_store::ThreadStore;
use codex_thread_store::UpdateThreadMetadataParams;
use core_test_support::responses;
@@ -1028,6 +1030,11 @@ async fn seed_pathless_store_thread(
source: ProtocolSessionSource::Cli,
base_instructions: BaseInstructions::default(),
dynamic_tools: Vec::new(),
metadata: ThreadPersistenceMetadata {
cwd: None,
model_provider: "test-provider".to_string(),
memory_mode: ThreadMemoryMode::Disabled,
},
event_persistence_mode: ThreadEventPersistenceMode::default(),
})
.await?;
@@ -71,6 +71,7 @@ use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::fs::FileTimes;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
@@ -1669,7 +1670,7 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> {
}
#[tokio::test]
async fn thread_resume_rejects_mismatched_path_when_thread_is_running() -> Result<()> {
async fn thread_resume_uses_path_over_thread_id_when_thread_is_running() -> Result<()> {
let server = responses::start_mock_server().await;
let first_body = responses::sse(vec![
responses::ev_response_created("resp-1"),
@@ -1749,24 +1750,71 @@ async fn thread_resume_rejects_mismatched_path_when_thread_is_running() -> Resul
)
.await??;
let resume_id = primary
let other_thread_id = ThreadId::new().to_string();
let stale_path = rollout_path(codex_home.path(), "2025-01-01T00-00-00", &thread_id);
std::fs::create_dir_all(stale_path.parent().expect("stale path parent"))?;
let thread_uuid = Uuid::parse_str(&thread_id)?;
let mut stale_file = std::fs::File::create(&stale_path)?;
let stale_meta = json!({
"timestamp": "2025-01-01T00:00:00Z",
"type": "session_meta",
"payload": {
"id": thread_uuid,
"timestamp": "2025-01-01T00:00:00Z",
"cwd": codex_home.path(),
"originator": "test_originator",
"cli_version": "test_version",
"source": "cli",
"model_provider": "test-provider",
},
});
writeln!(stale_file, "{stale_meta}")?;
let stale_user_event = json!({
"timestamp": "2025-01-01T00:00:00Z",
"type": "event_msg",
"payload": {
"type": "user_message",
"message": "stale history",
"kind": "plain",
},
});
writeln!(stale_file, "{stale_user_event}")?;
let stale_resume_id = primary
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread_id.clone(),
path: Some(PathBuf::from("/tmp/does-not-match-running-rollout.jsonl")),
thread_id: other_thread_id.clone(),
path: Some(stale_path),
..Default::default()
})
.await?;
let resume_err: JSONRPCError = timeout(
let stale_resume_err: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_error_message(RequestId::Integer(resume_id)),
primary.read_stream_until_error_message(RequestId::Integer(stale_resume_id)),
)
.await??;
assert!(
resume_err.error.message.contains("mismatched path"),
stale_resume_err.error.message.contains("stale path"),
"unexpected resume error: {}",
resume_err.error.message
stale_resume_err.error.message
);
let resume_by_path_id = primary
.send_thread_resume_request(ThreadResumeParams {
thread_id: other_thread_id.clone(),
path: thread.path,
..Default::default()
})
.await?;
let resume_by_path_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(resume_by_path_id)),
)
.await??;
let ThreadResumeResponse {
thread: resumed, ..
} = to_response::<ThreadResumeResponse>(resume_by_path_resp)?;
assert_eq!(resumed.id, thread_id);
primary
.interrupt_turn_and_wait_for_aborted(thread_id, running_turn.id, DEFAULT_READ_TIMEOUT)
.await?;
@@ -2463,7 +2511,7 @@ async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> {
}
#[tokio::test]
async fn thread_resume_prefers_path_over_thread_id() -> Result<()> {
async fn thread_resume_uses_path_over_invalid_thread_id() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
@@ -2523,13 +2571,6 @@ async fn thread_resume_prefers_path_over_thread_id() -> Result<()> {
thread: resumed, ..
} = to_response::<ThreadResumeResponse>(resume_resp)?;
assert_eq!(resumed.id, thread.id);
let resumed_path = resumed.path.as_ref().expect("resumed thread path");
let original_path = thread.path.as_ref().expect("original thread path");
assert_eq!(
normalized_existing_path(resumed_path)?,
normalized_existing_path(original_path)?
);
assert_eq!(resumed.status, ThreadStatus::Idle);
Ok(())
}