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

@@ -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())
}