Skip to content

Thread命名与摘要

从显式改名到追加事件派生摘要,再到分组位置分配,解释名称存储优先级、索引失败语义、列表时间和跨重启手工排序。

基于rust-v0.150.0
CodexRustAppServerThreadStore

Thread命名与摘要 ​

给一个对话改名之后,为什么数据库里还有另一段 title?为什么 preview 仍是首条消息,分组位置却不会随新消息改变?这些字段最终都出现在列表附近,但它们的生产者、权威来源和更新时间不同。把它们都叫“会话标题”,就无法解释改名失败、旧标题回流和排序漂移。

本文从 thread/name/set 追踪到存储与客户端通知,再看追加历史如何派生展示摘要,最后展开 section 的整数位置分配。读者需要理解 Rust 的 Option、异步调用和基本 SQL。可先读ThreadList与分页了解列表查询,再读ThreadRead历史加载区分存储摘要和完整轮次。这里的“摘要”是列表元数据或兼容接口 ConversationSummary,不涉及模型压缩长对话的算法。

Thread 是逻辑对话,CodexThread 是加载到 Core 的运行对象,LiveThread 是其持久化入口。一个 Thread 没有运行对象时,仍可从存储修改名称。name 是显式展示名,preview 是预览文字,Legacy 的 title 兼有历史展示职责;Paginated 则将显式 name 与派生 title 分开。下面按各自生产链阅读。

1. 显式名称的写入 ​

1.1 空白与身份 ​

公开请求只接受 ID 和字符串,不接受 null 来删除名称。

源码文件:codex-rs/app-server-protocol/src/protocol/common.rs

相关函数/类型:ClientRequest::ThreadSetName;行号:566-570。

rust
ThreadSetName => "thread/name/set" {
    params: v2::ThreadSetNameParams,
    serialization: thread_id(params.thread_id),
    response: v2::ThreadSetNameResponse,
},

源码文件:codex-rs/app-server-protocol/src/protocol/v2/thread.rs

相关函数/类型:ThreadSetNameParams / ThreadSetNameResponse;行号:753-756,768-768。

rust
pub struct ThreadSetNameParams {
    pub thread_id: String,
    pub name: String,
}
// ...
pub struct ThreadSetNameResponse {}

同一 Thread ID 的请求按注册的队列范围串行;ThreadSetNameResponse {} 不返回完整摘要。输入名如何处理,必须继续看业务函数,不能从 String 推断空串也有效。

源码文件:codex-rs/core/src/util.rs

相关函数/类型:normalize_thread_name;行号:102-109。

rust
pub fn normalize_thread_name(name: &str) -> Option<String> {
    let trimmed = name.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed.to_string())
    }
}

这个函数只去除首尾空白并拒绝空结果,没有截断、自动摘要或唯一名称约束。重名不会改变逻辑 Thread ID,调用方仍应使用 ID 定位对象。

源码文件:codex-rs/app-server/src/request_processors/thread_processor.rs

相关函数/类型:thread_set_name_response_inner;行号:1751-1783。

rust
async fn thread_set_name_response_inner(
    &self,
    params: ThreadSetNameParams,
) -> Result<(ThreadSetNameResponse, Option<ThreadNameUpdatedNotification>), JSONRPCErrorError>
{
    let ThreadSetNameParams { thread_id, name } = params;
    let thread_id = ThreadId::from_string(&thread_id)
        .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
    let Some(name) = codex_core::util::normalize_thread_name(&name) else {
        return Err(invalid_request("thread name must not be empty"));
    };

    let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?;
    self.thread_manager
        .update_thread_metadata(
            thread_id,
            StoreThreadMetadataPatch {
                name: Some(Some(name.clone())),
                ..Default::default()
            },
            /*include_archived*/ false,
        )
        .await
        .map_err(|err| core_thread_write_error("set thread name", err))?;

    Ok((
        ThreadSetNameResponse {},
        Some(ThreadNameUpdatedNotification {
            thread_id: thread_id.to_string(),
            thread_name: Some(name),
        }),
    ))
}

输入校验在取得列表状态 permit 前完成,之后经 ThreadManager 写入 name: Some(Some(...))。双层 Option 是内部 patch 的三态表示:外层决定是否更新,内层决定值或清空;公开改名 API 这里只产生“写入非空值”。include_archived=false 传给持久入口,具体来源有效性仍由 Store 读取与更新路径检查。

1.2 冷热两条路 ​

Core 将已加载和未加载对象汇合到存储契约。

源码文件:codex-rs/core/src/thread_manager.rs

相关函数/类型:update_thread_metadata;行号:793-838。

rust
pub async fn update_thread_metadata(
    &self,
    thread_id: ThreadId,
    patch: ThreadMetadataPatch,
    include_archived: bool,
) -> CodexResult<StoredThread> {
    if let Ok(thread) = self.get_thread(thread_id).await {
        if thread.config_snapshot().await.ephemeral {
            return Err(CodexErr::InvalidRequest(format!(
                "ephemeral thread does not support metadata updates: {thread_id}"
            )));
        }
        return thread
            .update_thread_metadata(patch, include_archived)
            .await
            .map_err(|err| thread_store_metadata_update_error(thread_id, err));
    }
    let updated = self
        .state
        .thread_store
        .update_thread_metadata(UpdateThreadMetadataParams {
            thread_id,
            patch,
            include_archived,
        })
        .await
        .map_err(|err| match err {
            ThreadStoreError::ThreadNotFound { thread_id } => {
                CodexErr::ThreadNotFound(thread_id)
            }
            err => thread_store_metadata_update_error(thread_id, err),
        })?;
    match updated {
        Some(thread) => Ok(thread),
        None => self
            .state
            .thread_store
            .read_thread(ReadThreadParams {
                thread_id,
                include_archived,
                include_history: false,
            })
            .await
            .map_err(|err| thread_store_metadata_update_error(thread_id, err)),
    }
}

已加载 ephemeral 对象被明确拒绝,因为它没有这项持久元数据承诺。普通加载对象经过 CodexThread::update_thread_metadata,取得 Session 的 LiveThread;未加载对象直接调用 Store。Store 若把空更新返回为 None,manager 用一次读取补足“返回实际 StoredThread”的约定,不能把 None 当成 Thread 不存在。

LiveThread 写显式 patch 前先处理待落盘的派生元数据。

源码文件:codex-rs/thread-store/src/live_thread.rs

相关函数/类型:update_metadata;行号:343-364。

rust
pub async fn update_metadata(
    &self,
    patch: ThreadMetadataPatch,
    include_archived: bool,
) -> ThreadStoreResult<StoredThread> {
    self.flush_pending_metadata_update().await?;
    let updated = self
        .thread_store
        .update_thread_metadata(UpdateThreadMetadataParams {
            thread_id: self.thread_id,
            patch,
            include_archived,
        })
        .await?;
    match updated {
        Some(thread) => Ok(thread),
        None => {
            self.read_thread(include_archived, /*include_history*/ false)
                .await
        }
    }
}

顺序是 pending metadata flush,再显式 patch,最后必要时 fallback read。假如先改名、再写此前积压的首条消息派生值,Legacy 的 title 更容易被旧观察覆盖;这里的顺序让显式修改发生在当前积压更新之后。它不是跨所有未来追加的永久锁,后文仍要区分两种历史模式的字段。

1.3 模式选择 ​

LocalThreadStore 先判断此次 patch 是否需要识别历史模式,以及 SQLite 写入是否必须成功。

源码文件:codex-rs/thread-store/src/local/update_thread_metadata.rs

相关函数/类型:update_thread_metadata 的模式与写入要求;行号:66-103。

