Skip to content

fix(codex): 在工作区会话列表中使用 Codex 的真实会话标题 - #468

Open
RainyCoder wants to merge 7 commits into
xintaofei:mainfrom
RainyCoder:codex/fix-codex-session-title
Open

fix(codex): 在工作区会话列表中使用 Codex 的真实会话标题#468
RainyCoder wants to merge 7 commits into
xintaofei:mainfrom
RainyCoder:codex/fix-codex-session-title

Conversation

@RainyCoder

Copy link
Copy Markdown

修改概述

$CODEX_HOME/session_index.jsonl 读取 Codex 的真实会话标题,
不再直接将 rollout 中的第一条用户消息作为会话标题。

修改后,工作区侧边栏、会话详情与 Codex 客户端显示的标题保持一致。

问题原因

Codex 将会话正文和会话标题分别存储在不同位置:

  • 会话正文:$CODEX_HOME/sessions/
  • 会话标题:$CODEX_HOME/session_index.jsonl

原有实现只解析了 rollout 会话正文,因此会将第一条用户消息作为标题。
该标题可能与 Codex 实际生成并显示的会话标题不同。

此外,仅修改解析器并不能解决工作区侧边栏的问题,因为侧边栏读取的是数据库中
持久化的会话记录,而不是直接使用解析器返回的会话摘要。

主要修改

  • 读取 session_index.jsonl 中的 thread_name
  • 索引中的真实标题优先于 rollout 推导出的回退标题。
  • 会话列表和会话详情使用一致的标题优先级。
  • 工作区查询列表前,将 Codex 标题同步到数据库中的未锁定会话。
  • 继续通过 title_locked 保护用户手动修改的标题,避免被自动覆盖。
  • 仅同步标题时不修改会话的 updated_at
  • 索引文件缺失、不可读或存在损坏记录时,继续使用原有 rollout 标题作为回退。

测试情况

  • 验证会话列表和详情均优先使用索引标题。
  • 验证 rollout 摘要命中缓存后,索引标题更新仍能立即生效。
  • 验证索引缺失、不可读或记录损坏时仍能正常加载会话。
  • 验证工作区列表及搜索能够立即使用同步后的标题。
  • 验证用户手动锁定的标题不会被覆盖。
  • 验证标题同步不会改变会话活动时间。
  • Codex parser 测试:92 个通过,1 个忽略。
  • 工作区列表定向测试:4 个通过。
  • 数据库标题同步测试:通过。
  • Server/MCP cargo check:通过。
  • Server/MCP Clippy(-D warnings):通过。

@xintaofei

Copy link
Copy Markdown
Owner

感谢这个 PR 👍 问题定位得很准 —— 侧边栏读的是 DB 持久化行而不是解析器返回值,所以「只改 parser 解决不了侧边栏」这个判断是对的,这也是这类问题最容易踩空的地方。

我把分支拉下来完整跑了一遍验证,全绿

检查 结果
cargo test --features test-utils(全量) 2578 passed / 0 failed / 1 ignored
cargo clippy --all-targets --features test-utils -- -D warnings clean
cargo test --no-default-features --bin codeg-server --lib ok
server / codeg-mcp clippy clean

总体判断:方向合理,parser 那一半做得挺扎实,但建议处理掉下面 2 个问题再合并。


做得好的地方

  • 容错:文件缺失 / 不可读 / 单行坏 JSON / 空 thread_name 全部安全回退到 rollout 标题。测试里用「在 index 路径上建一个目录」来构造不可读,比 chmod 跨平台可靠,这个细节很赞。
  • title_locked 保护完整:候选 SELECT 过滤 + refresh_auto_title 里数据库层原子重校验,手动改名不会被覆盖。
  • 不动 updated_at:符合仓库「标题回填是元数据不是活动」的既有约定,而且有测试钉住。
  • truncate_str(name, 100)title_from_user_text 的上限一致。
  • 同步早于过滤的顺序是对的,list_all_conversations_core_syncs_codex_index_title_before_search 把「同一次调用内搜索即可命中」钉住了。

