ensure thread.history_mode is immutable (#30261)

## Description

This PR makes `thread.history_mode` immutable after the thread's
canonical first `SessionMeta` has been written. Later same-thread
`SessionMeta` lines are compatibility metadata writes, not a new thread
definition.

Without this, an older binary could append a `SessionMeta` that omits
`history_mode`; when a newer binary replays it, serde defaults that
missing field to `legacy` and SQLite could downgrade a paginated thread.

## Why

`history_mode` is the persisted thread storage contract.
Paginated-thread fail-closed behavior and SQLite memory filtering depend
on it staying aligned with canonical rollout metadata, especially when
multiple Codex binary versions can touch the same local rollout.

## What changed

- Stop generic rollout metadata replay from overwriting `history_mode`
from later `SessionMeta` items.
- Remove `history_mode` from `ThreadMetadataPatch`, so mutable metadata
sync and app-server metadata updates cannot rewrite it.
- When local metadata sync has to recreate a missing SQLite row, recover
`history_mode` from the rollout's canonical first `SessionMeta` instead
of from a mutable patch.
- Keep the in-memory thread store using the created thread's canonical
`history_mode` instead of metadata patches.
- Fill the one remaining core test `CreateThreadParams` initializer with
the new `history_mode` field; Bazel CI caught this after the parent
history-mode PR landed.

## Validation

- `just fmt`
- `just test -p codex-thread-store`
- `just test -p codex-state
session_meta_does_not_set_model_or_reasoning_effort`
This commit is contained in:
Owen Lin
2026-06-26 12:32:31 -07:00
committed by GitHub
Unverified
parent cf36c688b3
commit 812cd2bb57
7 changed files with 226 additions and 66 deletions
+5 -2
View File
@@ -56,7 +56,7 @@ fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &Sessi
}
metadata.id = meta_line.meta.id;
metadata.source = enum_to_string(&meta_line.meta.source);
metadata.history_mode = meta_line.meta.history_mode;
// Later SessionMeta lines do not redefine the canonical history_mode.
metadata.thread_source = meta_line.meta.thread_source.clone();
metadata.agent_nickname = meta_line.meta.agent_nickname.clone();
metadata.agent_role = meta_line.meta.agent_role.clone();
@@ -178,6 +178,7 @@ mod tests {
use codex_protocol::protocol::ThreadGoal;
use codex_protocol::protocol::ThreadGoalStatus;
use codex_protocol::protocol::ThreadGoalUpdatedEvent;
use codex_protocol::protocol::ThreadHistoryMode;
use codex_protocol::protocol::TurnContextItem;
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
use codex_protocol::protocol::UserMessageEvent;
@@ -516,6 +517,7 @@ mod tests {
#[test]
fn session_meta_does_not_set_model_or_reasoning_effort() {
let mut metadata = metadata_for_test();
metadata.history_mode = ThreadHistoryMode::Paginated;
let thread_id = metadata.id;
apply_rollout_item(
@@ -540,7 +542,7 @@ mod tests {
dynamic_tools: None,
selected_capability_roots: Vec::new(),
memory_mode: None,
history_mode: Default::default(),
history_mode: ThreadHistoryMode::Legacy,
multi_agent_version: None,
context_window: None,
},
@@ -551,6 +553,7 @@ mod tests {
assert_eq!(metadata.model, None);
assert_eq!(metadata.reasoning_effort, None);
assert_eq!(metadata.history_mode, ThreadHistoryMode::Paginated);
}
fn metadata_for_test() -> ThreadMetadata {
+19 -22
View File
@@ -218,17 +218,22 @@ mod tests {
})
.await
.expect("register rollout path");
store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
history_mode: Some(ThreadHistoryMode::Paginated),
..Default::default()
},
include_archived: false,
})
.await
.expect("seed paginated metadata");
{
let mut state = store.state.lock().await;
state
.created_threads
.get_mut(&thread_id)
.expect("created thread")
.history_mode = ThreadHistoryMode::Paginated;
let Some(RolloutItem::SessionMeta(meta_line)) = state
.histories
.get_mut(&thread_id)
.and_then(|history| history.first_mut())
else {
panic!("canonical session meta");
};
meta_line.meta.history_mode = ThreadHistoryMode::Paginated;
}
let thread = store
.read_thread(ReadThreadParams {
@@ -784,9 +789,7 @@ fn stored_thread_from_state(
source: metadata
.and_then(|metadata| metadata.source.clone())
.unwrap_or_else(|| created.source.clone()),
history_mode: metadata
.and_then(|metadata| metadata.history_mode)
.unwrap_or(created.history_mode),
history_mode: created.history_mode,
thread_source: metadata
.and_then(|metadata| metadata.thread_source.clone())
.unwrap_or_else(|| created.thread_source.clone()),
@@ -811,15 +814,9 @@ fn history_mode_from_state(
thread_id: ThreadId,
) -> ThreadHistoryMode {
state
.metadata_updates
.created_threads
.get(&thread_id)
.and_then(|metadata| metadata.history_mode)
.or_else(|| {
state
.created_threads
.get(&thread_id)
.map(|thread| thread.history_mode)
})
.map(|thread| thread.history_mode)
.unwrap_or_default()
}
@@ -26,9 +26,12 @@ pub(super) async fn create_thread(
params: CreateThreadParams,
) -> ThreadStoreResult<()> {
let thread_id = params.thread_id;
let history_mode = params.history_mode;
store.ensure_live_recorder_absent(thread_id).await?;
let recorder = create_thread::create_thread(store, params).await?;
store.insert_live_recorder(thread_id, recorder).await
store
.insert_live_recorder(thread_id, recorder, history_mode)
.await
}
pub(super) async fn resume_thread(
@@ -98,7 +101,9 @@ pub(super) async fn resume_thread(
.map_err(|err| ThreadStoreError::Internal {
message: format!("failed to resume local thread recorder: {err}"),
})?;
store.insert_live_recorder(params.thread_id, recorder).await
store
.insert_live_recorder(params.thread_id, recorder, history_mode)
.await
}
#[tracing::instrument(
@@ -191,6 +196,7 @@ pub(super) async fn rollout_path(
.await
.get(&thread_id)
.ok_or(ThreadStoreError::ThreadNotFound { thread_id })?
.recorder
.rollout_path()
.to_path_buf())
}
+56 -3
View File
@@ -13,6 +13,7 @@ mod update_thread_metadata;
mod test_support;
use codex_protocol::ThreadId;
use codex_protocol::protocol::ThreadHistoryMode;
use codex_rollout::RolloutRecorder;
use codex_rollout::StateDbHandle;
use std::collections::HashMap;
@@ -57,10 +58,18 @@ use crate::UpdateThreadMetadataParams;
#[derive(Clone)]
pub struct LocalThreadStore {
pub(super) config: LocalThreadStoreConfig,
live_recorders: Arc<Mutex<HashMap<ThreadId, RolloutRecorder>>>,
live_recorders: Arc<Mutex<HashMap<ThreadId, LiveRecorderEntry>>>,
state_db: Option<StateDbHandle>,
}
struct LiveRecorderEntry {
recorder: RolloutRecorder,
// Local rollout files are materialized lazily, but metadata updates can arrive before the
// canonical SessionMeta is durable. Retain the mode captured when live persistence was opened
// so missing SQLite rows can still be seeded.
history_mode: ThreadHistoryMode,
}
/// Process-scoped configuration for local thread storage.
///
/// This describes where local storage lives. New-thread rollout metadata such
@@ -135,7 +144,7 @@ impl LocalThreadStore {
.lock()
.await
.get(&thread_id)
.cloned()
.map(|entry| entry.recorder.clone())
.ok_or(ThreadStoreError::ThreadNotFound { thread_id })
}
@@ -155,13 +164,17 @@ impl LocalThreadStore {
&self,
thread_id: ThreadId,
recorder: RolloutRecorder,
history_mode: ThreadHistoryMode,
) -> ThreadStoreResult<()> {
match self.live_recorders.lock().await.entry(thread_id) {
Entry::Occupied(entry) => Err(ThreadStoreError::InvalidRequest {
message: format!("thread {} already has a live local writer", entry.key()),
}),
Entry::Vacant(entry) => {
entry.insert(recorder);
entry.insert(LiveRecorderEntry {
recorder,
history_mode,
});
Ok(())
}
}
@@ -580,6 +593,46 @@ mod tests {
);
}
#[tokio::test]
async fn live_thread_memory_mode_update_before_rollout_materializes_keeps_history_mode() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let runtime = codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone())));
let thread_id = ThreadId::default();
let live_thread = LiveThread::create(store.clone(), create_thread_params(thread_id))
.await
.expect("create live thread");
live_thread
.update_memory_mode(ThreadMemoryMode::Disabled, /*include_archived*/ false)
.await
.expect("update memory mode");
assert_eq!(
runtime
.get_thread(thread_id)
.await
.expect("sqlite metadata read")
.expect("sqlite metadata")
.history_mode,
ThreadHistoryMode::Legacy
);
assert_eq!(
runtime
.get_thread_memory_mode(thread_id)
.await
.expect("thread memory mode should be readable")
.as_deref(),
Some("disabled")
);
}
#[tokio::test]
async fn live_thread_shutdown_with_buffered_items_materializes_before_metadata_read() {
let home = TempDir::new().expect("temp dir");
@@ -6,6 +6,7 @@ use codex_protocol::ThreadId;
use codex_protocol::protocol::GitInfo;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadHistoryMode;
use codex_protocol::protocol::ThreadMemoryMode;
use codex_rollout::ARCHIVED_SESSIONS_SUBDIR;
use codex_rollout::append_rollout_item_to_path;
@@ -225,31 +226,27 @@ async fn apply_metadata_update(
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.history_mode = patch.history_mode.unwrap_or_default();
builder.thread_source = patch.thread_source.clone().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);
let mut metadata = match existing.clone() {
Some(metadata) => metadata,
None => {
let rollout_path =
rollout_path
.as_deref()
.ok_or_else(|| ThreadStoreError::Internal {
message: format!(
"thread metadata missing rollout path for {thread_id}"
),
})?;
metadata_for_missing_sqlite_row(
store,
thread_id,
rollout_path,
rollout_path_archived,
&patch,
)
.await?
}
metadata
});
};
if let Some(rollout_path) = rollout_path {
metadata.rollout_path = rollout_path;
}
@@ -285,9 +282,6 @@ async fn apply_metadata_update(
if let Some(source) = patch.source {
metadata.source = enum_to_string(&source);
}
if let Some(history_mode) = patch.history_mode {
metadata.history_mode = history_mode;
}
if let Some(thread_source) = patch.thread_source {
metadata.thread_source = thread_source;
}
@@ -389,6 +383,78 @@ async fn apply_metadata_update(
.await
}
async fn metadata_for_missing_sqlite_row(
store: &LocalThreadStore,
thread_id: ThreadId,
rollout_path: &Path,
rollout_path_archived: bool,
patch: &ThreadMetadataPatch,
) -> ThreadStoreResult<codex_state::ThreadMetadata> {
let created_at = patch
.created_at
.or(patch.updated_at)
.unwrap_or_else(Utc::now);
let mut builder = ThreadMetadataBuilder::new(
thread_id,
rollout_path.to_path_buf(),
created_at,
patch.source.clone().unwrap_or(SessionSource::Unknown),
);
builder.model_provider = patch.model_provider.clone();
builder.history_mode = canonical_history_mode(store, thread_id, rollout_path).await?;
builder.thread_source = patch.thread_source.clone().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);
}
Ok(metadata)
}
async fn canonical_history_mode(
store: &LocalThreadStore,
thread_id: ThreadId,
rollout_path: &Path,
) -> ThreadStoreResult<ThreadHistoryMode> {
let session_meta = match read_session_meta_line(rollout_path).await {
Ok(session_meta) => session_meta,
Err(err) => {
if codex_rollout::existing_rollout_path(rollout_path)
.await
.is_none()
&& let Some(history_mode) = store
.live_recorders
.lock()
.await
.get(&thread_id)
.map(|entry| entry.history_mode)
{
// The live writer retains the canonical mode selected before its deferred
// SessionMeta reaches JSONL.
return Ok(history_mode);
}
return Err(ThreadStoreError::Internal {
message: format!(
"failed to read canonical session metadata for {thread_id}: {err}"
),
});
}
};
if session_meta.meta.id != thread_id {
return Err(ThreadStoreError::Internal {
message: format!(
"failed to rebuild thread metadata: rollout session metadata id mismatch: expected {thread_id}, found {}",
session_meta.meta.id
),
});
}
Ok(session_meta.meta.history_mode)
}
fn needs_rollout_compatibility_update(patch: &ThreadMetadataPatch) -> bool {
if patch.name.is_some() {
return true;
@@ -431,7 +497,6 @@ fn has_observed_metadata_facts(patch: &ThreadMetadataPatch) -> bool {
|| patch.permission_profile.is_some()
|| patch.token_usage.is_some()
|| patch.first_user_message.is_some()
|| patch.history_mode.is_some()
}
fn enum_to_string<T: serde::Serialize>(value: &T) -> String {
@@ -1475,6 +1540,51 @@ mod tests {
assert!(metadata.is_none());
}
#[tokio::test]
async fn observed_metadata_rebuilds_history_mode_from_canonical_session_meta() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let uuid = Uuid::from_u128(317);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
write_session_file_with_history_mode(
home.path(),
"2025-01-03T19-15-00",
uuid,
ThreadHistoryMode::Paginated,
)
.expect("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("Paginated preview".to_string()),
..Default::default()
},
include_archived: false,
})
.await
.expect("update paginated thread without sqlite row");
assert_eq!(thread.history_mode, ThreadHistoryMode::Paginated);
assert_eq!(
runtime
.get_thread(thread_id)
.await
.expect("sqlite metadata read")
.expect("sqlite metadata")
.history_mode,
ThreadHistoryMode::Paginated
);
}
#[tokio::test]
async fn update_thread_metadata_recreates_missing_archived_sqlite_row_as_archived() {
let home = TempDir::new().expect("temp dir");
@@ -76,7 +76,6 @@ impl ThreadMetadataSync {
cli_version: Some(env!("CARGO_PKG_VERSION").to_string()),
git_info: git_info.map(git_info_patch_from_observation),
memory_mode: Some(params.metadata.memory_mode),
history_mode: Some(params.history_mode),
..Default::default()
};
Self {
@@ -238,7 +237,6 @@ impl ThreadMetadataSync {
{
update.memory_mode = Some(memory_mode);
}
update.history_mode = Some(meta_line.meta.history_mode);
}
RolloutItem::TurnContext(turn_ctx) => {
if !self.cwd_seen {
@@ -377,7 +375,6 @@ fn update_has_metadata_facts(update: &ThreadMetadataPatch) -> bool {
|| update.first_user_message.is_some()
|| update.git_info.is_some()
|| update.memory_mode.is_some()
|| update.history_mode.is_some()
}
fn git_info_patch_from_observation(git_info: GitInfo) -> GitInfoPatch {
-6
View File
@@ -596,8 +596,6 @@ pub struct ThreadMetadataPatch {
pub git_info: Option<GitInfoPatch>,
/// Thread memory behavior.
pub memory_mode: Option<MemoryMode>,
/// Persisted thread history contract.
pub history_mode: Option<ThreadHistoryMode>,
}
impl ThreadMetadataPatch {
@@ -678,9 +676,6 @@ impl ThreadMetadataPatch {
if next.memory_mode.is_some() {
self.memory_mode = next.memory_mode;
}
if next.history_mode.is_some() {
self.history_mode = next.history_mode;
}
}
pub fn is_empty(&self) -> bool {
@@ -707,7 +702,6 @@ impl ThreadMetadataPatch {
&& self.first_user_message.is_none()
&& self.git_info.is_none()
&& self.memory_mode.is_none()
&& self.history_mode.is_none()
}
}