mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
Support paginated thread forks (#35220)
## Why `thread/fork` rejected threads using paginated history, so they could not use the same fork workflow as legacy threads. ## What changed - Create paginated forks by referencing a frozen source-history prefix while persisting only child-owned records in the new rollout. - Support latest, `lastTurnId`, and `beforeTurnId` boundaries, including inherited lineage, model-context reconstruction, interruption markers, and approval-reviewer inheritance. - Make paginated reads and occurrence search traverse referenced history while excluding source records added after the fork. - Coordinate fork preparation with archive and delete operations, and materialize compressed rollouts before they become fork references. ## Testing Added app-server and thread-store coverage for boundary selection, active turns, inherited reads and search, compressed lineage, and concurrent source lifecycle operations. GitOrigin-RevId: 66052d69517d7fb97a3b7817ab77a39c5ad995fd
This commit is contained in:
@@ -135,6 +135,7 @@ mod tests {
|
||||
selected_capability_roots: Vec::new(),
|
||||
multi_agent_version: None,
|
||||
history_mode: ThreadHistoryMode::Legacy,
|
||||
history_base: None,
|
||||
subagent_history_start_ordinal: None,
|
||||
initial_window_id: uuid::Uuid::now_v7().to_string(),
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
@@ -371,6 +372,7 @@ mod tests {
|
||||
selected_capability_roots: Vec::new(),
|
||||
multi_agent_version: None,
|
||||
history_mode,
|
||||
history_base: None,
|
||||
subagent_history_start_ordinal: None,
|
||||
initial_window_id: uuid::Uuid::now_v7().to_string(),
|
||||
metadata: thread_metadata(),
|
||||
@@ -488,7 +490,7 @@ impl InMemoryThreadStore {
|
||||
memory_mode: matches!(params.metadata.memory_mode, ThreadMemoryMode::Disabled)
|
||||
.then_some("disabled".to_string()),
|
||||
history_mode: params.history_mode,
|
||||
history_base: None,
|
||||
history_base: params.history_base,
|
||||
subagent_history_start_ordinal: params.subagent_history_start_ordinal,
|
||||
multi_agent_version: params.multi_agent_version,
|
||||
context_window: Some(SessionContextWindow::new(params.initial_window_id.clone())),
|
||||
|
||||
@@ -30,6 +30,7 @@ pub use types::CreateThreadParams;
|
||||
pub use types::DeleteThreadParams;
|
||||
pub use types::DeleteThreadsParams;
|
||||
pub use types::ExtraConfig;
|
||||
pub use types::ForkBoundary;
|
||||
pub use types::GitInfoPatch;
|
||||
pub use types::ItemPage;
|
||||
pub use types::ItemSortKey;
|
||||
@@ -37,6 +38,8 @@ pub use types::ListItemsParams;
|
||||
pub use types::ListThreadsParams;
|
||||
pub use types::ListTurnsParams;
|
||||
pub use types::LoadThreadHistoryParams;
|
||||
pub use types::PrepareForkParams;
|
||||
pub use types::PreparedFork;
|
||||
pub use types::ReadThreadByRolloutPathParams;
|
||||
pub use types::ReadThreadParams;
|
||||
pub use types::ResumeThreadParams;
|
||||
|
||||
@@ -22,6 +22,10 @@ pub(super) async fn archive_threads(
|
||||
lock_thread_ids.extend(thread_ids.iter().copied());
|
||||
lock_thread_ids.sort_unstable_by_key(ToString::to_string);
|
||||
lock_thread_ids.dedup();
|
||||
let mut _lifecycle_guards = Vec::with_capacity(lock_thread_ids.len());
|
||||
for thread_id in &lock_thread_ids {
|
||||
_lifecycle_guards.push(store.live_writer_locks.lock_lifecycle(*thread_id).await);
|
||||
}
|
||||
let mut _live_writer_guards = Vec::with_capacity(lock_thread_ids.len());
|
||||
for thread_id in &lock_thread_ids {
|
||||
_live_writer_guards.push(store.live_writer_locks.lock(*thread_id).await);
|
||||
@@ -109,6 +113,8 @@ pub(super) async fn archive_thread(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
@@ -128,6 +134,40 @@ mod tests {
|
||||
use crate::local::test_support::write_session_file;
|
||||
use crate::local::test_support::write_session_file_with_history_mode;
|
||||
|
||||
#[tokio::test]
|
||||
async fn archive_waits_for_fork_reservation_without_holding_writer_lock() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
|
||||
let uuid = Uuid::from_u128(205);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
let active_path = write_session_file_with_history_mode(
|
||||
home.path(),
|
||||
"2025-01-03T12-00-00",
|
||||
uuid,
|
||||
ThreadHistoryMode::Paginated,
|
||||
)
|
||||
.expect("session file");
|
||||
let reservation = store.live_writer_locks.reserve_lifecycle(thread_id).await;
|
||||
let mut archive = Box::pin(store.archive_thread(ArchiveThreadParams { thread_id }));
|
||||
|
||||
tokio::select! {
|
||||
biased;
|
||||
result = &mut archive => panic!("archive completed while the source was reserved: {result:?}"),
|
||||
_ = tokio::task::yield_now() => {}
|
||||
}
|
||||
let writer_guard = tokio::time::timeout(
|
||||
Duration::from_secs(1),
|
||||
store.live_writer_locks.lock(thread_id),
|
||||
)
|
||||
.await
|
||||
.expect("pending archive should not hold the writer lock");
|
||||
drop(writer_guard);
|
||||
drop(reservation);
|
||||
|
||||
archive.await.expect("archive reserved thread");
|
||||
assert!(!active_path.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn archive_threads_rejects_owned_descendants_before_archiving_anything() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
|
||||
@@ -41,6 +41,7 @@ pub(super) async fn create_thread(
|
||||
.with_selected_capability_roots(params.selected_capability_roots)
|
||||
.with_multi_agent_version(params.multi_agent_version)
|
||||
.with_history_mode(params.history_mode)
|
||||
.with_history_base(params.history_base)
|
||||
.with_subagent_history_start_ordinal(params.subagent_history_start_ordinal)
|
||||
.with_initial_window_id(params.initial_window_id),
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ pub(super) async fn delete_thread(
|
||||
params: DeleteThreadParams,
|
||||
) -> ThreadStoreResult<()> {
|
||||
let thread_id = params.thread_id;
|
||||
let _lifecycle_guard = store.live_writer_locks.lock_lifecycle(thread_id).await;
|
||||
let _live_writer_guard = store.live_writer_locks.lock(thread_id).await;
|
||||
let reference_index = scan_reference_index(store).await?;
|
||||
if reference_index.reference_count(thread_id) > 0 {
|
||||
@@ -50,6 +51,10 @@ pub(super) async fn delete_threads(
|
||||
let deletion_set: HashSet<_> = thread_ids.iter().copied().collect();
|
||||
let mut lock_thread_ids: Vec<_> = deletion_set.iter().copied().collect();
|
||||
lock_thread_ids.sort_unstable_by_key(ToString::to_string);
|
||||
let mut _lifecycle_guards = Vec::with_capacity(lock_thread_ids.len());
|
||||
for thread_id in &lock_thread_ids {
|
||||
_lifecycle_guards.push(store.live_writer_locks.lock_lifecycle(*thread_id).await);
|
||||
}
|
||||
let mut _live_writer_guards = Vec::with_capacity(lock_thread_ids.len());
|
||||
for &thread_id in &lock_thread_ids {
|
||||
_live_writer_guards.push(store.live_writer_locks.lock(thread_id).await);
|
||||
|
||||
@@ -5,6 +5,7 @@ mod helpers;
|
||||
mod list_threads;
|
||||
mod live_writer;
|
||||
mod model_context;
|
||||
mod paginated_fork;
|
||||
mod read_thread;
|
||||
// This lands before the reader PRs that consume the shared lineage resolver.
|
||||
#[allow(dead_code)]
|
||||
@@ -31,6 +32,9 @@ use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::OnceCell;
|
||||
use tokio::sync::OwnedMutexGuard;
|
||||
use tokio::sync::OwnedRwLockReadGuard;
|
||||
use tokio::sync::OwnedRwLockWriteGuard;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::AppendThreadItemsParams;
|
||||
use crate::ArchiveThreadParams;
|
||||
@@ -43,6 +47,8 @@ use crate::ListItemsParams;
|
||||
use crate::ListThreadsParams;
|
||||
use crate::ListTurnsParams;
|
||||
use crate::LoadThreadHistoryParams;
|
||||
use crate::PrepareForkParams;
|
||||
use crate::PreparedFork;
|
||||
use crate::ReadThreadByRolloutPathParams;
|
||||
use crate::ReadThreadParams;
|
||||
use crate::ResumeThreadParams;
|
||||
@@ -99,19 +105,55 @@ struct LiveRecorderEntry {
|
||||
struct LiveWriterLocks {
|
||||
// Keep per-thread locks after a writer goes idle. Removing one while another caller is about
|
||||
// to acquire it could let two operations for the same thread run at once.
|
||||
by_thread: Mutex<HashMap<ThreadId, Arc<Mutex<()>>>>,
|
||||
by_thread: Mutex<HashMap<ThreadId, Arc<ThreadCoordination>>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ThreadCoordination {
|
||||
// Serialize writes and capture consistent fork snapshots.
|
||||
writer: Arc<Mutex<()>>,
|
||||
// Forks hold a shared lease until their child reference is durable; deletion, archive, and
|
||||
// unarchive require exclusive access. Keeping this separate from `writer` lets the source
|
||||
// accept writes during child initialization, including MCP startup that can take 30 seconds.
|
||||
// Operations that need both locks must acquire `lifecycle` before `writer`.
|
||||
lifecycle: Arc<RwLock<()>>,
|
||||
}
|
||||
|
||||
impl LiveWriterLocks {
|
||||
async fn lock(&self, thread_id: ThreadId) -> OwnedMutexGuard<()> {
|
||||
let lock = self
|
||||
.by_thread
|
||||
async fn coordination(&self, thread_id: ThreadId) -> Arc<ThreadCoordination> {
|
||||
self.by_thread
|
||||
.lock()
|
||||
.await
|
||||
.entry(thread_id)
|
||||
.or_insert_with(|| Arc::new(Mutex::new(())))
|
||||
.clone();
|
||||
lock.lock_owned().await
|
||||
.or_default()
|
||||
.clone()
|
||||
}
|
||||
|
||||
async fn lock(&self, thread_id: ThreadId) -> OwnedMutexGuard<()> {
|
||||
self.coordination(thread_id)
|
||||
.await
|
||||
.writer
|
||||
.clone()
|
||||
.lock_owned()
|
||||
.await
|
||||
}
|
||||
|
||||
async fn reserve_lifecycle(&self, thread_id: ThreadId) -> OwnedRwLockReadGuard<()> {
|
||||
self.coordination(thread_id)
|
||||
.await
|
||||
.lifecycle
|
||||
.clone()
|
||||
.read_owned()
|
||||
.await
|
||||
}
|
||||
|
||||
async fn lock_lifecycle(&self, thread_id: ThreadId) -> OwnedRwLockWriteGuard<()> {
|
||||
self.coordination(thread_id)
|
||||
.await
|
||||
.lifecycle
|
||||
.clone()
|
||||
.write_owned()
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,6 +430,10 @@ impl ThreadStore for LocalThreadStore {
|
||||
Box::pin(async move { model_context::load_latest_model_context(self, params).await })
|
||||
}
|
||||
|
||||
fn prepare_fork(&self, params: PrepareForkParams) -> ThreadStoreFuture<'_, PreparedFork> {
|
||||
Box::pin(async move { paginated_fork::prepare(self, params).await })
|
||||
}
|
||||
|
||||
fn read_thread(&self, params: ReadThreadParams) -> ThreadStoreFuture<'_, StoredThread> {
|
||||
Box::pin(async move { read_thread::read_thread(self, params).await })
|
||||
}
|
||||
@@ -1313,6 +1359,15 @@ mod tests {
|
||||
RolloutItem::EventMsg(EventMsg::UserMessage(event)) if event.message == "Hello from user"
|
||||
)
|
||||
}));
|
||||
|
||||
let error = store
|
||||
.prepare_fork(PrepareForkParams {
|
||||
thread_id,
|
||||
boundary: crate::ForkBoundary::Latest,
|
||||
})
|
||||
.await
|
||||
.expect_err("external rollouts cannot be referenced by thread id");
|
||||
assert!(error.to_string().contains("must be in Codex home"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1575,6 +1630,7 @@ mod tests {
|
||||
selected_capability_roots: Vec::new(),
|
||||
multi_agent_version: None,
|
||||
history_mode: ThreadHistoryMode::Legacy,
|
||||
history_base: None,
|
||||
subagent_history_start_ordinal: None,
|
||||
initial_window_id: uuid::Uuid::now_v7().to_string(),
|
||||
metadata: thread_metadata(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
|
||||
use codex_protocol::protocol::HistoryPosition;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
@@ -75,6 +76,35 @@ pub(super) async fn load_latest_model_context(
|
||||
})
|
||||
}
|
||||
|
||||
/// Loads startup context from a fork's frozen inherited prefix.
|
||||
pub(super) async fn load_for_fork(
|
||||
lineage: RolloutLineage,
|
||||
history_base: Option<HistoryPosition>,
|
||||
) -> ThreadStoreResult<Vec<RolloutItem>> {
|
||||
let source_path = lineage
|
||||
.segments()
|
||||
.last()
|
||||
.map(|segment| segment.rollout_path.as_path())
|
||||
.ok_or_else(|| ThreadStoreError::Internal {
|
||||
message: "fork lineage has no source segment".to_string(),
|
||||
})?;
|
||||
let session_meta = codex_rollout::read_session_meta_line(source_path)
|
||||
.await
|
||||
.map_err(|err| ThreadStoreError::Internal {
|
||||
message: format!(
|
||||
"failed to read session metadata {}: {err}",
|
||||
source_path.display()
|
||||
),
|
||||
})?;
|
||||
match history_base {
|
||||
Some(history_base) => {
|
||||
let lineage = lineage.truncate_at(history_base).await?;
|
||||
scan_model_context_from_lineage(lineage, session_meta).await
|
||||
}
|
||||
None => Ok(vec![RolloutItem::SessionMeta(session_meta)]),
|
||||
}
|
||||
}
|
||||
|
||||
async fn scan_model_context_from_lineage(
|
||||
lineage: RolloutLineage,
|
||||
session_meta: SessionMetaLine,
|
||||
|
||||
@@ -81,6 +81,44 @@ async fn loads_latest_checkpoint_with_required_turn_metadata() {
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fork_context_excludes_items_after_frozen_cutoff() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let uuid = Uuid::from_u128(/*v*/ 1007);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("thread id");
|
||||
let path = write_ordinaled_paginated_rollout(
|
||||
home.path(),
|
||||
"2025-01-03T13-00-06",
|
||||
uuid,
|
||||
[turn_started("frozen-turn"), user_message("frozen message")],
|
||||
);
|
||||
let history_base =
|
||||
history_position(path.as_path(), thread_id, /*end_ordinal_exclusive*/ 3);
|
||||
append_items(path.as_path(), [user_message("later message")]);
|
||||
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
|
||||
let lineage = store
|
||||
.resolve_rollout_lineage(thread_id)
|
||||
.await
|
||||
.expect("resolve source lineage");
|
||||
let session_meta = codex_rollout::read_session_meta_line(path.as_path())
|
||||
.await
|
||||
.expect("read source metadata");
|
||||
|
||||
let context = load_for_fork(lineage, Some(history_base))
|
||||
.await
|
||||
.expect("load frozen fork context");
|
||||
|
||||
let expected = vec![
|
||||
RolloutItem::SessionMeta(session_meta),
|
||||
turn_started("frozen-turn"),
|
||||
user_message("frozen message"),
|
||||
];
|
||||
assert_eq!(
|
||||
serde_json::to_value(context).expect("serialize fork context"),
|
||||
serde_json::to_value(expected).expect("serialize expected fork context")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loads_turn_metadata_across_an_older_checkpoint() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
use codex_protocol::protocol::HistoryPosition;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::LocalThreadStore;
|
||||
use super::live_writer;
|
||||
use super::model_context;
|
||||
use super::thread_history::find_source_turn;
|
||||
use super::thread_history::find_visible_turn;
|
||||
use crate::ForkBoundary;
|
||||
use crate::PrepareForkParams;
|
||||
use crate::PreparedFork;
|
||||
use crate::ThreadStoreError;
|
||||
use crate::ThreadStoreResult;
|
||||
|
||||
pub(super) async fn prepare(
|
||||
store: &LocalThreadStore,
|
||||
params: PrepareForkParams,
|
||||
) -> ThreadStoreResult<PreparedFork> {
|
||||
let PrepareForkParams {
|
||||
thread_id,
|
||||
boundary,
|
||||
} = params;
|
||||
let source_reservation = store.live_writer_locks.reserve_lifecycle(thread_id).await;
|
||||
// Keep the source reserved until persistence and lineage materialization finish, even if the
|
||||
// caller cancels fork preparation.
|
||||
let lineage_store = store.clone();
|
||||
let (lineage, source_reservation) = tokio::spawn(async move {
|
||||
match live_writer::persist_thread(&lineage_store, thread_id).await {
|
||||
Ok(()) | Err(ThreadStoreError::ThreadNotFound { .. }) => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
let lineage = lineage_store
|
||||
.resolve_rollout_lineage_for_reference(thread_id)
|
||||
.await?;
|
||||
Ok::<_, ThreadStoreError>((lineage, source_reservation))
|
||||
})
|
||||
.await
|
||||
.map_err(|err| ThreadStoreError::Internal {
|
||||
message: format!("failed to resolve fork lineage: {err}"),
|
||||
})??;
|
||||
let source_segment = lineage
|
||||
.segments()
|
||||
.last()
|
||||
.ok_or_else(|| ThreadStoreError::Internal {
|
||||
message: "fork lineage has no source segment".to_string(),
|
||||
})?;
|
||||
if !matches!(boundary, ForkBoundary::Latest) {
|
||||
for segment in lineage
|
||||
.segments()
|
||||
.iter()
|
||||
.take(lineage.segments().len().saturating_sub(1))
|
||||
{
|
||||
let _ancestor_writer_guard = store.live_writer_locks.lock(segment.thread_id()).await;
|
||||
super::thread_history_materialization::materialize_to_sqlite(
|
||||
store,
|
||||
segment.thread_id(),
|
||||
segment.rollout_path.as_path(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
let source_writer_guard = store.live_writer_locks.lock(thread_id).await;
|
||||
super::thread_history_materialization::materialize_to_sqlite(
|
||||
store,
|
||||
thread_id,
|
||||
source_segment.rollout_path.as_path(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let latest_projection_state = super::thread_history::projection_state(store, thread_id)
|
||||
.await?
|
||||
.ok_or_else(|| ThreadStoreError::Internal {
|
||||
message: format!("missing projection state for paginated thread {thread_id}"),
|
||||
})?;
|
||||
let latest_position = HistoryPosition {
|
||||
thread_id,
|
||||
end_ordinal_exclusive: latest_projection_state.next_ordinal,
|
||||
end_byte_offset: latest_projection_state.next_byte_offset,
|
||||
};
|
||||
let pool = store.thread_history_db().await?;
|
||||
let position = match boundary {
|
||||
ForkBoundary::Latest => latest_position,
|
||||
ForkBoundary::ThroughTurn(turn_id) => {
|
||||
let row = find_visible_turn(pool, &lineage, turn_id.as_str()).await?;
|
||||
if row.status == "inProgress" {
|
||||
return Err(ThreadStoreError::InvalidRequest {
|
||||
message: format!("lastTurnId '{turn_id}' identifies an in-progress turn"),
|
||||
});
|
||||
}
|
||||
let rollout_end_ordinal = row
|
||||
.rollout_end_ordinal
|
||||
.ok_or_else(|| missing_turn_position(turn_id.as_str()))?;
|
||||
let rollout_end_byte_offset = row
|
||||
.rollout_end_byte_offset
|
||||
.ok_or_else(|| missing_turn_position(turn_id.as_str()))?;
|
||||
HistoryPosition {
|
||||
thread_id: row.physical_thread_id,
|
||||
end_ordinal_exclusive: u64::try_from(rollout_end_ordinal)
|
||||
.map_err(|_| invalid_turn_position(turn_id.as_str()))?
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| invalid_turn_position(turn_id.as_str()))?,
|
||||
end_byte_offset: u64::try_from(rollout_end_byte_offset)
|
||||
.map_err(|_| invalid_turn_position(turn_id.as_str()))?,
|
||||
}
|
||||
}
|
||||
ForkBoundary::BeforeTurn(turn_id) => {
|
||||
let row = find_source_turn(pool, &lineage, turn_id.as_str()).await?;
|
||||
if row.rollout_end_ordinal == Some(row.rollout_ordinal) {
|
||||
return Err(ThreadStoreError::InvalidRequest {
|
||||
message: format!("turn {turn_id} does not have a persisted start boundary"),
|
||||
});
|
||||
}
|
||||
let rollout_byte_offset = row
|
||||
.rollout_byte_offset
|
||||
.ok_or_else(|| missing_turn_position(turn_id.as_str()))?;
|
||||
HistoryPosition {
|
||||
thread_id: row.physical_thread_id,
|
||||
end_ordinal_exclusive: u64::try_from(row.rollout_ordinal)
|
||||
.map_err(|_| invalid_turn_position(turn_id.as_str()))?,
|
||||
end_byte_offset: u64::try_from(rollout_byte_offset)
|
||||
.map_err(|_| invalid_turn_position(turn_id.as_str()))?,
|
||||
}
|
||||
}
|
||||
};
|
||||
let segment_index = lineage
|
||||
.segments()
|
||||
.iter()
|
||||
.position(|segment| segment.thread_id() == position.thread_id)
|
||||
.ok_or_else(|| ThreadStoreError::Internal {
|
||||
message: "fork position is outside the source lineage".to_string(),
|
||||
})?;
|
||||
if lineage.segments()[segment_index].end.is_some_and(|end| {
|
||||
position.end_ordinal_exclusive > end.end_ordinal_exclusive
|
||||
|| position.end_byte_offset > end.end_byte_offset
|
||||
}) {
|
||||
return Err(ThreadStoreError::InvalidRequest {
|
||||
message: "fork boundary exceeds inherited source history".to_string(),
|
||||
});
|
||||
}
|
||||
let history_base =
|
||||
if position.end_ordinal_exclusive == lineage.segments()[segment_index].start_ordinal() {
|
||||
segment_index
|
||||
.checked_sub(1)
|
||||
.and_then(|index| lineage.segments()[index].end)
|
||||
} else {
|
||||
Some(position)
|
||||
};
|
||||
drop(source_writer_guard);
|
||||
let model_context = Arc::new(model_context::load_for_fork(lineage, history_base).await?);
|
||||
|
||||
Ok(PreparedFork::new(
|
||||
thread_id,
|
||||
history_base,
|
||||
model_context,
|
||||
source_reservation,
|
||||
))
|
||||
}
|
||||
|
||||
fn missing_turn_position(turn_id: &str) -> ThreadStoreError {
|
||||
ThreadStoreError::InvalidRequest {
|
||||
message: format!("turn {turn_id} does not have persisted rollout positions"),
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_turn_position(turn_id: &str) -> ThreadStoreError {
|
||||
ThreadStoreError::Internal {
|
||||
message: format!("invalid rollout position for turn {turn_id}"),
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use crate::ThreadStoreResult;
|
||||
pub(super) struct RolloutLineageSegment {
|
||||
pub(super) thread_id: ThreadId,
|
||||
pub(super) rollout_path: PathBuf,
|
||||
pub(super) start_ordinal: u64,
|
||||
pub(super) end: Option<HistoryPosition>,
|
||||
}
|
||||
|
||||
@@ -32,6 +33,29 @@ impl LocalThreadStore {
|
||||
pub(super) async fn resolve_rollout_lineage(
|
||||
&self,
|
||||
requested_thread_id: ThreadId,
|
||||
) -> ThreadStoreResult<RolloutLineage> {
|
||||
self.resolve_rollout_lineage_with_representation(
|
||||
requested_thread_id,
|
||||
LineageRepresentation::Existing,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn resolve_rollout_lineage_for_reference(
|
||||
&self,
|
||||
requested_thread_id: ThreadId,
|
||||
) -> ThreadStoreResult<RolloutLineage> {
|
||||
self.resolve_rollout_lineage_with_representation(
|
||||
requested_thread_id,
|
||||
LineageRepresentation::PlainForReference,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn resolve_rollout_lineage_with_representation(
|
||||
&self,
|
||||
requested_thread_id: ThreadId,
|
||||
representation: LineageRepresentation,
|
||||
) -> ThreadStoreResult<RolloutLineage> {
|
||||
let mut segments = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
@@ -42,10 +66,34 @@ impl LocalThreadStore {
|
||||
if !seen.insert(thread_id) {
|
||||
return Err(malformed_lineage(requested_thread_id, "cycle detected"));
|
||||
}
|
||||
let _writer_guard = match representation {
|
||||
LineageRepresentation::Existing => None,
|
||||
LineageRepresentation::PlainForReference => {
|
||||
Some(self.live_writer_locks.lock(thread_id).await)
|
||||
}
|
||||
};
|
||||
let rollout_path =
|
||||
read_thread::resolve_rollout_path(self, thread_id, /*include_archived*/ true)
|
||||
.await?
|
||||
.ok_or_else(|| malformed_lineage(thread_id, "missing source rollout"))?;
|
||||
let rollout_path = match representation {
|
||||
LineageRepresentation::Existing => rollout_path,
|
||||
LineageRepresentation::PlainForReference => {
|
||||
let rollout_path = super::helpers::scoped_rollout_path(
|
||||
self.config.codex_home.clone(),
|
||||
rollout_path.as_path(),
|
||||
"Codex home",
|
||||
)?;
|
||||
codex_rollout::materialize_rollout_for_reference(rollout_path.as_path())
|
||||
.await
|
||||
.map_err(|err| ThreadStoreError::Internal {
|
||||
message: format!(
|
||||
"failed to materialize referenced rollout {}: {err}",
|
||||
rollout_path.display()
|
||||
),
|
||||
})?
|
||||
}
|
||||
};
|
||||
let meta = codex_rollout::read_session_meta_line(rollout_path.as_path())
|
||||
.await
|
||||
.map_err(|err| ThreadStoreError::Internal {
|
||||
@@ -69,9 +117,16 @@ impl LocalThreadStore {
|
||||
if let Some(end) = end {
|
||||
validate_cutoff_bounds(requested_thread_id, rollout_path.as_path(), &end).await?;
|
||||
}
|
||||
let start_ordinal = match meta.meta.history_base {
|
||||
Some(base) => base.end_ordinal_exclusive.checked_add(1).ok_or_else(|| {
|
||||
malformed_lineage(requested_thread_id, "source ordinal overflow")
|
||||
})?,
|
||||
None => 1,
|
||||
};
|
||||
segments.push(RolloutLineageSegment {
|
||||
thread_id,
|
||||
rollout_path,
|
||||
start_ordinal,
|
||||
end,
|
||||
});
|
||||
|
||||
@@ -85,27 +140,50 @@ impl LocalThreadStore {
|
||||
segments.reverse();
|
||||
Ok(RolloutLineage { segments })
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn resolve_rollout_lineage_at(
|
||||
&self,
|
||||
end: HistoryPosition,
|
||||
) -> ThreadStoreResult<RolloutLineage> {
|
||||
let mut lineage = self.resolve_rollout_lineage(end.thread_id).await?;
|
||||
let Some(segment) = lineage.segments.last_mut() else {
|
||||
return Err(ThreadStoreError::Internal {
|
||||
message: "rollout lineage has no segments".to_string(),
|
||||
});
|
||||
};
|
||||
validate_cutoff_bounds(end.thread_id, segment.rollout_path.as_path(), &end).await?;
|
||||
segment.end = Some(end);
|
||||
Ok(lineage)
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum LineageRepresentation {
|
||||
Existing,
|
||||
PlainForReference,
|
||||
}
|
||||
|
||||
impl RolloutLineage {
|
||||
pub(super) fn segments(&self) -> &[RolloutLineageSegment] {
|
||||
self.segments.as_slice()
|
||||
}
|
||||
|
||||
pub(super) fn segment_index_for_ordinal(&self, ordinal: u64) -> Option<usize> {
|
||||
self.segments.iter().position(|segment| {
|
||||
ordinal >= segment.start_ordinal()
|
||||
&& segment
|
||||
.end_ordinal()
|
||||
.is_none_or(|end_ordinal| ordinal < end_ordinal)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn truncate_at(
|
||||
mut self,
|
||||
end: HistoryPosition,
|
||||
) -> ThreadStoreResult<RolloutLineage> {
|
||||
let segment_index = self
|
||||
.segments
|
||||
.iter()
|
||||
.position(|segment| segment.thread_id == end.thread_id)
|
||||
.ok_or_else(|| ThreadStoreError::Internal {
|
||||
message: "fork position is outside the source lineage".to_string(),
|
||||
})?;
|
||||
self.segments.truncate(segment_index + 1);
|
||||
let segment = self
|
||||
.segments
|
||||
.last_mut()
|
||||
.ok_or_else(|| ThreadStoreError::Internal {
|
||||
message: "rollout lineage has no segments".to_string(),
|
||||
})?;
|
||||
validate_cutoff_bounds(end.thread_id, segment.rollout_path.as_path(), &end).await?;
|
||||
segment.end = Some(end);
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl RolloutLineageSegment {
|
||||
@@ -114,7 +192,7 @@ impl RolloutLineageSegment {
|
||||
}
|
||||
|
||||
pub(super) fn start_ordinal(&self) -> u64 {
|
||||
1
|
||||
self.start_ordinal
|
||||
}
|
||||
|
||||
pub(super) fn end_ordinal(&self) -> Option<u64> {
|
||||
|
||||
@@ -33,7 +33,7 @@ async fn resolves_nested_lineage_with_empty_intermediate_segments() {
|
||||
let middle_end = history_position(
|
||||
middle_path.as_path(),
|
||||
middle,
|
||||
/*end_ordinal_exclusive*/ 1,
|
||||
/*end_ordinal_exclusive*/ 5,
|
||||
);
|
||||
let child_path = write_rollout(
|
||||
home.path(),
|
||||
@@ -53,16 +53,19 @@ async fn resolves_nested_lineage_with_empty_intermediate_segments() {
|
||||
RolloutLineageSegment {
|
||||
thread_id: root,
|
||||
rollout_path: root_path.clone(),
|
||||
start_ordinal: 1,
|
||||
end: Some(root_end),
|
||||
},
|
||||
RolloutLineageSegment {
|
||||
thread_id: middle,
|
||||
rollout_path: middle_path.clone(),
|
||||
start_ordinal: 5,
|
||||
end: Some(middle_end),
|
||||
},
|
||||
RolloutLineageSegment {
|
||||
thread_id: child,
|
||||
rollout_path: child_path,
|
||||
start_ordinal: 6,
|
||||
end: None,
|
||||
},
|
||||
]
|
||||
@@ -117,11 +120,14 @@ async fn resolves_lineage_at_explicit_history_position() {
|
||||
let end = history_position(
|
||||
child_path.as_path(),
|
||||
child,
|
||||
/*end_ordinal_exclusive*/ 2,
|
||||
/*end_ordinal_exclusive*/ 6,
|
||||
);
|
||||
|
||||
let lineage = store
|
||||
.resolve_rollout_lineage_at(end)
|
||||
.resolve_rollout_lineage(child)
|
||||
.await
|
||||
.expect("resolve child lineage")
|
||||
.truncate_at(end)
|
||||
.await
|
||||
.expect("resolve explicit position");
|
||||
|
||||
@@ -131,11 +137,13 @@ async fn resolves_lineage_at_explicit_history_position() {
|
||||
RolloutLineageSegment {
|
||||
thread_id: root,
|
||||
rollout_path: root_path.clone(),
|
||||
start_ordinal: 1,
|
||||
end: Some(root_end),
|
||||
},
|
||||
RolloutLineageSegment {
|
||||
thread_id: child,
|
||||
rollout_path: child_path.clone(),
|
||||
start_ordinal: 5,
|
||||
end: Some(end),
|
||||
},
|
||||
]
|
||||
@@ -235,8 +243,9 @@ fn write_rollout_under(
|
||||
) -> std::path::PathBuf {
|
||||
fs::create_dir_all(directory.as_path()).expect("create rollout directory");
|
||||
let path = directory.join(format!("rollout-2026-07-16T00-00-00-{thread_id}.jsonl"));
|
||||
let initial_ordinal = history_base.map_or(0, |base| base.end_ordinal_exclusive);
|
||||
let mut lines = vec![rollout_line(
|
||||
/*ordinal*/ 0,
|
||||
initial_ordinal,
|
||||
RolloutItem::SessionMeta(SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
session_id: thread_id.into(),
|
||||
@@ -248,7 +257,10 @@ fn write_rollout_under(
|
||||
git: None,
|
||||
}),
|
||||
)];
|
||||
for ordinal in 1..next_ordinal {
|
||||
for offset in 1..next_ordinal {
|
||||
let ordinal = initial_ordinal
|
||||
.checked_add(offset)
|
||||
.expect("fixture ordinal");
|
||||
lines.push(rollout_line(
|
||||
ordinal,
|
||||
RolloutItem::EventMsg(codex_protocol::protocol::EventMsg::ShutdownComplete),
|
||||
@@ -280,11 +292,16 @@ fn history_position(
|
||||
}
|
||||
|
||||
fn rollout_end_byte_offset(path: &Path, end_ordinal_exclusive: u64) -> u64 {
|
||||
let line_count = usize::try_from(end_ordinal_exclusive).expect("ordinal fits usize");
|
||||
let bytes = fs::read(path).expect("read rollout");
|
||||
let end_byte_offset = bytes
|
||||
.split_inclusive(|byte| *byte == b'\n')
|
||||
.take(line_count)
|
||||
.take_while(|line| {
|
||||
serde_json::from_slice::<RolloutLine>(line)
|
||||
.expect("parse rollout fixture")
|
||||
.ordinal
|
||||
.expect("paginated rollout ordinal")
|
||||
< end_ordinal_exclusive
|
||||
})
|
||||
.map(<[u8]>::len)
|
||||
.sum::<usize>();
|
||||
u64::try_from(end_byte_offset).expect("rollout byte offset fits u64")
|
||||
|
||||
@@ -11,10 +11,13 @@ use crate::ThreadStoreResult;
|
||||
mod read;
|
||||
mod search;
|
||||
mod segment_paging;
|
||||
mod turn_lookup;
|
||||
|
||||
pub(super) use read::list_items;
|
||||
pub(super) use read::list_turns;
|
||||
pub(super) use search::search_thread_occurrences;
|
||||
pub(super) use turn_lookup::find_source_turn;
|
||||
pub(super) use turn_lookup::find_visible_turn;
|
||||
|
||||
/// A valid complete rollout line with its absolute byte span in durable JSONL.
|
||||
///
|
||||
@@ -28,30 +31,55 @@ pub(super) struct ProjectedRolloutLine {
|
||||
pub changes: ThreadHistoryChangeSet,
|
||||
}
|
||||
|
||||
pub(super) async fn next_rollout_byte_offset(
|
||||
pub(super) struct RolloutProjectionState {
|
||||
pub next_byte_offset: u64,
|
||||
pub next_ordinal: u64,
|
||||
}
|
||||
|
||||
pub(super) async fn projection_state(
|
||||
store: &LocalThreadStore,
|
||||
thread_id: ThreadId,
|
||||
) -> ThreadStoreResult<u64> {
|
||||
) -> ThreadStoreResult<Option<RolloutProjectionState>> {
|
||||
let db_path = store.config.sqlite.thread_history_db_path();
|
||||
if !tokio::fs::try_exists(db_path.as_path())
|
||||
.await
|
||||
.map_err(thread_history_error)?
|
||||
{
|
||||
return Ok(0);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let pool = store.thread_history_db().await?;
|
||||
let offset = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT next_rollout_byte_offset FROM thread_history_projection_state WHERE thread_id = ?",
|
||||
let state = sqlx::query_as::<_, (i64, i64)>(
|
||||
r#"
|
||||
SELECT next_rollout_byte_offset, next_rollout_ordinal
|
||||
FROM thread_history_projection_state
|
||||
WHERE thread_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(thread_id.to_string())
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(thread_history_error)?
|
||||
.unwrap_or(0);
|
||||
u64::try_from(offset).map_err(|_| ThreadStoreError::Internal {
|
||||
message: format!("thread history projection for {thread_id} has a negative byte offset"),
|
||||
})
|
||||
.map_err(thread_history_error)?;
|
||||
state
|
||||
.map(|(next_byte_offset, next_ordinal)| {
|
||||
Ok(RolloutProjectionState {
|
||||
next_byte_offset: u64::try_from(next_byte_offset).map_err(|_| {
|
||||
ThreadStoreError::Internal {
|
||||
message: format!(
|
||||
"thread history projection for {thread_id} has a negative byte offset"
|
||||
),
|
||||
}
|
||||
})?,
|
||||
next_ordinal: u64::try_from(next_ordinal).map_err(|_| {
|
||||
ThreadStoreError::Internal {
|
||||
message: format!(
|
||||
"thread history projection for {thread_id} has a negative ordinal"
|
||||
),
|
||||
}
|
||||
})?,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub(super) async fn apply_projection(
|
||||
@@ -59,6 +87,7 @@ pub(super) async fn apply_projection(
|
||||
thread_id: ThreadId,
|
||||
start_offset: u64,
|
||||
next_offset: u64,
|
||||
initial_ordinal: u64,
|
||||
projections: Vec<ProjectedRolloutLine>,
|
||||
) -> ThreadStoreResult<()> {
|
||||
let pool = store.thread_history_db().await?;
|
||||
@@ -81,7 +110,8 @@ WHERE thread_id = ?
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await
|
||||
.map_err(thread_history_error)?;
|
||||
let (expected_offset, mut next_ordinal) = projection_state.unwrap_or((0, 0));
|
||||
let (expected_offset, mut next_ordinal) =
|
||||
projection_state.unwrap_or((0, sqlite_integer(initial_ordinal, "rollout ordinal")?));
|
||||
let start_offset = sqlite_integer(start_offset, "rollout byte offset")?;
|
||||
if expected_offset != start_offset {
|
||||
return Err(ThreadStoreError::Internal {
|
||||
@@ -311,9 +341,9 @@ WHERE thread_id = ?
|
||||
for item in changes.changed_items {
|
||||
let item_id = item.item.id().to_string();
|
||||
let item_json = serde_json::to_string(&item.item).map_err(thread_history_error)?;
|
||||
// The same item can appear again with a newer snapshot. Replace its JSON, but keep the
|
||||
// creation ordinal and timestamp from the first record so item identity and age stay
|
||||
// stable. Track the latest snapshot separately for incremental replay.
|
||||
// Completed items are immutable: local producers emit ItemCompleted exactly once per
|
||||
// item. Tolerate an unexpected duplicate defensively so it cannot poison materialization,
|
||||
// preserving the original creation ordinal and timestamp while updating its snapshot.
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO thread_items (
|
||||
|
||||
@@ -5,9 +5,12 @@ use serde::Serialize;
|
||||
use sqlx::Row;
|
||||
|
||||
use super::super::LocalThreadStore;
|
||||
use super::super::rollout_lineage::RolloutLineage;
|
||||
use super::segment_paging::page_item_rows;
|
||||
use super::segment_paging::page_turn_rows;
|
||||
use super::segment_paging::validate_page_size;
|
||||
use super::sqlite_integer;
|
||||
use super::turn_lookup::find_source_turn;
|
||||
use crate::ItemPage;
|
||||
use crate::ListItemsParams;
|
||||
use crate::ListTurnsParams;
|
||||
@@ -36,7 +39,6 @@ pub(super) enum CursorScope {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct HistoryCursor {
|
||||
pub requested_thread_id: ThreadId,
|
||||
pub physical_thread_id: ThreadId,
|
||||
pub rollout_ordinal: u64,
|
||||
pub include_anchor: bool,
|
||||
pub scope: CursorScope,
|
||||
@@ -92,7 +94,7 @@ pub(in crate::local) async fn list_turns(
|
||||
for turn in page.rows {
|
||||
let items = match params.items_view {
|
||||
StoredTurnItemsView::NotLoaded => Vec::new(),
|
||||
StoredTurnItemsView::Summary => load_summary_items(pool, &turn).await?,
|
||||
StoredTurnItemsView::Summary => load_summary_items(pool, &lineage, &turn).await?,
|
||||
};
|
||||
turns.push(StoredTurn {
|
||||
turn_id: turn.turn_id,
|
||||
@@ -168,22 +170,58 @@ pub(super) async fn validate_thread_for_paginated_reads(
|
||||
|
||||
async fn load_summary_items(
|
||||
pool: &sqlx::SqlitePool,
|
||||
lineage: &RolloutLineage,
|
||||
turn: &StoredTurnRow,
|
||||
) -> ThreadStoreResult<Vec<StoredThreadItem>> {
|
||||
let (item_owner, first_user_item_id, final_agent_item_id) =
|
||||
if matches!(turn.status, StoredTurnStatus::Interrupted)
|
||||
&& turn.first_user_item_id.is_none()
|
||||
&& turn.final_agent_item_id.is_none()
|
||||
{
|
||||
let source = find_source_turn(pool, lineage, turn.turn_id.as_str()).await?;
|
||||
(
|
||||
source.physical_thread_id,
|
||||
source.first_user_item_id,
|
||||
source.final_agent_item_id,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
turn.position.physical_thread_id,
|
||||
turn.first_user_item_id.clone(),
|
||||
turn.final_agent_item_id.clone(),
|
||||
)
|
||||
};
|
||||
let Some(segment) = lineage
|
||||
.segments()
|
||||
.iter()
|
||||
.find(|segment| segment.thread_id() == item_owner)
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let start_ordinal = sqlite_integer(segment.start_ordinal(), "rollout ordinal")?;
|
||||
let end_ordinal = segment
|
||||
.end_ordinal()
|
||||
.map(|ordinal| sqlite_integer(ordinal, "rollout ordinal"))
|
||||
.transpose()?;
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT turn_id, item_id, updated_at_ordinal, created_at_ms, item_json
|
||||
FROM thread_items
|
||||
WHERE thread_id = ?
|
||||
AND turn_id = ?
|
||||
AND rollout_ordinal >= ?
|
||||
AND (? IS NULL OR rollout_ordinal < ?)
|
||||
AND (item_id = ? OR item_id = ?)
|
||||
ORDER BY rollout_ordinal ASC
|
||||
"#,
|
||||
)
|
||||
.bind(turn.position.physical_thread_id.to_string())
|
||||
.bind(item_owner.to_string())
|
||||
.bind(turn.turn_id.as_str())
|
||||
.bind(turn.first_user_item_id.as_deref())
|
||||
.bind(turn.final_agent_item_id.as_deref())
|
||||
.bind(start_ordinal)
|
||||
.bind(end_ordinal)
|
||||
.bind(end_ordinal)
|
||||
.bind(first_user_item_id.as_deref())
|
||||
.bind(final_agent_item_id.as_deref())
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(super::thread_history_error)?;
|
||||
@@ -209,14 +247,13 @@ pub(super) fn parse_cursor(
|
||||
pub(super) fn serialize_cursor(
|
||||
requested_thread_id: ThreadId,
|
||||
scope: CursorScope,
|
||||
position: PhysicalHistoryPosition,
|
||||
rollout_ordinal: i64,
|
||||
include_anchor: bool,
|
||||
) -> ThreadStoreResult<String> {
|
||||
let rollout_ordinal = u64::try_from(position.rollout_ordinal)
|
||||
.map_err(|_| invalid_cursor("negative rollout ordinal"))?;
|
||||
let rollout_ordinal =
|
||||
u64::try_from(rollout_ordinal).map_err(|_| invalid_cursor("negative rollout ordinal"))?;
|
||||
serde_json::to_string(&HistoryCursor {
|
||||
requested_thread_id,
|
||||
physical_thread_id: position.physical_thread_id,
|
||||
rollout_ordinal,
|
||||
include_anchor,
|
||||
scope,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use codex_app_server_protocol::CodexErrorInfo;
|
||||
@@ -16,6 +17,7 @@ use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
use crate::ItemSortKey;
|
||||
use crate::SearchThreadOccurrencesParams;
|
||||
use crate::SortDirection;
|
||||
use crate::StoredTurnError;
|
||||
use crate::StoredTurnStatus;
|
||||
@@ -441,7 +443,7 @@ async fn lineage_reads_page_across_parent_and_child_segments() {
|
||||
(root_id, "root-1", 1, Some("root-user"), Some("root-agent")),
|
||||
(root_id, "root-2", 4, None, None),
|
||||
(root_id, "excluded-root", 6, None, None),
|
||||
(child_id, "child-1", 1, None, None),
|
||||
(child_id, "child-1", 7, None, None),
|
||||
] {
|
||||
insert_turn(
|
||||
db,
|
||||
@@ -460,7 +462,7 @@ async fn lineage_reads_page_across_parent_and_child_segments() {
|
||||
(root_id, "root-1", "root-agent", 3),
|
||||
(root_id, "root-2", "root-2-item", 5),
|
||||
(root_id, "excluded-root", "excluded-item", 7),
|
||||
(child_id, "child-1", "child-item", 2),
|
||||
(child_id, "child-1", "child-item", 8),
|
||||
] {
|
||||
insert_item(db, thread_id, turn_id, item_id, ordinal).await;
|
||||
}
|
||||
@@ -568,6 +570,65 @@ async fn lineage_reads_page_across_parent_and_child_segments() {
|
||||
assert!(matches!(error, ThreadStoreError::InvalidRequest { .. }));
|
||||
}
|
||||
|
||||
let first_occurrences = store
|
||||
.search_thread_occurrences(SearchThreadOccurrencesParams {
|
||||
thread_id: child_id,
|
||||
search_term: "item".to_string(),
|
||||
cursor: None,
|
||||
page_size: 2,
|
||||
})
|
||||
.await
|
||||
.expect("first inherited occurrence page");
|
||||
assert_eq!(
|
||||
first_occurrences
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.item_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["root-agent", "root-2-item"]
|
||||
);
|
||||
let inherited_turn = store
|
||||
.list_turns(turn_params(
|
||||
child_id,
|
||||
Some(first_occurrences.items[0].turn_cursor.clone()),
|
||||
/*page_size*/ 1,
|
||||
SortDirection::Asc,
|
||||
StoredTurnItemsView::NotLoaded,
|
||||
))
|
||||
.await
|
||||
.expect("navigate to inherited occurrence turn");
|
||||
assert_eq!(turn_ids(&inherited_turn), vec!["root-1"]);
|
||||
let child_occurrences = store
|
||||
.search_thread_occurrences(SearchThreadOccurrencesParams {
|
||||
thread_id: child_id,
|
||||
search_term: "item".to_string(),
|
||||
cursor: first_occurrences.next_cursor,
|
||||
page_size: 2,
|
||||
})
|
||||
.await
|
||||
.expect("continue inherited occurrence search");
|
||||
assert_eq!(child_occurrences.items[0].item_id, "child-item");
|
||||
assert_eq!(child_occurrences.next_cursor, None);
|
||||
|
||||
let gap_cursor = serde_json::to_string(&HistoryCursor {
|
||||
requested_thread_id: child_id,
|
||||
rollout_ordinal: 6,
|
||||
include_anchor: true,
|
||||
scope: CursorScope::Turns,
|
||||
})
|
||||
.expect("serialize cursor in metadata gap");
|
||||
let error = store
|
||||
.list_turns(turn_params(
|
||||
child_id,
|
||||
Some(gap_cursor),
|
||||
/*page_size*/ 1,
|
||||
SortDirection::Asc,
|
||||
StoredTurnItemsView::NotLoaded,
|
||||
))
|
||||
.await
|
||||
.expect_err("cursor cannot point to segment metadata");
|
||||
assert!(matches!(error, ThreadStoreError::InvalidRequest { .. }));
|
||||
|
||||
let (_other_home, other_store, other_thread_id) =
|
||||
store_with_mode(ThreadHistoryMode::Paginated).await;
|
||||
let error = other_store
|
||||
@@ -583,6 +644,62 @@ async fn lineage_reads_page_across_parent_and_child_segments() {
|
||||
assert!(matches!(error, ThreadStoreError::InvalidRequest { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inherited_search_excludes_turns_created_after_the_fork() {
|
||||
let (home, store, child_id) = store_with_mode(ThreadHistoryMode::Paginated).await;
|
||||
let source_id = ThreadId::default();
|
||||
let source_path = rollout_path(home.path(), source_id);
|
||||
write_rollout_with_end(
|
||||
source_path.as_path(),
|
||||
source_id,
|
||||
/*history_base*/ None,
|
||||
/*next_ordinal*/ 5,
|
||||
);
|
||||
write_rollout_with_end(
|
||||
rollout_path(home.path(), child_id).as_path(),
|
||||
child_id,
|
||||
Some(history_position(
|
||||
source_path.as_path(),
|
||||
source_id,
|
||||
/*end_ordinal_exclusive*/ 3,
|
||||
)),
|
||||
/*next_ordinal*/ 2,
|
||||
);
|
||||
let db = history_db(&store).await;
|
||||
insert_turn(
|
||||
db,
|
||||
source_id,
|
||||
"hidden-turn",
|
||||
/*rollout_ordinal*/ 3,
|
||||
"completed",
|
||||
/*error_json*/ None,
|
||||
/*first_user_item_id*/ Some("hidden-item"),
|
||||
/*final_agent_item_id*/ None,
|
||||
)
|
||||
.await;
|
||||
insert_item(
|
||||
db,
|
||||
source_id,
|
||||
"hidden-turn",
|
||||
"hidden-item",
|
||||
/*rollout_ordinal*/ 2,
|
||||
)
|
||||
.await;
|
||||
|
||||
let occurrences = store
|
||||
.search_thread_occurrences(SearchThreadOccurrencesParams {
|
||||
thread_id: child_id,
|
||||
search_term: "hidden".to_string(),
|
||||
cursor: None,
|
||||
page_size: 1,
|
||||
})
|
||||
.await
|
||||
.expect("search inherited history");
|
||||
|
||||
assert!(occurrences.items.is_empty());
|
||||
assert_eq!(occurrences.next_cursor, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lineage_reads_nested_forks() {
|
||||
let (home, store, child_id) = store_with_mode(ThreadHistoryMode::Paginated).await;
|
||||
@@ -593,7 +710,7 @@ async fn lineage_reads_nested_forks() {
|
||||
root_path.as_path(),
|
||||
root_id,
|
||||
/*history_base*/ None,
|
||||
/*next_ordinal*/ 3,
|
||||
/*next_ordinal*/ 5,
|
||||
);
|
||||
let middle_path = rollout_path(home.path(), middle_id);
|
||||
write_rollout_with_end(
|
||||
@@ -602,9 +719,9 @@ async fn lineage_reads_nested_forks() {
|
||||
Some(history_position(
|
||||
root_path.as_path(),
|
||||
root_id,
|
||||
/*end_ordinal_exclusive*/ 3,
|
||||
/*end_ordinal_exclusive*/ 4,
|
||||
)),
|
||||
/*next_ordinal*/ 2,
|
||||
/*next_ordinal*/ 3,
|
||||
);
|
||||
write_rollout_with_end(
|
||||
rollout_path(home.path(), child_id).as_path(),
|
||||
@@ -612,28 +729,46 @@ async fn lineage_reads_nested_forks() {
|
||||
Some(history_position(
|
||||
middle_path.as_path(),
|
||||
middle_id,
|
||||
/*end_ordinal_exclusive*/ 2,
|
||||
/*end_ordinal_exclusive*/ 7,
|
||||
)),
|
||||
/*next_ordinal*/ 2,
|
||||
);
|
||||
let db = history_db(&store).await;
|
||||
for (thread_id, turn_id) in [
|
||||
(root_id, "root"),
|
||||
(middle_id, "middle"),
|
||||
(child_id, "child"),
|
||||
for (thread_id, turn_id, ordinal, status, first_user_item_id) in [
|
||||
(root_id, "root", 1, "completed", None),
|
||||
(root_id, "shared", 2, "completed", Some("before-fork")),
|
||||
(middle_id, "shared", 5, "interrupted", None),
|
||||
(middle_id, "middle", 6, "completed", None),
|
||||
(child_id, "child", 8, "completed", None),
|
||||
] {
|
||||
insert_turn(
|
||||
db,
|
||||
thread_id,
|
||||
turn_id,
|
||||
/*rollout_ordinal*/ 1,
|
||||
"completed",
|
||||
ordinal,
|
||||
status,
|
||||
/*error_json*/ None,
|
||||
/*first_user_item_id*/ None,
|
||||
first_user_item_id,
|
||||
/*final_agent_item_id*/ None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
insert_item(
|
||||
db,
|
||||
root_id,
|
||||
"shared",
|
||||
"before-fork",
|
||||
/*rollout_ordinal*/ 3,
|
||||
)
|
||||
.await;
|
||||
insert_item(
|
||||
db,
|
||||
root_id,
|
||||
"shared",
|
||||
"after-fork",
|
||||
/*rollout_ordinal*/ 4,
|
||||
)
|
||||
.await;
|
||||
|
||||
let first_descending_page = store
|
||||
.list_turns(turn_params(
|
||||
@@ -652,11 +787,66 @@ async fn lineage_reads_nested_forks() {
|
||||
first_descending_page.next_cursor,
|
||||
/*page_size*/ 2,
|
||||
SortDirection::Desc,
|
||||
StoredTurnItemsView::NotLoaded,
|
||||
StoredTurnItemsView::Summary,
|
||||
))
|
||||
.await
|
||||
.expect("second nested descending page");
|
||||
assert_eq!(turn_ids(&second_descending_page), vec!["root"]);
|
||||
assert_eq!(turn_ids(&second_descending_page), vec!["shared", "root"]);
|
||||
assert_eq!(
|
||||
second_descending_page.turns[0].status,
|
||||
StoredTurnStatus::Interrupted
|
||||
);
|
||||
assert_eq!(
|
||||
second_descending_page.turns[0].items,
|
||||
vec![expected_item(
|
||||
"shared",
|
||||
"before-fork",
|
||||
/*rollout_ordinal*/ 3
|
||||
)]
|
||||
);
|
||||
|
||||
let ascending_page = store
|
||||
.list_turns(turn_params(
|
||||
child_id,
|
||||
/*cursor*/ None,
|
||||
/*page_size*/ 2,
|
||||
SortDirection::Asc,
|
||||
StoredTurnItemsView::Summary,
|
||||
))
|
||||
.await
|
||||
.expect("first nested ascending page");
|
||||
assert_eq!(turn_ids(&ascending_page), vec!["root", "shared"]);
|
||||
|
||||
let mut held_connections = Vec::new();
|
||||
for _ in 0..4 {
|
||||
held_connections.push(db.acquire().await.expect("hold history connection"));
|
||||
}
|
||||
let occurrences = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
store.search_thread_occurrences(SearchThreadOccurrencesParams {
|
||||
thread_id: child_id,
|
||||
search_term: "o".to_string(),
|
||||
cursor: None,
|
||||
page_size: 1,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("inherited search releases its row connection")
|
||||
.expect("search inherited occurrence");
|
||||
assert_eq!(occurrences.items[0].item_id, "before-fork");
|
||||
assert!(occurrences.next_cursor.is_some());
|
||||
|
||||
let occurrence_turn = store
|
||||
.list_turns(turn_params(
|
||||
child_id,
|
||||
Some(occurrences.items[0].turn_cursor.clone()),
|
||||
/*page_size*/ 1,
|
||||
SortDirection::Asc,
|
||||
StoredTurnItemsView::NotLoaded,
|
||||
))
|
||||
.await
|
||||
.expect("navigate to effective occurrence turn");
|
||||
assert_eq!(turn_ids(&occurrence_turn), vec!["shared"]);
|
||||
}
|
||||
|
||||
async fn store_with_mode(history_mode: ThreadHistoryMode) -> (TempDir, LocalThreadStore, ThreadId) {
|
||||
@@ -707,9 +897,10 @@ fn write_rollout_with_end(
|
||||
next_ordinal: u64,
|
||||
) {
|
||||
fs::create_dir_all(path.parent().expect("rollout parent")).expect("create rollout parent");
|
||||
let initial_ordinal = history_base.map_or(0, |base| base.end_ordinal_exclusive);
|
||||
let mut lines = vec![RolloutLine {
|
||||
timestamp: "2026-07-16T00:00:00.000Z".to_string(),
|
||||
ordinal: Some(0),
|
||||
ordinal: Some(initial_ordinal),
|
||||
item: RolloutItem::SessionMeta(SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
session_id: thread_id.into(),
|
||||
@@ -721,7 +912,10 @@ fn write_rollout_with_end(
|
||||
git: None,
|
||||
}),
|
||||
}];
|
||||
for ordinal in 1..next_ordinal {
|
||||
for offset in 1..next_ordinal {
|
||||
let ordinal = initial_ordinal
|
||||
.checked_add(offset)
|
||||
.expect("fixture ordinal");
|
||||
lines.push(RolloutLine {
|
||||
timestamp: "2026-07-16T00:00:00.000Z".to_string(),
|
||||
ordinal: Some(ordinal),
|
||||
@@ -761,11 +955,16 @@ fn history_position(
|
||||
}
|
||||
|
||||
fn rollout_end_byte_offset(path: &std::path::Path, end_ordinal_exclusive: u64) -> u64 {
|
||||
let line_count = usize::try_from(end_ordinal_exclusive).expect("ordinal fits usize");
|
||||
let bytes = fs::read(path).expect("read rollout");
|
||||
let end_byte_offset = bytes
|
||||
.split_inclusive(|byte| *byte == b'\n')
|
||||
.take(line_count)
|
||||
.take_while(|line| {
|
||||
serde_json::from_slice::<RolloutLine>(line)
|
||||
.expect("parse rollout fixture")
|
||||
.ordinal
|
||||
.expect("paginated rollout ordinal")
|
||||
< end_ordinal_exclusive
|
||||
})
|
||||
.map(<[u8]>::len)
|
||||
.sum::<usize>();
|
||||
u64::try_from(end_byte_offset).expect("rollout byte offset fits u64")
|
||||
@@ -821,8 +1020,9 @@ async fn insert_item(
|
||||
item_id: &str,
|
||||
rollout_ordinal: i64,
|
||||
) {
|
||||
let (item_type, item_json) = fixture_item(item_id);
|
||||
sqlx::query(
|
||||
"INSERT INTO thread_items (thread_id, turn_id, item_id, rollout_ordinal, updated_at_ordinal, created_at_ms, item_json) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO thread_items (thread_id, turn_id, item_id, rollout_ordinal, updated_at_ordinal, created_at_ms, item_type, item_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(thread_id.to_string())
|
||||
.bind(turn_id)
|
||||
@@ -830,7 +1030,8 @@ async fn insert_item(
|
||||
.bind(rollout_ordinal)
|
||||
.bind(rollout_ordinal)
|
||||
.bind(rollout_ordinal * 1_000)
|
||||
.bind(format!(r#"{{"type":"userMessage","id":"{item_id}","content":[]}}"#))
|
||||
.bind(item_type)
|
||||
.bind(item_json)
|
||||
.execute(db)
|
||||
.await
|
||||
.expect("insert item fixture");
|
||||
@@ -892,8 +1093,23 @@ fn expected_item(turn_id: &str, item_id: &str, rollout_ordinal: u64) -> StoredTh
|
||||
item_id: item_id.to_string(),
|
||||
updated_at_ordinal: rollout_ordinal,
|
||||
created_at_ms: i64::try_from(rollout_ordinal).expect("fixture ordinal fits i64") * 1_000,
|
||||
item_json: format!(r#"{{"type":"userMessage","id":"{item_id}","content":[]}}"#)
|
||||
.into_bytes(),
|
||||
item_json: fixture_item(item_id).1.into_bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture_item(item_id: &str) -> (&'static str, String) {
|
||||
if item_id.contains("agent") {
|
||||
(
|
||||
"agentMessage",
|
||||
format!(r#"{{"type":"agentMessage","id":"{item_id}","text":"{item_id} item"}}"#),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"userMessage",
|
||||
format!(
|
||||
r#"{{"type":"userMessage","id":"{item_id}","content":[{{"type":"text","text":"{item_id}"}}]}}"#
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
@@ -14,10 +15,11 @@ use sqlx::Row;
|
||||
|
||||
use super::super::LocalThreadStore;
|
||||
use super::read::CursorScope;
|
||||
use super::read::PhysicalHistoryPosition;
|
||||
use super::read::serialize_cursor;
|
||||
use super::read::validate_thread_for_paginated_reads;
|
||||
use super::sqlite_integer;
|
||||
use super::thread_history_error;
|
||||
use super::turn_lookup::find_visible_turn;
|
||||
use crate::SearchTextRange;
|
||||
use crate::SearchThreadOccurrencesParams;
|
||||
use crate::StoredThreadOccurrence;
|
||||
@@ -71,13 +73,43 @@ pub(in crate::local) async fn search_thread_occurrences(
|
||||
params.thread_id,
|
||||
¶ms.search_term,
|
||||
)?;
|
||||
let next_rollout_ordinal = cursor
|
||||
.as_ref()
|
||||
.map_or(0, |cursor| cursor.next_rollout_ordinal);
|
||||
let matcher = LiteralMatcher::new(params.search_term.as_str());
|
||||
let lineage = store.resolve_rollout_lineage(params.thread_id).await?;
|
||||
let cursor_segment = cursor
|
||||
.as_ref()
|
||||
.map(|cursor| {
|
||||
lineage
|
||||
.segment_index_for_ordinal(
|
||||
u64::try_from(cursor.next_rollout_ordinal)
|
||||
.map_err(|_| invalid_cursor("negative rollout ordinal"))?,
|
||||
)
|
||||
.ok_or_else(|| invalid_cursor("position outside thread lineage"))
|
||||
})
|
||||
.transpose()?;
|
||||
let pool = store.thread_history_db().await?;
|
||||
let mut rows = sqlx::query(
|
||||
r#"
|
||||
let mut items = Vec::with_capacity(params.page_size);
|
||||
let mut effective_turn_ordinals = HashMap::new();
|
||||
for (segment_index, segment) in lineage
|
||||
.segments()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(cursor_segment.unwrap_or(0))
|
||||
{
|
||||
let segment_start_ordinal = sqlite_integer(segment.start_ordinal(), "rollout ordinal")?;
|
||||
let next_rollout_ordinal = if Some(segment_index) == cursor_segment {
|
||||
cursor
|
||||
.as_ref()
|
||||
.map_or(0, |cursor| cursor.next_rollout_ordinal)
|
||||
} else {
|
||||
segment_start_ordinal
|
||||
};
|
||||
let end_rollout_ordinal = segment
|
||||
.end_ordinal()
|
||||
.map(|ordinal| sqlite_integer(ordinal, "rollout ordinal"))
|
||||
.transpose()?
|
||||
.unwrap_or(i64::MAX);
|
||||
let mut rows = sqlx::query(
|
||||
r#"
|
||||
SELECT turn_id, item_id, rollout_ordinal, item_json, turn_rollout_ordinal
|
||||
FROM (
|
||||
SELECT
|
||||
@@ -93,6 +125,9 @@ FROM (
|
||||
WHERE items.thread_id = ?
|
||||
AND items.item_type = 'userMessage'
|
||||
AND items.rollout_ordinal >= ?
|
||||
AND items.rollout_ordinal < ?
|
||||
AND turns.rollout_ordinal >= ?
|
||||
AND turns.rollout_ordinal < ?
|
||||
|
||||
UNION ALL
|
||||
|
||||
@@ -110,71 +145,107 @@ FROM (
|
||||
WHERE turns.thread_id = ?
|
||||
AND turns.final_agent_item_id IS NOT NULL
|
||||
AND items.rollout_ordinal >= ?
|
||||
AND items.rollout_ordinal < ?
|
||||
AND turns.rollout_ordinal >= ?
|
||||
AND turns.rollout_ordinal < ?
|
||||
)
|
||||
ORDER BY rollout_ordinal ASC
|
||||
"#,
|
||||
)
|
||||
.bind(params.thread_id.to_string())
|
||||
.bind(next_rollout_ordinal)
|
||||
.bind(params.thread_id.to_string())
|
||||
.bind(next_rollout_ordinal)
|
||||
.fetch(pool);
|
||||
)
|
||||
.bind(segment.thread_id().to_string())
|
||||
.bind(next_rollout_ordinal)
|
||||
.bind(end_rollout_ordinal)
|
||||
.bind(segment_start_ordinal)
|
||||
.bind(end_rollout_ordinal)
|
||||
.bind(segment.thread_id().to_string())
|
||||
.bind(next_rollout_ordinal)
|
||||
.bind(end_rollout_ordinal)
|
||||
.bind(segment_start_ordinal)
|
||||
.bind(end_rollout_ordinal)
|
||||
.fetch(pool);
|
||||
|
||||
let mut items = Vec::with_capacity(params.page_size);
|
||||
while let Some(row) = rows.try_next().await.map_err(thread_history_error)? {
|
||||
let row = candidate_row(row)?;
|
||||
let item = serde_json::from_str::<ThreadItem>(row.item_json.as_str()).map_err(|err| {
|
||||
ThreadStoreError::Internal {
|
||||
message: format!("failed to deserialize stored thread item: {err}"),
|
||||
}
|
||||
})?;
|
||||
let Some(text) = searchable_text(&item) else {
|
||||
continue;
|
||||
};
|
||||
let first_occurrence_index = cursor
|
||||
.as_ref()
|
||||
.filter(|cursor| cursor.next_rollout_ordinal == row.rollout_ordinal)
|
||||
.map_or(0, |cursor| cursor.next_occurrence_index);
|
||||
let remaining = params
|
||||
.page_size
|
||||
.saturating_add(1)
|
||||
.saturating_sub(items.len());
|
||||
let turn_cursor = serialize_cursor(
|
||||
params.thread_id,
|
||||
CursorScope::Turns,
|
||||
PhysicalHistoryPosition {
|
||||
physical_thread_id: params.thread_id,
|
||||
rollout_ordinal: row.turn_rollout_ordinal,
|
||||
},
|
||||
/*include_anchor*/ true,
|
||||
)?;
|
||||
for (occurrence_index, matched) in matcher
|
||||
.find_ranges(
|
||||
let mut matching_rows = Vec::new();
|
||||
let mut matching_occurrences = 0_usize;
|
||||
while let Some(row) = rows.try_next().await.map_err(thread_history_error)? {
|
||||
let row = candidate_row(row)?;
|
||||
let item =
|
||||
serde_json::from_str::<ThreadItem>(row.item_json.as_str()).map_err(|err| {
|
||||
ThreadStoreError::Internal {
|
||||
message: format!("failed to deserialize stored thread item: {err}"),
|
||||
}
|
||||
})?;
|
||||
let Some(text) = searchable_text(&item) else {
|
||||
continue;
|
||||
};
|
||||
let first_occurrence_index = cursor
|
||||
.as_ref()
|
||||
.filter(|cursor| cursor.next_rollout_ordinal == row.rollout_ordinal)
|
||||
.map_or(0, |cursor| cursor.next_occurrence_index);
|
||||
let remaining = params
|
||||
.page_size
|
||||
.saturating_add(1)
|
||||
.saturating_sub(items.len())
|
||||
.saturating_sub(matching_occurrences);
|
||||
let matches = matcher.find_ranges(
|
||||
text.as_ref(),
|
||||
first_occurrence_index.saturating_add(remaining),
|
||||
)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.skip(first_occurrence_index)
|
||||
{
|
||||
if items.len() == params.page_size {
|
||||
return Ok(ThreadOccurrenceSearchPage {
|
||||
items,
|
||||
next_cursor: Some(serialize_cursor_for_search(SearchCursor {
|
||||
thread_id: params.thread_id,
|
||||
search_term: params.search_term,
|
||||
next_rollout_ordinal: row.rollout_ordinal,
|
||||
next_occurrence_index: occurrence_index,
|
||||
})?),
|
||||
});
|
||||
);
|
||||
if matches.len() <= first_occurrence_index {
|
||||
continue;
|
||||
}
|
||||
matching_occurrences += matches.len() - first_occurrence_index;
|
||||
matching_rows.push((row, text.into_owned(), matches, first_occurrence_index));
|
||||
if matching_occurrences
|
||||
== params
|
||||
.page_size
|
||||
.saturating_add(1)
|
||||
.saturating_sub(items.len())
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
drop(rows);
|
||||
|
||||
for (row, text, matches, first_occurrence_index) in matching_rows {
|
||||
let turn_rollout_ordinal = if segment_index + 1 == lineage.segments().len() {
|
||||
row.turn_rollout_ordinal
|
||||
} else if let Some(ordinal) = effective_turn_ordinals.get(&row.turn_id) {
|
||||
*ordinal
|
||||
} else {
|
||||
let ordinal = find_visible_turn(pool, &lineage, row.turn_id.as_str())
|
||||
.await?
|
||||
.rollout_ordinal;
|
||||
effective_turn_ordinals.insert(row.turn_id.clone(), ordinal);
|
||||
ordinal
|
||||
};
|
||||
let turn_cursor = serialize_cursor(
|
||||
params.thread_id,
|
||||
CursorScope::Turns,
|
||||
turn_rollout_ordinal,
|
||||
/*include_anchor*/ true,
|
||||
)?;
|
||||
for (occurrence_index, matched) in
|
||||
matches.into_iter().enumerate().skip(first_occurrence_index)
|
||||
{
|
||||
if items.len() == params.page_size {
|
||||
return Ok(ThreadOccurrenceSearchPage {
|
||||
items,
|
||||
next_cursor: Some(serialize_cursor_for_search(SearchCursor {
|
||||
thread_id: params.thread_id,
|
||||
search_term: params.search_term,
|
||||
next_rollout_ordinal: row.rollout_ordinal,
|
||||
next_occurrence_index: occurrence_index,
|
||||
})?),
|
||||
});
|
||||
}
|
||||
items.push(occurrence_in_item(
|
||||
row.turn_id.as_str(),
|
||||
row.item_id.as_str(),
|
||||
text.as_str(),
|
||||
matched,
|
||||
turn_cursor.as_str(),
|
||||
));
|
||||
}
|
||||
items.push(occurrence_in_item(
|
||||
row.turn_id.as_str(),
|
||||
row.item_id.as_str(),
|
||||
text.as_ref(),
|
||||
matched,
|
||||
turn_cursor.as_str(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,9 @@ pub(super) async fn page_turn_rows(
|
||||
) -> ThreadStoreResult<SegmentPage<StoredTurnRow>> {
|
||||
let cursor = parse_cursor(cursor, requested_thread_id, CursorScope::Turns)?;
|
||||
let mut rows = Vec::new();
|
||||
for (segment, segment_cursor) in segments_from_cursor(lineage, direction, cursor.as_ref())? {
|
||||
for (segment_index, segment, segment_cursor) in
|
||||
segments_from_cursor(lineage, direction, cursor.as_ref())?
|
||||
{
|
||||
let remaining = remaining_limit(page_size, rows.len())?;
|
||||
if remaining == 0 {
|
||||
break;
|
||||
@@ -71,6 +73,19 @@ WHERE thread_id =
|
||||
);
|
||||
query.push_bind(segment.thread_id().to_string());
|
||||
push_segment_range(&mut query, segment)?;
|
||||
for newer_segment in &lineage.segments()[segment_index + 1..] {
|
||||
query
|
||||
.push(" AND NOT EXISTS (SELECT 1 FROM thread_turns AS newer_turn WHERE newer_turn.thread_id = ")
|
||||
.push_bind(newer_segment.thread_id().to_string())
|
||||
.push(" AND newer_turn.turn_id = thread_turns.turn_id AND newer_turn.rollout_ordinal >= ")
|
||||
.push_bind(sqlite_integer(newer_segment.start_ordinal())?);
|
||||
if let Some(end_ordinal) = newer_segment.end_ordinal() {
|
||||
query
|
||||
.push(" AND newer_turn.rollout_ordinal < ")
|
||||
.push_bind(sqlite_integer(end_ordinal)?);
|
||||
}
|
||||
query.push(")");
|
||||
}
|
||||
push_cursor_clause(&mut query, direction, segment_cursor)?;
|
||||
push_order_and_limit(&mut query, direction, remaining);
|
||||
rows.extend(
|
||||
@@ -113,7 +128,7 @@ pub(super) async fn page_item_rows(
|
||||
CursorScope::ItemsByCreatedAtOrdinal,
|
||||
)?;
|
||||
let mut rows = Vec::new();
|
||||
for (segment, segment_cursor) in
|
||||
for (_, segment, segment_cursor) in
|
||||
segments_from_cursor(lineage, params.sort_direction, cursor.as_ref())?
|
||||
{
|
||||
let remaining = remaining_limit(params.page_size, rows.len())?;
|
||||
@@ -168,12 +183,6 @@ async fn page_updated_item_rows(
|
||||
params.thread_id,
|
||||
CursorScope::ItemsByUpdatedAtOrdinal,
|
||||
)?;
|
||||
if cursor
|
||||
.as_ref()
|
||||
.is_some_and(|cursor| cursor.physical_thread_id != params.thread_id)
|
||||
{
|
||||
return Err(invalid_cursor("unknown physical segment"));
|
||||
}
|
||||
let mut query = QueryBuilder::<Sqlite>::new(
|
||||
r#"
|
||||
SELECT turn_id, item_id, updated_at_ordinal AS rollout_ordinal, updated_at_ordinal, created_at_ms, item_json
|
||||
@@ -230,22 +239,15 @@ fn segments_from_cursor<'a>(
|
||||
lineage: &'a RolloutLineage,
|
||||
direction: SortDirection,
|
||||
cursor: Option<&'a HistoryCursor>,
|
||||
) -> ThreadStoreResult<Vec<(&'a RolloutLineageSegment, Option<&'a HistoryCursor>)>> {
|
||||
) -> ThreadStoreResult<Vec<(usize, &'a RolloutLineageSegment, Option<&'a HistoryCursor>)>> {
|
||||
let segments = lineage.segments();
|
||||
let cursor_index = cursor
|
||||
.map(|cursor| {
|
||||
segments
|
||||
.iter()
|
||||
.position(|segment| segment.thread_id() == cursor.physical_thread_id)
|
||||
.ok_or_else(|| invalid_cursor("unknown physical segment"))
|
||||
lineage
|
||||
.segment_index_for_ordinal(cursor.rollout_ordinal)
|
||||
.ok_or_else(|| invalid_cursor("position outside thread lineage"))
|
||||
})
|
||||
.transpose()?;
|
||||
if let Some(cursor) = cursor
|
||||
&& let Some(index) = cursor_index
|
||||
&& !cursor_in_segment(cursor, &segments[index])
|
||||
{
|
||||
return Err(invalid_cursor("position outside physical segment"));
|
||||
}
|
||||
let indexes: Vec<usize> = match direction {
|
||||
SortDirection::Asc => (cursor_index.unwrap_or(0)..segments.len()).collect(),
|
||||
SortDirection::Desc => {
|
||||
@@ -261,19 +263,11 @@ fn segments_from_cursor<'a>(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(&segments[index], segment_cursor)
|
||||
(index, &segments[index], segment_cursor)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn cursor_in_segment(cursor: &HistoryCursor, segment: &RolloutLineageSegment) -> bool {
|
||||
let ordinal = cursor.rollout_ordinal;
|
||||
ordinal >= segment.start_ordinal()
|
||||
&& segment
|
||||
.end_ordinal()
|
||||
.is_none_or(|end_ordinal| ordinal < end_ordinal)
|
||||
}
|
||||
|
||||
fn push_segment_range(
|
||||
query: &mut QueryBuilder<Sqlite>,
|
||||
segment: &RolloutLineageSegment,
|
||||
@@ -344,7 +338,7 @@ fn finish_page<T: HasPosition>(
|
||||
serialize_cursor(
|
||||
requested_thread_id,
|
||||
scope.clone(),
|
||||
row.position(),
|
||||
row.position().rollout_ordinal,
|
||||
/*include_anchor*/ true,
|
||||
)
|
||||
})
|
||||
@@ -355,7 +349,7 @@ fn finish_page<T: HasPosition>(
|
||||
serialize_cursor(
|
||||
requested_thread_id,
|
||||
scope,
|
||||
row.position(),
|
||||
row.position().rollout_ordinal,
|
||||
/*include_anchor*/ false,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
use codex_protocol::ThreadId;
|
||||
use sqlx::Row;
|
||||
|
||||
use super::super::rollout_lineage::RolloutLineage;
|
||||
use super::super::rollout_lineage::RolloutLineageSegment;
|
||||
use super::sqlite_integer;
|
||||
use crate::ThreadStoreError;
|
||||
use crate::ThreadStoreResult;
|
||||
|
||||
pub(in crate::local) struct TurnRow {
|
||||
pub physical_thread_id: ThreadId,
|
||||
pub rollout_ordinal: i64,
|
||||
pub rollout_byte_offset: Option<i64>,
|
||||
pub rollout_end_ordinal: Option<i64>,
|
||||
pub rollout_end_byte_offset: Option<i64>,
|
||||
pub status: String,
|
||||
pub first_user_item_id: Option<String>,
|
||||
pub final_agent_item_id: Option<String>,
|
||||
}
|
||||
|
||||
pub(in crate::local) async fn find_source_turn(
|
||||
pool: &sqlx::SqlitePool,
|
||||
lineage: &RolloutLineage,
|
||||
turn_id: &str,
|
||||
) -> ThreadStoreResult<TurnRow> {
|
||||
find_turn(pool, lineage.segments().iter(), turn_id).await
|
||||
}
|
||||
|
||||
pub(in crate::local) async fn find_visible_turn(
|
||||
pool: &sqlx::SqlitePool,
|
||||
lineage: &RolloutLineage,
|
||||
turn_id: &str,
|
||||
) -> ThreadStoreResult<TurnRow> {
|
||||
find_turn(pool, lineage.segments().iter().rev(), turn_id).await
|
||||
}
|
||||
|
||||
async fn find_turn<'a>(
|
||||
pool: &sqlx::SqlitePool,
|
||||
segments: impl Iterator<Item = &'a RolloutLineageSegment>,
|
||||
turn_id: &str,
|
||||
) -> ThreadStoreResult<TurnRow> {
|
||||
for segment in segments {
|
||||
if let Some(row) = query_turn_row(pool, segment, turn_id).await? {
|
||||
return Ok(row);
|
||||
}
|
||||
}
|
||||
Err(ThreadStoreError::InvalidRequest {
|
||||
message: format!("turn not found: {turn_id}"),
|
||||
})
|
||||
}
|
||||
|
||||
async fn query_turn_row(
|
||||
pool: &sqlx::SqlitePool,
|
||||
segment: &RolloutLineageSegment,
|
||||
turn_id: &str,
|
||||
) -> ThreadStoreResult<Option<TurnRow>> {
|
||||
let end_ordinal = segment
|
||||
.end_ordinal()
|
||||
.map(|ordinal| sqlite_integer(ordinal, "rollout ordinal"))
|
||||
.transpose()?;
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
rollout_ordinal,
|
||||
rollout_byte_offset,
|
||||
rollout_end_ordinal,
|
||||
rollout_end_byte_offset,
|
||||
status,
|
||||
first_user_item_id,
|
||||
final_agent_item_id
|
||||
FROM thread_turns
|
||||
WHERE thread_id = ?
|
||||
AND turn_id = ?
|
||||
AND rollout_ordinal >= ?
|
||||
AND (? IS NULL OR rollout_ordinal < ?)
|
||||
"#,
|
||||
)
|
||||
.bind(segment.thread_id().to_string())
|
||||
.bind(turn_id)
|
||||
.bind(sqlite_integer(segment.start_ordinal(), "rollout ordinal")?)
|
||||
.bind(end_ordinal)
|
||||
.bind(end_ordinal)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|err| ThreadStoreError::Internal {
|
||||
message: format!("failed to resolve logical turn: {err}"),
|
||||
})
|
||||
.map(|row| {
|
||||
row.map(|row| TurnRow {
|
||||
physical_thread_id: segment.thread_id(),
|
||||
rollout_ordinal: row.get("rollout_ordinal"),
|
||||
rollout_byte_offset: row.get("rollout_byte_offset"),
|
||||
rollout_end_ordinal: row.get("rollout_end_ordinal"),
|
||||
rollout_end_byte_offset: row.get("rollout_end_byte_offset"),
|
||||
status: row.get("status"),
|
||||
first_user_item_id: row.get("first_user_item_id"),
|
||||
final_agent_item_id: row.get("final_agent_item_id"),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -26,17 +26,22 @@ pub(super) async fn materialize_to_sqlite(
|
||||
thread_id: ThreadId,
|
||||
rollout_path: &Path,
|
||||
) -> ThreadStoreResult<()> {
|
||||
let start_offset = super::thread_history::next_rollout_byte_offset(store, thread_id).await?;
|
||||
let start_offset = super::thread_history::projection_state(store, thread_id)
|
||||
.await?
|
||||
.map_or(0, |state| state.next_byte_offset);
|
||||
let (lines, next_offset) = read_complete_rollout_lines(rollout_path, start_offset).await?;
|
||||
// Empty valid records can still consume bytes through blank or rejected complete lines.
|
||||
if lines.is_empty() && start_offset == next_offset {
|
||||
return Ok(());
|
||||
}
|
||||
let subagent_history_start_ordinal = codex_rollout::read_session_meta_line(rollout_path)
|
||||
let session_meta = codex_rollout::read_session_meta_line(rollout_path)
|
||||
.await
|
||||
.map_err(thread_store_io_error)?
|
||||
.meta
|
||||
.subagent_history_start_ordinal;
|
||||
.meta;
|
||||
let initial_ordinal = session_meta
|
||||
.history_base
|
||||
.map_or(0, |base| base.end_ordinal_exclusive);
|
||||
let subagent_history_start_ordinal = session_meta.subagent_history_start_ordinal;
|
||||
|
||||
let projections = lines
|
||||
.iter()
|
||||
@@ -67,6 +72,7 @@ pub(super) async fn materialize_to_sqlite(
|
||||
thread_id,
|
||||
start_offset,
|
||||
next_offset,
|
||||
initial_ordinal,
|
||||
projections,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
@@ -10,8 +11,11 @@ use codex_protocol::items::AgentMessageItem;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::items::UserMessageItem;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::MessagePhase;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::HistoryPosition;
|
||||
use codex_protocol::protocol::ItemCompletedEvent;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
@@ -34,8 +38,11 @@ use super::super::test_support::test_config;
|
||||
use crate::AppendThreadItemsParams;
|
||||
use crate::CreateThreadParams;
|
||||
use crate::DeleteThreadParams;
|
||||
use crate::ForkBoundary;
|
||||
use crate::ListThreadsParams;
|
||||
use crate::ListTurnsParams;
|
||||
use crate::PrepareForkParams;
|
||||
use crate::PreparedFork;
|
||||
use crate::ResumeThreadParams;
|
||||
use crate::SortDirection;
|
||||
use crate::StoredTurnItemsView;
|
||||
@@ -373,6 +380,224 @@ WHERE thread_id = ?
|
||||
assert_eq!(projection_state, (rollout_len, 5));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn referenced_paginated_rollout_projects_inherited_ordinal_range() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
|
||||
let source_id = ThreadId::default();
|
||||
create_paginated_thread(&store, source_id).await;
|
||||
store
|
||||
.append_items(AppendThreadItemsParams {
|
||||
thread_id: source_id,
|
||||
items: vec![
|
||||
turn_started("source-turn"),
|
||||
user_message("source message"),
|
||||
turn_completed("source-turn"),
|
||||
],
|
||||
})
|
||||
.await
|
||||
.expect("append source history");
|
||||
let source_path = store
|
||||
.live_rollout_path(source_id)
|
||||
.await
|
||||
.expect("source rollout path");
|
||||
let (_, source_end_byte_offset) =
|
||||
rollout_line_byte_offsets(source_path.as_path(), /*ordinal*/ 3);
|
||||
let child_id = ThreadId::default();
|
||||
let history_base = HistoryPosition {
|
||||
thread_id: source_id,
|
||||
end_ordinal_exclusive: 4,
|
||||
end_byte_offset: u64::try_from(source_end_byte_offset).expect("source byte offset"),
|
||||
};
|
||||
create_paginated_subagent_thread(
|
||||
&store,
|
||||
child_id,
|
||||
Some(history_base),
|
||||
/*subagent_history_start_ordinal*/ None,
|
||||
)
|
||||
.await;
|
||||
store
|
||||
.persist_thread(child_id)
|
||||
.await
|
||||
.expect("persist child metadata");
|
||||
assert_eq!(
|
||||
prepare_paginated_fork(&store, child_id, ForkBoundary::Latest)
|
||||
.await
|
||||
.history_base,
|
||||
Some(history_base)
|
||||
);
|
||||
store
|
||||
.append_items(AppendThreadItemsParams {
|
||||
thread_id: child_id,
|
||||
items: vec![
|
||||
turn_started("child-turn"),
|
||||
user_message("child message"),
|
||||
turn_completed("child-turn"),
|
||||
],
|
||||
})
|
||||
.await
|
||||
.expect("append child history");
|
||||
let latest_history_base = prepare_paginated_fork(&store, child_id, ForkBoundary::Latest)
|
||||
.await
|
||||
.history_base;
|
||||
for (boundary, expected_base) in [
|
||||
(ForkBoundary::Latest, latest_history_base),
|
||||
(
|
||||
ForkBoundary::ThroughTurn("source-turn".to_string()),
|
||||
Some(history_base),
|
||||
),
|
||||
(
|
||||
ForkBoundary::BeforeTurn("child-turn".to_string()),
|
||||
Some(history_base),
|
||||
),
|
||||
(
|
||||
ForkBoundary::ThroughTurn("child-turn".to_string()),
|
||||
latest_history_base,
|
||||
),
|
||||
(ForkBoundary::BeforeTurn("source-turn".to_string()), None),
|
||||
] {
|
||||
let prepared = prepare_paginated_fork(&store, child_id, boundary).await;
|
||||
assert_eq!(prepared.history_base, expected_base);
|
||||
assert!(matches!(
|
||||
prepared.model_context.first(),
|
||||
Some(RolloutItem::SessionMeta(meta)) if meta.meta.id == child_id
|
||||
));
|
||||
assert_eq!(
|
||||
contains_user_message(&prepared.model_context, "source message"),
|
||||
expected_base.is_some()
|
||||
);
|
||||
assert_eq!(
|
||||
contains_user_message(&prepared.model_context, "child message"),
|
||||
expected_base == latest_history_base
|
||||
);
|
||||
}
|
||||
|
||||
let pool = codex_state::open_thread_history_db(&codex_state::SqliteConfig::new_for_testing(
|
||||
home.path().abs(),
|
||||
))
|
||||
.await
|
||||
.expect("open thread history db");
|
||||
let turn_ordinal = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT rollout_ordinal FROM thread_turns WHERE thread_id = ? AND turn_id = ?",
|
||||
)
|
||||
.bind(child_id.to_string())
|
||||
.bind("child-turn")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read child turn ordinal");
|
||||
let next_ordinal = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT next_rollout_ordinal FROM thread_history_projection_state WHERE thread_id = ?",
|
||||
)
|
||||
.bind(child_id.to_string())
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read child projection ordinal");
|
||||
assert_eq!((turn_ordinal, next_ordinal), (5, 8));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn named_fork_boundaries_reject_invisible_and_noncanonical_turns() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
|
||||
let source_id = ThreadId::default();
|
||||
create_paginated_thread(&store, source_id).await;
|
||||
store
|
||||
.append_items(AppendThreadItemsParams {
|
||||
thread_id: source_id,
|
||||
items: vec![turn_started("inherited-turn"), user_message("before fork")],
|
||||
})
|
||||
.await
|
||||
.expect("append inherited active turn");
|
||||
|
||||
let history_base = prepare_paginated_fork(&store, source_id, ForkBoundary::Latest)
|
||||
.await
|
||||
.history_base;
|
||||
let child_id = ThreadId::default();
|
||||
create_paginated_subagent_thread(
|
||||
&store,
|
||||
child_id,
|
||||
history_base,
|
||||
/*subagent_history_start_ordinal*/ None,
|
||||
)
|
||||
.await;
|
||||
store
|
||||
.persist_thread(child_id)
|
||||
.await
|
||||
.expect("persist child metadata");
|
||||
store
|
||||
.append_items(AppendThreadItemsParams {
|
||||
thread_id: child_id,
|
||||
items: vec![turn_started("child-turn"), turn_completed("child-turn")],
|
||||
})
|
||||
.await
|
||||
.expect("append child turn after inherited active turn");
|
||||
let error = store
|
||||
.prepare_fork(PrepareForkParams {
|
||||
thread_id: child_id,
|
||||
boundary: ForkBoundary::ThroughTurn("inherited-turn".to_string()),
|
||||
})
|
||||
.await
|
||||
.expect_err("cannot fork through an inherited active turn");
|
||||
assert!(matches!(
|
||||
error,
|
||||
crate::ThreadStoreError::InvalidRequest { message }
|
||||
if message == "lastTurnId 'inherited-turn' identifies an in-progress turn"
|
||||
));
|
||||
store
|
||||
.append_items(AppendThreadItemsParams {
|
||||
thread_id: source_id,
|
||||
items: vec![
|
||||
user_message("after fork"),
|
||||
turn_completed("inherited-turn"),
|
||||
turn_started("stale-turn"),
|
||||
turn_started("replacement-turn"),
|
||||
turn_completed("replacement-turn"),
|
||||
completed_item(
|
||||
source_id,
|
||||
"review-turn",
|
||||
TurnItem::UserMessage(UserMessageItem {
|
||||
id: "review-message".to_string(),
|
||||
client_id: None,
|
||||
content: Vec::new(),
|
||||
}),
|
||||
),
|
||||
turn_completed("review-turn"),
|
||||
],
|
||||
})
|
||||
.await
|
||||
.expect("append invisible, stale, and terminal-only turns");
|
||||
|
||||
for (thread_id, boundary, expected_error) in [
|
||||
(
|
||||
child_id,
|
||||
ForkBoundary::ThroughTurn("inherited-turn".to_string()),
|
||||
"fork boundary exceeds inherited source history",
|
||||
),
|
||||
(
|
||||
source_id,
|
||||
ForkBoundary::ThroughTurn("stale-turn".to_string()),
|
||||
"lastTurnId 'stale-turn' identifies an in-progress turn",
|
||||
),
|
||||
(
|
||||
source_id,
|
||||
ForkBoundary::BeforeTurn("review-turn".to_string()),
|
||||
"turn review-turn does not have a persisted start boundary",
|
||||
),
|
||||
] {
|
||||
let error = store
|
||||
.prepare_fork(PrepareForkParams {
|
||||
thread_id,
|
||||
boundary,
|
||||
})
|
||||
.await
|
||||
.expect_err("reject an invalid fork boundary");
|
||||
assert!(matches!(
|
||||
error,
|
||||
crate::ThreadStoreError::InvalidRequest { message } if message == expected_error
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn active_turn_stores_only_its_start_position() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
@@ -408,6 +633,326 @@ async fn active_turn_stores_only_its_start_position() {
|
||||
.await
|
||||
.expect("read active turn position");
|
||||
assert_eq!(turn_position, (Some(turn_start_byte_offset), None, None));
|
||||
|
||||
let (latest_byte_offset, latest_ordinal) = projection_state(&pool, thread_id).await;
|
||||
let prepared = prepare_paginated_fork(&store, thread_id, ForkBoundary::Latest).await;
|
||||
assert_eq!(
|
||||
prepared.history_base,
|
||||
Some(HistoryPosition {
|
||||
thread_id,
|
||||
end_ordinal_exclusive: u64::try_from(latest_ordinal).expect("latest ordinal"),
|
||||
end_byte_offset: u64::try_from(latest_byte_offset).expect("latest byte offset"),
|
||||
})
|
||||
);
|
||||
assert!(prepared.model_context.iter().any(|item| {
|
||||
matches!(item, RolloutItem::EventMsg(EventMsg::TurnStarted(event)) if event.turn_id == "turn-1")
|
||||
}));
|
||||
assert_eq!(
|
||||
prepare_paginated_fork(
|
||||
&store,
|
||||
thread_id,
|
||||
ForkBoundary::BeforeTurn("turn-1".to_string()),
|
||||
)
|
||||
.await
|
||||
.history_base,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paginated_fork_persists_empty_source() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
|
||||
let thread_id = ThreadId::default();
|
||||
create_paginated_thread(&store, thread_id).await;
|
||||
let rollout_path = store
|
||||
.live_rollout_path(thread_id)
|
||||
.await
|
||||
.expect("rollout path");
|
||||
assert!(!rollout_path.exists());
|
||||
|
||||
let prepared = prepare_paginated_fork(&store, thread_id, ForkBoundary::Latest).await;
|
||||
|
||||
assert!(rollout_path.exists());
|
||||
assert_eq!(prepared.history_base, None);
|
||||
assert!(matches!(
|
||||
prepared.model_context.as_slice(),
|
||||
[RolloutItem::SessionMeta(meta)] if meta.meta.id == thread_id
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paginated_fork_materializes_compressed_source_and_ancestor() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
|
||||
let ancestor_thread_id = ThreadId::default();
|
||||
create_paginated_thread(&store, ancestor_thread_id).await;
|
||||
store
|
||||
.persist_thread(ancestor_thread_id)
|
||||
.await
|
||||
.expect("persist ancestor meta");
|
||||
store
|
||||
.append_items(AppendThreadItemsParams {
|
||||
thread_id: ancestor_thread_id,
|
||||
items: vec![
|
||||
turn_started("ancestor-turn"),
|
||||
user_message("inherited ancestor message"),
|
||||
turn_completed("ancestor-turn"),
|
||||
],
|
||||
})
|
||||
.await
|
||||
.expect("append ancestor turn");
|
||||
let ancestor_path = store
|
||||
.live_rollout_path(ancestor_thread_id)
|
||||
.await
|
||||
.expect("ancestor rollout path");
|
||||
let ancestor_base = prepare_paginated_fork(&store, ancestor_thread_id, ForkBoundary::Latest)
|
||||
.await
|
||||
.history_base
|
||||
.expect("ancestor prefix");
|
||||
store
|
||||
.shutdown_thread(ancestor_thread_id)
|
||||
.await
|
||||
.expect("shutdown ancestor");
|
||||
|
||||
let source_thread_id = ThreadId::default();
|
||||
create_paginated_subagent_thread(
|
||||
&store,
|
||||
source_thread_id,
|
||||
Some(ancestor_base),
|
||||
/*subagent_history_start_ordinal*/ None,
|
||||
)
|
||||
.await;
|
||||
store
|
||||
.persist_thread(source_thread_id)
|
||||
.await
|
||||
.expect("persist source meta");
|
||||
store
|
||||
.append_items(AppendThreadItemsParams {
|
||||
thread_id: source_thread_id,
|
||||
items: vec![
|
||||
turn_started("source-turn"),
|
||||
user_message("inherited source message"),
|
||||
turn_completed("source-turn"),
|
||||
],
|
||||
})
|
||||
.await
|
||||
.expect("append source turn");
|
||||
let source_path = store
|
||||
.live_rollout_path(source_thread_id)
|
||||
.await
|
||||
.expect("source rollout path");
|
||||
store
|
||||
.shutdown_thread(source_thread_id)
|
||||
.await
|
||||
.expect("shutdown source");
|
||||
let ancestor_compressed_path = ancestor_path.with_extension("jsonl.zst");
|
||||
let source_compressed_path = source_path.with_extension("jsonl.zst");
|
||||
compress_rollout(ancestor_path.as_path());
|
||||
compress_rollout(source_path.as_path());
|
||||
let ancestor_modified = fs::metadata(&ancestor_compressed_path)
|
||||
.and_then(|metadata| metadata.modified())
|
||||
.expect("read compressed ancestor timestamp");
|
||||
let source_modified = fs::metadata(&source_compressed_path)
|
||||
.and_then(|metadata| metadata.modified())
|
||||
.expect("read compressed source timestamp");
|
||||
|
||||
let (first, second) = tokio::join!(
|
||||
prepare_paginated_fork(&store, source_thread_id, ForkBoundary::Latest),
|
||||
prepare_paginated_fork(&store, source_thread_id, ForkBoundary::Latest),
|
||||
);
|
||||
assert!(ancestor_path.exists());
|
||||
assert!(source_path.exists());
|
||||
assert!(!ancestor_compressed_path.exists());
|
||||
assert!(!source_compressed_path.exists());
|
||||
assert_eq!(
|
||||
fs::metadata(&ancestor_path)
|
||||
.and_then(|metadata| metadata.modified())
|
||||
.expect("read materialized ancestor timestamp"),
|
||||
ancestor_modified
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&source_path)
|
||||
.and_then(|metadata| metadata.modified())
|
||||
.expect("read materialized source timestamp"),
|
||||
source_modified
|
||||
);
|
||||
for prepared in [first, second] {
|
||||
assert!(matches!(
|
||||
prepared.model_context.first(),
|
||||
Some(RolloutItem::SessionMeta(meta)) if meta.meta.id == source_thread_id
|
||||
));
|
||||
for message in ["inherited ancestor message", "inherited source message"] {
|
||||
assert!(contains_user_message(
|
||||
prepared.model_context.as_slice(),
|
||||
message
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_fork_keeps_source_reserved_until_lineage_materialization_finishes() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
|
||||
let ancestor_thread_id = ThreadId::default();
|
||||
create_paginated_thread(&store, ancestor_thread_id).await;
|
||||
store
|
||||
.append_items(AppendThreadItemsParams {
|
||||
thread_id: ancestor_thread_id,
|
||||
items: vec![
|
||||
turn_started("ancestor-turn"),
|
||||
turn_completed("ancestor-turn"),
|
||||
],
|
||||
})
|
||||
.await
|
||||
.expect("append ancestor history");
|
||||
let ancestor_base = prepare_paginated_fork(&store, ancestor_thread_id, ForkBoundary::Latest)
|
||||
.await
|
||||
.history_base
|
||||
.expect("ancestor prefix");
|
||||
|
||||
let source_thread_id = ThreadId::default();
|
||||
create_paginated_subagent_thread(
|
||||
&store,
|
||||
source_thread_id,
|
||||
Some(ancestor_base),
|
||||
/*subagent_history_start_ordinal*/ None,
|
||||
)
|
||||
.await;
|
||||
store
|
||||
.persist_thread(source_thread_id)
|
||||
.await
|
||||
.expect("persist source metadata");
|
||||
let source_path = store
|
||||
.live_rollout_path(source_thread_id)
|
||||
.await
|
||||
.expect("source rollout path");
|
||||
store
|
||||
.shutdown_thread(source_thread_id)
|
||||
.await
|
||||
.expect("shutdown source");
|
||||
compress_rollout(source_path.as_path());
|
||||
|
||||
let ancestor_writer_guard = store.live_writer_locks.lock(ancestor_thread_id).await;
|
||||
let preparation_store = store.clone();
|
||||
let preparation = tokio::spawn(async move {
|
||||
preparation_store
|
||||
.prepare_fork(PrepareForkParams {
|
||||
thread_id: source_thread_id,
|
||||
boundary: ForkBoundary::Latest,
|
||||
})
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while !source_path.exists() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("detached lineage task should materialize the source");
|
||||
preparation.abort();
|
||||
assert!(
|
||||
preparation
|
||||
.await
|
||||
.expect_err("fork preparation should be cancelled")
|
||||
.is_cancelled()
|
||||
);
|
||||
|
||||
let mut delete = Box::pin(store.delete_thread(DeleteThreadParams {
|
||||
thread_id: source_thread_id,
|
||||
}));
|
||||
tokio::select! {
|
||||
biased;
|
||||
result = &mut delete => {
|
||||
panic!("source deletion completed while lineage materialization was active: {result:?}")
|
||||
}
|
||||
_ = tokio::task::yield_now() => {}
|
||||
}
|
||||
drop(ancestor_writer_guard);
|
||||
delete
|
||||
.await
|
||||
.expect("delete source after lineage materialization finishes");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepared_fork_reserves_source_until_child_reference_is_durable() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
|
||||
let source_thread_id = ThreadId::default();
|
||||
create_paginated_thread(&store, source_thread_id).await;
|
||||
store
|
||||
.persist_thread(source_thread_id)
|
||||
.await
|
||||
.expect("persist source metadata");
|
||||
store
|
||||
.append_items(AppendThreadItemsParams {
|
||||
thread_id: source_thread_id,
|
||||
items: vec![turn_started("source-turn"), turn_completed("source-turn")],
|
||||
})
|
||||
.await
|
||||
.expect("append source turn");
|
||||
let prepared = store
|
||||
.prepare_fork(PrepareForkParams {
|
||||
thread_id: source_thread_id,
|
||||
boundary: ForkBoundary::Latest,
|
||||
})
|
||||
.await
|
||||
.expect("prepare referenced fork");
|
||||
let history_base = prepared.history_base.expect("source history base");
|
||||
|
||||
let mut delete = Box::pin(store.delete_thread(DeleteThreadParams {
|
||||
thread_id: source_thread_id,
|
||||
}));
|
||||
tokio::select! {
|
||||
biased;
|
||||
result = &mut delete => {
|
||||
panic!("source deletion completed before its child reference was durable: {result:?}")
|
||||
}
|
||||
_ = tokio::task::yield_now() => {}
|
||||
}
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
store.append_items(AppendThreadItemsParams {
|
||||
thread_id: source_thread_id,
|
||||
items: vec![
|
||||
turn_started("later-turn"),
|
||||
user_message("later source message"),
|
||||
turn_completed("later-turn"),
|
||||
],
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("source write should not wait behind deletion")
|
||||
.expect("source writes remain available during fork preparation");
|
||||
assert_eq!(prepared.history_base, Some(history_base));
|
||||
assert!(!contains_user_message(
|
||||
prepared.model_context.as_slice(),
|
||||
"later source message"
|
||||
));
|
||||
|
||||
let child_thread_id = ThreadId::default();
|
||||
create_paginated_subagent_thread(
|
||||
&store,
|
||||
child_thread_id,
|
||||
Some(history_base),
|
||||
/*subagent_history_start_ordinal*/ None,
|
||||
)
|
||||
.await;
|
||||
store
|
||||
.persist_thread(child_thread_id)
|
||||
.await
|
||||
.expect("persist child history reference");
|
||||
drop(prepared);
|
||||
|
||||
let error = delete
|
||||
.await
|
||||
.expect_err("durable child reference protects its source");
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("forked history still references")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -418,6 +963,7 @@ async fn subagent_prefix_advances_projection_without_materializing_history() {
|
||||
create_paginated_subagent_thread(
|
||||
&store,
|
||||
thread_id,
|
||||
/*history_base*/ None,
|
||||
/*subagent_history_start_ordinal*/ Some(4),
|
||||
)
|
||||
.await;
|
||||
@@ -491,7 +1037,7 @@ async fn subagent_prefix_advances_projection_without_materializing_history() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replayed_item_snapshot_preserves_creation_ordinal_and_advances_update_ordinal() {
|
||||
async fn unexpected_duplicate_item_completion_does_not_poison_projection() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
|
||||
let thread_id = ThreadId::default();
|
||||
@@ -514,7 +1060,7 @@ async fn replayed_item_snapshot_preserves_creation_ordinal_and_advances_update_o
|
||||
],
|
||||
})
|
||||
.await
|
||||
.expect("append first item snapshot");
|
||||
.expect("append completed item");
|
||||
let pool = codex_state::open_thread_history_db(&codex_state::SqliteConfig::new_for_testing(
|
||||
home.path().abs(),
|
||||
))
|
||||
@@ -543,7 +1089,7 @@ async fn replayed_item_snapshot_preserves_creation_ordinal_and_advances_update_o
|
||||
)],
|
||||
})
|
||||
.await
|
||||
.expect("append replayed item snapshot");
|
||||
.expect("append unexpected duplicate item completion");
|
||||
|
||||
let item = sqlx::query_as::<_, (i64, i64, i64, String)>(
|
||||
r#"
|
||||
@@ -1273,14 +1819,29 @@ SELECT
|
||||
|
||||
async fn create_paginated_thread(store: &LocalThreadStore, thread_id: ThreadId) {
|
||||
create_paginated_subagent_thread(
|
||||
store, thread_id, /*subagent_history_start_ordinal*/ None,
|
||||
store, thread_id, /*history_base*/ None, /*subagent_history_start_ordinal*/ None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn prepare_paginated_fork(
|
||||
store: &LocalThreadStore,
|
||||
thread_id: ThreadId,
|
||||
boundary: ForkBoundary,
|
||||
) -> PreparedFork {
|
||||
store
|
||||
.prepare_fork(PrepareForkParams {
|
||||
thread_id,
|
||||
boundary,
|
||||
})
|
||||
.await
|
||||
.expect("prepare paginated fork")
|
||||
}
|
||||
|
||||
async fn create_paginated_subagent_thread(
|
||||
store: &LocalThreadStore,
|
||||
thread_id: ThreadId,
|
||||
history_base: Option<HistoryPosition>,
|
||||
subagent_history_start_ordinal: Option<u64>,
|
||||
) {
|
||||
store
|
||||
@@ -1298,6 +1859,7 @@ async fn create_paginated_subagent_thread(
|
||||
selected_capability_roots: Vec::new(),
|
||||
multi_agent_version: None,
|
||||
history_mode: ThreadHistoryMode::Paginated,
|
||||
history_base,
|
||||
subagent_history_start_ordinal,
|
||||
initial_window_id: "window-1".to_string(),
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
@@ -1332,6 +1894,30 @@ fn turn_completed(turn_id: &str) -> RolloutItem {
|
||||
}))
|
||||
}
|
||||
|
||||
fn user_message(message: &str) -> RolloutItem {
|
||||
RolloutItem::ResponseItem(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: message.to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn contains_user_message(items: &[RolloutItem], expected: &str) -> bool {
|
||||
items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
RolloutItem::ResponseItem(ResponseItem::Message { content, .. })
|
||||
if content.iter().any(|content| {
|
||||
matches!(content, ContentItem::InputText { text } if text == expected)
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn completed_item(thread_id: ThreadId, turn_id: &str, item: TurnItem) -> RolloutItem {
|
||||
RolloutItem::EventMsg(EventMsg::ItemCompleted(ItemCompletedEvent {
|
||||
thread_id,
|
||||
@@ -1372,6 +1958,18 @@ fn rollout_line_byte_offsets(path: &std::path::Path, ordinal: u64) -> (i64, i64)
|
||||
panic!("missing rollout ordinal {ordinal}");
|
||||
}
|
||||
|
||||
fn compress_rollout(path: &Path) {
|
||||
let mut compressed_path = path.as_os_str().to_os_string();
|
||||
compressed_path.push(".zst");
|
||||
let compressed = zstd::stream::encode_all(
|
||||
fs::File::open(path).expect("open rollout for compression"),
|
||||
/*level*/ 0,
|
||||
)
|
||||
.expect("compress rollout");
|
||||
fs::write(compressed_path, compressed).expect("write compressed rollout");
|
||||
fs::remove_file(path).expect("remove plain rollout");
|
||||
}
|
||||
|
||||
async fn projection_state(pool: &sqlx::SqlitePool, thread_id: ThreadId) -> (i64, i64) {
|
||||
sqlx::query_as::<_, (i64, i64)>(
|
||||
r#"
|
||||
|
||||
@@ -15,6 +15,7 @@ pub(super) async fn unarchive_thread(
|
||||
params: ArchiveThreadParams,
|
||||
) -> ThreadStoreResult<StoredThread> {
|
||||
let thread_id = params.thread_id;
|
||||
let _lifecycle_guard = store.live_writer_locks.lock_lifecycle(thread_id).await;
|
||||
let state_db_ctx = store.state_db().await;
|
||||
let archived_path = find_archived_thread_path_by_id_str(
|
||||
store.config.codex_home.as_path(),
|
||||
|
||||
@@ -15,6 +15,8 @@ use crate::ListItemsParams;
|
||||
use crate::ListThreadsParams;
|
||||
use crate::ListTurnsParams;
|
||||
use crate::LoadThreadHistoryParams;
|
||||
use crate::PrepareForkParams;
|
||||
use crate::PreparedFork;
|
||||
use crate::ReadThreadByRolloutPathParams;
|
||||
use crate::ReadThreadParams;
|
||||
use crate::ResumeThreadParams;
|
||||
@@ -95,6 +97,17 @@ pub trait ThreadStore: Any + Send + Sync {
|
||||
})
|
||||
}
|
||||
|
||||
/// Freezes source history and model context used to initialize a referenced fork.
|
||||
///
|
||||
/// Stores without reference-backed fork support can retain this default implementation.
|
||||
fn prepare_fork(&self, _params: PrepareForkParams) -> ThreadStoreFuture<'_, PreparedFork> {
|
||||
Box::pin(async {
|
||||
Err(ThreadStoreError::Unsupported {
|
||||
operation: "prepare_fork",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads a thread summary and optionally its persisted history.
|
||||
fn read_thread(&self, params: ReadThreadParams) -> ThreadStoreFuture<'_, StoredThread>;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::GitInfo;
|
||||
use codex_protocol::protocol::HistoryPosition;
|
||||
use codex_protocol::protocol::MultiAgentVersion;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
@@ -95,6 +96,8 @@ pub struct CreateThreadParams {
|
||||
pub multi_agent_version: Option<MultiAgentVersion>,
|
||||
/// Persisted thread history contract selected when the thread was created.
|
||||
pub history_mode: ThreadHistoryMode,
|
||||
/// Exclusive prefix of another paginated rollout inherited by this thread.
|
||||
pub history_base: Option<HistoryPosition>,
|
||||
/// First rollout ordinal that belongs to this subagent's projected history.
|
||||
pub subagent_history_start_ordinal: Option<u64>,
|
||||
/// Initial context-window identity captured when the thread was created.
|
||||
@@ -175,6 +178,56 @@ pub struct StoredModelContext {
|
||||
pub items: Vec<RolloutItem>,
|
||||
}
|
||||
|
||||
/// Requested boundary for inheriting a paginated thread's history.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ForkBoundary {
|
||||
/// Inherit the source thread's latest durable state.
|
||||
Latest,
|
||||
/// Inherit history through the newest visible occurrence of this turn.
|
||||
ThroughTurn(String),
|
||||
/// Inherit history preceding the original visible occurrence of this turn.
|
||||
BeforeTurn(String),
|
||||
}
|
||||
|
||||
/// Parameters for freezing the source history used to initialize a fork.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PrepareForkParams {
|
||||
/// Immediate source thread whose metadata and approval settings are inherited.
|
||||
pub thread_id: ThreadId,
|
||||
/// Requested inclusive or exclusive fork boundary.
|
||||
pub boundary: ForkBoundary,
|
||||
}
|
||||
|
||||
/// Frozen source history and model context for a reference-backed fork.
|
||||
#[derive(Debug)]
|
||||
pub struct PreparedFork {
|
||||
/// Immediate source thread, even when the normalized history base names an ancestor.
|
||||
pub source_thread_id: ThreadId,
|
||||
/// Frozen physical rollout prefix inherited by the child.
|
||||
pub history_base: Option<HistoryPosition>,
|
||||
/// Bounded model context selected by the requested fork boundary.
|
||||
pub model_context: Arc<Vec<RolloutItem>>,
|
||||
/// Blocks source deletion until the child's history reference is durable.
|
||||
_source_reservation: Box<dyn std::fmt::Debug + Send>,
|
||||
}
|
||||
|
||||
impl PreparedFork {
|
||||
/// Creates a frozen fork snapshot while retaining a backend-owned source reservation.
|
||||
pub fn new(
|
||||
source_thread_id: ThreadId,
|
||||
history_base: Option<HistoryPosition>,
|
||||
model_context: Arc<Vec<RolloutItem>>,
|
||||
source_reservation: impl std::fmt::Debug + Send + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
source_thread_id,
|
||||
history_base,
|
||||
model_context,
|
||||
_source_reservation: Box::new(source_reservation),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters for reading a thread summary and optionally its replay history.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReadThreadParams {
|
||||
|
||||
Reference in New Issue
Block a user