数据源我也单独核了一遍:~/.codex/session_index.jsonl 确实是 {"id","thread_name","updated_at"} 这个形状,本机 25 条记录的 id 与 rollout 文件 1:1 对得上,codex-cli 0.141.0 二进制里也仍然有 session_index.jsonlthread_name 字样。


建议合并前处理

1. 标题可能写到「换了身份」的那一行上(数据一致性)

refresh_codex_auto_titles 是按 external_id 选行的(conversation_service.rs:233),但真正的 UPDATE 走 refresh_auto_title,只按 id + title_locked=false + 标题不同来匹配(conversation_service.rs:196),WHERE 里没有 external_id。中间这段窗口里:

  1. 候选读到行 R,此时 external_id = S1
  2. 分叉把 R 重新指向 S2 —— manager.rs:1674 就是显式改写 external_id 的地方
  3. UPDATE 把 titles[S1] 写到了已经代表 S2 的 R 上

title_locked 在这里挡不住:manager.rs 自己的注释写了「A titleless row writes no title here and stays unlocked」,无标题的分叉行是不加锁的。

同一个缺口还让「并发软删之后仍然写标题」成为可能(UPDATE 里也没重查 deleted_at),虽然不会复活行,但和函数注释里声明的 live-row 范围对不上。

建议:把 external_id(和 deleted_at IS NULL)一起带进 UPDATE 的 WHERE,最好再对候选读到的 title 做一次 CAS。

2. 尽力而为的标题同步不应该能让整个会话列表挂掉

spawn_blocking 的 JoinError(conversations.rs:51)和 refresh_codex_auto_titles 的 DbError(conversations.rs:79)现在都是直接 ? 抛出去的。一旦写库失败(争用超过 busy_timeout=5000、磁盘 IO 错误、库变只读),本来能正常返回的读请求就整个失败了:

  • app-workspace-store.tsrefreshConversations 走 catch → conversationsError → 侧边栏变错误态
  • 搜索对话框走 catch { setResults([]) } → 表现成「一条会话都搜不到」

仓库里其实已经把相反的约定写死了 —— import_service.rs:327"A row error is logged and skipped: a best-effort refresh must never fail the scan it rides along with."conversations.rs:1439 也是这么做的。

建议:log + 继续,别影响 list 的返回。


建议改(不阻塞,但很划算)

3. 写了库但没广播,绕过了两个下游同步

refresh_auto_title 的注释明确写了 "Returns true when a row was written so the caller can broadcast a sidebar upsert",但新调用方把返回值丢掉了。对比同类路径 conversations.rs:1420 附近,那边改完标题会 emit_conversation_upsert + chat_channel_manager.sync_conversation_title,这里两个都没有:

  • 多窗口 / 多 Web 客户端:只有发起这次 list 的客户端看到新标题
  • Telegram 论坛话题标题会和会话标题漂移 —— 而且因为详情路径有「标题相同就短路」的前置判断,一旦 list 先把新标题写进库,详情路径就再也不会触发那次 sync_conversation_title漂移会变成永久的

另外 conversations.rs:704 的导入扫描路径也只 emit_conversation_upsert、没有 sync_conversation_title。现在 parser 会返回 index 标题了,这条路径也会先发现新标题,所以只修 list 这一处是不够的。

4. 候选查询可以走已有的唯一索引

conversation_service.rs:233 会把所有符合条件的 codex 行整行取出来(我本机 1425 行),而 titles 通常很小(本机 25 条)。表上已经有 UNIQUE INDEX idx_conversation_external_agent (external_id, agent_type),加个 external_id IN (...) 就是索引查找(map 大的时候分块,避开 SQLite 变量上限)。

单看 SQL 延迟这不算大事(实测差 2ms 左右,而且前端有 300ms 去抖)。更值得关注的是首次收敛时那一串逐条 autocommit UPDATE —— 如果一个用户有几百条 index 记录待收敛,那次侧边栏请求会明显卡一下。