rust
let staged_requires_rollout_compat = pending_patch
    .as_ref()
    .is_some_and(|patch| patch.memory_mode.is_some() || patch.git_info.is_some());
let requires_rollout_compat =
    staged_requires_rollout_compat || requires_rollout_compatibility_update(&patch);
let has_explicit_metadata = patch.name.is_some() || requires_rollout_compat;
let history_mode = if has_explicit_metadata {
    match live_writer::live_writer_parts(store, thread_id).await {
        Ok((_recorder, _rollout_id, history_mode)) => Some(history_mode),
        Err(ThreadStoreError::ThreadNotFound { .. }) => Some(
            read_thread::read_thread(
                store,
                ReadThreadParams {
                    thread_id,
                    include_archived: params.include_archived,
                    include_history: false,
                },
            )
            .await?
            .history_mode,
        ),
        Err(err) => return Err(err),
    }
} else {
    None
};
let paginated = matches!(history_mode, Some(ThreadHistoryMode::Paginated));
let require_sqlite_write =
    pending_patch.is_some() || sqlite_write_failure_should_block(&patch) || paginated;
let mut updated = apply_metadata_update(
    store,
    thread_id,
    patch.clone(),
    params.include_archived,
    require_sqlite_write,
    history_mode,
)
.await?;

名称修改会触发模式判定,优先从 live writer 取得模式,否则读取摘要。paginated 会强制 SQLite 成功,pending patch 也会提高写入要求。这个结果随后控制故障是否可被降级,不能一概说“SQLite 只是缓存,坏了仍可改名”。

源码文件:codex-rs/thread-store/src/local/update_thread_metadata.rs

相关函数/类型:apply_metadata_update 的显式名称写入;行号:492-520。

rust
if let Some(name) = patch.name.as_ref() {
    let history_mode = history_mode.ok_or_else(|| ThreadStoreError::Internal {
        message: format!(
            "thread history mode unavailable before name update: {thread_id}"
        ),
    })?;
    let updated = match history_mode {
        ThreadHistoryMode::Legacy => {
            state_db
                .update_thread_title(thread_id, name.as_deref().unwrap_or_default())
                .await
        }
        ThreadHistoryMode::Paginated => {
            state_db
                .update_thread_name(thread_id, name.as_deref())
                .await
        }
    }
    .map_err(|err| ThreadStoreError::Internal {
        message: format!("failed to set thread name: {err}"),
    })?;
    if !updated {
        return Err(ThreadStoreError::Internal {
            message: format!(
                "thread metadata unavailable before name update: {thread_id}"
            ),
        });
    }
}

同样的内部 patch.name,Legacy 调用 update_thread_title,Paginated 调用 update_thread_name。数据库的两个 helper 分别执行 UPDATE threads SET title = ? 和 UPDATE threads SET name = ?。这两个字段的并存来自真实兼容边界,不是重复冗余数据。

2. 权威来源与降级 ​

2.1 两种索引承诺 ​

Paginated 更新 SQLite 后也尝试追加名称索引,但失败只记告警。

源码文件:codex-rs/thread-store/src/local/update_thread_metadata.rs

相关函数/类型:update_thread_metadata 的 Paginated 索引;行号:126-143。

rust
if paginated {
    // Paginated metadata lives in SQLite. Keep the name index update, then stop before the
    // legacy SessionMeta compatibility path below.
    if let Some(name) = patch.name.as_ref()
        && let Err(err) = append_thread_name(
            store.config.codex_home.as_path(),
            thread_id,
            name.as_deref().unwrap_or_default(),
        )
        .await
    {
        warn!("failed to index paginated thread name for {thread_id}: {err}");
    }
    if pending_patch.is_some() {
        remove_pending_thread_metadata(store, thread_id, &mut pending_metadata).await;
    }
    return Ok(updated);
}

此处保留索引是为了仍需要名称检索的消费者;失败并不撤销已成功的 SQLite 名称。这个分支随后返回,不再进入 Legacy SessionMeta 兼容路径。改名无须给 Paginated 的历史日志追加一条伪对话消息。

Legacy 则在必要的物化与兼容处理后追加索引,并传播索引失败。

源码文件:codex-rs/thread-store/src/local/update_thread_metadata.rs

相关函数/类型:update_thread_metadata 的 Legacy 索引;行号:183-193。

rust
if let Some(name) = name {
    append_thread_name(
        store.config.codex_home.as_path(),
        thread_id,
        &name.unwrap_or_default(),
    )
    .await
    .map_err(|err| ThreadStoreError::Internal {
        message: format!("failed to index thread name: {err}"),
    })?;
}

因此相同的 session_index.jsonl 不可写故障,会得到不同 RPC 结果。Legacy 在索引失败前已经可能改过 SQLite title,报错不保证“什么都没改”;Paginated 则能在 SQLite 已更新、索引失败时继续成功。

2.2 SQLite 失败 ​

决定写入强度的辅助函数还区分显式 patch 与历史观察。

源码文件:codex-rs/thread-store/src/local/update_thread_metadata.rs

相关函数/类型:sqlite_write_failure_should_block;行号:651-659。

rust
fn sqlite_write_failure_should_block(patch: &ThreadMetadataPatch) -> bool {
    // Before live metadata sync moved above the rollout writer, SQLite sync failures for
    // transcript-derived metadata, thread names, and memory-mode indexing were log-only. Keep that
    // failure isolation so a corrupted optional state DB does not make JSONL transcript durability
    // look broken. Explicit git-only updates still require SQLite because partial git patches need
    // the existing SQLite value to preserve unspecified fields. Project updates always require
    // SQLite because assignment only exists in the state database.
    patch.project_id.is_some() || (patch.git_info.is_some() && !has_observed_metadata_facts(patch))
}

显式 project 必须依赖 SQLite,显式 Git 部分更新也需要已有状态保留未指定字段;历史派生信息和 Legacy 名称则保留旧的故障隔离。名称仍会被外层 paginated 或 pending 条件提升为强制写入,不能只读这个 helper 的返回值就下结论。

SQLite 结果按以下分支处理,摘录从缺少 state DB 的分支开始。

源码文件:codex-rs/thread-store/src/local/update_thread_metadata.rs

相关函数/类型:apply_metadata_update 的错误分流;行号:544-560。

rust
} else if require_sqlite_write {
    Err(ThreadStoreError::Internal {
        message: format!("sqlite state db unavailable for thread {thread_id}"),
    })
} else {
    Ok(())
};
match sqlite_write_result {
    Ok(()) => {}
    Err(err) if require_sqlite_write || !sqlite_write_error_is_best_effort(&err) => {
        return Err(err);
    }
    Err(err) => {
        warn!("state db update_thread_metadata failed for {thread_id}: {err}");
    }
}

只有允许尽力写入的 Internal 错误会记录 warning 后继续,非法来源等错误不在这一降级承诺内。文件日志、名称索引与 SQLite 并不存在包住所有步骤的共同事务;定位时要确认最后完成了哪一个写入面。

测试直接设置 Paginated 名称,然后写入不同的派生 title/preview,再把索引路径变成目录以制造追加失败。

源码文件:codex-rs/thread-store/src/local/update_thread_metadata.rs

相关函数/类型:paginated_name_updates_use_sqlite_without_rollout_writes;行号:1093-1139。

rust
assert_eq!(thread.name.as_deref(), Some("Canonical paginated name"));
let metadata = runtime
    .get_thread(thread_id)
    .await
    .expect("read metadata")
    .expect("thread metadata");
