mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
state: pass state db handles through consumers (#20561)
## 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.
This commit is contained in:
@@ -1239,6 +1239,7 @@ async fn find_thread_path_by_id_str_in_subdir(
|
||||
codex_home: &Path,
|
||||
subdir: &str,
|
||||
id_str: &str,
|
||||
state_db_ctx: Option<&codex_state::StateRuntime>,
|
||||
) -> io::Result<Option<PathBuf>> {
|
||||
// Validate UUID format early.
|
||||
if Uuid::parse_str(id_str).is_err() {
|
||||
@@ -1253,8 +1254,8 @@ async fn find_thread_path_by_id_str_in_subdir(
|
||||
_ => None,
|
||||
};
|
||||
let thread_id = ThreadId::from_string(id_str).ok();
|
||||
let state_db_ctx = state_db::open_if_present(codex_home, "").await;
|
||||
if let Some(state_db_ctx) = state_db_ctx.as_deref()
|
||||
let mut unverified_db_path = None;
|
||||
if let Some(state_db_ctx) = state_db_ctx
|
||||
&& let Some(thread_id) = thread_id
|
||||
&& let Some(db_path) = state_db::find_rollout_path_by_id(
|
||||
Some(state_db_ctx),
|
||||
@@ -1265,21 +1266,43 @@ async fn find_thread_path_by_id_str_in_subdir(
|
||||
.await
|
||||
{
|
||||
if tokio::fs::try_exists(&db_path).await.unwrap_or(false) {
|
||||
return Ok(Some(db_path));
|
||||
match read_session_meta_line(&db_path).await {
|
||||
Ok(meta_line) if meta_line.meta.id == thread_id => {
|
||||
return Ok(Some(db_path));
|
||||
}
|
||||
Ok(meta_line) => {
|
||||
tracing::error!(
|
||||
"state db returned rollout path for thread {id_str} but file belongs to thread {}: {}",
|
||||
meta_line.meta.id,
|
||||
db_path.display()
|
||||
);
|
||||
tracing::warn!(
|
||||
"state db discrepancy during find_thread_path_by_id_str_in_subdir: mismatched_db_path"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::debug!(
|
||||
"state db returned rollout path for thread {id_str} that could not be verified: {}: {err}",
|
||||
db_path.display()
|
||||
);
|
||||
unverified_db_path = Some(db_path);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::error!(
|
||||
"state db returned stale rollout path for thread {id_str}: {}",
|
||||
db_path.display()
|
||||
);
|
||||
tracing::warn!(
|
||||
"state db discrepancy during find_thread_path_by_id_str_in_subdir: stale_db_path"
|
||||
);
|
||||
}
|
||||
tracing::error!(
|
||||
"state db returned stale rollout path for thread {id_str}: {}",
|
||||
db_path.display()
|
||||
);
|
||||
tracing::warn!(
|
||||
"state db discrepancy during find_thread_path_by_id_str_in_subdir: stale_db_path"
|
||||
);
|
||||
}
|
||||
|
||||
let mut root = codex_home.to_path_buf();
|
||||
root.push(subdir);
|
||||
if !root.exists() {
|
||||
return Ok(None);
|
||||
return Ok(unverified_db_path);
|
||||
}
|
||||
// This is safe because we know the values are valid.
|
||||
#[allow(clippy::unwrap_used)]
|
||||
@@ -1301,7 +1324,7 @@ async fn find_thread_path_by_id_str_in_subdir(
|
||||
"state db discrepancy during find_thread_path_by_id_str_in_subdir: falling_back"
|
||||
);
|
||||
state_db::read_repair_rollout_path(
|
||||
state_db_ctx.as_deref(),
|
||||
state_db_ctx,
|
||||
thread_id,
|
||||
archived_only,
|
||||
found_path.as_path(),
|
||||
@@ -1309,7 +1332,7 @@ async fn find_thread_path_by_id_str_in_subdir(
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
Ok(found.or(unverified_db_path))
|
||||
}
|
||||
|
||||
/// Locate a recorded thread rollout file by its UUID string using the existing
|
||||
@@ -1318,16 +1341,19 @@ async fn find_thread_path_by_id_str_in_subdir(
|
||||
pub async fn find_thread_path_by_id_str(
|
||||
codex_home: &Path,
|
||||
id_str: &str,
|
||||
state_db_ctx: Option<&codex_state::StateRuntime>,
|
||||
) -> io::Result<Option<PathBuf>> {
|
||||
find_thread_path_by_id_str_in_subdir(codex_home, SESSIONS_SUBDIR, id_str).await
|
||||
find_thread_path_by_id_str_in_subdir(codex_home, SESSIONS_SUBDIR, id_str, state_db_ctx).await
|
||||
}
|
||||
|
||||
/// Locate an archived thread rollout file by its UUID string.
|
||||
pub async fn find_archived_thread_path_by_id_str(
|
||||
codex_home: &Path,
|
||||
id_str: &str,
|
||||
state_db_ctx: Option<&codex_state::StateRuntime>,
|
||||
) -> io::Result<Option<PathBuf>> {
|
||||
find_thread_path_by_id_str_in_subdir(codex_home, ARCHIVED_SESSIONS_SUBDIR, id_str).await
|
||||
find_thread_path_by_id_str_in_subdir(codex_home, ARCHIVED_SESSIONS_SUBDIR, id_str, state_db_ctx)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Extract the `YYYY/MM/DD` directory components from a rollout filename.
|
||||
|
||||
@@ -136,6 +136,21 @@ pub(crate) async fn backfill_sessions(
|
||||
runtime: &codex_state::StateRuntime,
|
||||
codex_home: &Path,
|
||||
default_provider: &str,
|
||||
) {
|
||||
backfill_sessions_with_lease(
|
||||
runtime,
|
||||
codex_home,
|
||||
default_provider,
|
||||
BACKFILL_LEASE_SECONDS,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn backfill_sessions_with_lease(
|
||||
runtime: &codex_state::StateRuntime,
|
||||
codex_home: &Path,
|
||||
default_provider: &str,
|
||||
backfill_lease_seconds: i64,
|
||||
) {
|
||||
let metric_client = codex_otel::global();
|
||||
let timer = metric_client
|
||||
@@ -154,7 +169,7 @@ pub(crate) async fn backfill_sessions(
|
||||
if backfill_state.status == BackfillStatus::Complete {
|
||||
return;
|
||||
}
|
||||
let claimed = match runtime.try_claim_backfill(BACKFILL_LEASE_SECONDS).await {
|
||||
let claimed = match runtime.try_claim_backfill(backfill_lease_seconds).await {
|
||||
Ok(claimed) => claimed,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
|
||||
@@ -79,7 +79,6 @@ pub struct RolloutRecorder {
|
||||
tx: Sender<RolloutCmd>,
|
||||
writer_task: Arc<RolloutWriterTask>,
|
||||
pub(crate) rollout_path: PathBuf,
|
||||
state_db: Option<StateDbHandle>,
|
||||
event_persistence_mode: EventPersistenceMode,
|
||||
}
|
||||
|
||||
@@ -230,6 +229,7 @@ impl RolloutRecorder {
|
||||
/// List threads (rollout files) under the provided Codex home directory.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn list_threads(
|
||||
state_db_ctx: Option<StateDbHandle>,
|
||||
config: &impl RolloutConfigView,
|
||||
page_size: usize,
|
||||
cursor: Option<&Cursor>,
|
||||
@@ -242,6 +242,7 @@ impl RolloutRecorder {
|
||||
search_term: Option<&str>,
|
||||
) -> std::io::Result<ThreadsPage> {
|
||||
Self::list_threads_with_db_fallback(
|
||||
state_db_ctx,
|
||||
config,
|
||||
page_size,
|
||||
cursor,
|
||||
@@ -260,6 +261,7 @@ impl RolloutRecorder {
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn list_threads_from_state_db(
|
||||
state_db_ctx: Option<StateDbHandle>,
|
||||
config: &impl RolloutConfigView,
|
||||
page_size: usize,
|
||||
cursor: Option<&Cursor>,
|
||||
@@ -272,6 +274,7 @@ impl RolloutRecorder {
|
||||
search_term: Option<&str>,
|
||||
) -> std::io::Result<ThreadsPage> {
|
||||
Self::list_threads_with_db_fallback(
|
||||
state_db_ctx,
|
||||
config,
|
||||
page_size,
|
||||
cursor,
|
||||
@@ -291,6 +294,7 @@ impl RolloutRecorder {
|
||||
/// List archived threads (rollout files) under the archived sessions directory.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn list_archived_threads(
|
||||
state_db_ctx: Option<StateDbHandle>,
|
||||
config: &impl RolloutConfigView,
|
||||
page_size: usize,
|
||||
cursor: Option<&Cursor>,
|
||||
@@ -303,6 +307,7 @@ impl RolloutRecorder {
|
||||
search_term: Option<&str>,
|
||||
) -> std::io::Result<ThreadsPage> {
|
||||
Self::list_threads_with_db_fallback(
|
||||
state_db_ctx,
|
||||
config,
|
||||
page_size,
|
||||
cursor,
|
||||
@@ -321,6 +326,7 @@ impl RolloutRecorder {
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn list_archived_threads_from_state_db(
|
||||
state_db_ctx: Option<StateDbHandle>,
|
||||
config: &impl RolloutConfigView,
|
||||
page_size: usize,
|
||||
cursor: Option<&Cursor>,
|
||||
@@ -333,6 +339,7 @@ impl RolloutRecorder {
|
||||
search_term: Option<&str>,
|
||||
) -> std::io::Result<ThreadsPage> {
|
||||
Self::list_threads_with_db_fallback(
|
||||
state_db_ctx,
|
||||
config,
|
||||
page_size,
|
||||
cursor,
|
||||
@@ -351,6 +358,7 @@ impl RolloutRecorder {
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn list_threads_with_db_fallback(
|
||||
state_db_ctx: Option<StateDbHandle>,
|
||||
config: &impl RolloutConfigView,
|
||||
page_size: usize,
|
||||
cursor: Option<&Cursor>,
|
||||
@@ -365,7 +373,6 @@ impl RolloutRecorder {
|
||||
search_term: Option<&str>,
|
||||
) -> std::io::Result<ThreadsPage> {
|
||||
let codex_home = config.codex_home();
|
||||
let state_db_ctx = state_db::get_state_db(config).await;
|
||||
let archived = match archive_filter {
|
||||
ThreadListArchiveFilter::Active => false,
|
||||
ThreadListArchiveFilter::Archived => true,
|
||||
@@ -575,6 +582,7 @@ impl RolloutRecorder {
|
||||
/// Find the newest recorded thread path, optionally filtering to a matching cwd.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn find_latest_thread_path(
|
||||
state_db_ctx: Option<StateDbHandle>,
|
||||
config: &impl RolloutConfigView,
|
||||
page_size: usize,
|
||||
cursor: Option<&Cursor>,
|
||||
@@ -585,7 +593,6 @@ impl RolloutRecorder {
|
||||
filter_cwd: Option<&Path>,
|
||||
) -> std::io::Result<Option<PathBuf>> {
|
||||
let codex_home = config.codex_home();
|
||||
let state_db_ctx = state_db::get_state_db(config).await;
|
||||
let cwd_filter = filter_cwd.map(Path::to_path_buf);
|
||||
if state_db_ctx.is_some() {
|
||||
let mut db_cursor = cursor.cloned();
|
||||
@@ -770,7 +777,6 @@ impl RolloutRecorder {
|
||||
tx,
|
||||
writer_task,
|
||||
rollout_path,
|
||||
state_db: state_db_ctx,
|
||||
event_persistence_mode,
|
||||
})
|
||||
}
|
||||
@@ -779,10 +785,6 @@ impl RolloutRecorder {
|
||||
self.rollout_path.as_path()
|
||||
}
|
||||
|
||||
pub fn state_db(&self) -> Option<StateDbHandle> {
|
||||
self.state_db.clone()
|
||||
}
|
||||
|
||||
pub async fn record_items(&self, items: &[RolloutItem]) -> std::io::Result<()> {
|
||||
let mut filtered = Vec::new();
|
||||
for item in items {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use super::*;
|
||||
use crate::config::RolloutConfig;
|
||||
use chrono::TimeZone;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AgentMessageEvent;
|
||||
@@ -11,6 +12,9 @@ use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionMeta;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_protocol::protocol::UserMessageEvent;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -65,6 +69,77 @@ fn write_session_file(root: &Path, ts: &str, uuid: Uuid) -> std::io::Result<Path
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn state_db_init_backfills_before_returning() -> anyhow::Result<()> {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let uuid = Uuid::new_v4();
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string())?;
|
||||
let rollout_path = home.path().join(format!(
|
||||
"sessions/2026/01/27/rollout-2026-01-27T12-34-56-{uuid}.jsonl"
|
||||
));
|
||||
let parent = rollout_path
|
||||
.parent()
|
||||
.expect("rollout path should have parent");
|
||||
fs::create_dir_all(parent)?;
|
||||
|
||||
let session_meta_line = SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
id: thread_id,
|
||||
forked_from_id: None,
|
||||
timestamp: "2026-01-27T12:34:56Z".to_string(),
|
||||
cwd: home.path().to_path_buf(),
|
||||
originator: "test".to_string(),
|
||||
cli_version: "test".to_string(),
|
||||
source: SessionSource::Cli,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
model_provider: None,
|
||||
base_instructions: None,
|
||||
dynamic_tools: None,
|
||||
memory_mode: None,
|
||||
},
|
||||
git: None,
|
||||
};
|
||||
let lines = [
|
||||
RolloutLine {
|
||||
timestamp: "2026-01-27T12:34:56Z".to_string(),
|
||||
item: RolloutItem::SessionMeta(session_meta_line),
|
||||
},
|
||||
RolloutLine {
|
||||
timestamp: "2026-01-27T12:34:57Z".to_string(),
|
||||
item: RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "hello from startup backfill".to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
})),
|
||||
},
|
||||
];
|
||||
let jsonl = lines
|
||||
.iter()
|
||||
.map(serde_json::to_string)
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.join("\n");
|
||||
fs::write(&rollout_path, format!("{jsonl}\n"))?;
|
||||
|
||||
let runtime = crate::state_db::init(&test_config(home.path()))
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
|
||||
let metadata = runtime
|
||||
.get_thread(thread_id)
|
||||
.await?
|
||||
.expect("thread should be backfilled before init returns");
|
||||
assert_eq!(metadata.rollout_path, rollout_path);
|
||||
assert_eq!(
|
||||
runtime.get_backfill_state().await?.status,
|
||||
codex_state::BackfillStatus::Complete
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_rollout_items_skips_legacy_ghost_snapshot_lines() -> std::io::Result<()> {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
@@ -526,6 +601,7 @@ async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Re
|
||||
|
||||
let default_provider = config.model_provider_id.clone();
|
||||
let page1 = RolloutRecorder::list_threads(
|
||||
/*state_db_ctx*/ None,
|
||||
&config,
|
||||
/*page_size*/ 1,
|
||||
/*cursor*/ None,
|
||||
@@ -543,6 +619,7 @@ async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Re
|
||||
let cursor = page1.next_cursor.clone().expect("cursor should be present");
|
||||
|
||||
let page2 = RolloutRecorder::list_threads(
|
||||
/*state_db_ctx*/ None,
|
||||
&config,
|
||||
/*page_size*/ 1,
|
||||
Some(&cursor),
|
||||
@@ -602,6 +679,7 @@ async fn list_threads_db_enabled_drops_missing_rollout_paths() -> std::io::Resul
|
||||
|
||||
let default_provider = config.model_provider_id.clone();
|
||||
let page = RolloutRecorder::list_threads(
|
||||
Some(runtime.clone()),
|
||||
&config,
|
||||
/*page_size*/ 10,
|
||||
/*cursor*/ None,
|
||||
@@ -666,6 +744,7 @@ async fn list_threads_db_enabled_repairs_stale_rollout_paths() -> std::io::Resul
|
||||
|
||||
let default_provider = config.model_provider_id.clone();
|
||||
let page = RolloutRecorder::list_threads(
|
||||
Some(runtime.clone()),
|
||||
&config,
|
||||
/*page_size*/ 1,
|
||||
/*cursor*/ None,
|
||||
@@ -738,6 +817,7 @@ async fn list_threads_state_db_only_skips_jsonl_repair_scan() -> std::io::Result
|
||||
|
||||
let cwd_filters = [home.path().to_path_buf()];
|
||||
let state_db_only_page = RolloutRecorder::list_threads_from_state_db(
|
||||
Some(runtime.clone()),
|
||||
&config,
|
||||
/*page_size*/ 10,
|
||||
/*cursor*/ None,
|
||||
@@ -753,6 +833,7 @@ async fn list_threads_state_db_only_skips_jsonl_repair_scan() -> std::io::Result
|
||||
assert_eq!(state_db_only_page.items.len(), 0);
|
||||
|
||||
let repaired_page = RolloutRecorder::list_threads(
|
||||
Some(runtime.clone()),
|
||||
&config,
|
||||
/*page_size*/ 10,
|
||||
/*cursor*/ None,
|
||||
@@ -768,6 +849,7 @@ async fn list_threads_state_db_only_skips_jsonl_repair_scan() -> std::io::Result
|
||||
assert_eq!(repaired_page.items.len(), 1);
|
||||
|
||||
let repaired_state_db_only_page = RolloutRecorder::list_threads_from_state_db(
|
||||
Some(runtime.clone()),
|
||||
&config,
|
||||
/*page_size*/ 10,
|
||||
/*cursor*/ None,
|
||||
@@ -825,6 +907,7 @@ async fn list_threads_default_filter_returns_filesystem_scan_results() -> std::i
|
||||
|
||||
let cwd_filters = [stale_cwd];
|
||||
let state_db_only_page = RolloutRecorder::list_threads_from_state_db(
|
||||
Some(runtime.clone()),
|
||||
&config,
|
||||
/*page_size*/ 10,
|
||||
/*cursor*/ None,
|
||||
@@ -840,6 +923,7 @@ async fn list_threads_default_filter_returns_filesystem_scan_results() -> std::i
|
||||
assert_eq!(state_db_only_page.items.len(), 1);
|
||||
|
||||
let scanned_page = RolloutRecorder::list_threads(
|
||||
Some(runtime.clone()),
|
||||
&config,
|
||||
/*page_size*/ 10,
|
||||
/*cursor*/ None,
|
||||
@@ -855,6 +939,7 @@ async fn list_threads_default_filter_returns_filesystem_scan_results() -> std::i
|
||||
assert_eq!(scanned_page.items.len(), 0);
|
||||
|
||||
let repaired_state_db_only_page = RolloutRecorder::list_threads_from_state_db(
|
||||
Some(runtime.clone()),
|
||||
&config,
|
||||
/*page_size*/ 10,
|
||||
/*cursor*/ None,
|
||||
@@ -913,6 +998,7 @@ async fn list_threads_metadata_filter_overlays_state_db_list_metadata() -> std::
|
||||
.expect("state db upsert should succeed");
|
||||
|
||||
let page = RolloutRecorder::list_threads(
|
||||
Some(runtime.clone()),
|
||||
&config,
|
||||
/*page_size*/ 10,
|
||||
/*cursor*/ None,
|
||||
@@ -1039,6 +1125,7 @@ async fn list_threads_search_repairs_stale_state_db_hits_before_returning() -> s
|
||||
.expect("state db upsert should succeed");
|
||||
|
||||
let stale_state_db_only_page = RolloutRecorder::list_threads_from_state_db(
|
||||
Some(runtime.clone()),
|
||||
&config,
|
||||
/*page_size*/ 10,
|
||||
/*cursor*/ None,
|
||||
@@ -1054,6 +1141,7 @@ async fn list_threads_search_repairs_stale_state_db_hits_before_returning() -> s
|
||||
assert_eq!(stale_state_db_only_page.items.len(), 1);
|
||||
|
||||
let scanned_page = RolloutRecorder::list_threads(
|
||||
Some(runtime.clone()),
|
||||
&config,
|
||||
/*page_size*/ 10,
|
||||
/*cursor*/ None,
|
||||
@@ -1069,6 +1157,7 @@ async fn list_threads_search_repairs_stale_state_db_hits_before_returning() -> s
|
||||
assert_eq!(scanned_page.items.len(), 0);
|
||||
|
||||
let repaired_state_db_only_page = RolloutRecorder::list_threads_from_state_db(
|
||||
Some(runtime.clone()),
|
||||
&config,
|
||||
/*page_size*/ 10,
|
||||
/*cursor*/ None,
|
||||
|
||||
@@ -117,6 +117,7 @@ pub async fn find_thread_names_by_ids(
|
||||
pub async fn find_thread_meta_by_name_str(
|
||||
codex_home: &Path,
|
||||
name: &str,
|
||||
state_db_ctx: Option<&codex_state::StateRuntime>,
|
||||
) -> std::io::Result<Option<(PathBuf, SessionMetaLine)>> {
|
||||
if name.trim().is_empty() {
|
||||
return Ok(None);
|
||||
@@ -135,8 +136,12 @@ pub async fn find_thread_meta_by_name_str(
|
||||
while let Some(thread_id) = rx.recv().await {
|
||||
// Keep walking until a matching id resolves to a loadable rollout so an unsaved or partial
|
||||
// rename cannot shadow an older persisted session with the same name.
|
||||
if let Some(path) =
|
||||
super::list::find_thread_path_by_id_str(codex_home, &thread_id.to_string()).await?
|
||||
if let Some(path) = super::list::find_thread_path_by_id_str(
|
||||
codex_home,
|
||||
&thread_id.to_string(),
|
||||
state_db_ctx,
|
||||
)
|
||||
.await?
|
||||
&& let Ok(session_meta) = super::list::read_session_meta_line(&path).await
|
||||
{
|
||||
drop(rx);
|
||||
|
||||
@@ -99,7 +99,7 @@ async fn find_thread_meta_by_name_str_skips_newest_entry_without_rollout() -> st
|
||||
];
|
||||
write_index(&path, &lines)?;
|
||||
|
||||
let found = find_thread_meta_by_name_str(temp.path(), "same").await?;
|
||||
let found = find_thread_meta_by_name_str(temp.path(), "same", /*state_db_ctx*/ None).await?;
|
||||
|
||||
assert_eq!(
|
||||
found.map(|(path, session_meta)| (path, session_meta.meta.id)),
|
||||
@@ -136,7 +136,7 @@ async fn find_thread_meta_by_name_str_skips_partial_rollout() -> std::io::Result
|
||||
];
|
||||
write_index(&path, &lines)?;
|
||||
|
||||
let found = find_thread_meta_by_name_str(temp.path(), "same").await?;
|
||||
let found = find_thread_meta_by_name_str(temp.path(), "same", /*state_db_ctx*/ None).await?;
|
||||
|
||||
assert_eq!(found.map(|(path, _)| path), Some(saved_rollout_path));
|
||||
Ok(())
|
||||
@@ -174,7 +174,7 @@ async fn find_thread_meta_by_name_str_ignores_historical_name_after_rename() ->
|
||||
];
|
||||
write_index(&path, &lines)?;
|
||||
|
||||
let found = find_thread_meta_by_name_str(temp.path(), "same").await?;
|
||||
let found = find_thread_meta_by_name_str(temp.path(), "same", /*state_db_ctx*/ None).await?;
|
||||
|
||||
assert_eq!(found.map(|(path, _)| path), Some(current_rollout_path));
|
||||
Ok(())
|
||||
|
||||
@@ -17,15 +17,52 @@ use serde_json::Value;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
/// Core-facing handle to the SQLite-backed state runtime.
|
||||
pub type StateDbHandle = Arc<codex_state::StateRuntime>;
|
||||
|
||||
/// Initialize the state runtime for thread state persistence and backfill checks.
|
||||
#[cfg(not(test))]
|
||||
const STARTUP_BACKFILL_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||
#[cfg(test)]
|
||||
const STARTUP_BACKFILL_POLL_INTERVAL: Duration = Duration::from_millis(10);
|
||||
#[cfg(not(test))]
|
||||
const STARTUP_BACKFILL_WAIT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
#[cfg(test)]
|
||||
const STARTUP_BACKFILL_WAIT_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Initialize the state runtime for thread state persistence.
|
||||
///
|
||||
/// This is the process entry point for local state: it opens the SQLite-backed
|
||||
/// runtime, applies rollout metadata backfills as needed, and returns the
|
||||
/// initialized handle.
|
||||
pub async fn init(config: &impl RolloutConfigView) -> Option<StateDbHandle> {
|
||||
let config = RolloutConfig::from_view(config);
|
||||
init_with_roots(
|
||||
match try_init_with_roots(
|
||||
config.codex_home,
|
||||
config.sqlite_home,
|
||||
config.model_provider_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(runtime) => Some(runtime),
|
||||
Err(err) => {
|
||||
emit_startup_warning(&format!("failed to initialize state runtime: {err}"));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the state runtime and return any initialization error to the caller.
|
||||
///
|
||||
/// Prefer [`init`] unless the caller needs to surface the exact failure after
|
||||
/// tracing or UI setup has completed.
|
||||
pub async fn try_init(config: &impl RolloutConfigView) -> anyhow::Result<StateDbHandle> {
|
||||
let config = RolloutConfig::from_view(config);
|
||||
try_init_with_roots(
|
||||
config.codex_home,
|
||||
config.sqlite_home,
|
||||
config.model_provider_id,
|
||||
@@ -33,52 +70,128 @@ pub async fn init(config: &impl RolloutConfigView) -> Option<StateDbHandle> {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Initialize the state runtime for a local thread store.
|
||||
pub async fn init_with_roots(
|
||||
async fn try_init_with_roots(
|
||||
codex_home: PathBuf,
|
||||
sqlite_home: PathBuf,
|
||||
default_model_provider_id: String,
|
||||
) -> Option<StateDbHandle> {
|
||||
let runtime = match codex_state::StateRuntime::init(
|
||||
sqlite_home.clone(),
|
||||
default_model_provider_id.clone(),
|
||||
) -> anyhow::Result<StateDbHandle> {
|
||||
try_init_with_roots_inner(
|
||||
codex_home,
|
||||
sqlite_home,
|
||||
default_model_provider_id,
|
||||
/*backfill_lease_seconds*/ None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(runtime) => runtime,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to initialize state runtime at {}: {err}",
|
||||
sqlite_home.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let backfill_state = match runtime.get_backfill_state().await {
|
||||
Ok(state) => state,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn try_init_with_roots_and_backfill_lease(
|
||||
codex_home: PathBuf,
|
||||
sqlite_home: PathBuf,
|
||||
default_model_provider_id: String,
|
||||
backfill_lease_seconds: i64,
|
||||
) -> anyhow::Result<StateDbHandle> {
|
||||
try_init_with_roots_inner(
|
||||
codex_home,
|
||||
sqlite_home,
|
||||
default_model_provider_id,
|
||||
Some(backfill_lease_seconds),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn try_init_with_roots_inner(
|
||||
codex_home: PathBuf,
|
||||
sqlite_home: PathBuf,
|
||||
default_model_provider_id: String,
|
||||
backfill_lease_seconds: Option<i64>,
|
||||
) -> anyhow::Result<StateDbHandle> {
|
||||
let runtime =
|
||||
codex_state::StateRuntime::init(sqlite_home.clone(), default_model_provider_id.clone())
|
||||
.await
|
||||
.map_err(|err| {
|
||||
anyhow::anyhow!(
|
||||
"failed to initialize state runtime at {}: {err}",
|
||||
sqlite_home.display()
|
||||
)
|
||||
})?;
|
||||
let wait_started = Instant::now();
|
||||
let mut reported_wait = false;
|
||||
loop {
|
||||
let backfill_state = runtime.get_backfill_state().await.map_err(|err| {
|
||||
anyhow::anyhow!(
|
||||
"failed to read backfill state at {}: {err}",
|
||||
codex_home.display()
|
||||
);
|
||||
return None;
|
||||
)
|
||||
})?;
|
||||
if backfill_state.status == codex_state::BackfillStatus::Complete {
|
||||
return Ok(runtime);
|
||||
}
|
||||
};
|
||||
if backfill_state.status != codex_state::BackfillStatus::Complete {
|
||||
let runtime_for_backfill = runtime.clone();
|
||||
tokio::spawn(async move {
|
||||
|
||||
if let Some(backfill_lease_seconds) = backfill_lease_seconds {
|
||||
metadata::backfill_sessions_with_lease(
|
||||
runtime.as_ref(),
|
||||
codex_home.as_path(),
|
||||
default_model_provider_id.as_str(),
|
||||
backfill_lease_seconds,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
metadata::backfill_sessions(
|
||||
runtime_for_backfill.as_ref(),
|
||||
runtime.as_ref(),
|
||||
codex_home.as_path(),
|
||||
default_model_provider_id.as_str(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
let backfill_state = runtime.get_backfill_state().await.map_err(|err| {
|
||||
anyhow::anyhow!(
|
||||
"failed to read backfill state at {} after startup backfill: {err}",
|
||||
codex_home.display()
|
||||
)
|
||||
})?;
|
||||
if backfill_state.status == codex_state::BackfillStatus::Complete {
|
||||
return Ok(runtime);
|
||||
}
|
||||
if wait_started.elapsed() >= STARTUP_BACKFILL_WAIT_TIMEOUT {
|
||||
return Err(anyhow::anyhow!(
|
||||
"timed out waiting for state db backfill at {} after {:?} (status: {})",
|
||||
codex_home.display(),
|
||||
STARTUP_BACKFILL_WAIT_TIMEOUT,
|
||||
backfill_state.status.as_str()
|
||||
));
|
||||
}
|
||||
|
||||
let message = format!(
|
||||
"state db backfill is {} at {}; waiting up to {:?} before retrying startup initialization",
|
||||
backfill_state.status.as_str(),
|
||||
codex_home.display(),
|
||||
STARTUP_BACKFILL_WAIT_TIMEOUT,
|
||||
);
|
||||
if reported_wait {
|
||||
info!("{message}");
|
||||
} else {
|
||||
emit_startup_warning(&message);
|
||||
reported_wait = true;
|
||||
}
|
||||
tokio::time::sleep(STARTUP_BACKFILL_POLL_INTERVAL).await;
|
||||
}
|
||||
Some(runtime)
|
||||
}
|
||||
|
||||
/// Get the DB if the feature is enabled and the DB exists.
|
||||
fn emit_startup_warning(message: &str) {
|
||||
warn!("{message}");
|
||||
if !tracing::dispatcher::has_been_set() {
|
||||
#[allow(clippy::print_stderr)]
|
||||
{
|
||||
eprintln!("{message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the DB if it exists and its startup backfill has already completed.
|
||||
///
|
||||
/// Unlike [`init`], this helper does not run rollout backfill. It is for
|
||||
/// optional local reads from non-owning contexts such as remote app-server mode.
|
||||
pub async fn get_state_db(config: &impl RolloutConfigView) -> Option<StateDbHandle> {
|
||||
let state_path = codex_state::state_db_path(config.sqlite_home());
|
||||
if !tokio::fs::try_exists(&state_path).await.unwrap_or(false) {
|
||||
@@ -93,21 +206,6 @@ pub async fn get_state_db(config: &impl RolloutConfigView) -> Option<StateDbHand
|
||||
require_backfill_complete(runtime, config.sqlite_home()).await
|
||||
}
|
||||
|
||||
/// Open the state runtime when the SQLite file exists, without feature gating.
|
||||
///
|
||||
/// This is used for parity checks during the SQLite migration phase.
|
||||
pub async fn open_if_present(codex_home: &Path, default_provider: &str) -> Option<StateDbHandle> {
|
||||
let db_path = codex_state::state_db_path(codex_home);
|
||||
if !tokio::fs::try_exists(&db_path).await.unwrap_or(false) {
|
||||
return None;
|
||||
}
|
||||
let runtime =
|
||||
codex_state::StateRuntime::init(codex_home.to_path_buf(), default_provider.to_string())
|
||||
.await
|
||||
.ok()?;
|
||||
require_backfill_complete(runtime, codex_home).await
|
||||
}
|
||||
|
||||
async fn require_backfill_complete(
|
||||
runtime: StateDbHandle,
|
||||
codex_home: &Path,
|
||||
|
||||
@@ -7,6 +7,7 @@ use chrono::NaiveDateTime;
|
||||
use chrono::Timelike;
|
||||
use chrono::Utc;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn cursor_to_anchor_normalizes_timestamp_format() {
|
||||
@@ -22,3 +23,64 @@ fn cursor_to_anchor_normalizes_timestamp_format() {
|
||||
|
||||
assert_eq!(anchor.ts, expected_ts);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn try_init_waits_for_concurrent_startup_backfill() -> anyhow::Result<()> {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let runtime =
|
||||
codex_state::StateRuntime::init(home.path().to_path_buf(), "test-provider".to_string())
|
||||
.await?;
|
||||
let claimed = runtime.try_claim_backfill(/*lease_seconds*/ 60).await?;
|
||||
assert!(claimed);
|
||||
let runtime_for_completion = runtime.clone();
|
||||
let complete_backfill = tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||
runtime_for_completion
|
||||
.mark_backfill_complete(/*last_watermark*/ None)
|
||||
.await
|
||||
});
|
||||
|
||||
let initialized = try_init_with_roots_and_backfill_lease(
|
||||
home.path().to_path_buf(),
|
||||
home.path().to_path_buf(),
|
||||
"test-provider".to_string(),
|
||||
/*backfill_lease_seconds*/ 60,
|
||||
)
|
||||
.await?;
|
||||
complete_backfill.await??;
|
||||
assert_eq!(
|
||||
initialized.get_backfill_state().await?.status,
|
||||
codex_state::BackfillStatus::Complete
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn try_init_times_out_waiting_for_stuck_startup_backfill() -> anyhow::Result<()> {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let runtime =
|
||||
codex_state::StateRuntime::init(home.path().to_path_buf(), "test-provider".to_string())
|
||||
.await?;
|
||||
let claimed = runtime.try_claim_backfill(/*lease_seconds*/ 60).await?;
|
||||
assert!(claimed);
|
||||
|
||||
let result = try_init_with_roots_and_backfill_lease(
|
||||
home.path().to_path_buf(),
|
||||
home.path().to_path_buf(),
|
||||
"test-provider".to_string(),
|
||||
/*backfill_lease_seconds*/ 60,
|
||||
)
|
||||
.await;
|
||||
let err = match result {
|
||||
Ok(_) => panic!("state db init should not wait forever for incomplete backfill"),
|
||||
Err(err) => err,
|
||||
};
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("timed out waiting for state db backfill"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ async fn insert_state_db_thread(
|
||||
thread_id: ThreadId,
|
||||
rollout_path: &Path,
|
||||
archived: bool,
|
||||
) {
|
||||
) -> crate::state_db::StateDbHandle {
|
||||
let runtime = codex_state::StateRuntime::init(home.to_path_buf(), TEST_PROVIDER.to_string())
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
@@ -87,6 +87,7 @@ async fn insert_state_db_thread(
|
||||
.upsert_thread(&metadata)
|
||||
.await
|
||||
.expect("state db upsert should succeed");
|
||||
runtime
|
||||
}
|
||||
|
||||
// TODO(jif) fix
|
||||
@@ -236,7 +237,7 @@ async fn find_thread_path_falls_back_when_db_path_is_stale() {
|
||||
let stale_db_path = home.join(format!(
|
||||
"sessions/2099/01/01/rollout-2099-01-01T00-00-00-{uuid}.jsonl"
|
||||
));
|
||||
insert_state_db_thread(
|
||||
let runtime = insert_state_db_thread(
|
||||
home,
|
||||
thread_id,
|
||||
stale_db_path.as_path(),
|
||||
@@ -244,7 +245,52 @@ async fn find_thread_path_falls_back_when_db_path_is_stale() {
|
||||
)
|
||||
.await;
|
||||
|
||||
let found = find_thread_path_by_id_str(home, &uuid.to_string())
|
||||
let found = find_thread_path_by_id_str(home, &uuid.to_string(), Some(runtime.as_ref()))
|
||||
.await
|
||||
.expect("lookup should succeed");
|
||||
assert_eq!(found, Some(fs_rollout_path.clone()));
|
||||
assert_state_db_rollout_path(home, thread_id, Some(fs_rollout_path.as_path())).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_thread_path_falls_back_when_db_path_points_to_another_thread() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let home = temp.path();
|
||||
let uuid = Uuid::from_u128(304);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
let ts = "2025-01-03T13-00-00";
|
||||
write_session_file(
|
||||
home,
|
||||
ts,
|
||||
uuid,
|
||||
/*num_records*/ 1,
|
||||
Some(SessionSource::Cli),
|
||||
)
|
||||
.unwrap();
|
||||
let fs_rollout_path = home.join(format!("sessions/2025/01/03/rollout-{ts}-{uuid}.jsonl"));
|
||||
|
||||
let other_uuid = Uuid::from_u128(1304);
|
||||
let other_ts = "2025-01-04T13-00-00";
|
||||
write_session_file(
|
||||
home,
|
||||
other_ts,
|
||||
other_uuid,
|
||||
/*num_records*/ 1,
|
||||
Some(SessionSource::Cli),
|
||||
)
|
||||
.unwrap();
|
||||
let stale_db_path = home.join(format!(
|
||||
"sessions/2025/01/04/rollout-{other_ts}-{other_uuid}.jsonl"
|
||||
));
|
||||
let runtime = insert_state_db_thread(
|
||||
home,
|
||||
thread_id,
|
||||
stale_db_path.as_path(),
|
||||
/*archived*/ false,
|
||||
)
|
||||
.await;
|
||||
|
||||
let found = find_thread_path_by_id_str(home, &uuid.to_string(), Some(runtime.as_ref()))
|
||||
.await
|
||||
.expect("lookup should succeed");
|
||||
assert_eq!(found, Some(fs_rollout_path.clone()));
|
||||
@@ -269,21 +315,44 @@ async fn find_thread_path_repairs_missing_db_row_after_filesystem_fallback() {
|
||||
let fs_rollout_path = home.join(format!("sessions/2025/01/03/rollout-{ts}-{uuid}.jsonl"));
|
||||
|
||||
// Create an empty state DB so lookup takes the DB-first path and then falls back to files.
|
||||
let _runtime = codex_state::StateRuntime::init(home.to_path_buf(), TEST_PROVIDER.to_string())
|
||||
let runtime = codex_state::StateRuntime::init(home.to_path_buf(), TEST_PROVIDER.to_string())
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
_runtime
|
||||
runtime
|
||||
.mark_backfill_complete(/*last_watermark*/ None)
|
||||
.await
|
||||
.expect("backfill should be complete");
|
||||
|
||||
let found = find_thread_path_by_id_str(home, &uuid.to_string())
|
||||
let found = find_thread_path_by_id_str(home, &uuid.to_string(), Some(runtime.as_ref()))
|
||||
.await
|
||||
.expect("lookup should succeed");
|
||||
assert_eq!(found, Some(fs_rollout_path.clone()));
|
||||
assert_state_db_rollout_path(home, thread_id, Some(fs_rollout_path.as_path())).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_thread_path_accepts_existing_state_db_path_without_canonical_filename() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let home = temp.path();
|
||||
let uuid = Uuid::from_u128(305);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
let db_rollout_path = home.join("sessions/2025/01/03/custom-rollout-name.jsonl");
|
||||
fs::create_dir_all(db_rollout_path.parent().expect("rollout parent")).unwrap();
|
||||
fs::write(&db_rollout_path, "").unwrap();
|
||||
let runtime = insert_state_db_thread(
|
||||
home,
|
||||
thread_id,
|
||||
db_rollout_path.as_path(),
|
||||
/*archived*/ false,
|
||||
)
|
||||
.await;
|
||||
|
||||
let found = find_thread_path_by_id_str(home, &uuid.to_string(), Some(runtime.as_ref()))
|
||||
.await
|
||||
.expect("lookup should succeed");
|
||||
assert_eq!(found, Some(db_rollout_path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_date_parts_extracts_directory_components() {
|
||||
let file_name = OsStr::new("rollout-2025-03-01T09-00-00-123.jsonl");
|
||||
|
||||
Reference in New Issue
Block a user