Support metadata staging for reserved thread IDs (#38819)

## Why

Callers need to associate host-owned state with a thread before Core starts it.

## What changed

- Add `ThreadManager::reserve_thread_id` and allow new threads to start with the reserved ID. Reject reserved IDs when resuming an existing thread.
- Let thread stores stage metadata for a reserved ID. The local store merges staged values into the first successful metadata update and then removes them.
- Clear staged metadata when an unmaterialized thread shuts down or is discarded, and require the state database for staging.

## Testing

- Cover reserved ID generation and resume validation.
- Cover metadata persistence, merging, rollout-compatible updates, cleanup, and invalid staging requests.

GitOrigin-RevId: 3f9bc48f0f7764011cc90ea01349e497dda0a9e3
This commit is contained in:
Anton Panasenko authored and copyberry committed 2026-08-16 00:57:28 +00:00
1 parent 5ba12929f8
commit fcdae21073
17 files changed
+650 -21

No files matched your search

+56
View File
@@ -58,6 +58,62 @@ fn thread_id_generator_defaults_to_standard_ids() {
);
}
#[tokio::test]
async fn reserved_thread_id_is_used_without_changing_normal_id_generation() {
let temp_dir = tempdir().expect("tempdir");
let mut config = test_config().await;
config.codex_home = temp_dir.path().join("codex-home").abs();
config.cwd = config.codex_home.abs();
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
let generated_ids = [
ThreadId::from_u128(/*value*/ 0x018f_0000_0000_7000_8000_0000_0000_0001),
ThreadId::from_u128(/*value*/ 0x018f_0000_0000_7000_8000_0000_0000_0002),
ThreadId::from_u128(/*value*/ 0x018f_0000_0000_7000_8000_0000_0000_0003),
];
let next_id = std::sync::atomic::AtomicUsize::new(0);
let manager = ThreadManager::with_models_provider_and_home_for_tests(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
)
.with_thread_id_generator(move || generated_ids[next_id.fetch_add(1, Ordering::Relaxed)]);
let reserved_id = manager.reserve_thread_id();
let mut reserved_options = StartThreadOptions::new(config.clone());
reserved_options.reserved_thread_id = Some(reserved_id);
let reserved = manager
.start_thread(reserved_options)
.await
.expect("start reserved thread");
let mut resumed_options = StartThreadOptions::new(config.clone());
resumed_options.initial_history = InitialHistory::Resumed(ResumedHistory {
conversation_id: reserved.thread_id,
history: Arc::new(Vec::new()),
rollout_path: None,
});
let resumed_id = manager.reserve_thread_id();
resumed_options.reserved_thread_id = Some(resumed_id);
let resume_error = manager
.start_thread(resumed_options)
.await
.err()
.expect("reject reserved ID for resume");
let generated = manager
.start_thread(StartThreadOptions::new(config.clone()))
.await
.expect("start generated thread");
assert_eq!(reserved.thread_id, generated_ids[0]);
assert!(matches!(
resume_error.details(),
codex_protocol::error::CodexErrorDetails::InvalidRequest(message)
if message == "reserved thread ID cannot be used when resuming a thread"
));
assert_eq!(generated.thread_id, generated_ids[2]);
}
/// One custom ID factory supplies identifiers for roots, actual child agents, and forks.
#[tokio::test]
async fn thread_id_generator_applies_to_roots_children_and_forks() {