assert_eq!(metadata.name.as_deref(), Some("Canonical paginated name"));
assert!(metadata.title.is_empty());
assert_eq!(
    codex_rollout::find_thread_name_by_id(home.path(), &thread_id)
        .await
        .expect("find thread name")
        .as_deref(),
    Some("Canonical paginated name")
);

let thread = store
    .update_thread_metadata(UpdateThreadMetadataParams {
        thread_id,
        patch: ThreadMetadataPatch {
            title: Some("Derived first message".to_string()),
            preview: Some("Derived first message".to_string()),
            ..Default::default()
        },
        include_archived: false,
    })
    .await
    .expect("apply derived paginated metadata")
    .expect("local store returns updated thread");
assert_eq!(thread.name.as_deref(), Some("Canonical paginated name"));

let session_index_path = home.path().join("session_index.jsonl");
std::fs::remove_file(&session_index_path).expect("remove session index");
std::fs::create_dir(&session_index_path).expect("block session index writes");
let thread = store
    .update_thread_metadata(UpdateThreadMetadataParams {
        thread_id,
        patch: ThreadMetadataPatch {
            name: Some(Some("Updated SQLite name".to_string())),
            ..Default::default()
        },
        include_archived: false,
    })
    .await
    .expect("set paginated thread name with unavailable index")
    .expect("local store returns updated thread");
assert_eq!(thread.name.as_deref(), Some("Updated SQLite name"));

第一次检查数据库 name 与索引一致,第二次要求派生 title 不覆盖显式名称,第三次要求索引不可用时 SQLite 改名仍成功。测试还检查原 rollout 字节不变。它验证的是该模式下的权威分离,不能外推到 Legacy 的索引故障策略。

2.3 读取优先级 ​

Store 单对象名称读取明确按模式分流。

源码文件:codex-rs/thread-store/src/local/read_thread.rs

相关函数/类型:thread_name_from_metadata;行号:407-426。

rust
async fn thread_name_from_metadata(
    store: &LocalThreadStore,
    metadata: &ThreadMetadata,
    history_mode: ThreadHistoryMode,
) -> Option<String> {
    match history_mode {
        ThreadHistoryMode::Paginated => sqlite_thread_name(metadata),
        ThreadHistoryMode::Legacy => {
            if let Some(title) = distinct_thread_metadata_title(metadata) {
                Some(title)
            } else {
                find_thread_name_by_id(store.config.codex_home.as_path(), &metadata.id)
                    .await
                    .ok()
                    .flatten()
                    .filter(|name| !name.trim().is_empty())
            }
        }
    }
}

Paginated 只接受 SQLite name 的非空值;Legacy 先找有效的 distinct title,再查名称索引。索引查询失败在这里通过 .ok().flatten() 降级为空,与写入路径的错误处理不同。

源码文件:codex-rs/thread-store/src/local/helpers.rs

相关函数/类型:distinct_thread_metadata_title;行号:274-281。

rust
pub(super) fn distinct_thread_metadata_title(metadata: &ThreadMetadata) -> Option<String> {
    let title = metadata.title.trim();
    if title.is_empty() || metadata.first_user_message.as_deref().map(str::trim) == Some(title) {
        None
    } else {
        Some(title.to_string())
    }
}

Legacy 的 title 若等于首条用户消息,就被看作派生标题而非独立名称。注意比较对象是 first_user_message,不是任意一条最新消息。后续向摘要安装名字时还有 preview 相等的过滤。

源码文件:codex-rs/thread-store/src/local/helpers.rs

相关函数/类型:set_thread_name;行号:283-287。

rust
pub(super) fn set_thread_name(thread: &mut StoredThread, name: String) {
    if thread.history_mode == ThreadHistoryMode::Paginated || thread.preview.trim() != name.trim() {
        thread.name = Some(name);
    }
}

Paginated 允许显式名称正好等于 preview;Legacy 会过滤这个重复展示。于是“同样输入一个名称”在两种模式下可能得到不同的 name 展示结果,而 preview 仍有可读文字。

批量读取还要避免从旧索引覆盖已有权威名称。

源码文件:codex-rs/thread-store/src/local/helpers.rs

相关函数/类型:resolve_thread_names;行号:237-272。

rust
pub(super) async fn resolve_thread_names(
    store: &LocalThreadStore,
    thread_history_modes: &HashMap<ThreadId, ThreadHistoryMode>,
) -> HashMap<ThreadId, String> {
    let mut names = HashMap::<ThreadId, String>::with_capacity(thread_history_modes.len());
    let legacy_thread_ids = thread_history_modes
        .iter()
        .filter_map(|(&thread_id, &history_mode)| {
            (history_mode == ThreadHistoryMode::Legacy).then_some(thread_id)
        })
        .collect::<HashSet<_>>();
    if let Some(state_db_ctx) = store.state_db().await {
        for (&thread_id, &history_mode) in thread_history_modes {
            let Ok(Some(metadata)) = state_db_ctx.get_thread(thread_id).await else {
                continue;
            };
            let name = match history_mode {
                ThreadHistoryMode::Legacy => distinct_thread_metadata_title(&metadata),
                ThreadHistoryMode::Paginated => sqlite_thread_name(&metadata),
            };
            if let Some(name) = name {
                names.insert(thread_id, name);
            }
        }
    }
    if let Ok(legacy_names) =
        find_thread_names_by_ids(store.config.codex_home.as_path(), &legacy_thread_ids).await
    {
        // Legacy titles remain authoritative when present; the index only fills
        // names for threads whose SQLite title is still derived from the preview.
        for (thread_id, name) in legacy_names {
            names.entry(thread_id).or_insert(name);
        }
    }
    names
}

只有 Legacy ID 进入 index 补齐集合;SQLite 的 distinct title 先写入 names,索引用 entry(...).or_insert(...) 填缺项。这与单对象读取共同约束 read/list 的一致性,不能将索引当成所有模式下的最高优先级来源。

下面把读取选择画成决策图。

图中的分流不是客户端自行猜测;它发生在 Store 及摘要装配处。调查一个旧名称时,应先查 history_mode,再决定读取 SQLite 的哪个字段,最后才决定索引是否参与。

3. 名称索引与广播 ​

3.1 追加日志 ​

名称索引是 JSONL 更新日志,并非名称到唯一 Thread 的字典文件。

源码文件:codex-rs/rollout/src/session_index.rs

相关函数/类型:SessionIndexEntry 与索引锁;行号:21-29。

rust
const SESSION_INDEX_FILE: &str = "session_index.jsonl";
static SESSION_INDEX_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionIndexEntry {
    pub id: ThreadId,
    pub thread_name: String,
    pub updated_at: String,
}

id 定位逻辑 Thread,thread_name 保存此次名称,updated_at 是索引记录自己的时间。进程内静态 mutex 保护本进程的写入操作,不能把它描述为跨进程排他文件锁。

源码文件:codex-rs/rollout/src/session_index.rs

相关函数/类型:append_thread_name;行号:33-50。

rust
pub async fn append_thread_name(
    codex_home: &Path,
    thread_id: ThreadId,
    name: &str,
) -> std::io::Result<()> {
    use time::OffsetDateTime;
    use time::format_description::well_known::Rfc3339;

    let updated_at = OffsetDateTime::now_utc()
        .format(&Rfc3339)
        .unwrap_or_else(|_| "unknown".to_string());
    let entry = SessionIndexEntry {
        id: thread_id,
        thread_name: name.to_string(),
        updated_at,
    };
    append_session_index_entry(codex_home, &entry).await
}

时间记录采用 RFC3339,时间格式化失败有字符串回退;读取当前名称的先后依据仍是日志位置,不是重新按这个字符串排序。

源码文件:codex-rs/rollout/src/session_index.rs