5. index 标题 vs rollout 原生 thread_name_updated 的优先级建议补个测试

解析器其实早就处理了 rollout 里的原生 thread_name_updatedcodex.rs:217,最新非空者胜),并不是只会拿第一条用户消息 —— 我在本机 ~/.codex/sessions 里找到 15 个含该事件的 rollout,其中至少 019da588-... 同时还有 index 记录,两个来源是会共存的。

PR 的策略是 index 无条件覆盖(codex.rs:334/codex.rs:385),这个策略本身是明确声明过的(codex.rs:48 的注释说 transcript 是 fallback,测试也叫 session_index_title_wins_for_list_and_detail),所以不是「未定义」。只是:index 行里的 updated_at 解析了但没用上,两个来源冲突时的行为也没有测试钉住。补一个冲突用例把这个策略固化下来就够了,不一定要改成比时间戳。


记录一下,不用改

  • 同步范围比它服务的查询宽list_all 会排除 kind='loop'、默认排除 parent_id IS NOT NULL,还会按 folder / agent / status 过滤;refresh_codex_auto_titles 一个都不带,会去改列表根本不显示的行。如果是有意做全局对账,加句注释说明就好。
  • 收益面可能比预期窄:我本机 2470 个 rollout 只对应 25 条 index 记录,且该文件最后写入停在 7/24,而会话一直持续到 8/15;当前 codex 把 thread 状态放在 $CODEX_HOME/state_5.sqlitethreads 表里。看起来 thread_name 只有在 thread 真的被命名时才会写,ACP/无头方式跑的会话基本不产生。这只是我一台机器的观察,不能代表所有安装,但如果你手上也是类似情况,第 4 点的性价比值得再掂量一下。(不建议在这个 PR 里去读 state_5.sqlite,那是别人的库,还要处理 WAL 和锁。)
  • Windows 下 base_dir.parent() 的行为没问题;server/Docker 模式下没有 ~/.codex 也是安全的(空 map 直接早返回);详情页每次重读一遍 index 相比遍历 rollout 目录可以忽略。

建议补的测试

  • 注入一次标题刷新 UPDATE 失败,断言 list 仍然正常返回
  • 从 list 和导入扫描两条发现路径分别断言 upsert 广播 + chat channel 同步
  • 候选选中之后、UPDATE 之前 external_id 发生变化的并发用例
  • rollout 原生标题与 index 标题冲突时的优先级用例

前两个问题处理掉之后我觉得就可以合了,其余的按你的判断来 🙏

@RainyCoder

Copy link
Copy Markdown
Author

按照要求又进行了一次修复补充

@xintaofei

Copy link
Copy Markdown
Owner

又看了一遍 e1d20c88五项全部落地了,而且做得比我建议的还细 👏 特别是那几个针对性测试,质量很高。

先放验证结果(在最新 HEAD 8a6185f9 上跑的):

检查 结果
cargo test --features test-utils(全量) 2662 passed / 0 failed / 1 ignored
cargo clippy --all-targets --features test-utils -- -D warnings clean
server / codeg-mcp test + clippy clean
PR CI(7 个 job) 全绿

上一轮的问题逐条确认

