mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
## Why Clients that display or coordinate spawned subagents need an authoritative snapshot of a thread's immediate spawned children when they connect to app-server or recover after missing live events. `thread/list` cannot query by parent, so clients must otherwise scan unrelated threads or reconstruct relationships from rollout history and transient events. The direct spawn relationship already exists in persisted `thread_spawn_edges` state. Review and Guardian threads do not participate in that lifecycle and are intentionally outside this filter's scope. ## What changed This adds an experimental `parentThreadId` filter to `thread/list`. Parent-filtered requests return direct spawned children from persisted state while preserving the existing response shape, explicit filters, sorting, and timestamp-only cursor behavior. The lookup does not read rollout transcripts or recursively return descendants. Supersedes #25112 with the narrower `thread/list` filter approach. ## How it works 1. An experimental client passes a valid thread ID as `parentThreadId`. 2. App-server routes the list through the existing thread-store and state-database boundaries. 3. SQLite selects threads whose IDs have a direct persisted spawn edge from that parent. 4. Omitted provider and source filters include all values; explicit filters keep ordinary `thread/list` semantics. 5. Grandchildren, Review threads, and Guardian threads are excluded. ## Verification State (144 tests), rollout (69 tests), and focused app-server thread-list (31 tests) suites passed. Scoped Clippy checks and repository formatting also passed. Coverage includes direct spawned children, omitted grandchildren, pagination, malformed IDs, mixed source kinds, explicit filters, and operation without rollout files.
117 lines
3.5 KiB
Rust
117 lines
3.5 KiB
Rust
use crate::config::edit::ConfigEditsBuilder;
|
|
use codex_config::config_toml::ConfigToml;
|
|
use codex_protocol::config_types::Personality;
|
|
use codex_rollout::state_db::StateDbHandle;
|
|
use codex_thread_store::ListThreadsParams;
|
|
use codex_thread_store::LocalThreadStore;
|
|
use codex_thread_store::LocalThreadStoreConfig;
|
|
use codex_thread_store::ThreadSortKey;
|
|
use codex_thread_store::ThreadStore;
|
|
use std::io;
|
|
use std::path::Path;
|
|
use tokio::fs::OpenOptions;
|
|
use tokio::io::AsyncWriteExt;
|
|
|
|
pub const PERSONALITY_MIGRATION_FILENAME: &str = ".personality_migration";
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum PersonalityMigrationStatus {
|
|
SkippedMarker,
|
|
SkippedExplicitPersonality,
|
|
SkippedNoSessions,
|
|
Applied,
|
|
}
|
|
|
|
pub async fn maybe_migrate_personality(
|
|
codex_home: &Path,
|
|
config_toml: &ConfigToml,
|
|
state_db: Option<StateDbHandle>,
|
|
) -> io::Result<PersonalityMigrationStatus> {
|
|
let marker_path = codex_home.join(PERSONALITY_MIGRATION_FILENAME);
|
|
if tokio::fs::try_exists(&marker_path).await? {
|
|
return Ok(PersonalityMigrationStatus::SkippedMarker);
|
|
}
|
|
|
|
if config_toml.personality.is_some() {
|
|
create_marker(&marker_path).await?;
|
|
return Ok(PersonalityMigrationStatus::SkippedExplicitPersonality);
|
|
}
|
|
|
|
let model_provider_id = config_toml
|
|
.model_provider
|
|
.clone()
|
|
.unwrap_or_else(|| "openai".to_string());
|
|
|
|
if !has_recorded_sessions(codex_home, model_provider_id.as_str(), state_db).await? {
|
|
create_marker(&marker_path).await?;
|
|
return Ok(PersonalityMigrationStatus::SkippedNoSessions);
|
|
}
|
|
|
|
ConfigEditsBuilder::new(codex_home)
|
|
.set_personality(Some(Personality::Pragmatic))
|
|
.apply()
|
|
.await
|
|
.map_err(|err| {
|
|
io::Error::other(format!("failed to persist personality migration: {err}"))
|
|
})?;
|
|
|
|
create_marker(&marker_path).await?;
|
|
Ok(PersonalityMigrationStatus::Applied)
|
|
}
|
|
|
|
async fn has_recorded_sessions(
|
|
codex_home: &Path,
|
|
default_provider: &str,
|
|
state_db: Option<StateDbHandle>,
|
|
) -> io::Result<bool> {
|
|
let store = LocalThreadStore::new(
|
|
LocalThreadStoreConfig {
|
|
codex_home: codex_home.to_path_buf(),
|
|
sqlite_home: codex_home.to_path_buf(),
|
|
default_model_provider_id: default_provider.to_string(),
|
|
},
|
|
state_db,
|
|
);
|
|
if has_threads(&store, /*archived*/ false).await? {
|
|
return Ok(true);
|
|
}
|
|
has_threads(&store, /*archived*/ true).await
|
|
}
|
|
|
|
async fn has_threads(store: &LocalThreadStore, archived: bool) -> io::Result<bool> {
|
|
store
|
|
.list_threads(ListThreadsParams {
|
|
page_size: 1,
|
|
cursor: None,
|
|
sort_key: ThreadSortKey::CreatedAt,
|
|
sort_direction: codex_thread_store::SortDirection::Desc,
|
|
allowed_sources: Vec::new(),
|
|
model_providers: None,
|
|
cwd_filters: None,
|
|
parent_thread_id: None,
|
|
archived,
|
|
search_term: None,
|
|
use_state_db_only: false,
|
|
})
|
|
.await
|
|
.map(|page| !page.items.is_empty())
|
|
.map_err(io::Error::other)
|
|
}
|
|
|
|
async fn create_marker(marker_path: &Path) -> io::Result<()> {
|
|
match OpenOptions::new()
|
|
.create_new(true)
|
|
.write(true)
|
|
.open(marker_path)
|
|
.await
|
|
{
|
|
Ok(mut file) => file.write_all(b"v1\n").await,
|
|
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => Ok(()),
|
|
Err(err) => Err(err),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "personality_migration_tests.rs"]
|
|
mod tests;
|