Unify thread metadata updates above store (#22236)

- make ThreadStore::update_thread_metadata accept a broad range of
metadata patches
- keep ThreadStore::append_items as raw canonical history append (no
metadata side effects)
- in the local store, write these metadata updates to a combination of
sqlite and rollout jsonl files for backwards-compat. It special cases
which fields need to go into jsonl vs sqlite vs whatever, confining the
awkwardness to just this implementation
- in remote stores we can simply persist the metadata directly to a
database, no special casing required.
- move the "implicit metadata updates triggered by appending rollout
items" from the RolloutRecorder (which is local-threadstore-specific) to
the LiveThread layer above the ThreadStore, inside of a private helper
utility called ThreadMetadataSync. LiveThread calls ThreadStore
append_items and update_metadata separately.
- Add a generic update metadata method to ThreadManager that works on
both live threads and "cold" threads
- Call that ThreadManager method from app server code, so app server
doesn't need to worry about whether the thread is live or not
This commit is contained in:
Tom
2026-05-13 00:28:15 +00:00
committed by GitHub
parent f11ad1eacb
commit c51c65ad09
31 changed files with 2382 additions and 762 deletions
+66 -21
View File
@@ -22,6 +22,7 @@ use crate::ReadThreadParams;
use crate::ResumeThreadParams;
use crate::StoredThread;
use crate::StoredThreadHistory;
use crate::ThreadMetadataPatch;
use crate::ThreadPage;
use crate::ThreadStore;
use crate::ThreadStoreError;
@@ -127,6 +128,7 @@ struct InMemoryThreadStoreState {
calls: InMemoryThreadStoreCalls,
created_threads: HashMap<ThreadId, CreateThreadParams>,
histories: HashMap<ThreadId, Vec<RolloutItem>>,
metadata_updates: HashMap<ThreadId, ThreadMetadataPatch>,
names: HashMap<ThreadId, Option<String>>,
rollout_paths: HashMap<PathBuf, ThreadId>,
}
@@ -271,9 +273,14 @@ impl ThreadStore for InMemoryThreadStore {
) -> ThreadStoreResult<StoredThread> {
let mut state = self.state.lock().await;
state.calls.update_thread_metadata += 1;
if let Some(name) = params.patch.name {
state.names.insert(params.thread_id, Some(name));
if let Some(name) = params.patch.name.clone() {
state.names.insert(params.thread_id, name);
}
state
.metadata_updates
.entry(params.thread_id)
.or_default()
.merge(params.patch);
stored_thread_from_state(&state, params.thread_id, /*include_history*/ false)
}
@@ -307,6 +314,7 @@ fn stored_thread_from_state(
items: history_items.clone(),
});
let name = state.names.get(&thread_id).cloned().flatten();
let metadata = state.metadata_updates.get(&thread_id);
let rollout_path = state
.rollout_paths
.iter()
@@ -316,28 +324,65 @@ fn stored_thread_from_state(
Ok(StoredThread {
thread_id,
rollout_path,
rollout_path: metadata
.and_then(|metadata| metadata.rollout_path.clone())
.or(rollout_path),
forked_from_id: created.forked_from_id,
preview: String::new(),
preview: metadata
.and_then(|metadata| metadata.preview.clone())
.unwrap_or_default(),
name,
model_provider: "test".to_string(),
model: None,
reasoning_effort: None,
created_at: Utc::now(),
updated_at: Utc::now(),
model_provider: metadata
.and_then(|metadata| metadata.model_provider.clone())
.unwrap_or_else(|| "test".to_string()),
model: metadata.and_then(|metadata| metadata.model.clone()),
reasoning_effort: metadata.and_then(|metadata| metadata.reasoning_effort),
created_at: metadata
.and_then(|metadata| metadata.created_at)
.unwrap_or_else(Utc::now),
updated_at: metadata
.and_then(|metadata| metadata.updated_at)
.unwrap_or_else(Utc::now),
archived_at: None,
cwd: PathBuf::new(),
cli_version: "test".to_string(),
source: created.source.clone(),
thread_source: created.thread_source,
agent_nickname: None,
agent_role: None,
agent_path: None,
git_info: None,
approval_mode: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
token_usage: None,
first_user_message: None,
cwd: metadata
.and_then(|metadata| metadata.cwd.clone())
.unwrap_or_default(),
cli_version: metadata
.and_then(|metadata| metadata.cli_version.clone())
.unwrap_or_else(|| "test".to_string()),
source: metadata
.and_then(|metadata| metadata.source.clone())
.unwrap_or_else(|| created.source.clone()),
thread_source: metadata
.and_then(|metadata| metadata.thread_source)
.unwrap_or(created.thread_source),
agent_nickname: metadata.and_then(|metadata| metadata.agent_nickname.clone().flatten()),
agent_role: metadata.and_then(|metadata| metadata.agent_role.clone().flatten()),
agent_path: metadata.and_then(|metadata| metadata.agent_path.clone().flatten()),
git_info: metadata.and_then(git_info_from_patch),
approval_mode: metadata
.and_then(|metadata| metadata.approval_mode)
.unwrap_or(AskForApproval::Never),
sandbox_policy: metadata
.and_then(|metadata| metadata.sandbox_policy.clone())
.unwrap_or_else(SandboxPolicy::new_read_only_policy),
token_usage: metadata.and_then(|metadata| metadata.token_usage.clone()),
first_user_message: metadata.and_then(|metadata| metadata.first_user_message.clone()),
history,
})
}
fn git_info_from_patch(patch: &ThreadMetadataPatch) -> Option<codex_protocol::protocol::GitInfo> {
let git_info = patch.git_info.as_ref()?;
let sha = git_info.sha.clone().flatten();
let branch = git_info.branch.clone().flatten();
let origin_url = git_info.origin_url.clone().flatten();
if sha.is_none() && branch.is_none() && origin_url.is_none() {
return None;
}
Some(codex_protocol::protocol::GitInfo {
commit_hash: sha.as_deref().map(codex_git_utils::GitSha::new),
branch,
repository_url: origin_url,
})
}