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 authored and GitHub committed 2026-06-26 12:32:31 -07:00
1 parent cf36c688b3
commit 812cd2bb57
7 files changed
+226 -66

No files matched your search

+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");