状态 说明
B1 写错行 ✅ 已解决 refresh_codex_auto_title_candidateconversation_service.rs:293)现在是真正的 CAS:UPDATE 里重查 id + agent_type + external_id + deleted_at IS NULL + title_locked=false + 候选读到的那个旧标题,而且旧标题为 None 时正确走 IS NULL 分支。四个测试分别钉住每一项重查
B2 拖垮列表 ✅ 已解决 refresh_codex_auto_titles 改成返回 Vec<i32> 而不是 Result;chunk 级和行级失败都 warn! + 跳过;JoinError 降级成空 map(conversations.rs:53)。list_all_conversations_core_returns_persisted_rows_when_title_sync_fails 用 SQLite BEFORE UPDATE ... RAISE(FAIL) 触发器注入真实写失败——这是货真价实的故障注入,不是打桩 👍
S1 不广播 ✅ 已解决 notify_conversation_title_updatesconversations.rs:1597)接到了 list(:87)和导入扫描(:733)两条路径,web handler 和 scan_importable_sessions_core 也都把 &ChatChannelManager 串进去了
S2 全量扫描 ✅ 已解决 ExternalId.is_in(chunk) + 500 分块,走上了 idx_conversation_external_agent
B3 优先级 ✅ 已解决 session_index_title_wins_over_rollout_thread_name_update 往 rollout 里追加了真实的 thread_name_updated 事件,把「index 胜出」这个策略钉死了

这轮修复引入的两个新问题

1. Telegram 网络调用现在跑在列表读路径里

notify_conversation_title_updatesconversations.rs:1603)是串行 await sync_conversation_title_to_channels_core 的,一路走到 ChatChannelManager::sync_conversation_titlechat_channel/manager.rs:212),每个启用了 title_sync_enabled 的 Telegram 论坛绑定都会 await 一次 editForumTopic HTTP POST。而 reqwest client 是 connect_timeout(10s) + timeout(60s)chat_channel/backends/telegram.rs:30-32)。

也就是说 list_all_conversations —— 侧边栏的主读路径,还被搜索框/管理弹窗以 300ms 去抖逐键调用 —— 现在可能阻塞在外部网络 I/O 上,上限约 60s ×「符合条件的绑定数」(是按绑定算,不是按会话算),首次收敛时全部串行。这个 PR 之前这条路径是纯 DB 的。

好消息是它自限:只有真正改了标题的行才会走,收敛后归零,而且只影响配了 Telegram 桥的用户。但 Telegram 慢或不可达时侧边栏会跟着卡住。

建议:通知是纯副作用,list 的返回值并不依赖它,丢到后台任务即可。ChatChannelManager 已经有 clone_ref()chat_channel/manager.rs:54),不需要额外加 Clone

2. 软删文件夹里的会话会被广播进侧边栏

候选查询(conversation_service.rs:350)只看会话自己的 deleted_at,不看所属 folder 的。而 list_allfolder_ids 为空时会显式排除软删文件夹下的会话(conversation_service.rs:729),前端 applyConversationUpsertapp-workspace-store.ts:296)也只挡 parent_id != null

于是:软删文件夹里的 codex 会话如果命中了 index 标题,会被刷新 → 广播 upsert → 插进所有客户端的侧边栏,直到下次重新拉取才消失。folder 软删是真实操作(folder_service.rs:392)。

修复前因为压根不广播,所以不存在这个泄漏,是这轮加广播引入的。加个 folder 存活过滤就好。


一个既有 bug,不该算在这个 PR 头上

评审时发现:内嵌桌面 HTTP 的 AppState 用的是 default_chat_channel_manager() 新建的空 managerweb/mod.rs:788),而不是 Tauri 注册并启动了后台任务的那一个(lib.rs:211)。空 manager 没有任何 channel,所以 edit_thread_title 直接返回 NotFound —— 从浏览器/远程客户端连桌面版 codeg 时,Telegram 标题同步是不生效的。

不过这个在 main 上就已经存在update_conversation_title 这个 web handler 早就在用 state.chat_channel_manager 做标题同步,web/mod.rs:788 那行在 main 上也是一样的。所以这个 PR 只是让一条已经坏掉的路径多了个入口,不是它引入的。有意思的是同一个 struct literal 里 workspace_transferpet_state 都是用 app.state() 从 Tauri 那边取的,就 chat_channel_manager 是新建的 —— 看着像当初漏了。建议单开 issue 修