相关函数/类型:append_session_index_entry;行号:54-71。

rust
pub async fn append_session_index_entry(
    codex_home: &Path,
    entry: &SessionIndexEntry,
) -> std::io::Result<()> {
    let _guard = SESSION_INDEX_LOCK
        .lock()
        .map_err(|err| std::io::Error::other(err.to_string()))?;
    let path = session_index_path(codex_home);
    let mut file = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&path)?;
    let mut line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
    line.push('\n');
    file.write_all(line.as_bytes())?;
    file.flush()?;
    Ok(())
}

一次序列化结果加换行后写入 append 文件并 flush。这个 async 函数内部使用同步文件调用和同步 mutex;不能因为签名带 async 就推断 I/O 已自动放入独立线程。

3.2 单读与批读 ​

按一个 ID 查询使用逆向扫描,并把扫描放进 blocking task。

源码文件:codex-rs/rollout/src/session_index.rs

相关函数/类型:find_thread_name_by_id;行号:108-121。

rust
pub async fn find_thread_name_by_id(
    codex_home: &Path,
    thread_id: &ThreadId,
) -> std::io::Result<Option<String>> {
    let path = session_index_path(codex_home);
    if !path.exists() {
        return Ok(None);
    }
    let id = *thread_id;
    let entry = tokio::task::spawn_blocking(move || scan_index_from_end_by_id(&path, &id))
        .await
        .map_err(std::io::Error::other)??;
    Ok(entry.map(|entry| entry.thread_name))
}

scan_index_from_end_by_id 委托通用逆向 JSONL 扫描器,命中该 ID 的最新记录即停止。这里取的是最后记录的 thread_name,是否为空由更上层消费者处理。

批量读取采用另一种成本模型。

源码文件:codex-rs/rollout/src/session_index.rs

相关函数/类型:find_thread_names_by_ids;行号:124-153。

rust
pub async fn find_thread_names_by_ids(
    codex_home: &Path,
    thread_ids: &HashSet<ThreadId>,
) -> std::io::Result<HashMap<ThreadId, String>> {
    let path = session_index_path(codex_home);
    if thread_ids.is_empty() || !path.exists() {
        return Ok(HashMap::new());
    }

    let file = tokio::fs::File::open(&path).await?;
    let reader = tokio::io::BufReader::new(file);
    let mut lines = reader.lines();
    let mut names = HashMap::with_capacity(thread_ids.len());

    while let Some(line) = lines.next_line().await? {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        let Ok(entry) = serde_json::from_str::<SessionIndexEntry>(trimmed) else {
            continue;
        };
        let name = entry.thread_name.trim();
        if !name.is_empty() && thread_ids.contains(&entry.id) {
            names.insert(entry.id, name.to_string());
        }
    }

    Ok(names)
}

正向遍历时同 ID 的后续非空名称覆盖前值,一次扫描服务整批请求;坏 JSON 与空行跳过,空名称也不会插入。不能把这个实现误写成“对每个 ID 分别逆向查一次”。同时,底层空名称记录在单读和批读中的处理并不相同,不能假定它是全体消费者一致的 tombstone;公开改名入口已经拒绝空白名。

3.3 跨连接传播 ​

显式改名外层在存储成功后排队响应,再广播名称通知。

源码文件:codex-rs/app-server/src/request_processors/thread_processor.rs

相关函数/类型:thread_set_name;行号:635-656。

rust
pub(crate) async fn thread_set_name(
    &self,
    request_id: ConnectionRequestId,
    params: ThreadSetNameParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
    match self.thread_set_name_response_inner(params).await {
        Ok((response, notification)) => {
            self.outgoing
                .send_response(request_id.clone(), response)
                .await;
            if let Some(notification) = notification {
                self.outgoing
                    .send_server_notification(ServerNotification::ThreadNameUpdated(
                        notification,
                    ))
                    .await;
            }
            Ok(None)
        }
        Err(error) => Err(error),
    }
}

无论对象是否加载,这个通知都由 App Server 产生,而非必须等某个 Core Turn 再转发。返回 None 表示分支已接管响应,通用请求层不应再次返回一个空结果。

下面的时序只画加载对象的成功路径。

广播意味着另一连接即使未提交这次请求,也能更新列表名称。出站顺序是服务器排队顺序,不代表 UI 已完成渲染。WebSocket 测试用两条连接验证真实消费者。

源码文件:codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs

相关函数/类型:thread_name_updated_broadcasts_for_loaded_threads;行号:71-86。

rust
let (rename_resp, ws1_notification) = read_response_and_notification_for_method(
    &mut ws1,
    /*id*/ 11,
    "thread/name/updated",
)
.await?;
let _: ThreadSetNameResponse = to_response::<ThreadSetNameResponse>(rename_resp)?;
assert_thread_name_updated(ws1_notification, &conversation_id, renamed)?;

let ws2_notification =
    read_notification_for_method(&mut ws2, "thread/name/updated").await?;
assert_thread_name_updated(ws2_notification, &conversation_id, renamed)?;
assert_legacy_thread_name(codex_home.path(), &conversation_id, renamed).await?;

assert_no_message(&mut ws1, Duration::from_millis(250)).await?;
assert_no_message(&mut ws2, Duration::from_millis(250)).await?;

测试要求两条连接都收到匹配 ID 与新名称的通知,并检查 Legacy 持久名称;随后短时间内两者都不能再收到重复消息。另一个测试覆盖未加载 Thread。两者证明广播与冷热路径,不证明断线客户端可以离线补收每一次名称变更;重连后应读取当前摘要。

4. 摘要的生产者 ​

4.1 首条内容 ​

preview 不必是纯文本,空文字的图像或音频输入也能形成可见摘要。

源码文件:codex-rs/protocol/src/protocol.rs

相关函数/类型:user_message_preview;行号:2392-2409。

rust
pub fn user_message_preview(user: &UserMessageEvent) -> Option<String> {
    let message = strip_user_message_prefix(user.message.as_str());
    if !message.is_empty() {
        return Some(message.to_string());
    }
    if user
        .images
        .as_ref()
        .is_some_and(|images| !images.is_empty())
        || !user.local_images.is_empty()
    {
        return Some("[Image]".to_string());
    }
    if user.audio.as_ref().is_some_and(|audio| !audio.is_empty()) || !user.local_audio.is_empty() {
        return Some("[Audio]".to_string());
    }
    None
}

先剥离用户消息前缀,有文字就返回文字;否则图像优先得到 [Image],再看音频得到 [Audio]。这是一段确定性的内容选择,没有向模型请求自动命名。

MetadataSync 分别记录是否已经看见 preview、首条用户内容和 title。

源码文件:codex-rs/thread-store/src/thread_metadata_sync.rs

相关函数/类型:observe_user_message;行号:315-333。

rust
fn observe_user_message(&mut self, user: &UserMessageEvent, update: &mut ThreadMetadataPatch) {
    if let Some(preview) = user_message_preview(user) {
        if !self.first_user_message_seen {
            self.first_user_message_seen = true;
            update.first_user_message = Some(preview.clone());
        }
        if !self.preview_seen {
            self.preview_seen = true;
            update.preview = Some(preview);
        }
    }
    if !self.title_seen {
        let title = strip_user_message_prefix(user.message.as_str());
        if !title.is_empty() {
            self.title_seen = true;
            update.title = Some(title.to_string());
        }
    }
}

图片输入可以先填 preview 与 first_user_message,但由于正文为空,没有设置 title_seen。之后真正的文字消息仍可能填入 title。因此不能依赖 title == preview == first_user_message 作为不变量,也不能把“第一次设置”简化成“第一个事件都写满所有字段”。

