mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
cf36c688b3
commit
812cd2bb57
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user