mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
## Why SQLite state was still being opened from consumer paths, including lazy `OnceCell`-backed thread-store call sites. That let one process construct multiple state DB connections for the same Codex home, which makes SQLite lock contention and `database is locked` failures much easier to hit. State DB lifetime should be chosen by main-like entrypoints and tests, then passed through explicitly. Consumers should use the supplied `Option<StateDbHandle>` or `StateDbHandle` and keep their existing filesystem fallback or error behavior when no handle is available. The startup path also needs to keep the rollout crate in charge of SQLite state initialization. Opening `codex_state::StateRuntime` directly bypasses rollout metadata backfill, so entrypoints should initialize through `codex_rollout::state_db` and receive a handle only after required rollout backfills have completed. ## What Changed - Initialize the state DB in main-like entrypoints for CLI, TUI, app-server, exec, MCP server, and the thread-manager sample. - Pass `Option<StateDbHandle>` through `ThreadManager`, `LocalThreadStore`, app-server processors, TUI app wiring, rollout listing/recording, personality migration, shell snapshot cleanup, session-name lookup, and memory/device-key consumers. - Remove the lazy local state DB wrapper from the thread store so non-test consumers use only the supplied handle or their existing fallback path. - Make `codex_rollout::state_db::init` the local state startup path: it opens/migrates SQLite, runs rollout metadata backfill when needed, waits for concurrent backfill workers up to a bounded timeout, verifies completion, and then returns the initialized handle. - Keep optional/non-owning SQLite helpers, such as remote TUI local reads, as open-only paths that do not run startup backfill. - Switch app-server startup from direct `codex_state::StateRuntime::init` to the rollout state initializer so app-server cannot skip rollout backfill. - Collapse split rollout lookup/list APIs so callers use the normal methods with an optional state handle instead of `_with_state_db` variants. - Restore `getConversationSummary(ThreadId)` to delegate through `ThreadStore::read_thread` instead of a LocalThreadStore-specific rollout path special case. - Keep DB-backed rollout path lookup keyed on the DB row and file existence, without imposing the filesystem filename convention on existing DB rows. - Verify readable DB-backed rollout paths against `session_meta.id` before returning them, so a stale SQLite row that points at another thread's JSONL falls back to filesystem search and read-repairs the DB row. - Keep `debug prompt-input` filesystem-only so a one-off debug command does not initialize or backfill SQLite state just to print prompt input. - Keep goal-session test Codex homes alive only in the goal-specific helper, rather than leaking tempdirs from the shared session test helper. - Update tests and call sites to pass explicit state handles where DB behavior is expected and explicit `None` where filesystem-only behavior is intended. ## Validation - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo check -p codex-rollout -p codex-thread-store -p codex-app-server -p codex-core -p codex-tui -p codex-exec -p codex-cli --tests` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout state_db_` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout find_thread_path` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout find_thread_path -- --nocapture` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout try_init_ -- --nocapture` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo clippy -p codex-rollout --lib -- -D warnings` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-thread-store read_thread_falls_back_when_sqlite_path_points_to_another_thread -- --nocapture` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-thread-store` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core shell_snapshot` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core --test all personality_migration` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core --test all rollout_list_find` - `RUST_MIN_STACK=8388608 CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core --test all rollout_list_find::find_prefers_sqlite_path_by_id -- --nocapture` - `RUST_MIN_STACK=8388608 CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core --test all rollout_list_find -- --nocapture` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core interrupt_accounts_active_goal_before_pausing` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-app-server get_auth_status -- --test-threads=1` - `CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-app-server --lib` - `CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo check -p codex-rollout -p codex-app-server --tests` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db just fix -p codex-rollout -p codex-thread-store -p codex-core -p codex-app-server -p codex-tui -p codex-exec -p codex-cli` - `CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db just fix -p codex-rollout -p codex-app-server` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db just fix -p codex-rollout` - `CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db just fix -p codex-core` - `just argument-comment-lint -p codex-core` - `just argument-comment-lint -p codex-rollout` Focused coverage added in `codex-rollout`: - `recorder::tests::state_db_init_backfills_before_returning` verifies the rollout metadata row exists before startup init returns. - `state_db::tests::try_init_waits_for_concurrent_startup_backfill` verifies startup waits for another worker to finish backfill instead of disabling the handle for the process. - `state_db::tests::try_init_times_out_waiting_for_stuck_startup_backfill` verifies startup does not hang indefinitely on a stuck backfill lease. - `tests::find_thread_path_accepts_existing_state_db_path_without_canonical_filename` verifies DB-backed lookup accepts valid existing rollout paths even when the filename does not include the thread UUID. - `tests::find_thread_path_falls_back_when_db_path_points_to_another_thread` verifies DB-backed lookup ignores a stale row whose existing path belongs to another thread and read-repairs the row after filesystem fallback. Focused coverage updated in `codex-core`: - `rollout_list_find::find_prefers_sqlite_path_by_id` now uses a DB-preferred rollout file with matching `session_meta.id`, so it still verifies that valid SQLite paths win without depending on stale/empty rollout contents. `cargo test -p codex-app-server thread_list_respects_search_term_filter -- --test-threads=1 --nocapture` was attempted locally but timed out waiting for the app-server test harness `initialize` response before reaching the changed thread-list code path. `bazel test //codex-rs/thread-store:thread-store-unit-tests --test_output=errors` was attempted locally after the thread-store fix, but this container failed before target analysis while fetching `v8+` through BuildBuddy/direct GitHub. The equivalent local crate coverage, including `cargo test -p codex-thread-store`, passes. A plain local `cargo check -p codex-rollout -p codex-app-server --tests` also requires system `libcap.pc` for `codex-linux-sandbox`; the follow-up app-server check above used `CODEX_SKIP_VENDORED_BWRAP=1` in this container.
777 lines
26 KiB
Rust
777 lines
26 KiB
Rust
mod archive_thread;
|
|
mod create_thread;
|
|
mod helpers;
|
|
mod list_threads;
|
|
mod live_writer;
|
|
mod read_thread;
|
|
mod unarchive_thread;
|
|
mod update_thread_metadata;
|
|
|
|
#[cfg(test)]
|
|
mod test_support;
|
|
|
|
use async_trait::async_trait;
|
|
use codex_protocol::ThreadId;
|
|
use codex_rollout::RolloutRecorder;
|
|
use codex_rollout::StateDbHandle;
|
|
use std::collections::HashMap;
|
|
use std::collections::hash_map::Entry;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use tokio::sync::Mutex;
|
|
|
|
use crate::AppendThreadItemsParams;
|
|
use crate::ArchiveThreadParams;
|
|
use crate::CreateThreadParams;
|
|
use crate::ListThreadsParams;
|
|
use crate::LoadThreadHistoryParams;
|
|
use crate::ReadThreadByRolloutPathParams;
|
|
use crate::ReadThreadParams;
|
|
use crate::ResumeThreadParams;
|
|
use crate::StoredThread;
|
|
use crate::StoredThreadHistory;
|
|
use crate::ThreadPage;
|
|
use crate::ThreadStore;
|
|
use crate::ThreadStoreError;
|
|
use crate::ThreadStoreResult;
|
|
use crate::UpdateThreadMetadataParams;
|
|
|
|
/// Local filesystem/SQLite-backed implementation of [`ThreadStore`].
|
|
#[derive(Clone)]
|
|
pub struct LocalThreadStore {
|
|
pub(super) config: LocalThreadStoreConfig,
|
|
live_recorders: Arc<Mutex<HashMap<ThreadId, RolloutRecorder>>>,
|
|
state_db: Option<StateDbHandle>,
|
|
}
|
|
|
|
/// Process-scoped configuration for local thread storage.
|
|
///
|
|
/// This describes where local storage lives. New-thread rollout metadata such
|
|
/// as cwd, provider, and memory mode is supplied when live persistence is opened.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct LocalThreadStoreConfig {
|
|
pub codex_home: PathBuf,
|
|
pub sqlite_home: PathBuf,
|
|
/// Provider used only when older local metadata does not contain one.
|
|
pub default_model_provider_id: String,
|
|
}
|
|
|
|
impl LocalThreadStoreConfig {
|
|
pub fn from_config(config: &impl codex_rollout::RolloutConfigView) -> Self {
|
|
Self {
|
|
codex_home: config.codex_home().to_path_buf(),
|
|
sqlite_home: config.sqlite_home().to_path_buf(),
|
|
default_model_provider_id: config.model_provider_id().to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for LocalThreadStore {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("LocalThreadStore")
|
|
.field("config", &self.config)
|
|
.finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
impl LocalThreadStore {
|
|
/// Create a local store using an already initialized state DB handle.
|
|
pub fn new(config: LocalThreadStoreConfig, state_db: Option<StateDbHandle>) -> Self {
|
|
Self {
|
|
config,
|
|
live_recorders: Arc::new(Mutex::new(HashMap::new())),
|
|
state_db,
|
|
}
|
|
}
|
|
|
|
/// Return the state DB handle used by local rollout writers.
|
|
pub async fn state_db(&self) -> Option<StateDbHandle> {
|
|
self.state_db.clone()
|
|
}
|
|
|
|
/// Read a local rollout-backed thread by path.
|
|
pub async fn read_thread_by_rollout_path(
|
|
&self,
|
|
rollout_path: PathBuf,
|
|
include_archived: bool,
|
|
include_history: bool,
|
|
) -> ThreadStoreResult<StoredThread> {
|
|
read_thread::read_thread_by_rollout_path(
|
|
self,
|
|
rollout_path,
|
|
include_archived,
|
|
include_history,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Return the live local rollout path for legacy local-only code paths.
|
|
pub async fn live_rollout_path(&self, thread_id: ThreadId) -> ThreadStoreResult<PathBuf> {
|
|
live_writer::rollout_path(self, thread_id).await
|
|
}
|
|
|
|
pub(super) async fn live_recorder(
|
|
&self,
|
|
thread_id: ThreadId,
|
|
) -> ThreadStoreResult<RolloutRecorder> {
|
|
self.live_recorders
|
|
.lock()
|
|
.await
|
|
.get(&thread_id)
|
|
.cloned()
|
|
.ok_or(ThreadStoreError::ThreadNotFound { thread_id })
|
|
}
|
|
|
|
pub(super) async fn ensure_live_recorder_absent(
|
|
&self,
|
|
thread_id: ThreadId,
|
|
) -> ThreadStoreResult<()> {
|
|
if self.live_recorders.lock().await.contains_key(&thread_id) {
|
|
return Err(ThreadStoreError::InvalidRequest {
|
|
message: format!("thread {thread_id} already has a live local writer"),
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) async fn insert_live_recorder(
|
|
&self,
|
|
thread_id: ThreadId,
|
|
recorder: RolloutRecorder,
|
|
) -> ThreadStoreResult<()> {
|
|
match self.live_recorders.lock().await.entry(thread_id) {
|
|
Entry::Occupied(entry) => Err(ThreadStoreError::InvalidRequest {
|
|
message: format!("thread {} already has a live local writer", entry.key()),
|
|
}),
|
|
Entry::Vacant(entry) => {
|
|
entry.insert(recorder);
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ThreadStore for LocalThreadStore {
|
|
fn as_any(&self) -> &dyn std::any::Any {
|
|
self
|
|
}
|
|
|
|
async fn create_thread(&self, params: CreateThreadParams) -> ThreadStoreResult<()> {
|
|
live_writer::create_thread(self, params).await
|
|
}
|
|
|
|
async fn resume_thread(&self, params: ResumeThreadParams) -> ThreadStoreResult<()> {
|
|
live_writer::resume_thread(self, params).await
|
|
}
|
|
|
|
async fn append_items(&self, params: AppendThreadItemsParams) -> ThreadStoreResult<()> {
|
|
live_writer::append_items(self, params).await
|
|
}
|
|
|
|
async fn persist_thread(&self, thread_id: ThreadId) -> ThreadStoreResult<()> {
|
|
live_writer::persist_thread(self, thread_id).await
|
|
}
|
|
|
|
async fn flush_thread(&self, thread_id: ThreadId) -> ThreadStoreResult<()> {
|
|
live_writer::flush_thread(self, thread_id).await
|
|
}
|
|
|
|
async fn shutdown_thread(&self, thread_id: ThreadId) -> ThreadStoreResult<()> {
|
|
live_writer::shutdown_thread(self, thread_id).await
|
|
}
|
|
|
|
async fn discard_thread(&self, thread_id: ThreadId) -> ThreadStoreResult<()> {
|
|
live_writer::discard_thread(self, thread_id).await
|
|
}
|
|
|
|
async fn load_history(
|
|
&self,
|
|
params: LoadThreadHistoryParams,
|
|
) -> ThreadStoreResult<StoredThreadHistory> {
|
|
if let Ok(rollout_path) = live_writer::rollout_path(self, params.thread_id).await {
|
|
if !params.include_archived
|
|
&& helpers::rollout_path_is_archived(
|
|
self.config.codex_home.as_path(),
|
|
rollout_path.as_path(),
|
|
)
|
|
{
|
|
return Err(ThreadStoreError::InvalidRequest {
|
|
message: format!("thread {} is archived", params.thread_id),
|
|
});
|
|
}
|
|
return read_thread::read_thread_by_rollout_path(
|
|
self,
|
|
rollout_path,
|
|
/*include_archived*/ true,
|
|
/*include_history*/ true,
|
|
)
|
|
.await?
|
|
.history
|
|
.ok_or_else(|| ThreadStoreError::Internal {
|
|
message: format!("failed to load history for thread {}", params.thread_id),
|
|
});
|
|
}
|
|
|
|
read_thread::read_thread(
|
|
self,
|
|
ReadThreadParams {
|
|
thread_id: params.thread_id,
|
|
include_archived: params.include_archived,
|
|
include_history: true,
|
|
},
|
|
)
|
|
.await?
|
|
.history
|
|
.ok_or_else(|| ThreadStoreError::Internal {
|
|
message: format!("failed to load history for thread {}", params.thread_id),
|
|
})
|
|
}
|
|
|
|
async fn read_thread(&self, params: ReadThreadParams) -> ThreadStoreResult<StoredThread> {
|
|
read_thread::read_thread(self, params).await
|
|
}
|
|
|
|
async fn read_thread_by_rollout_path(
|
|
&self,
|
|
params: ReadThreadByRolloutPathParams,
|
|
) -> ThreadStoreResult<StoredThread> {
|
|
read_thread::read_thread_by_rollout_path(
|
|
self,
|
|
params.rollout_path,
|
|
params.include_archived,
|
|
params.include_history,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn list_threads(&self, params: ListThreadsParams) -> ThreadStoreResult<ThreadPage> {
|
|
list_threads::list_threads(self, params).await
|
|
}
|
|
|
|
async fn update_thread_metadata(
|
|
&self,
|
|
params: UpdateThreadMetadataParams,
|
|
) -> ThreadStoreResult<StoredThread> {
|
|
update_thread_metadata::update_thread_metadata(self, params).await
|
|
}
|
|
|
|
async fn archive_thread(&self, params: ArchiveThreadParams) -> ThreadStoreResult<()> {
|
|
archive_thread::archive_thread(self, params).await
|
|
}
|
|
|
|
async fn unarchive_thread(
|
|
&self,
|
|
params: ArchiveThreadParams,
|
|
) -> ThreadStoreResult<StoredThread> {
|
|
unarchive_thread::unarchive_thread(self, params).await
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::models::BaseInstructions;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::RolloutItem;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_protocol::protocol::ThreadMemoryMode;
|
|
use codex_protocol::protocol::UserMessageEvent;
|
|
use tempfile::TempDir;
|
|
|
|
use super::*;
|
|
use crate::ThreadEventPersistenceMode;
|
|
use crate::ThreadPersistenceMetadata;
|
|
use crate::local::test_support::test_config;
|
|
use crate::local::test_support::write_archived_session_file;
|
|
use crate::local::test_support::write_session_file;
|
|
|
|
#[tokio::test]
|
|
async fn live_writer_lifecycle_writes_and_closes() {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
|
|
let thread_id = ThreadId::default();
|
|
|
|
store
|
|
.create_thread(create_thread_params(thread_id))
|
|
.await
|
|
.expect("create live thread");
|
|
let rollout_path = store
|
|
.live_rollout_path(thread_id)
|
|
.await
|
|
.expect("load rollout path");
|
|
|
|
store
|
|
.append_items(AppendThreadItemsParams {
|
|
thread_id,
|
|
items: vec![user_message_item("first live write")],
|
|
})
|
|
.await
|
|
.expect("append live item");
|
|
store
|
|
.persist_thread(thread_id)
|
|
.await
|
|
.expect("persist live thread");
|
|
store
|
|
.flush_thread(thread_id)
|
|
.await
|
|
.expect("flush live thread");
|
|
|
|
assert_rollout_contains_message(rollout_path.as_path(), "first live write").await;
|
|
|
|
store
|
|
.shutdown_thread(thread_id)
|
|
.await
|
|
.expect("shutdown live thread");
|
|
let err = store
|
|
.append_items(AppendThreadItemsParams {
|
|
thread_id,
|
|
items: vec![user_message_item("write after shutdown")],
|
|
})
|
|
.await
|
|
.expect_err("shutdown should remove the live thread writer");
|
|
assert!(
|
|
matches!(err, ThreadStoreError::ThreadNotFound { thread_id: missing } if missing == thread_id)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_thread_rejects_missing_cwd() {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
|
|
let thread_id = ThreadId::default();
|
|
let mut params = create_thread_params(thread_id);
|
|
params.metadata.cwd = None;
|
|
|
|
let err = store
|
|
.create_thread(params)
|
|
.await
|
|
.expect_err("local thread store should require cwd");
|
|
|
|
assert!(matches!(
|
|
err,
|
|
ThreadStoreError::InvalidRequest { message }
|
|
if message == "local thread store requires a cwd"
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discard_thread_drops_unmaterialized_live_writer() {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
|
|
let thread_id = ThreadId::default();
|
|
|
|
store
|
|
.create_thread(create_thread_params(thread_id))
|
|
.await
|
|
.expect("create live thread");
|
|
let rollout_path = store
|
|
.live_rollout_path(thread_id)
|
|
.await
|
|
.expect("load rollout path");
|
|
store
|
|
.discard_thread(thread_id)
|
|
.await
|
|
.expect("discard live thread");
|
|
|
|
assert!(
|
|
!tokio::fs::try_exists(rollout_path.as_path())
|
|
.await
|
|
.expect("check rollout path")
|
|
);
|
|
let err = store
|
|
.append_items(AppendThreadItemsParams {
|
|
thread_id,
|
|
items: vec![user_message_item("write after discard")],
|
|
})
|
|
.await
|
|
.expect_err("discard should remove the live thread writer");
|
|
assert!(
|
|
matches!(err, ThreadStoreError::ThreadNotFound { thread_id: missing } if missing == thread_id)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resume_thread_reopens_live_writer_and_appends() {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let config = test_config(home.path());
|
|
let thread_id = ThreadId::default();
|
|
|
|
let first_store = LocalThreadStore::new(config.clone(), /*state_db*/ None);
|
|
first_store
|
|
.create_thread(create_thread_params(thread_id))
|
|
.await
|
|
.expect("create initial thread");
|
|
first_store
|
|
.append_items(AppendThreadItemsParams {
|
|
thread_id,
|
|
items: vec![user_message_item("before resume")],
|
|
})
|
|
.await
|
|
.expect("append initial item");
|
|
first_store
|
|
.persist_thread(thread_id)
|
|
.await
|
|
.expect("persist initial thread");
|
|
first_store
|
|
.flush_thread(thread_id)
|
|
.await
|
|
.expect("flush initial thread");
|
|
let rollout_path = first_store
|
|
.live_rollout_path(thread_id)
|
|
.await
|
|
.expect("load rollout path");
|
|
first_store
|
|
.shutdown_thread(thread_id)
|
|
.await
|
|
.expect("shutdown initial writer");
|
|
|
|
let resumed_store = LocalThreadStore::new(config, /*state_db*/ None);
|
|
resumed_store
|
|
.resume_thread(ResumeThreadParams {
|
|
thread_id,
|
|
rollout_path: None,
|
|
history: None,
|
|
include_archived: true,
|
|
metadata: thread_metadata(),
|
|
event_persistence_mode: ThreadEventPersistenceMode::Limited,
|
|
})
|
|
.await
|
|
.expect("resume live thread");
|
|
resumed_store
|
|
.append_items(AppendThreadItemsParams {
|
|
thread_id,
|
|
items: vec![user_message_item("after resume")],
|
|
})
|
|
.await
|
|
.expect("append resumed item");
|
|
resumed_store
|
|
.flush_thread(thread_id)
|
|
.await
|
|
.expect("flush resumed thread");
|
|
|
|
assert_rollout_contains_message(rollout_path.as_path(), "before resume").await;
|
|
assert_rollout_contains_message(rollout_path.as_path(), "after resume").await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_thread_rejects_duplicate_live_writer() {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
|
|
let thread_id = ThreadId::default();
|
|
|
|
store
|
|
.create_thread(create_thread_params(thread_id))
|
|
.await
|
|
.expect("create live thread");
|
|
|
|
let err = store
|
|
.create_thread(create_thread_params(thread_id))
|
|
.await
|
|
.expect_err("duplicate live writer should fail");
|
|
|
|
assert!(matches!(err, ThreadStoreError::InvalidRequest { .. }));
|
|
assert!(err.to_string().contains("already has a live local writer"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resume_thread_rejects_duplicate_live_writer() {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
|
|
let thread_id = ThreadId::default();
|
|
|
|
store
|
|
.create_thread(create_thread_params(thread_id))
|
|
.await
|
|
.expect("create live thread");
|
|
let rollout_path = store
|
|
.live_rollout_path(thread_id)
|
|
.await
|
|
.expect("live rollout path");
|
|
let err = store
|
|
.resume_thread(ResumeThreadParams {
|
|
thread_id,
|
|
rollout_path: Some(rollout_path),
|
|
history: None,
|
|
include_archived: true,
|
|
metadata: thread_metadata(),
|
|
event_persistence_mode: ThreadEventPersistenceMode::Limited,
|
|
})
|
|
.await
|
|
.expect_err("duplicate live resume should fail");
|
|
assert!(matches!(err, ThreadStoreError::InvalidRequest { .. }));
|
|
assert!(err.to_string().contains("already has a live local writer"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resume_thread_rejects_missing_cwd() {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
|
|
let uuid = uuid::Uuid::from_u128(407);
|
|
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
|
let rollout_path =
|
|
write_session_file(home.path(), "2025-01-04T11-30-00", uuid).expect("session file");
|
|
let err = store
|
|
.resume_thread(ResumeThreadParams {
|
|
thread_id,
|
|
rollout_path: Some(rollout_path),
|
|
history: None,
|
|
include_archived: true,
|
|
metadata: ThreadPersistenceMetadata {
|
|
cwd: None,
|
|
model_provider: "test-provider".to_string(),
|
|
memory_mode: ThreadMemoryMode::Enabled,
|
|
},
|
|
event_persistence_mode: ThreadEventPersistenceMode::Limited,
|
|
})
|
|
.await
|
|
.expect_err("missing cwd should fail");
|
|
|
|
assert!(matches!(err, ThreadStoreError::InvalidRequest { .. }));
|
|
assert!(err.to_string().contains("requires a cwd"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_history_uses_live_writer_rollout_path() {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let external_home = TempDir::new().expect("external temp dir");
|
|
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
|
|
let uuid = uuid::Uuid::from_u128(404);
|
|
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
|
let rollout_path = write_session_file(external_home.path(), "2025-01-04T10-00-00", uuid)
|
|
.expect("external session file");
|
|
|
|
store
|
|
.resume_thread(ResumeThreadParams {
|
|
thread_id,
|
|
rollout_path: Some(rollout_path),
|
|
history: None,
|
|
include_archived: true,
|
|
metadata: thread_metadata(),
|
|
event_persistence_mode: ThreadEventPersistenceMode::Limited,
|
|
})
|
|
.await
|
|
.expect("resume live thread");
|
|
store
|
|
.append_items(AppendThreadItemsParams {
|
|
thread_id,
|
|
items: vec![user_message_item("external history item")],
|
|
})
|
|
.await
|
|
.expect("append live item");
|
|
store
|
|
.flush_thread(thread_id)
|
|
.await
|
|
.expect("flush live thread");
|
|
|
|
let history = store
|
|
.load_history(LoadThreadHistoryParams {
|
|
thread_id,
|
|
include_archived: false,
|
|
})
|
|
.await
|
|
.expect("load external live history");
|
|
|
|
assert!(history.items.iter().any(|item| {
|
|
matches!(
|
|
item,
|
|
RolloutItem::EventMsg(EventMsg::UserMessage(event)) if event.message == "external history item"
|
|
)
|
|
}));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_thread_uses_live_writer_rollout_path_for_external_resume() {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let external_home = TempDir::new().expect("external temp dir");
|
|
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
|
|
let uuid = uuid::Uuid::from_u128(406);
|
|
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
|
let rollout_path = write_session_file(external_home.path(), "2025-01-04T11-00-00", uuid)
|
|
.expect("external session file");
|
|
|
|
store
|
|
.resume_thread(ResumeThreadParams {
|
|
thread_id,
|
|
rollout_path: Some(rollout_path.clone()),
|
|
history: None,
|
|
include_archived: true,
|
|
metadata: thread_metadata(),
|
|
event_persistence_mode: ThreadEventPersistenceMode::Limited,
|
|
})
|
|
.await
|
|
.expect("resume live thread");
|
|
|
|
let thread = store
|
|
.read_thread(ReadThreadParams {
|
|
thread_id,
|
|
include_archived: false,
|
|
include_history: true,
|
|
})
|
|
.await
|
|
.expect("read external live thread");
|
|
|
|
assert_eq!(thread.rollout_path, Some(rollout_path));
|
|
assert!(thread.history.expect("history").items.iter().any(|item| {
|
|
matches!(
|
|
item,
|
|
RolloutItem::EventMsg(EventMsg::UserMessage(event)) if event.message == "Hello from user"
|
|
)
|
|
}));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_history_uses_live_writer_rollout_path_for_archived_source() {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
|
|
let uuid = uuid::Uuid::from_u128(405);
|
|
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
|
let rollout_path = write_archived_session_file(home.path(), "2025-01-04T10-30-00", uuid)
|
|
.expect("archived session file");
|
|
|
|
store
|
|
.resume_thread(ResumeThreadParams {
|
|
thread_id,
|
|
rollout_path: Some(rollout_path),
|
|
history: None,
|
|
include_archived: true,
|
|
metadata: thread_metadata(),
|
|
event_persistence_mode: ThreadEventPersistenceMode::Limited,
|
|
})
|
|
.await
|
|
.expect("resume live archived thread");
|
|
store
|
|
.append_items(AppendThreadItemsParams {
|
|
thread_id,
|
|
items: vec![user_message_item("archived live history item")],
|
|
})
|
|
.await
|
|
.expect("append live item");
|
|
store
|
|
.flush_thread(thread_id)
|
|
.await
|
|
.expect("flush live thread");
|
|
|
|
let err = store
|
|
.read_thread(ReadThreadParams {
|
|
thread_id,
|
|
include_archived: false,
|
|
include_history: false,
|
|
})
|
|
.await
|
|
.expect_err("active-only read should reject archived live thread");
|
|
assert!(matches!(err, ThreadStoreError::InvalidRequest { .. }));
|
|
|
|
let err = store
|
|
.load_history(LoadThreadHistoryParams {
|
|
thread_id,
|
|
include_archived: false,
|
|
})
|
|
.await
|
|
.expect_err("active-only history should reject archived live thread");
|
|
assert!(matches!(err, ThreadStoreError::InvalidRequest { .. }));
|
|
assert!(err.to_string().contains("archived"));
|
|
|
|
let history = store
|
|
.load_history(LoadThreadHistoryParams {
|
|
thread_id,
|
|
include_archived: true,
|
|
})
|
|
.await
|
|
.expect("load archived live history");
|
|
|
|
assert!(history.items.iter().any(|item| {
|
|
matches!(
|
|
item,
|
|
RolloutItem::EventMsg(EventMsg::UserMessage(event)) if event.message == "archived live history item"
|
|
)
|
|
}));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_thread_by_rollout_path_includes_history() {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
|
|
let thread_id = ThreadId::default();
|
|
|
|
store
|
|
.create_thread(create_thread_params(thread_id))
|
|
.await
|
|
.expect("create thread");
|
|
store
|
|
.append_items(AppendThreadItemsParams {
|
|
thread_id,
|
|
items: vec![user_message_item("path read")],
|
|
})
|
|
.await
|
|
.expect("append item");
|
|
store.flush_thread(thread_id).await.expect("flush thread");
|
|
let rollout_path = store
|
|
.live_rollout_path(thread_id)
|
|
.await
|
|
.expect("load rollout path");
|
|
|
|
let thread = store
|
|
.read_thread_by_rollout_path(
|
|
rollout_path,
|
|
/*include_archived*/ true,
|
|
/*include_history*/ true,
|
|
)
|
|
.await
|
|
.expect("read thread by rollout path");
|
|
|
|
assert_eq!(thread.thread_id, thread_id);
|
|
assert_eq!(
|
|
thread
|
|
.history
|
|
.expect("history")
|
|
.items
|
|
.into_iter()
|
|
.filter(|item| matches!(item, RolloutItem::EventMsg(EventMsg::UserMessage(_))))
|
|
.count(),
|
|
1
|
|
);
|
|
}
|
|
|
|
fn create_thread_params(thread_id: ThreadId) -> CreateThreadParams {
|
|
CreateThreadParams {
|
|
thread_id,
|
|
forked_from_id: None,
|
|
source: SessionSource::Exec,
|
|
base_instructions: BaseInstructions::default(),
|
|
dynamic_tools: Vec::new(),
|
|
metadata: thread_metadata(),
|
|
event_persistence_mode: ThreadEventPersistenceMode::Limited,
|
|
}
|
|
}
|
|
|
|
fn thread_metadata() -> ThreadPersistenceMetadata {
|
|
ThreadPersistenceMetadata {
|
|
cwd: Some(std::env::current_dir().expect("cwd")),
|
|
model_provider: "test-provider".to_string(),
|
|
memory_mode: ThreadMemoryMode::Enabled,
|
|
}
|
|
}
|
|
|
|
fn user_message_item(message: &str) -> RolloutItem {
|
|
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
|
message: message.to_string(),
|
|
images: None,
|
|
local_images: Vec::new(),
|
|
text_elements: Vec::new(),
|
|
}))
|
|
}
|
|
|
|
async fn assert_rollout_contains_message(path: &std::path::Path, expected: &str) {
|
|
let (items, _, _) = RolloutRecorder::load_rollout_items(path)
|
|
.await
|
|
.expect("load rollout items");
|
|
assert!(items.iter().any(|item| {
|
|
matches!(
|
|
item,
|
|
RolloutItem::EventMsg(EventMsg::UserMessage(event)) if event.message == expected
|
|
)
|
|
}));
|
|
}
|
|
}
|