goal 还有独立的 preview 来源。

源码文件:codex-rs/thread-store/src/thread_metadata_sync.rs

相关函数/类型:observe_items_with_update 的 goal 分支;行号:282-290。

rust
RolloutItem::EventMsg(EventMsg::ThreadGoalUpdated(event)) => {
    if !self.preview_seen {
        let objective = event.goal.objective.trim();
        if !objective.is_empty() {
            self.preview_seen = true;
            update.preview = Some(objective.to_string());
        }
    }
}

只有尚无 preview 时才采用非空 objective;它不会同时把该 objective 写成用户消息。若 goal 比第一条用户输入先到,预览可以是目标,而 first_user_message 后来才出现。

4.2 观察与提交 ​

派生元数据的状态所有者如下。

源码文件:codex-rs/thread-store/src/thread_metadata_sync.rs

相关函数/类型:ThreadMetadataSync / PendingThreadMetadataPatch;行号:34-50。

rust
pub(crate) struct ThreadMetadataSync {
    thread_id: ThreadId,
    cwd_seen: bool,
    preview_seen: bool,
    first_user_message_seen: bool,
    title_seen: bool,
    pending_update: Option<ThreadMetadataPatch>,
    pending_update_generation: u64,
    last_touch_persisted_at: Option<Instant>,
    defer_create_update_until_history_exists: bool,
    defer_resume_update_until_append: bool,
}

pub(crate) struct PendingThreadMetadataPatch {
    pub(crate) patch: ThreadMetadataPatch,
    generation: u64,
}

*_seen 控制首次观察,pending_update 保存待写 patch,generation 区分补丁批次,两个 defer 标记分别避免新对象过早持久化、以及 resume 只读过程中重新提交历史观察。时间节流只控制更新频度,不改变名称读取权威。

图中 pending patch 是提交快照,不能把一次成功写入理解成“清空其后所有观察”。generation 正是区分这些时间点的依据。

源码文件:codex-rs/thread-store/src/thread_metadata_sync.rs

相关函数/类型:mark_pending_update_applied;行号:150-157。

rust
pub(crate) fn mark_pending_update_applied(&mut self, update: &PendingThreadMetadataPatch) {
    if self.pending_update_generation == update.generation {
        self.pending_update = None;
    }
    if update.patch.updated_at.is_some() {
        self.last_touch_persisted_at = Some(Instant::now());
    }
}

LiveThread 在 Store 写入成功后才确认这一代 patch。

源码文件:codex-rs/thread-store/src/live_thread.rs

相关函数/类型:apply_pending_metadata_update;行号:397-416。

rust
async fn apply_pending_metadata_update(
    &self,
    update: Option<crate::thread_metadata_sync::PendingThreadMetadataPatch>,
) -> ThreadStoreResult<()> {
    let Some(update) = update else {
        return Ok(());
    };
    self.thread_store
        .update_thread_metadata(UpdateThreadMetadataParams {
            thread_id: self.thread_id,
            patch: update.patch.clone(),
            include_archived: true,
        })
        .await?;
    self.metadata_sync
        .lock()
        .await
        .mark_pending_update_applied(&update);
    Ok(())
}

Store 返回错误时 ? 提前结束,没有调用 mark-applied;metadata_sync 锁不跨 Store I/O 持有,因此后续观察可以产生新 generation。只有当前 generation 与已写快照相等,才清除 pending。若等待持久化期间已有更新合并,旧快照成功不能擦掉新内容。updated_at 被实际提交时才更新节流时钟。

4.3 两种时间 ​

追加路径同时处理 updated_at 与 recency_at,但触发条件不同。

源码文件:codex-rs/thread-store/src/thread_metadata_sync.rs

相关函数/类型:observe_appended_items;行号:159-192。

rust
pub(crate) fn observe_appended_items(
    &mut self,
    items: &[RolloutItem],
) -> Option<PendingThreadMetadataPatch> {
    self.defer_create_update_until_history_exists = false;
    self.defer_resume_update_until_append = false;
    let affects_metadata = items
        .iter()
        .any(codex_state::rollout_item_affects_thread_metadata);
    let advances_recency = items
        .iter()
        .any(|item| matches!(item, RolloutItem::EventMsg(EventMsg::TurnStarted(_))));
    let mut update = if affects_metadata {
        self.observe_items(items)?
    } else {
        thread_updated_at_touch()
    };
    if advances_recency {
        update.advance_recency_at = Some(Utc::now());
    }
    self.merge_pending_update(Some(update));
    if !affects_metadata
        && !self
            .pending_update
            .as_ref()
            .is_some_and(update_has_metadata_facts)
        && self.last_touch_persisted_at.is_some_and(|last_touch| {
            Instant::now().duration_since(last_touch) < THREAD_UPDATED_AT_TOUCH_INTERVAL
        })
    {
        return None;
    }
    self.take_pending_update()
}

TurnStarted 推进 recency,普通不影响元数据的追加只提出 updated_at touch,且五秒内没有新事实时可合并等待。这个条件不能读成“所有元数据五秒才更新一次”:有元数据事实、首次写入和应提交的 pending 都有自己的路径。

section 手工位置不会被这里的时间更新重算。排序故障必须先判断当前列表按 recency、updated_at 还是 SectionPosition 查询;比较不同排序键下的列表结果没有意义。

5. 兼容摘要的边界 ​

ConversationSummary 是兼容入口返回的元数据对象,不是一个名为 summary 的新持久字段。

源码文件:codex-rs/app-server/src/request_processors/thread_processor.rs

相关函数/类型:get_thread_summary_response_inner;行号:5123-5163。

rust
async fn get_thread_summary_response_inner(
    &self,
    params: GetConversationSummaryParams,
) -> Result<GetConversationSummaryResponse, JSONRPCErrorError> {
    let fallback_provider = self.config.model_provider_id.as_str();
    let read_result = match params {
        GetConversationSummaryParams::ThreadId { conversation_id } => self
            .thread_store
            .read_thread(StoreReadThreadParams {
                thread_id: conversation_id,
                include_archived: true,
                include_history: false,
            })
            .await
            .map_err(|err| conversation_summary_thread_id_read_error(conversation_id, err)),
        GetConversationSummaryParams::RolloutPath { rollout_path } => {
            let Some(local_thread_store) = self
                .thread_store
                .as_any()
                .downcast_ref::<LocalThreadStore>()
            else {
                return Err(invalid_request(
                    "rollout path queries are only supported with the local thread store",
                ));
            };

            local_thread_store
                .read_thread_by_rollout_path(
                    rollout_path.clone(),
                    /*include_archived*/ true,
                    /*include_history*/ false,
                )
                .await
                .map_err(|err| conversation_summary_rollout_path_read_error(&rollout_path, err))
        }
    };

    let stored_thread = read_result?;
    let summary = summary_from_stored_thread(stored_thread, fallback_provider);
    Ok(GetConversationSummaryResponse { summary })
}

调用按 ID 或 rollout path 读取 Store 摘要,路径分支要求实际后端为 LocalThreadStore,并读取可用来源。它没有触发模型压缩、重新生成标题或请求完整 turns。出错映射取决于真实来源分支,不能按 UI 上同一个“摘要”词统一处理。

源码文件:codex-rs/app-server/src/request_processors/thread_processor.rs

相关函数/类型:summary_from_stored_thread;行号:5862-5903。