其余记录一下,都不阻塞

  • 逐行 UPDATE 仍然是串行 autocommit(conversation_service.rs:370),首次收敛量大时会有一下停顿。索引那半已经解决了,这半按需再说
  • 每个刷新行 get_by_id 会查两次 —— emit_conversation_upsert 一次,sync_conversation_title_to_channels_coreconversations.rs:2046)又一次
  • import_local_conversations_core 仍然只发 upsert、不同步频道标题。这是既有的,但现在跟刚修好的另外两条路径不一致了
  • 候选查询仍不过滤 kind='loop'。我确认了一下 ConversationKind::Loop 目前只在测试里写入(conversation_service.rs:1857mod tests:804 开始),生产上不可达,所以只是潜在项。委派子会话那边前端处理是对的(applyConversationUpsertparent_id != null 直接早返回)

上面两个新问题里,第 2 个(软删文件夹泄漏)我建议合并前顺手带上,改动很小;第 1 个(内联网络 I/O)看你怎么权衡 —— 如果你觉得 Telegram 桥的用户面够窄,也可以先合了再单独优化。剩下的都是记录性质。

整体质量很高,尤其是故障注入和四个 CAS 竞态测试,辛苦了 🙏

Follow-up hardening for the Codex session-title sync.

Detach chat-channel propagation from the caller. `notify_conversation_title_updates`
awaited `sync_conversation_title_to_channels_core` inline, which ends in a Telegram
`editForumTopic` POST with a 60s timeout per bound thread. `list_all_conversations`
is the sidebar's primary read and is driven per-keystroke behind a 300ms debounce by
the search and manage dialogs, so a slow Telegram could hang it. The sidebar upsert
still emits inline (DB-only, and the caller's response must not disagree with what
other clients were just told); the channel half moves to a detached task.

Because a detached edit can land arbitrarily late, `sync_conversation_title_until_current`
re-reads the title before every attempt and re-sends if it moved underneath. The loop
is uncapped by design: it exits only when the value just read equals the one last sent,
so any exit leaves the provider holding the current title, and any fixed cap would exit
stale on a long enough run of mid-flight renames. It cannot spin — an iteration happens
only when a new title was observed. Serializing per conversation was rejected because
the lock would also be taken by the inline rename path, blocking a user's rename behind
a stalled background sync.

Restrict refresh candidates to what the list can actually show. The candidate query
checked only the conversation's own `deleted_at`, while `list_all` also excludes
`kind = 'loop'` and conversations in soft-deleted folders. Since every refreshed id is
broadcast as an upsert, and the frontend rejects only `parent_id != null`, a refreshed
row in a soft-deleted folder was pushed into every client's sidebar until its next
refetch. The write-time CAS gained the same folder-alive guard; `kind` stays out of it
(written once at insert, never updated).

Share the Tauri-managed `ChatChannelManager` with the embedded desktop HTTP server,
which built its `AppState` with a fresh one. A fresh manager has an empty channel
registry, so every handler renaming a bound thread failed with `NotFound` and never
retried. This predates the title-sync work but the new entry points ride the same path.

Also give `import_local_conversations_core` the channel propagation the scan and list
paths already have.

Tests: detachment proven with a semaphore-gated fake backend (upsert already emitted,
no edit attempted while parked); convergence proven with a hook that applies a queued
rename during each in-flight edit, asserting the provider's last edit equals the row's
current title across five consecutive mid-flight renames; candidate scope, the
folder-deletion CAS, and the previously untested `title IS NULL` CAS branch covered.
@xintaofei

Copy link
Copy Markdown
Owner

直接把上一轮提到的问题在 c416920 里改掉了,就不留着让你再跑一趟了 🙏 只动了 4 个文件,你的实现逻辑一行没改,全是围绕通知路径和候选范围。

改了什么

1. Telegram 调用挪出列表读路径

notify_conversation_title_updates 现在只把侧边栏 upsert 留在 inline(纯 DB,而且这次请求要返回的内容不能和刚广播给别人的不一致),频道同步整个丢到 tokio::spawn 的后台任务里。list_all_conversations 再也不会等 editForumTopic 了。

