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
2026-08-16 00:57:28 +00:00
committed by copyberry
parent 5ba12929f8
commit fcdae21073
17 changed files with 650 additions and 21 deletions
+15
View File
@@ -231,6 +231,8 @@ pub struct StartThreadOptions {
pub environments: Option<Vec<TurnEnvironmentSelection>>,
pub thread_extension_init: ExtensionDataInit,
pub client_mcp_extensions: ClientMcpExtensions,
/// Thread ID reserved before startup so the caller can associate host-owned state with it.
pub reserved_thread_id: Option<ThreadId>,
}
impl StartThreadOptions {
@@ -248,6 +250,7 @@ impl StartThreadOptions {
environments: None,
thread_extension_init: ExtensionDataInit::default(),
client_mcp_extensions: ClientMcpExtensions::default(),
reserved_thread_id: None,
}
}
}
@@ -891,6 +894,11 @@ impl ThreadManager {
Box::pin(self.start_thread_inner(options, /*forked_from_thread_id*/ None)).await
}
/// Allocates a thread ID before startup so a caller can associate host-owned state with it.
pub fn reserve_thread_id(&self) -> ThreadId {
self.state.thread_id_generator.as_ref()()
}
async fn start_thread_inner(
&self,
mut options: StartThreadOptions,
@@ -1755,6 +1763,7 @@ impl ThreadManagerState {
environments,
thread_extension_init,
client_mcp_extensions,
reserved_thread_id,
} = options;
let session_source = session_source.unwrap_or_else(|| self.session_source.clone());
let environments = environments.unwrap_or_else(|| {
@@ -1765,6 +1774,11 @@ impl ThreadManagerState {
)
});
let is_resumed_thread = matches!(&initial_history, InitialHistory::Resumed(_));
if reserved_thread_id.is_some() && matches!(&initial_history, InitialHistory::Resumed(_)) {
return Err(CodexErr::InvalidRequest(
"reserved thread ID cannot be used when resuming a thread".to_string(),
));
}
if let InitialHistory::Resumed(resumed) = &initial_history {
let mut threads = self.threads.write().await;
if let Some(thread) = threads.get(&resumed.conversation_id).cloned() {
@@ -1851,6 +1865,7 @@ impl ThreadManagerState {
environment_selections: environments,
thread_extension_init,
client_mcp_extensions,
reserved_thread_id,
analytics_events_client: self.analytics_events_client.clone(),
thread_store: Arc::clone(&self.thread_store),
attestation_provider: self.attestation_provider.clone(),