rust
fn summary_from_stored_thread(
    thread: StoredThread,
    fallback_provider: &str,
) -> ConversationSummary {
    let path = thread.rollout_path.unwrap_or_default();
    let source = with_thread_spawn_agent_metadata(
        thread.source,
        thread.agent_nickname.clone(),
        thread.agent_role.clone(),
    );
    let git_info = thread.git_info.map(|git| ConversationGitInfo {
        sha: git.commit_hash.map(|sha| sha.0),
        branch: git.branch,
        origin_url: git.repository_url,
    });
    ConversationSummary {
        conversation_id: thread.thread_id,
        path,
        preview: thread.preview,
        // Preserve millisecond precision from the thread store so thread/list cursors
        // round-trip the same ordering key used by pagination queries.
        timestamp: Some(
            thread
                .created_at
                .to_rfc3339_opts(SecondsFormat::Millis, true),
        ),
        updated_at: Some(
            thread
                .updated_at
                .to_rfc3339_opts(SecondsFormat::Millis, true),
        ),
        model_provider: if thread.model_provider.is_empty() {
            fallback_provider.to_string()
        } else {
            thread.model_provider
        },
        cwd: thread.cwd,
        cli_version: thread.cli_version,
        source,
        git_info,
    }
}

返回字段包括 preview、提供方、cwd、Git 等,但没有显式 name 或 section。两个时间保留毫秒级 RFC3339 精度;如果在兼容序列化里截成秒,分页消费者就可能失去原排序键精度。pathless Store 在这条旧契约中使用默认空路径,这不等于新的 Thread API 也必须伪造本地文件。

视图显示文字来源身份与时间需要完整历史吗
V2 Thread 摘要Store 的 name、previewThread ID,秒级公开时间字段不必
兼容 ConversationSummarypreviewconversation ID,毫秒 RFC3339不必
模型历史ResponseItem 等上下文Turn/条目边界取决于模型重建路径

这三类消费者共享同一个对话身份,但不能相互代替。改名后兼容 summary 的 preview 未变,不足以证明写入丢失。

6. 分组身份与位置 ​

6.1 位置请求 ​

section 是服务端保存的分组实体,移动请求不携带客户端自行计算的浮点 rank。

源码文件:codex-rs/app-server-protocol/src/protocol/v2/thread.rs

相关函数/类型:ThreadSectionMoveParams;行号:1040-1054。

rust
pub struct ThreadSectionMoveParams {
    /// Thread to move into, within, or out of a section.
    pub thread_id: String,
    /// Destination section, or `null` to remove the thread from its section.
    #[serde(deserialize_with = "Option::deserialize")]
    #[schemars(
        required,
        schema_with = "crate::protocol::serde_helpers::nullable_string_schema"
    )]
    #[ts(type = "string | null")]
    pub section_id: Option<String>,
    /// Existing thread to insert before; omission or null appends to the section.
    #[ts(optional = nullable)]
    pub before_thread_id: Option<String>,
}

sectionId 是必填的可空字段:null 表示移出,缺省不是同一种输入。beforeThreadId 缺省或 null 表示追加到目标组末尾。这种约束来自反序列化定义,而非文档约定。

源码文件:codex-rs/app-server/src/request_processors/thread_processor.rs

相关函数/类型:thread_section_move;行号:667-707。

rust
pub(crate) async fn thread_section_move(
    &self,
    params: ThreadSectionMoveParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
    let ThreadSectionMoveParams {
        thread_id,
        section_id,
        before_thread_id,
    } = params;
    let thread_uuid = ThreadId::from_string(&thread_id)
        .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
    if section_id
        .as_deref()
        .is_some_and(|section| section.trim().is_empty())
    {
        return Err(invalid_request("sectionId must not be empty"));
    }
    if section_id.is_none() && before_thread_id.is_some() {
        return Err(invalid_request(
            "beforeThreadId requires a non-null sectionId",
        ));
    }
    let before_thread_uuid = before_thread_id
        .map(|thread_id| {
            ThreadId::from_string(&thread_id)
                .map_err(|err| invalid_request(format!("invalid before thread id: {err}")))
        })
        .transpose()?;

    {
        let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?;
        self.thread_manager
            .move_thread_to_section(thread_uuid, section_id.as_deref(), before_thread_uuid)
            .await
            .map_err(|err| core_thread_write_error("move thread in section", err))?;
    }

    Ok(Some(ClientResponsePayload::ThreadSectionMove(
        ThreadSectionMoveResponse {},
    )))
}

空白 section ID 被拒绝,清空 section 时不能指定锚点;ID 都需解析为 ThreadId。真正的变更仍走 manager,并在列表状态 permit 内完成。持久层还会检查目标 section 是否存在、锚点是否在同组,不能把上层字符串校验当成全部合法性检查。

6.2 事务内移动 ​

核心算法先拿写事务,读取当前位置后决定移出、同组重排或跨组移动。

源码文件:codex-rs/state/src/runtime/thread_section_order.rs

相关函数/类型:move_thread_to_section;行号:104-190。

rust
pub async fn move_thread_to_section(
    &self,
    thread_id: ThreadId,
    section: Option<&str>,
    before_thread_id: Option<ThreadId>,
) -> anyhow::Result<bool> {
    if section.is_none() && before_thread_id.is_some() {
        return Err(anyhow::anyhow!(
            "before thread cannot be specified without a section"
        ));
    }

    let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
    let thread_id = thread_id.to_string();
    let current_section = sqlx::query_scalar::<_, Option<String>>(
        "SELECT thread_section_id FROM threads WHERE id = ?",
    )
    .bind(&thread_id)
    .fetch_optional(&mut *tx)
    .await?;
    let Some(current_section) = current_section else {
        return Ok(false);
    };
    let Some(section) = section else {
        sqlx::query(
            "UPDATE threads SET thread_section_id = NULL, section_position = NULL, section_entered_at_ms = NULL WHERE id = ?",
        )
        .bind(&thread_id)
        .execute(&mut *tx)
        .await?;
        tx.commit().await?;
        return Ok(true);
    };

    if sqlx::query_scalar::<_, i64>("SELECT 1 FROM thread_sections WHERE id = ?")
        .bind(section)
        .fetch_optional(&mut *tx)
        .await?
        .is_none()
    {
        return Err(anyhow::anyhow!("section {section} does not exist"));
    }

    let before_thread_id = before_thread_id.map(|id| id.to_string());
    if before_thread_id.as_deref() == Some(thread_id.as_str()) {
        return Err(anyhow::anyhow!(
            "thread {thread_id} cannot be moved before itself"
        ));
    }

    if let Some(before_thread_id) = before_thread_id.as_deref() {
        let before_section = sqlx::query_scalar::<_, Option<String>>(
            "SELECT thread_section_id FROM threads WHERE id = ?",
        )
        .bind(before_thread_id)
        .fetch_optional(&mut *tx)
        .await?;
        if before_section.flatten().as_deref() != Some(section) {
            return Err(anyhow::anyhow!(
                "before thread {before_thread_id} is not in section {section}"
            ));
        }
    }

    let position =
        section_move_position(&mut tx, section, &thread_id, before_thread_id.as_deref())
            .await?;
    if current_section.as_deref() == Some(section) {
        sqlx::query("UPDATE threads SET section_position = ? WHERE id = ?")
            .bind(position)
            .bind(&thread_id)
            .execute(&mut *tx)
            .await?;
    } else {
        sqlx::query(
            "UPDATE threads SET thread_section_id = ?, section_position = ?, section_entered_at_ms = ? WHERE id = ?",
        )
        .bind(section)
        .bind(position)
        .bind(Utc::now().timestamp_millis())
        .bind(&thread_id)
        .execute(&mut *tx)
        .await?;
    }
    tx.commit().await?;
    Ok(true)
}

BEGIN IMMEDIATE 在计算新位置前建立写事务,避免两个移动同时根据同一空隙作出决定。未知 Thread 返回 false;锚点等于自己或在其他组则失败。同组仅更新 section_position,跨组才同时写组 ID 与新的进入时间;移出则清空三个字段。

这些更新没有把 Thread 的 recency 当作手工顺序。用户将一条旧对话拖到组首,之后另一条对话产生新 Turn,不应自动改变这个显式位置关系。

列表消费者必须显式选用位置排序。

源码文件:codex-rs/app-server/src/request_processors/thread_processor.rs

相关函数/类型:thread_list_response_inner 排序键与默认方向;行号:2509-2520。

rust
let store_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) {
    ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt,
    ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt,
    ThreadSortKey::RecencyAt => StoreThreadSortKey::RecencyAt,
    ThreadSortKey::SectionPosition => StoreThreadSortKey::SectionPosition,
};
let sort_direction = sort_direction.unwrap_or(match store_sort_key {
    StoreThreadSortKey::SectionPosition => SortDirection::Asc,
    StoreThreadSortKey::CreatedAt
    | StoreThreadSortKey::UpdatedAt
    | StoreThreadSortKey::RecencyAt => SortDirection::Desc,
});

sortKey=SectionPosition 默认升序,其余键默认降序;完全不传 sortKey 仍是 CreatedAt。把 Thread 移进 section,并不意味着所有列表请求自动改为手工次序。SQL 层据此选择 threads.section_position,返回的列表顺序才消费到前面保存的 rank。

6.3 空隙分配 ​

位置使用 i64,初始间隔常量 SECTION_POSITION_GAP 为 1,000,000。

源码文件:codex-rs/state/src/runtime/thread_section_order.rs

相关函数/类型:section_move_position;行号:193-253。

rust
async fn section_move_position(
    tx: &mut sqlx::Transaction<'_, Sqlite>,
    section: &str,
    thread_id: &str,
    before_thread_id: Option<&str>,
) -> anyhow::Result<i64> {
    let mut renumbered = false;
    loop {
        let position = if let Some(before_thread_id) = before_thread_id {
            let upper = sqlx::query_scalar::<_, Option<i64>>(
                "SELECT section_position FROM threads WHERE id = ? AND thread_section_id = ?",
            )
            .bind(before_thread_id)
            .bind(section)
            .fetch_optional(&mut **tx)
            .await?
            .flatten()
            .ok_or_else(|| {
                anyhow::anyhow!("before thread {before_thread_id} is not in section {section}")
            })?;
            let lower = sqlx::query_scalar::<_, Option<i64>>(
                "SELECT MAX(section_position) FROM threads WHERE thread_section_id = ? AND section_position < ? AND id <> ?",
            )
            .bind(section)
            .bind(upper)
            .bind(thread_id)
            .fetch_one(&mut **tx)
            .await?;
            match lower {
                Some(lower) if i128::from(upper) - i128::from(lower) > 1 => Some(i64::try_from(
                    i128::from(lower) + (i128::from(upper) - i128::from(lower)) / 2,
                )?),
                Some(_) => None,
                None if upper > 1 => Some(upper / 2),
                None => None,
            }
        } else {
            let max_position = sqlx::query_scalar::<_, Option<i64>>(
                "SELECT MAX(section_position) FROM threads WHERE thread_section_id = ? AND id <> ?",
            )
            .bind(section)
            .bind(thread_id)
            .fetch_one(&mut **tx)
            .await?;
            max_position
                .unwrap_or_default()
                .checked_add(SECTION_POSITION_GAP)
        };

        if let Some(position) = position {
            return Ok(position);
        }
        if renumbered {
            return Err(anyhow::anyhow!(
                "section {section} has no remaining thread positions"
            ));
        }
        renumber_section_positions(tx, section, Some(thread_id)).await?;
        renumbered = true;
    }
}

插入锚点前,先找严格小于 upper 且排除当前 Thread 的最大 lower。有足够空隙就取整数中点;在最前面可取 upper 的一半;追加末尾则使用 checked_add(GAP)。差值计算先提升到 i128,避免 upper−lower 在 i64 中溢出。没有空间时只允许重排一次,再无空间则明确报错。

下面用真实算法中的位置说明重排触发点。

图中的循环只在当前事务内重算,不是发生冲突后重新发一次 API。被移动 Thread 不参与邻居 rank 重排,避免拿自身旧位置堵住插入空隙。

源码文件:codex-rs/state/src/runtime/thread_section_order.rs

相关函数/类型:renumber_section_positions;行号:255-280。

rust
async fn renumber_section_positions(
    tx: &mut sqlx::Transaction<'_, Sqlite>,
    section: &str,
    excluded_thread_id: Option<&str>,
) -> anyhow::Result<()> {
    sqlx::query(
        r#"
UPDATE threads
SET section_position = ranked.position
FROM (
    SELECT id,
           ROW_NUMBER() OVER (ORDER BY section_position ASC, id ASC) * ? AS position
    FROM threads
    WHERE thread_section_id = ? AND (? IS NULL OR id <> ?)
) AS ranked
WHERE threads.id = ranked.id
        "#,
    )
    .bind(SECTION_POSITION_GAP)
    .bind(section)
    .bind(excluded_thread_id)
    .bind(excluded_thread_id)
    .execute(&mut **tx)
    .await?;
    Ok(())
}

ROW_NUMBER() 按原 position、ID 排序,再乘 GAP 恢复稀疏空间。ID 是并列位置的稳定次序,避免同样数据每次重排出现不同结果。正常移动只分配一个位置;空间耗尽时需要更新该 section 的多个成员,不能承诺所有拖动成本都与分组大小无关。

测试先得到一百万、两百万、三百万位置;移动第三项到第二项前得到一百五十万,进入时间保持不变。随后人工把三个位置压成 1、2、3,强制走重排。

源码文件:codex-rs/state/src/runtime/thread_section_order_tests.rs

相关函数/类型:section_moves_preserve_entry_order_and_renumber_exhausted_ranks;行号:483-495,504-510,530-561。

rust
assert_eq!(
    initial
        .iter()
        .map(|thread| thread.section_position)
        .collect::<Vec<_>>(),
    vec![Some(1_000_000), Some(2_000_000), Some(3_000_000)]
);
assert!(
    initial
        .iter()
        .all(|thread| thread.section_entered_at.is_some())
);
let original_entered_at = initial[2].section_entered_at;
// ...
runtime
    .move_thread_to_section(third, Some(CUSTOM_THREAD_SECTION_ID), Some(second))
    .await
    .unwrap();
let moved = runtime.get_thread(third).await.unwrap().unwrap();
assert_eq!(moved.section_position, Some(1_500_000));
assert_eq!(moved.section_entered_at, original_entered_at);
// ...
for (thread_id, position) in [(first, 1_i64), (second, 2), (third, 3)] {
    sqlx::query("UPDATE threads SET section_position = ? WHERE id = ?")
        .bind(position)
        .bind(thread_id.to_string())
        .execute(runtime.pool.as_ref())
        .await
        .unwrap();
}
runtime
    .move_thread_to_section(third, Some(CUSTOM_THREAD_SECTION_ID), Some(second))
    .await
    .unwrap();
let reordered = sqlx::query_scalar::<_, String>(
    "SELECT id FROM threads WHERE thread_section_id = ? ORDER BY section_position, id",
)
.bind(CUSTOM_THREAD_SECTION_ID)
.fetch_all(runtime.pool.as_ref())
.await
.unwrap();
assert_eq!(
    reordered,
    vec![first.to_string(), third.to_string(), second.to_string()]
);
assert_eq!(
    runtime
        .get_thread(third)
        .await
        .unwrap()
        .unwrap()
        .section_position,
    Some(1_500_000)
);

