mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +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.
1225 lines
39 KiB
Rust
1225 lines
39 KiB
Rust
#![allow(warnings, clippy::all)]
|
|
|
|
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;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
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;
|
|
use std::fs;
|
|
use std::fs::File;
|
|
use std::io::Write;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
use tempfile::TempDir;
|
|
use uuid::Uuid;
|
|
|
|
fn test_config(codex_home: &Path) -> RolloutConfig {
|
|
RolloutConfig {
|
|
codex_home: codex_home.to_path_buf(),
|
|
sqlite_home: codex_home.to_path_buf(),
|
|
cwd: codex_home.to_path_buf(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
generate_memories: true,
|
|
}
|
|
}
|
|
|
|
fn write_session_file(root: &Path, ts: &str, uuid: Uuid) -> std::io::Result<PathBuf> {
|
|
let day_dir = root.join("sessions/2025/01/03");
|
|
fs::create_dir_all(&day_dir)?;
|
|
let path = day_dir.join(format!("rollout-{ts}-{uuid}.jsonl"));
|
|
let mut file = File::create(&path)?;
|
|
let meta = serde_json::json!({
|
|
"timestamp": ts,
|
|
"type": "session_meta",
|
|
"payload": {
|
|
"id": uuid,
|
|
"timestamp": ts,
|
|
"cwd": ".",
|
|
"originator": "test_originator",
|
|
"cli_version": "test_version",
|
|
"source": "cli",
|
|
"model_provider": "test-provider",
|
|
},
|
|
});
|
|
writeln!(file, "{meta}")?;
|
|
let user_event = serde_json::json!({
|
|
"timestamp": ts,
|
|
"type": "event_msg",
|
|
"payload": {
|
|
"type": "user_message",
|
|
"message": "Hello from user",
|
|
"kind": "plain",
|
|
},
|
|
});
|
|
writeln!(file, "{user_event}")?;
|
|
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");
|
|
let rollout_path = home.path().join("rollout.jsonl");
|
|
let mut file = File::create(&rollout_path)?;
|
|
let thread_id = ThreadId::new();
|
|
let ts = "2025-01-03T12:00:00Z";
|
|
|
|
writeln!(
|
|
file,
|
|
"{}",
|
|
serde_json::json!({
|
|
"timestamp": ts,
|
|
"type": "session_meta",
|
|
"payload": {
|
|
"id": thread_id,
|
|
"timestamp": ts,
|
|
"cwd": ".",
|
|
"originator": "test_originator",
|
|
"cli_version": "test_version",
|
|
"source": "cli",
|
|
"model_provider": "test-provider",
|
|
},
|
|
})
|
|
)?;
|
|
writeln!(
|
|
file,
|
|
"{}",
|
|
serde_json::json!({
|
|
"timestamp": ts,
|
|
"type": "response_item",
|
|
"payload": {
|
|
"type": "ghost_snapshot",
|
|
"ghost_commit": {
|
|
"id": "deadbeef",
|
|
"preexisting_untracked_dirs": [],
|
|
"preexisting_untracked_files": [],
|
|
},
|
|
},
|
|
})
|
|
)?;
|
|
writeln!(
|
|
file,
|
|
"{}",
|
|
serde_json::json!({
|
|
"timestamp": ts,
|
|
"type": "response_item",
|
|
"payload": {
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "output_text",
|
|
"text": "hello",
|
|
}
|
|
],
|
|
},
|
|
})
|
|
)?;
|
|
|
|
let (items, loaded_thread_id, parse_errors) =
|
|
RolloutRecorder::load_rollout_items(&rollout_path).await?;
|
|
|
|
assert_eq!(loaded_thread_id, Some(thread_id));
|
|
assert_eq!(parse_errors, 0);
|
|
assert_eq!(items.len(), 2);
|
|
assert!(matches!(items[0], RolloutItem::SessionMeta(_)));
|
|
assert!(matches!(
|
|
items[1],
|
|
RolloutItem::ResponseItem(ResponseItem::Message { .. })
|
|
));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_rollout_items_filters_legacy_ghost_snapshots_from_compaction_history()
|
|
-> std::io::Result<()> {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let rollout_path = home.path().join("rollout.jsonl");
|
|
let mut file = File::create(&rollout_path)?;
|
|
let thread_id = ThreadId::new();
|
|
let ts = "2025-01-03T12:00:00Z";
|
|
|
|
writeln!(
|
|
file,
|
|
"{}",
|
|
serde_json::json!({
|
|
"timestamp": ts,
|
|
"type": "session_meta",
|
|
"payload": {
|
|
"id": thread_id,
|
|
"timestamp": ts,
|
|
"cwd": ".",
|
|
"originator": "test_originator",
|
|
"cli_version": "test_version",
|
|
"source": "cli",
|
|
"model_provider": "test-provider",
|
|
},
|
|
})
|
|
)?;
|
|
writeln!(
|
|
file,
|
|
"{}",
|
|
serde_json::json!({
|
|
"timestamp": ts,
|
|
"type": "compacted",
|
|
"payload": {
|
|
"message": "summary",
|
|
"replacement_history": [
|
|
{
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "output_text",
|
|
"text": "kept",
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"type": "ghost_snapshot",
|
|
"ghost_commit": {
|
|
"id": "deadbeef",
|
|
"preexisting_untracked_dirs": [],
|
|
"preexisting_untracked_files": [],
|
|
},
|
|
}
|
|
],
|
|
},
|
|
})
|
|
)?;
|
|
|
|
let (items, loaded_thread_id, parse_errors) =
|
|
RolloutRecorder::load_rollout_items(&rollout_path).await?;
|
|
|
|
assert_eq!(loaded_thread_id, Some(thread_id));
|
|
assert_eq!(parse_errors, 0);
|
|
assert_eq!(items.len(), 2);
|
|
let RolloutItem::Compacted(compacted) = &items[1] else {
|
|
panic!("expected compacted rollout item");
|
|
};
|
|
let replacement_history = compacted
|
|
.replacement_history
|
|
.as_ref()
|
|
.expect("replacement history");
|
|
assert_eq!(replacement_history.len(), 1);
|
|
assert!(matches!(
|
|
&replacement_history[0],
|
|
ResponseItem::Message { .. }
|
|
));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result<()> {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let config = test_config(home.path());
|
|
let thread_id = ThreadId::new();
|
|
let recorder = RolloutRecorder::new(
|
|
&config,
|
|
RolloutRecorderParams::new(
|
|
thread_id,
|
|
/*forked_from_id*/ None,
|
|
SessionSource::Exec,
|
|
BaseInstructions::default(),
|
|
Vec::new(),
|
|
EventPersistenceMode::Limited,
|
|
),
|
|
/*state_db_ctx*/ None,
|
|
/*state_builder*/ None,
|
|
)
|
|
.await?;
|
|
|
|
let rollout_path = recorder.rollout_path().to_path_buf();
|
|
assert!(
|
|
!rollout_path.exists(),
|
|
"rollout file should not exist before the first recordable item"
|
|
);
|
|
|
|
recorder
|
|
.record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage(
|
|
AgentMessageEvent {
|
|
message: "buffered-event".to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
},
|
|
))])
|
|
.await?;
|
|
recorder.flush().await?;
|
|
assert!(
|
|
rollout_path.exists(),
|
|
"flush with pending items should materialize the rollout"
|
|
);
|
|
|
|
recorder
|
|
.record_items(&[RolloutItem::EventMsg(EventMsg::UserMessage(
|
|
UserMessageEvent {
|
|
message: "first-user-message".to_string(),
|
|
images: None,
|
|
local_images: Vec::new(),
|
|
text_elements: Vec::new(),
|
|
},
|
|
))])
|
|
.await?;
|
|
recorder.flush().await?;
|
|
|
|
recorder.persist().await?;
|
|
// Second call verifies `persist()` is idempotent after materialization.
|
|
recorder.persist().await?;
|
|
assert!(rollout_path.exists(), "rollout file should be materialized");
|
|
|
|
let text = std::fs::read_to_string(&rollout_path)?;
|
|
assert!(
|
|
text.contains("\"type\":\"session_meta\""),
|
|
"expected session metadata in rollout"
|
|
);
|
|
let buffered_idx = text
|
|
.find("buffered-event")
|
|
.expect("buffered event in rollout");
|
|
let user_idx = text
|
|
.find("first-user-message")
|
|
.expect("first user message in rollout");
|
|
assert!(
|
|
buffered_idx < user_idx,
|
|
"buffered items should preserve ordering"
|
|
);
|
|
let text_after_second_persist = std::fs::read_to_string(&rollout_path)?;
|
|
assert_eq!(text_after_second_persist, text);
|
|
|
|
recorder.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn persist_reports_filesystem_error_and_retries_buffered_items() -> std::io::Result<()> {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let config = test_config(home.path());
|
|
let thread_id = ThreadId::new();
|
|
let recorder = RolloutRecorder::new(
|
|
&config,
|
|
RolloutRecorderParams::new(
|
|
thread_id,
|
|
/*forked_from_id*/ None,
|
|
SessionSource::Exec,
|
|
BaseInstructions::default(),
|
|
Vec::new(),
|
|
EventPersistenceMode::Limited,
|
|
),
|
|
/*state_db_ctx*/ None,
|
|
/*state_builder*/ None,
|
|
)
|
|
.await?;
|
|
let rollout_path = recorder.rollout_path().to_path_buf();
|
|
|
|
recorder
|
|
.record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage(
|
|
AgentMessageEvent {
|
|
message: "buffered-before-persist".to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
},
|
|
))])
|
|
.await?;
|
|
let sessions_blocker_path = home.path().join("sessions");
|
|
File::create(&sessions_blocker_path)?;
|
|
|
|
let err = recorder
|
|
.persist()
|
|
.await
|
|
.expect_err("blocked sessions directory should fail persist");
|
|
assert_ne!(err.kind(), std::io::ErrorKind::Interrupted);
|
|
assert!(
|
|
!rollout_path.exists(),
|
|
"failed persist should keep the rollout deferred"
|
|
);
|
|
|
|
fs::remove_file(sessions_blocker_path)?;
|
|
recorder.flush().await?;
|
|
let text = std::fs::read_to_string(&rollout_path)?;
|
|
assert!(
|
|
text.contains("buffered-before-persist"),
|
|
"retry should preserve items buffered before the failed persist"
|
|
);
|
|
|
|
recorder.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn writer_state_retries_write_error_before_reporting_flush_success() -> std::io::Result<()> {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let config = test_config(home.path());
|
|
let rollout_path = home.path().join("rollout.jsonl");
|
|
File::create(&rollout_path)?;
|
|
let read_only_file = std::fs::OpenOptions::new().read(true).open(&rollout_path)?;
|
|
let mut state = RolloutWriterState::new(
|
|
Some(tokio::fs::File::from_std(read_only_file)),
|
|
/*deferred_log_file_info*/ None,
|
|
/*meta*/ None,
|
|
home.path().to_path_buf(),
|
|
rollout_path.clone(),
|
|
/*state_db_ctx*/ None,
|
|
/*state_builder*/ None,
|
|
config.model_provider_id.clone(),
|
|
config.generate_memories,
|
|
);
|
|
state.add_items(vec![RolloutItem::EventMsg(EventMsg::AgentMessage(
|
|
AgentMessageEvent {
|
|
message: "queued-after-writer-error".to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
},
|
|
))]);
|
|
|
|
state.flush().await?;
|
|
let text_after_retry = std::fs::read_to_string(&rollout_path)?;
|
|
assert!(
|
|
text_after_retry.contains("queued-after-writer-error"),
|
|
"flush should retry after reopening and write buffered items"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Result<()> {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let config = test_config(home.path());
|
|
|
|
let state_db = StateRuntime::init(home.path().to_path_buf(), config.model_provider_id.clone())
|
|
.await
|
|
.expect("state db should initialize");
|
|
state_db
|
|
.mark_backfill_complete(/*last_watermark*/ None)
|
|
.await
|
|
.expect("backfill should be complete");
|
|
|
|
let thread_id = ThreadId::new();
|
|
let recorder = RolloutRecorder::new(
|
|
&config,
|
|
RolloutRecorderParams::new(
|
|
thread_id,
|
|
/*forked_from_id*/ None,
|
|
SessionSource::Cli,
|
|
BaseInstructions::default(),
|
|
Vec::new(),
|
|
EventPersistenceMode::Limited,
|
|
),
|
|
Some(state_db.clone()),
|
|
/*state_builder*/ None,
|
|
)
|
|
.await?;
|
|
|
|
recorder
|
|
.record_items(&[RolloutItem::EventMsg(EventMsg::UserMessage(
|
|
UserMessageEvent {
|
|
message: "first-user-message".to_string(),
|
|
images: None,
|
|
local_images: Vec::new(),
|
|
text_elements: Vec::new(),
|
|
},
|
|
))])
|
|
.await?;
|
|
recorder.persist().await?;
|
|
recorder.flush().await?;
|
|
let initial_thread = state_db
|
|
.get_thread(thread_id)
|
|
.await
|
|
.expect("thread should load")
|
|
.expect("thread should exist");
|
|
let initial_updated_at = initial_thread.updated_at;
|
|
let initial_title = initial_thread.title.clone();
|
|
let initial_first_user_message = initial_thread.first_user_message.clone();
|
|
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
|
|
recorder
|
|
.record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage(
|
|
AgentMessageEvent {
|
|
message: "assistant text".to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
},
|
|
))])
|
|
.await?;
|
|
recorder.flush().await?;
|
|
|
|
let updated_thread = state_db
|
|
.get_thread(thread_id)
|
|
.await
|
|
.expect("thread should load after agent message")
|
|
.expect("thread should still exist");
|
|
|
|
assert!(updated_thread.updated_at > initial_updated_at);
|
|
assert_eq!(updated_thread.title, initial_title);
|
|
assert_eq!(
|
|
updated_thread.first_user_message,
|
|
initial_first_user_message
|
|
);
|
|
|
|
recorder.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn metadata_irrelevant_events_fall_back_to_upsert_when_thread_missing() -> std::io::Result<()>
|
|
{
|
|
let home = TempDir::new().expect("temp dir");
|
|
let config = test_config(home.path());
|
|
|
|
let state_db = StateRuntime::init(home.path().to_path_buf(), config.model_provider_id.clone())
|
|
.await
|
|
.expect("state db should initialize");
|
|
let thread_id = ThreadId::new();
|
|
let rollout_path = home.path().join("rollout.jsonl");
|
|
let builder = ThreadMetadataBuilder::new(
|
|
thread_id,
|
|
rollout_path.clone(),
|
|
Utc::now(),
|
|
SessionSource::Cli,
|
|
);
|
|
let items = vec![RolloutItem::EventMsg(EventMsg::AgentMessage(
|
|
AgentMessageEvent {
|
|
message: "assistant text".to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
},
|
|
))];
|
|
|
|
sync_thread_state_after_write(
|
|
Some(state_db.as_ref()),
|
|
rollout_path.as_path(),
|
|
Some(&builder),
|
|
items.as_slice(),
|
|
config.model_provider_id.as_str(),
|
|
/*new_thread_memory_mode*/ None,
|
|
)
|
|
.await;
|
|
|
|
let thread = state_db
|
|
.get_thread(thread_id)
|
|
.await
|
|
.expect("thread should load after fallback")
|
|
.expect("thread should be inserted after fallback");
|
|
assert_eq!(thread.id, thread_id);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Result<()> {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let config = test_config(home.path());
|
|
|
|
let newest = write_session_file(home.path(), "2025-01-03T12-00-00", Uuid::from_u128(9001))?;
|
|
let middle = write_session_file(home.path(), "2025-01-02T12-00-00", Uuid::from_u128(9002))?;
|
|
let _oldest = write_session_file(home.path(), "2025-01-01T12-00-00", Uuid::from_u128(9003))?;
|
|
|
|
let default_provider = config.model_provider_id.clone();
|
|
let page1 = RolloutRecorder::list_threads(
|
|
/*state_db_ctx*/ None,
|
|
&config,
|
|
/*page_size*/ 1,
|
|
/*cursor*/ None,
|
|
ThreadSortKey::CreatedAt,
|
|
SortDirection::Desc,
|
|
&[],
|
|
/*model_providers*/ None,
|
|
/*cwd_filters*/ None,
|
|
default_provider.as_str(),
|
|
/*search_term*/ None,
|
|
)
|
|
.await?;
|
|
assert_eq!(page1.items.len(), 1);
|
|
assert_eq!(page1.items[0].path, newest);
|
|
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),
|
|
ThreadSortKey::CreatedAt,
|
|
SortDirection::Desc,
|
|
&[],
|
|
/*model_providers*/ None,
|
|
/*cwd_filters*/ None,
|
|
default_provider.as_str(),
|
|
/*search_term*/ None,
|
|
)
|
|
.await?;
|
|
assert_eq!(page2.items.len(), 1);
|
|
assert_eq!(page2.items[0].path, middle);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_threads_db_enabled_drops_missing_rollout_paths() -> std::io::Result<()> {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let config = test_config(home.path());
|
|
|
|
let uuid = Uuid::from_u128(9010);
|
|
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
|
let stale_path = home.path().join(format!(
|
|
"sessions/2099/01/01/rollout-2099-01-01T00-00-00-{uuid}.jsonl"
|
|
));
|
|
|
|
let runtime = codex_state::StateRuntime::init(
|
|
home.path().to_path_buf(),
|
|
config.model_provider_id.clone(),
|
|
)
|
|
.await
|
|
.expect("state db should initialize");
|
|
runtime
|
|
.mark_backfill_complete(/*last_watermark*/ None)
|
|
.await
|
|
.expect("backfill should be complete");
|
|
let created_at = chrono::Utc
|
|
.with_ymd_and_hms(2025, 1, 3, 13, 0, 0)
|
|
.single()
|
|
.expect("valid datetime");
|
|
let mut builder = codex_state::ThreadMetadataBuilder::new(
|
|
thread_id,
|
|
stale_path,
|
|
created_at,
|
|
SessionSource::Cli,
|
|
);
|
|
builder.model_provider = Some(config.model_provider_id.clone());
|
|
builder.cwd = home.path().to_path_buf();
|
|
let mut metadata = builder.build(config.model_provider_id.as_str());
|
|
metadata.first_user_message = Some("Hello from user".to_string());
|
|
runtime
|
|
.upsert_thread(&metadata)
|
|
.await
|
|
.expect("state db upsert should succeed");
|
|
|
|
let default_provider = config.model_provider_id.clone();
|
|
let page = RolloutRecorder::list_threads(
|
|
Some(runtime.clone()),
|
|
&config,
|
|
/*page_size*/ 10,
|
|
/*cursor*/ None,
|
|
ThreadSortKey::CreatedAt,
|
|
SortDirection::Desc,
|
|
&[],
|
|
/*model_providers*/ None,
|
|
/*cwd_filters*/ None,
|
|
default_provider.as_str(),
|
|
/*search_term*/ None,
|
|
)
|
|
.await?;
|
|
assert_eq!(page.items.len(), 0);
|
|
let stored_path = runtime
|
|
.find_rollout_path_by_id(thread_id, Some(false))
|
|
.await
|
|
.expect("state db lookup should succeed");
|
|
assert_eq!(stored_path, None);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_threads_db_enabled_repairs_stale_rollout_paths() -> std::io::Result<()> {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let config = test_config(home.path());
|
|
|
|
let uuid = Uuid::from_u128(9011);
|
|
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
|
let real_path = write_session_file(home.path(), "2025-01-03T13-00-00", uuid)?;
|
|
let stale_path = home.path().join(format!(
|
|
"sessions/2099/01/01/rollout-2099-01-01T00-00-00-{uuid}.jsonl"
|
|
));
|
|
|
|
let runtime = codex_state::StateRuntime::init(
|
|
home.path().to_path_buf(),
|
|
config.model_provider_id.clone(),
|
|
)
|
|
.await
|
|
.expect("state db should initialize");
|
|
runtime
|
|
.mark_backfill_complete(/*last_watermark*/ None)
|
|
.await
|
|
.expect("backfill should be complete");
|
|
let created_at = chrono::Utc
|
|
.with_ymd_and_hms(2025, 1, 3, 13, 0, 0)
|
|
.single()
|
|
.expect("valid datetime");
|
|
let mut builder = codex_state::ThreadMetadataBuilder::new(
|
|
thread_id,
|
|
stale_path,
|
|
created_at,
|
|
SessionSource::Cli,
|
|
);
|
|
builder.model_provider = Some(config.model_provider_id.clone());
|
|
builder.cwd = home.path().to_path_buf();
|
|
let mut metadata = builder.build(config.model_provider_id.as_str());
|
|
metadata.first_user_message = Some("Hello from user".to_string());
|
|
runtime
|
|
.upsert_thread(&metadata)
|
|
.await
|
|
.expect("state db upsert should succeed");
|
|
|
|
let default_provider = config.model_provider_id.clone();
|
|
let page = RolloutRecorder::list_threads(
|
|
Some(runtime.clone()),
|
|
&config,
|
|
/*page_size*/ 1,
|
|
/*cursor*/ None,
|
|
ThreadSortKey::CreatedAt,
|
|
SortDirection::Desc,
|
|
&[],
|
|
/*model_providers*/ None,
|
|
/*cwd_filters*/ None,
|
|
default_provider.as_str(),
|
|
/*search_term*/ None,
|
|
)
|
|
.await?;
|
|
assert_eq!(page.items.len(), 1);
|
|
assert_eq!(page.items[0].path, real_path);
|
|
|
|
let repaired_path = runtime
|
|
.find_rollout_path_by_id(thread_id, Some(false))
|
|
.await
|
|
.expect("state db lookup should succeed");
|
|
assert_eq!(repaired_path, Some(real_path));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_threads_state_db_only_skips_jsonl_repair_scan() -> std::io::Result<()> {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let config = test_config(home.path());
|
|
|
|
let runtime = codex_state::StateRuntime::init(
|
|
home.path().to_path_buf(),
|
|
config.model_provider_id.clone(),
|
|
)
|
|
.await
|
|
.expect("state db should initialize");
|
|
runtime
|
|
.mark_backfill_complete(/*last_watermark*/ None)
|
|
.await
|
|
.expect("backfill should be complete");
|
|
|
|
let uuid = Uuid::from_u128(9012);
|
|
let ts = "2025-01-03T14-00-00";
|
|
let day_dir = home.path().join("sessions/2025/01/03");
|
|
fs::create_dir_all(&day_dir)?;
|
|
let path = day_dir.join(format!("rollout-{ts}-{uuid}.jsonl"));
|
|
let mut file = File::create(&path)?;
|
|
let meta = serde_json::json!({
|
|
"timestamp": ts,
|
|
"type": "session_meta",
|
|
"payload": {
|
|
"id": uuid,
|
|
"timestamp": ts,
|
|
"cwd": home.path().display().to_string(),
|
|
"originator": "test_originator",
|
|
"cli_version": "test_version",
|
|
"source": "cli",
|
|
"model_provider": "test-provider",
|
|
},
|
|
});
|
|
writeln!(file, "{meta}")?;
|
|
let user_event = serde_json::json!({
|
|
"timestamp": ts,
|
|
"type": "event_msg",
|
|
"payload": {
|
|
"type": "user_message",
|
|
"message": "Hello from user",
|
|
"kind": "plain",
|
|
},
|
|
});
|
|
writeln!(file, "{user_event}")?;
|
|
|
|
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,
|
|
ThreadSortKey::CreatedAt,
|
|
SortDirection::Desc,
|
|
&[],
|
|
/*model_providers*/ None,
|
|
/*cwd_filters*/ Some(cwd_filters.as_slice()),
|
|
config.model_provider_id.as_str(),
|
|
/*search_term*/ None,
|
|
)
|
|
.await?;
|
|
assert_eq!(state_db_only_page.items.len(), 0);
|
|
|
|
let repaired_page = RolloutRecorder::list_threads(
|
|
Some(runtime.clone()),
|
|
&config,
|
|
/*page_size*/ 10,
|
|
/*cursor*/ None,
|
|
ThreadSortKey::CreatedAt,
|
|
SortDirection::Desc,
|
|
&[],
|
|
/*model_providers*/ None,
|
|
/*cwd_filters*/ Some(cwd_filters.as_slice()),
|
|
config.model_provider_id.as_str(),
|
|
/*search_term*/ None,
|
|
)
|
|
.await?;
|
|
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,
|
|
ThreadSortKey::CreatedAt,
|
|
SortDirection::Desc,
|
|
&[],
|
|
/*model_providers*/ None,
|
|
/*cwd_filters*/ Some(cwd_filters.as_slice()),
|
|
config.model_provider_id.as_str(),
|
|
/*search_term*/ None,
|
|
)
|
|
.await?;
|
|
assert_eq!(repaired_state_db_only_page.items.len(), 1);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_threads_default_filter_returns_filesystem_scan_results() -> std::io::Result<()> {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let config = test_config(home.path());
|
|
|
|
let uuid = Uuid::from_u128(9013);
|
|
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
|
let real_path = write_session_file(home.path(), "2025-01-03T13-00-00", uuid)?;
|
|
let stale_cwd = home.path().join("stale-cwd");
|
|
|
|
let runtime = codex_state::StateRuntime::init(
|
|
home.path().to_path_buf(),
|
|
config.model_provider_id.clone(),
|
|
)
|
|
.await
|
|
.expect("state db should initialize");
|
|
runtime
|
|
.mark_backfill_complete(/*last_watermark*/ None)
|
|
.await
|
|
.expect("backfill should be complete");
|
|
let created_at = chrono::Utc
|
|
.with_ymd_and_hms(2025, 1, 3, 13, 0, 0)
|
|
.single()
|
|
.expect("valid datetime");
|
|
let mut builder = codex_state::ThreadMetadataBuilder::new(
|
|
thread_id,
|
|
real_path,
|
|
created_at,
|
|
SessionSource::Cli,
|
|
);
|
|
builder.model_provider = Some(config.model_provider_id.clone());
|
|
builder.cwd = stale_cwd.clone();
|
|
let mut metadata = builder.build(config.model_provider_id.as_str());
|
|
metadata.first_user_message = Some("Hello from user".to_string());
|
|
runtime
|
|
.upsert_thread(&metadata)
|
|
.await
|
|
.expect("state db upsert should succeed");
|
|
|
|
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,
|
|
ThreadSortKey::CreatedAt,
|
|
SortDirection::Desc,
|
|
&[],
|
|
/*model_providers*/ None,
|
|
/*cwd_filters*/ Some(cwd_filters.as_slice()),
|
|
config.model_provider_id.as_str(),
|
|
/*search_term*/ None,
|
|
)
|
|
.await?;
|
|
assert_eq!(state_db_only_page.items.len(), 1);
|
|
|
|
let scanned_page = RolloutRecorder::list_threads(
|
|
Some(runtime.clone()),
|
|
&config,
|
|
/*page_size*/ 10,
|
|
/*cursor*/ None,
|
|
ThreadSortKey::CreatedAt,
|
|
SortDirection::Desc,
|
|
&[],
|
|
/*model_providers*/ None,
|
|
/*cwd_filters*/ Some(cwd_filters.as_slice()),
|
|
config.model_provider_id.as_str(),
|
|
/*search_term*/ None,
|
|
)
|
|
.await?;
|
|
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,
|
|
ThreadSortKey::CreatedAt,
|
|
SortDirection::Desc,
|
|
&[],
|
|
/*model_providers*/ None,
|
|
/*cwd_filters*/ Some(cwd_filters.as_slice()),
|
|
config.model_provider_id.as_str(),
|
|
/*search_term*/ None,
|
|
)
|
|
.await?;
|
|
assert_eq!(repaired_state_db_only_page.items.len(), 0);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_threads_metadata_filter_overlays_state_db_list_metadata() -> std::io::Result<()> {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let config = test_config(home.path());
|
|
|
|
let uuid = Uuid::from_u128(9015);
|
|
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
|
let rollout_path = write_session_file(home.path(), "2025-01-03T16-00-00", uuid)?;
|
|
|
|
let runtime = codex_state::StateRuntime::init(
|
|
home.path().to_path_buf(),
|
|
config.model_provider_id.clone(),
|
|
)
|
|
.await
|
|
.expect("state db should initialize");
|
|
runtime
|
|
.mark_backfill_complete(/*last_watermark*/ None)
|
|
.await
|
|
.expect("backfill should be complete");
|
|
let created_at = chrono::Utc
|
|
.with_ymd_and_hms(2025, 1, 3, 16, 0, 0)
|
|
.single()
|
|
.expect("valid datetime");
|
|
let mut builder = codex_state::ThreadMetadataBuilder::new(
|
|
thread_id,
|
|
rollout_path,
|
|
created_at,
|
|
SessionSource::Cli,
|
|
);
|
|
builder.model_provider = Some(config.model_provider_id.clone());
|
|
builder.cwd = home.path().to_path_buf();
|
|
builder.git_branch = Some("sqlite-branch".to_string());
|
|
builder.git_sha = Some("sqlite-sha".to_string());
|
|
builder.git_origin_url = Some("https://example.com/repo.git".to_string());
|
|
let mut metadata = builder.build(config.model_provider_id.as_str());
|
|
metadata.first_user_message = Some("Hello from user".to_string());
|
|
runtime
|
|
.upsert_thread(&metadata)
|
|
.await
|
|
.expect("state db upsert should succeed");
|
|
|
|
let page = RolloutRecorder::list_threads(
|
|
Some(runtime.clone()),
|
|
&config,
|
|
/*page_size*/ 10,
|
|
/*cursor*/ None,
|
|
ThreadSortKey::CreatedAt,
|
|
SortDirection::Desc,
|
|
&[SessionSource::Cli],
|
|
/*model_providers*/ None,
|
|
/*cwd_filters*/ None,
|
|
config.model_provider_id.as_str(),
|
|
/*search_term*/ None,
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(page.items.len(), 1);
|
|
assert_eq!(page.items[0].git_branch.as_deref(), Some("sqlite-branch"));
|
|
assert_eq!(page.items[0].git_sha.as_deref(), Some("sqlite-sha"));
|
|
assert_eq!(
|
|
page.items[0].git_origin_url.as_deref(),
|
|
Some("https://example.com/repo.git")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fields() {
|
|
let filesystem_thread_id = ThreadId::new();
|
|
let state_thread_id = ThreadId::new();
|
|
let filesystem_path = PathBuf::from("/tmp/filesystem-rollout.jsonl");
|
|
let state_path = PathBuf::from("/tmp/state-rollout.jsonl");
|
|
let mut item = ThreadItem {
|
|
path: filesystem_path.clone(),
|
|
thread_id: Some(filesystem_thread_id),
|
|
first_user_message: Some("filesystem message".to_string()),
|
|
cwd: None,
|
|
git_branch: Some("filesystem-branch".to_string()),
|
|
git_sha: Some("filesystem-sha".to_string()),
|
|
git_origin_url: Some("https://example.com/filesystem.git".to_string()),
|
|
source: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
model_provider: None,
|
|
cli_version: None,
|
|
created_at: None,
|
|
updated_at: None,
|
|
};
|
|
let state_item = ThreadItem {
|
|
path: state_path,
|
|
thread_id: Some(state_thread_id),
|
|
first_user_message: Some("state message".to_string()),
|
|
cwd: Some(PathBuf::from("/tmp/state-cwd")),
|
|
git_branch: Some("state-branch".to_string()),
|
|
git_sha: Some("state-sha".to_string()),
|
|
git_origin_url: Some("https://example.com/state.git".to_string()),
|
|
source: Some(SessionSource::Exec),
|
|
agent_nickname: Some("state-agent".to_string()),
|
|
agent_role: Some("state-role".to_string()),
|
|
model_provider: Some("state-provider".to_string()),
|
|
cli_version: Some("state-version".to_string()),
|
|
created_at: Some("2025-01-03T16:00:00Z".to_string()),
|
|
updated_at: Some("2025-01-03T16:01:02.003Z".to_string()),
|
|
};
|
|
|
|
fill_missing_thread_item_metadata(&mut item, state_item);
|
|
|
|
assert_eq!(item.path, filesystem_path);
|
|
assert_eq!(item.thread_id, Some(filesystem_thread_id));
|
|
assert_eq!(
|
|
item.first_user_message.as_deref(),
|
|
Some("filesystem message")
|
|
);
|
|
assert_eq!(item.cwd.as_deref(), Some(Path::new("/tmp/state-cwd")));
|
|
assert_eq!(item.git_branch.as_deref(), Some("state-branch"));
|
|
assert_eq!(item.git_sha.as_deref(), Some("state-sha"));
|
|
assert_eq!(
|
|
item.git_origin_url.as_deref(),
|
|
Some("https://example.com/state.git")
|
|
);
|
|
assert_eq!(item.source, Some(SessionSource::Exec));
|
|
assert_eq!(item.agent_nickname.as_deref(), Some("state-agent"));
|
|
assert_eq!(item.agent_role.as_deref(), Some("state-role"));
|
|
assert_eq!(item.model_provider.as_deref(), Some("state-provider"));
|
|
assert_eq!(item.cli_version.as_deref(), Some("state-version"));
|
|
assert_eq!(item.created_at.as_deref(), Some("2025-01-03T16:00:00Z"));
|
|
assert_eq!(item.updated_at.as_deref(), Some("2025-01-03T16:01:02.003Z"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_threads_search_repairs_stale_state_db_hits_before_returning() -> std::io::Result<()> {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let config = test_config(home.path());
|
|
|
|
let uuid = Uuid::from_u128(9014);
|
|
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
|
let real_path = write_session_file(home.path(), "2025-01-03T15-00-00", uuid)?;
|
|
|
|
let runtime = codex_state::StateRuntime::init(
|
|
home.path().to_path_buf(),
|
|
config.model_provider_id.clone(),
|
|
)
|
|
.await
|
|
.expect("state db should initialize");
|
|
runtime
|
|
.mark_backfill_complete(/*last_watermark*/ None)
|
|
.await
|
|
.expect("backfill should be complete");
|
|
let created_at = chrono::Utc
|
|
.with_ymd_and_hms(2025, 1, 3, 15, 0, 0)
|
|
.single()
|
|
.expect("valid datetime");
|
|
let mut builder = codex_state::ThreadMetadataBuilder::new(
|
|
thread_id,
|
|
real_path,
|
|
created_at,
|
|
SessionSource::Cli,
|
|
);
|
|
builder.model_provider = Some(config.model_provider_id.clone());
|
|
builder.cwd = home.path().to_path_buf();
|
|
let mut metadata = builder.build(config.model_provider_id.as_str());
|
|
metadata.title = "needle stale title".to_string();
|
|
metadata.first_user_message = Some("stale first user".to_string());
|
|
runtime
|
|
.upsert_thread(&metadata)
|
|
.await
|
|
.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,
|
|
ThreadSortKey::CreatedAt,
|
|
SortDirection::Desc,
|
|
&[],
|
|
/*model_providers*/ None,
|
|
/*cwd_filters*/ None,
|
|
config.model_provider_id.as_str(),
|
|
Some("needle"),
|
|
)
|
|
.await?;
|
|
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,
|
|
ThreadSortKey::CreatedAt,
|
|
SortDirection::Desc,
|
|
&[],
|
|
/*model_providers*/ None,
|
|
/*cwd_filters*/ None,
|
|
config.model_provider_id.as_str(),
|
|
Some("needle"),
|
|
)
|
|
.await?;
|
|
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,
|
|
ThreadSortKey::CreatedAt,
|
|
SortDirection::Desc,
|
|
&[],
|
|
/*model_providers*/ None,
|
|
/*cwd_filters*/ None,
|
|
config.model_provider_id.as_str(),
|
|
Some("needle"),
|
|
)
|
|
.await?;
|
|
assert_eq!(repaired_state_db_only_page.items.len(), 0);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Result<()> {
|
|
let home = TempDir::new().expect("temp dir");
|
|
let stale_cwd = home.path().join("stale");
|
|
let latest_cwd = home.path().join("latest");
|
|
fs::create_dir_all(&stale_cwd)?;
|
|
fs::create_dir_all(&latest_cwd)?;
|
|
|
|
let path = write_session_file(home.path(), "2025-01-03T13-00-00", Uuid::from_u128(9012))?;
|
|
let mut file = std::fs::OpenOptions::new().append(true).open(&path)?;
|
|
let turn_context = RolloutLine {
|
|
timestamp: "2025-01-03T13:00:01Z".to_string(),
|
|
item: RolloutItem::TurnContext(TurnContextItem {
|
|
turn_id: Some("turn-1".to_string()),
|
|
trace_id: None,
|
|
cwd: latest_cwd.clone(),
|
|
current_date: None,
|
|
timezone: None,
|
|
approval_policy: AskForApproval::Never,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
permission_profile: None,
|
|
network: None,
|
|
file_system_sandbox_policy: None,
|
|
model: "test-model".to_string(),
|
|
personality: None,
|
|
collaboration_mode: None,
|
|
realtime_active: None,
|
|
effort: None,
|
|
summary: ReasoningSummaryConfig::Auto,
|
|
user_instructions: None,
|
|
developer_instructions: None,
|
|
final_output_json_schema: None,
|
|
truncation_policy: None,
|
|
}),
|
|
};
|
|
writeln!(file, "{}", serde_json::to_string(&turn_context)?)?;
|
|
|
|
assert!(
|
|
resume_candidate_matches_cwd(
|
|
path.as_path(),
|
|
Some(stale_cwd.as_path()),
|
|
latest_cwd.as_path(),
|
|
"test-provider",
|
|
)
|
|
.await
|
|
);
|
|
Ok(())
|
|
}
|