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 authored and GitHub committed 2026-05-13 00:28:15 +00:00
1 parent f11ad1eacb
commit c51c65ad09
31 files changed
+2382 -762

No files matched your search

@@ -1,9 +1,11 @@
use std::path::Path;
use std::path::PathBuf;
use chrono::Utc;
use codex_protocol::ThreadId;
use codex_protocol::protocol::GitInfo;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadMemoryMode;
use codex_rollout::ARCHIVED_SESSIONS_SUBDIR;
use codex_rollout::append_rollout_item_to_path;
@@ -11,6 +13,7 @@ use codex_rollout::append_thread_name;
use codex_rollout::find_archived_thread_path_by_id_str;
use codex_rollout::find_thread_path_by_id_str;
use codex_rollout::read_session_meta_line;
use codex_state::ThreadMetadataBuilder;
use super::LocalThreadStore;
use super::helpers::git_info_from_parts;
@@ -18,6 +21,7 @@ use super::live_writer;
use crate::GitInfoPatch;
use crate::ReadThreadParams;
use crate::StoredThread;
use crate::ThreadMetadataPatch;
use crate::ThreadStoreError;
use crate::ThreadStoreResult;
use crate::UpdateThreadMetadataParams;
@@ -32,25 +36,35 @@ pub(super) async fn update_thread_metadata(
store: &LocalThreadStore,
params: UpdateThreadMetadataParams,
) -> ThreadStoreResult<StoredThread> {
let field_count = usize::from(params.patch.name.is_some())
+ usize::from(params.patch.memory_mode.is_some())
+ usize::from(params.patch.git_info.is_some());
if field_count > 1 {
return Err(ThreadStoreError::InvalidRequest {
message: "local thread store applies one metadata field per patch in this slice"
.to_string(),
});
let thread_id = params.thread_id;
let patch = params.patch;
if patch.is_empty() {
return read_thread::read_thread(
store,
ReadThreadParams {
thread_id,
include_archived: params.include_archived,
include_history: false,
},
)
.await;
}
let needs_rollout_compat = needs_rollout_compatibility_update(&patch);
let updated =
apply_metadata_update(store, thread_id, patch.clone(), params.include_archived).await?;
if !needs_rollout_compat {
return Ok(updated);
}
let thread_id = params.thread_id;
if live_writer::rollout_path(store, thread_id).await.is_ok() {
live_writer::persist_thread(store, thread_id).await?;
}
let resolved_rollout_path =
resolve_rollout_path(store, thread_id, params.include_archived).await?;
let name = params.patch.name;
let git_info = params.patch.git_info;
if let Some(memory_mode) = params.patch.memory_mode {
let name = patch.name;
let git_info = patch.git_info;
if let Some(memory_mode) = patch.memory_mode {
apply_thread_memory_mode(resolved_rollout_path.path.as_path(), thread_id, memory_mode)
.await?;
}
@@ -68,7 +82,7 @@ pub(super) async fn update_thread_metadata(
.await;
if let Some(name) = name {
apply_thread_name(store, thread_id, name).await?;
apply_thread_name(store, thread_id, name.unwrap_or_default()).await?;
}
let resolved_git_info = match git_info {
@@ -150,6 +164,218 @@ pub(super) async fn update_thread_metadata(
Ok(thread)
}
async fn apply_metadata_update(
store: &LocalThreadStore,
thread_id: ThreadId,
patch: ThreadMetadataPatch,
include_archived: bool,
) -> ThreadStoreResult<StoredThread> {
let live_rollout_path = live_writer::rollout_path(store, thread_id).await.ok();
let mut rollout_path = patch.rollout_path.clone().or(live_rollout_path);
let mut rollout_path_archived = rollout_path
.as_deref()
.is_some_and(|path| rollout_path_is_archived(store, path));
let state_db = store.state_db().await;
let sqlite_write_result: ThreadStoreResult<()> = if let Some(state_db) = state_db.as_ref() {
let patch = patch.clone();
async {
let existing =
state_db
.get_thread(thread_id)
.await
.map_err(|err| ThreadStoreError::Internal {
message: format!("failed to read thread metadata for {thread_id}: {err}"),
})?;
if existing.is_none() && rollout_path.is_none() {
let resolved = resolve_rollout_path(store, thread_id, include_archived).await?;
rollout_path_archived = resolved.archived;
rollout_path = Some(resolved.path);
}
let mut metadata = existing.clone().unwrap_or_else(|| {
let created_at = patch
.created_at
.or(patch.updated_at)
.unwrap_or_else(Utc::now);
let mut builder = ThreadMetadataBuilder::new(
thread_id,
rollout_path.clone().unwrap_or_default(),
created_at,
patch.source.clone().unwrap_or(SessionSource::Unknown),
);
builder.model_provider = patch.model_provider.clone();
builder.thread_source = patch.thread_source.flatten();
builder.agent_nickname = patch.agent_nickname.clone().flatten();
builder.agent_role = patch.agent_role.clone().flatten();
builder.agent_path = patch.agent_path.clone().flatten();
builder.cwd = patch.cwd.clone().map(normalize_cwd).unwrap_or_default();
builder.cli_version = patch.cli_version.clone();
let mut metadata = builder.build(store.config.default_model_provider_id.as_str());
if rollout_path_archived {
metadata.archived_at = Some(metadata.updated_at);
}
metadata
});
if let Some(rollout_path) = rollout_path {
metadata.rollout_path = rollout_path;
}
if let Some(preview) = patch.preview {
metadata.preview = Some(preview);
}
if let Some(name) = patch.name {
metadata.title = name.unwrap_or_default();
}
if let Some(title) = patch.title {
metadata.title = title;
}
if let Some(model_provider) = patch.model_provider {
metadata.model_provider = model_provider;
}
if let Some(model) = patch.model {
metadata.model = Some(model);
}
if let Some(reasoning_effort) = patch.reasoning_effort {
metadata.reasoning_effort = Some(reasoning_effort);
}
if let Some(created_at) = patch.created_at {
metadata.created_at = created_at;
}
if let Some(updated_at) = patch.updated_at {
metadata.updated_at = updated_at;
}
if let Some(source) = patch.source {
metadata.source = enum_to_string(&source);
}
if let Some(thread_source) = patch.thread_source {
metadata.thread_source = thread_source;
}
if let Some(agent_nickname) = patch.agent_nickname {
metadata.agent_nickname = agent_nickname;
}
if let Some(agent_role) = patch.agent_role {
metadata.agent_role = agent_role;
}
if let Some(agent_path) = patch.agent_path {
metadata.agent_path = agent_path;
}
if let Some(cwd) = patch.cwd {
metadata.cwd = normalize_cwd(cwd);
}
if let Some(cli_version) = patch.cli_version {
metadata.cli_version = cli_version;
}
if let Some(approval_mode) = patch.approval_mode {
metadata.approval_mode = enum_to_string(&approval_mode);
}
if let Some(sandbox_policy) = patch.sandbox_policy {
metadata.sandbox_policy = enum_to_string(&sandbox_policy);
}
if let Some(token_usage) = patch.token_usage {
metadata.tokens_used = token_usage.total_tokens.max(0);
}
if let Some(first_user_message) = patch.first_user_message {
metadata.first_user_message = Some(first_user_message);
}
if let Some(git_info) = patch.git_info {
let existing_git_info = git_info_from_parts(
metadata.git_sha.clone(),
metadata.git_branch.clone(),
metadata.git_origin_url.clone(),
);
let (sha, branch, origin_url) = resolve_git_info_patch(existing_git_info, git_info);
metadata.git_sha = sha;
metadata.git_branch = branch;
metadata.git_origin_url = origin_url;
}
state_db
.upsert_thread(&metadata)
.await
.map_err(|err| ThreadStoreError::Internal {
message: format!("failed to update thread metadata for {thread_id}: {err}"),
})?;
if let Some(memory_mode) = patch.memory_mode {
state_db
.set_thread_memory_mode(thread_id, memory_mode_as_str(memory_mode))
.await
.map_err(|err| ThreadStoreError::Internal {
message: format!("failed to update memory mode for {thread_id}: {err}"),
})?;
}
if let Some(dynamic_tools) = patch.dynamic_tools {
state_db
.persist_dynamic_tools(thread_id, Some(dynamic_tools.as_slice()))
.await
.map_err(|err| ThreadStoreError::Internal {
message: format!("failed to update dynamic tools for {thread_id}: {err}"),
})?;
}
Ok(())
}
.await
} else {
Ok(())
};
match (state_db.is_some(), sqlite_write_result) {
(true, Ok(())) => {}
(true, Err(err)) => return Err(err),
(false, Ok(())) => {}
(false, Err(err)) => return Err(err),
}
read_thread::read_thread(
store,
ReadThreadParams {
thread_id,
include_archived,
include_history: false,
},
)
.await
}
fn needs_rollout_compatibility_update(patch: &ThreadMetadataPatch) -> bool {
if patch.name.is_some() {
return true;
}
if patch.memory_mode.is_none() && patch.git_info.is_none() {
return false;
}
!has_observed_metadata_facts(patch)
}
fn has_observed_metadata_facts(patch: &ThreadMetadataPatch) -> bool {
patch.rollout_path.is_some()
|| patch.preview.is_some()
|| patch.title.is_some()
|| patch.model_provider.is_some()
|| patch.model.is_some()
|| patch.reasoning_effort.is_some()
|| patch.created_at.is_some()
|| patch.source.is_some()
|| patch.thread_source.is_some()
|| patch.agent_nickname.is_some()
|| patch.agent_role.is_some()
|| patch.agent_path.is_some()
|| patch.cwd.is_some()
|| patch.cli_version.is_some()
|| patch.approval_mode.is_some()
|| patch.sandbox_policy.is_some()
|| patch.token_usage.is_some()
|| patch.first_user_message.is_some()
|| patch.dynamic_tools.is_some()
}
fn enum_to_string<T: serde::Serialize>(value: &T) -> String {
match serde_json::to_value(value) {
Ok(serde_json::Value::String(value)) => value,
Ok(other) => other.to_string(),
Err(_) => String::new(),
}
}
fn normalize_cwd(cwd: PathBuf) -> PathBuf {
codex_utils_path::normalize_for_path_comparison(cwd.as_path()).unwrap_or(cwd)
}
async fn apply_thread_git_info(
store: &LocalThreadStore,
thread_id: ThreadId,
@@ -363,10 +589,13 @@ mod tests {
use super::*;
use crate::GitInfoPatch;
use crate::ListThreadsParams;
use crate::ResumeThreadParams;
use crate::SortDirection;
use crate::ThreadEventPersistenceMode;
use crate::ThreadMetadataPatch;
use crate::ThreadPersistenceMetadata;
use crate::ThreadSortKey;
use crate::ThreadStore;
use crate::local::LocalThreadStore;
use crate::local::test_support::test_config;
@@ -385,7 +614,7 @@ mod tests {
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
name: Some("A sharper name".to_string()),
name: Some(Some("A sharper name".to_string())),
..Default::default()
},
include_archived: false,
@@ -855,44 +1084,306 @@ mod tests {
}
#[tokio::test]
async fn update_thread_metadata_rejects_multi_field_patch_without_partial_write() {
async fn update_thread_metadata_applies_combined_explicit_patch() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let config = test_config(home.path());
let runtime = codex_state::StateRuntime::init(
home.path().to_path_buf(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config, Some(runtime.clone()));
let uuid = Uuid::from_u128(305);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let path =
write_session_file(home.path(), "2025-01-03T15-30-00", uuid).expect("session file");
let original = std::fs::read_to_string(&path).expect("read rollout");
let err = store
let thread = store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
name: Some("Should not persist".to_string()),
name: Some(Some("Combined metadata".to_string())),
memory_mode: Some(ThreadMemoryMode::Disabled),
git_info: Some(GitInfoPatch {
branch: Some(Some("combined".to_string())),
..Default::default()
}),
..Default::default()
},
include_archived: false,
})
.await
.expect_err("multi-field patch should fail");
.expect("combined patch should apply");
assert!(matches!(err, ThreadStoreError::InvalidRequest { .. }));
assert_eq!(thread.name.as_deref(), Some("Combined metadata"));
assert_eq!(
std::fs::read_to_string(&path).expect("read rollout"),
original
thread.git_info.expect("git info").branch.as_deref(),
Some("combined")
);
let appended = last_rollout_item(path.as_path());
assert_eq!(appended["type"], "session_meta");
assert_eq!(appended["payload"]["memory_mode"], "disabled");
assert_eq!(appended["payload"]["git"]["branch"], "combined");
let latest_name = codex_rollout::find_thread_name_by_id(home.path(), &thread_id)
.await
.expect("find thread name");
assert_eq!(latest_name, None);
assert_eq!(latest_name.as_deref(), Some("Combined metadata"));
let memory_mode = runtime
.get_thread_memory_mode(thread_id)
.await
.expect("thread memory mode should be readable");
assert_eq!(memory_mode.as_deref(), Some("disabled"));
}
#[tokio::test]
async fn metadata_patch_applies_title_over_existing_name() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let runtime = codex_state::StateRuntime::init(
home.path().to_path_buf(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config, Some(runtime));
let uuid = Uuid::from_u128(306);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
write_session_file(home.path(), "2025-01-03T15-45-00", uuid).expect("session file");
store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
name: Some(Some("User chosen name".to_string())),
..Default::default()
},
include_archived: false,
})
.await
.expect("set explicit name");
let thread = store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
title: Some("Derived first message".to_string()),
preview: Some("Derived first message".to_string()),
..Default::default()
},
include_archived: false,
})
.await
.expect("apply observed metadata");
assert_eq!(thread.name.as_deref(), Some("Derived first message"));
}
#[tokio::test]
async fn metadata_patch_applies_latest_preview_and_first_user_message() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let runtime = codex_state::StateRuntime::init(
home.path().to_path_buf(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config, Some(runtime.clone()));
let uuid = Uuid::from_u128(313);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
write_session_file(home.path(), "2025-01-03T19-00-00", uuid).expect("session file");
store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
preview: Some("Original preview".to_string()),
first_user_message: Some("Original first message".to_string()),
..Default::default()
},
include_archived: false,
})
.await
.expect("set observed metadata");
let thread = store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
preview: Some("Later preview".to_string()),
first_user_message: Some("Later first message".to_string()),
..Default::default()
},
include_archived: false,
})
.await
.expect("apply later observed metadata");
assert_eq!(thread.preview, "Hello from user");
assert_eq!(
thread.first_user_message.as_deref(),
Some("Hello from user")
);
let metadata = runtime
.get_thread(thread_id)
.await
.expect("sqlite metadata read")
.expect("sqlite metadata");
assert_eq!(metadata.preview.as_deref(), Some("Later preview"));
assert_eq!(
metadata.first_user_message.as_deref(),
Some("Later first message")
);
}
#[tokio::test]
async fn observed_metadata_rejects_unknown_thread_without_rollout() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let runtime = codex_state::StateRuntime::init(
home.path().to_path_buf(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config, Some(runtime.clone()));
let uuid = Uuid::from_u128(314);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let err = store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
preview: Some("phantom".to_string()),
..Default::default()
},
include_archived: false,
})
.await
.expect_err("metadata-only update should not create a missing thread");
assert!(matches!(
err,
ThreadStoreError::InvalidRequest { message }
if message == format!("thread not found: {thread_id}")
));
let metadata = runtime
.get_thread(thread_id)
.await
.expect("sqlite metadata read");
assert!(metadata.is_none());
}
#[tokio::test]
async fn update_thread_metadata_recreates_missing_archived_sqlite_row_as_archived() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let uuid = Uuid::from_u128(315);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
write_archived_session_file(home.path(), "2025-01-03T19-30-00", uuid)
.expect("archived session file");
let runtime = codex_state::StateRuntime::init(
home.path().to_path_buf(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config, Some(runtime.clone()));
let thread = store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
preview: Some("Archived missing sqlite row".to_string()),
..Default::default()
},
include_archived: true,
})
.await
.expect("update archived thread without sqlite row");
assert!(thread.archived_at.is_some());
assert!(
runtime
.get_thread(thread_id)
.await
.expect("get metadata")
.expect("metadata")
.archived_at
.is_some()
);
}
#[tokio::test]
async fn observed_metadata_normalizes_cwd_for_list_filters() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let runtime = codex_state::StateRuntime::init(
home.path().to_path_buf(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config, Some(runtime.clone()));
let uuid = Uuid::from_u128(316);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
write_session_file(home.path(), "2025-01-03T20-00-00", uuid).expect("session file");
let workspace = home.path().join("workspace");
let child = workspace.join("child");
std::fs::create_dir_all(child.as_path()).expect("create workspace");
let unnormalized_cwd = child.join("..");
let normalized_cwd = codex_utils_path::normalize_for_path_comparison(workspace.as_path())
.expect("normalize cwd");
store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
cwd: Some(unnormalized_cwd),
preview: Some("cwd preview".to_string()),
..Default::default()
},
include_archived: false,
})
.await
.expect("update observed cwd");
let metadata = runtime
.get_thread(thread_id)
.await
.expect("get metadata")
.expect("metadata");
assert_eq!(metadata.cwd, normalized_cwd);
let page = store
.list_threads(ListThreadsParams {
page_size: 10,
cursor: None,
sort_key: ThreadSortKey::UpdatedAt,
sort_direction: SortDirection::Desc,
allowed_sources: Vec::new(),
model_providers: Some(Vec::new()),
cwd_filters: Some(vec![workspace]),
archived: false,
search_term: None,
use_state_db_only: true,
})
.await
.expect("list threads by cwd");
assert_eq!(
page.items
.iter()
.map(|thread| thread.thread_id)
.collect::<Vec<_>>(),
vec![thread_id]
);
}
#[tokio::test]
async fn update_thread_metadata_keeps_archived_thread_archived_in_sqlite() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let uuid = Uuid::from_u128(306);
let uuid = Uuid::from_u128(307);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let archived_path = write_archived_session_file(home.path(), "2025-01-03T16-00-00", uuid)
.expect("archived session file");
@@ -931,7 +1422,7 @@ mod tests {
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
name: Some("Archived title".to_string()),
name: Some(Some("Archived title".to_string())),
..Default::default()
},
include_archived: true,
@@ -996,7 +1487,7 @@ mod tests {
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
name: Some("Live archived title".to_string()),
name: Some(Some("Live archived title".to_string())),
..Default::default()
},
include_archived: true,