最后 SQL 查询必须得到 first、third、second,third 重新位于 1,500,000。这个反例既检验最终次序,又确保算法确实经历没有空隙的分支。另有并发移动测试约束位置唯一性,跨重启集成测试约束服务端持久顺序;单测中的整数结果不应由客户端自行复制为另一个排序实现。

7. 分组更新与移除 ​

7.1 稳定 ID ​

重命名 section 不替换其 ID;内建 Pinned 有额外限制。

源码文件:codex-rs/app-server/src/request_processors/thread_sections.rs

相关函数/类型:thread_section_update;行号:89-131。

rust
pub(crate) async fn thread_section_update(
    &self,
    params: ThreadSectionUpdateParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
    const OPERATION: &str = "threadSection/update";
    self.ensure_thread_sections_supported(OPERATION)?;
    let name = params.name.trim();
    if name.is_empty() {
        return Err(invalid_params("section name must not be empty"));
    }
    if params.section_id.trim().is_empty() {
        return Err(invalid_params("sectionId must not be empty"));
    }
    if params.section_id == PINNED_THREAD_SECTION_ID {
        return Err(invalid_params(
            "the built-in pinned section cannot be renamed",
        ));
    }
    if let Some(Some(appearance)) = params.appearance.as_ref() {
        validate_thread_section_appearance(appearance)?;
    }
    let section = self
        .thread_store
        .rename_thread_section(StoreRenameThreadSectionParams {
            section_id: params.section_id.clone(),
            name: name.to_string(),
            appearance: params
                .appearance
                .map(|appearance| appearance.map(state_thread_section_appearance)),
        })
        .await
        .map_err(|err| thread_section_store_error(OPERATION, err))?
        .ok_or_else(|| {
            invalid_params(format!("thread section not found: {}", params.section_id))
        })?;

    Ok(Some(
        ThreadSectionUpdateResponse {
            section: api_thread_section(section),
        }
        .into(),
    ))
}

请求需要非空名称和 ID,Pinned 不能被重命名。appearance 使用双层 Option:省略保留、null 清空、对象替换。只有提供的非空对象进入值校验。

源码文件:codex-rs/app-server/src/request_processors/thread_sections.rs

相关函数/类型:validate_thread_section_appearance;行号:176-190。

rust
fn validate_thread_section_appearance(
    appearance: &ThreadSectionAppearance,
) -> Result<(), JSONRPCErrorError> {
    for (field, value) in [
        ("icon", appearance.icon.as_ref()),
        ("color", appearance.color.as_ref()),
    ] {
        if value.is_some_and(|value| value.len() > MAX_THREAD_SECTION_APPEARANCE_FIELD_BYTES) {
            return Err(invalid_params(format!(
                "section appearance {field} must not exceed {MAX_THREAD_SECTION_APPEARANCE_FIELD_BYTES} bytes"
            )));
        }
    }
    Ok(())
}

限制是每个 appearance 字段最多 64 字节,不是 64 个 Unicode 字符,也没有在此枚举允许的图标或颜色名称。supports_thread_sections 为 false 时,处理器返回方法不可用;这项功能依赖后端能力,不能用前端隐藏分组代替服务端校验。

7.2 删除分组 ​

删除 section 与Thread归档与删除中的删除 Thread 完全不同。

源码文件:codex-rs/state/migrations/0045_threads_section.sql

相关函数/类型:thread_sections 外键;行号:1-10。

sql
CREATE TABLE thread_sections (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL
);

INSERT INTO thread_sections (id, name)
VALUES ('01984de2-8f74-7c91-a3b2-5c5e937cf318', 'Pinned');

ALTER TABLE threads ADD COLUMN thread_section_id TEXT
    REFERENCES thread_sections(id) ON DELETE SET NULL;

外键使用 ON DELETE SET NULL,删除组不级联删除成员对话。Pinned 在初始化迁移中以固定 ID 存在,应用层和 StateRuntime 均拒绝删除它。

源码文件:codex-rs/state/src/runtime/thread_sections.rs

相关函数/类型:delete_thread_section;行号:66-88。

rust
pub async fn delete_thread_section(&self, id: &str) -> anyhow::Result<bool> {
    if id == PINNED_THREAD_SECTION_ID {
        anyhow::bail!("built-in pinned thread section cannot be deleted");
    }

    let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
    sqlx::query(
        "UPDATE threads SET section_position = NULL, section_entered_at_ms = NULL WHERE thread_section_id = ?",
    )
    .bind(id)
    .execute(&mut *tx)
    .await?;

    let deleted = sqlx::query("DELETE FROM thread_sections WHERE id = ?")
        .bind(id)
        .execute(&mut *tx)
        .await?
        .rows_affected()
        > 0;
    tx.commit().await?;

    Ok(deleted)
}

事务先清理成员 position 与 entered_at,再删除组行,外键负责置空 section ID。源码没有附加 archived=false 条件,所以归档成员也会解除分组;测试 deleting_custom_sections_unassigns_active_and_archived_members 特意覆盖两类成员。

下面按字段观察两个操作的不同影响。

section 的消失只改变列表组织方式,没有关闭 Core 运行对象或清除对话历史。调查“分组删除后找不到对话”时,应先取消列表的 section 过滤器,再确认 Thread 是否真的不存在。

8. 展示故障的定位 ​

把以下现象分别落到生产者、权威存储和消费者,不要先通过重复改名修复表面症状。

现象应追踪的条件关键位置
改名后 preview 不变preview 与 name 是不同生产链observe_user_message、名称 patch
名称与首条文字相同后不显示 nameLegacy 的 distinct 与重复预览过滤thread_name_from_metadata、set_thread_name
索引追加失败但 RPC 成功是否 Paginated、SQLite 是否已写成功update_thread_metadata 分流
一个查询看到旧名称,另一个没有单读/批读、SQLite 权威及空索引项差异resolve_thread_names、find_thread_names_by_ids
新 Turn 没把分组条目顶到最前当前使用手工位置而非 recencysection_position 与列表排序键
拖动失败锚点自己、跨组、未知 section 或无剩余位置move_thread_to_section

以上逻辑不按操作系统改变字段优先级;本地索引与 SQLite 的可用性、路径权限和 I/O 失败仍由运行环境决定。跨进程索引写入、损坏文件恢复和后端更换不能仅凭本机集成测试作出全局承诺。

可在 Codex 仓库根目录运行以下测试,所有写入都在测试临时目录中发生。

bash
just test --locked -p codex-app-server --test all \
  -E 'test(suite::v2::thread_name_websocket::) or test(suite::v2::thread_sections::) or test(suite::v2::thread_metadata_update::) or test(paginated_thread_name_set_is_reflected_in_read_list_and_metadata_resume)'
just test --locked -p codex-thread-store --lib \
  -E 'test(local::update_thread_metadata::tests)'
just test --locked -p codex-state --lib \
  -E 'test(thread_section_order::tests) or test(thread_sections::tests)'
rg -n 'thread_set_name_response_inner|summary_from_stored_thread' \
  codex-rs/app-server/src/request_processors/thread_processor.rs
rg -n 'section_move_position|renumber_section_positions' \
  codex-rs/state/src/runtime/thread_section_order.rs

尝试解释两个场景:先用纯图片建立对话,随后写入文字并设置显式名称,判断三个文字字段各自来源;再将三个 section 位置压成 1、2、3,把第三项移到第二项前,说明事务内何时重排、哪些时间字段保持不变。能够从输入走到 Store 再走到查询结果,才算区分了“标题写错”“摘要未变”和“排序键不同”。