2. 后台任务改成「收敛到当前标题」而不是带着快照跑

这是改的过程中 codex review 抓出来的一个我自己没想到的洞:任务如果带着 spawn 时的标题快照跑,而这次 edit 在网络上卡住了,期间用户手动改名成功,那卡住的那次晚一步落地就会把 Telegram 和 display_title 写回旧标题,而且之后没有任何东西会重试

所以 sync_conversation_title_until_current 每次发送前都重新读一次标题,发完再读一次确认;变了就再发。循环故意不设次数上限 —— 只有「刚读到的 == 上次发出去的」才会退出,所以退出时 provider 手上一定是当前标题;任何固定上限(我一开始写的是 3)在连续改名够多的时候都会正好卡在上限退出、留下旧标题。它也不会空转:只有观察到新标题才会多跑一轮,而每轮都被一次网络往返限速。

没用「按会话加锁串行」是因为那把锁手动改名那条 inline 路径(sync_conversation_title_to_channels_core)也得拿,结果就是用户改个名要在一个卡住的后台同步后面等最多 60 秒 —— 等于把刚拆掉的卡顿换个地方装回来。

3. 候选范围对齐 list_all 的可见性

候选查询加了 kind != 'loop' 和 folder 存活子查询;写入时的 CAS 也加了同一个 folder 守卫,这样 folder 在读和写之间被软删也挡得住。kind 没进 CAS —— 它 insert 时写一次之后再不更新,候选查询的过滤不会失效。

4. 内嵌桌面 HTTP 的空 manager(上轮说的那个既有 bug)

web/mod.rs 改成复用 Tauri 注册并启动的那个 ChatChannelManagertry_state(...).clone_ref(),跟旁边 workspace_transfer/pet_state 一个写法),不再新建一个空的。顺手也给 import_local_conversations_core 补上了 scan/list 已经有的频道同步。

注:每行刷新会查两次 get_by_id 这个小问题我本来顺手优化掉了,但第 2 点要求后台任务必须重新读,所以又还原了 —— 现在这次重复读是故意且必要的,注释里写清楚了。

测试

  • notify_conversation_title_updates_detaches_channel_sync_from_the_caller —— 用信号量把假后端的 edit_thread_title 卡住,断言此时 upsert 已经在线上、而 edit 一次都没发起
  • detached_title_sync_converges_after_a_run_of_mid_flight_renames —— 假后端每次 edit 都会在返回前触发下一次排队的改名(确定性复现「改名恰好在 edit 飞行途中落地」,不用 sleep),连续 5 次,断言 provider 最后一次 edit 等于该行的当前标题
  • detached_title_sync_converges_on_a_rename_that_lands_mid_flight —— 单次改名的版本,另外断言 binding 的 display_title
  • refresh_codex_auto_titles_skips_rows_the_sidebar_list_hides —— 活 folder / 软删 folder / loop 三行,只有第一行进 refreshed
  • refresh_codex_auto_title_candidate_rechecks_folder_deletion_at_write_time
  • refresh_codex_auto_title_candidate_adopts_a_title_over_null —— 补上之前没覆盖的 title IS NULL CAS 分支
  • scan_importable_sessions_syncs_title_and_notifies_clients —— 频道断言改走有界等待(那半现在是 detached 的)

两个关键测试我都做了变异验证,确认不是摆设:把收敛循环换成单次发送,第一个失败;把上限还原成 for _ in 0..3,第二个失败在 left: Some("#1 title 3"), right: Some("#1 title 4")(provider 比行落后一次改名)。

验证

检查 结果
cargo test --features test-utils(全量) 2668 passed / 0 failed / 1 ignored
cargo clippy --all-targets --features test-utils -- -D warnings clean
server / codeg-mcp clippy clean

改动本身也过了一轮独立 review(两轮,第二轮就是上面第 2 点那个洞)。

你看下有没有哪里跟你原本的想法冲突,不合适的话随时改回去 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants