青雲的博客
拆开 Codex 第一部分 建立全局地图 第 01 章

不要从目录开始读 Codex

沿一次任务的真实生命周期建立 Codex 源码地图,分清协议、会话、工具、执行、持久化与界面的职责。

源码版本
rust-v0.144.6
验证日期
Commit
5d1fbf26c43abc65a203928b2e31561cb039e06d

打开 openai/codex 的 Rust workspace,最容易做的事是先看目录:protocol 管协议,core 跑 Agent,tui 画界面,app-server 对外提供 RPC。这个说法没有错,但它解释不了一个更实际的问题:用户输入一句话以后,究竟是谁创建了 turn,谁反复请求模型,工具结果又为什么能回到下一次请求里?

如果按 crate 顺序读,调用链会在边界处不断断掉。你会在 TUI 里看到提交,在 app-server 里看到 turn/start,在 core 里又遇到 SubmissionRegularTaskrun_turn。这些名字都像“一轮任务”,却不是同一个层次。更麻烦的是,模型一次返回工具调用时,当前 turn 并没有结束;一次用户可见的任务也通常不等于一次模型请求。

所以这本小册不从目录开始。先固定版本,再沿一个 task 的生命周期走一遍。crate 只在它真正接管控制权时出现。

先把版本钉住

本册只对应 openai/codexrust-v0.144.6 tag,验证 commit 是:

5d1fbf26c43abc65a203928b2e31561cb039e06d

后文所有行号都指向这个 commit,而不是会继续移动的 main。这不只是为了避免链接失效。Codex 的工具规划、统一命令执行和 app-server 事件协议都在持续变化;把不同版本的类型拼成一条调用链,很容易得到一份“每个名字都存在、整体却从未运行过”的架构图。

先准备一份读者自己拥有的固定 checkout。下面默认放在 home 目录;若你希望使用其他位置,先设置 CODEX_SOURCE_DIR

repo="${CODEX_SOURCE_DIR:-$HOME/codex-rust-v0.144.6}"
git clone --depth 1 --branch rust-v0.144.6 \
  https://github.com/openai/codex.git "$repo"
test "$(git -C "$repo" rev-parse HEAD)" = \
  5d1fbf26c43abc65a203928b2e31561cb039e06d
git -C "$repo" describe --tags --exact-match HEAD

第一次 clone 会访问网络并写入目标目录;后两条校验只读取 Git 对象。预期 test 静默通过,最后输出 rust-v0.144.6。如果目标目录已经存在,不要覆盖它,改用一个空目录或把 repo 指向你确认过的 checkout。

可复现实验:只找控制权交接点

下面的实验只依赖刚才的固定 checkout 和 Git,不再访问网络,也不写文件。它不是为了统计命中数,而是训练一种读法:每次只找“谁把控制权交给了谁”。

repo="${CODEX_SOURCE_DIR:-$HOME/codex-rust-v0.144.6}"

git -C "$repo" grep -n 'pub struct Submission' rust-v0.144.6 -- \
  codex-rs/protocol/src/protocol.rs
git -C "$repo" grep -n 'submission_loop' rust-v0.144.6 -- \
  codex-rs/core/src/session
git -C "$repo" grep -n 'RegularTask::new' rust-v0.144.6 -- \
  codex-rs/core/src/session codex-rs/core/src/tasks
git -C "$repo" grep -n 'run_turn(' rust-v0.144.6 -- \
  codex-rs/core/src/tasks/regular.rs codex-rs/core/src/session/turn.rs
git -C "$repo" grep -n 'record_conversation_items' rust-v0.144.6 -- \
  codex-rs/core/src/session/turn.rs codex-rs/core/src/session/mod.rs

预期能依次看到:协议类型、submission consumer、task 创建、turn loop、history/rollout 写入。若某次升级后锚点消失,先重新追调用链,不要只把旧行号改成新行号。

四个容易混用的词

先约定本册的术语,否则后面看到相同 id 时很容易把层级抹平。

本册中的含义
submission进入 core 提交队列的一条操作,包含唯一 id 和一个 Op。用户输入、打断、审批结果都可以是 submission。
turn一次 active task 占用的运行生命周期。普通 RegularTask 通常由用户输入触发,compact/review task 也会占用同一生命周期。app-server 在 turn/start 中把 core 返回的 submission id 作为 turn id。
threadapp-server 协议暴露给调用方的长期会话身份,多个 turn 归在同一 thread 下。
sessioncore 内部长期存活的运行时对象,持有队列、历史、工具服务、rollout 和当前 active turn。

这里有一个刻意保留的细节:turn/start 的实现确实“拿 submission id 当 turn id”,但 submission 这个概念比 turn 更宽。Op::Interrupt 也是 submission,却不会因此创建一个新 turn。看到 id 相同,只能说明这条用户输入在两个边界上的关联方式,不能反推两个类型完全等价。

Codex 本身也把这个边界写得很直白:它是“一对队列”的高层接口,一边发送 Submission,另一边接收 Event。提交队列有容量上限,事件队列则是无界通道。这里的背压发生在“客户端把操作交给 core”这一层,不等同于模型流的背压。

一条 task 的完整地图

下面这张图先忽略错误分支,只保留控制权真正发生转移的节点。

flowchart TD
    accTitle: Codex 一次 task 的端到端生命周期
    accDescr: 用户输入经 TUI、app-server 和 core submission queue 启动 RegularTask;run_turn 调用模型与工具,结果写回 history 和 rollout,事件再回到 TUI;活跃 turn 的 steer 则直接进入 Session。
    U["用户在 TUI 提交输入"] --> T["TUI AppCommand::UserTurn"]
    T -->|"空闲: turn/start"| A["app-server turn_start"]
    T -->|"活跃"| S["app-server turn_steer"]
    A -->|"Op::UserInput"| Q["core bounded Submission queue"]
    S -->|"turn/steer"| SI["Session::steer_input"]
    SI --> PI["active turn pending input"]
    Q --> L["submission_loop"]
    L --> R["RegularTask"]
    R --> RT["run_turn"]
    PI --> RT
    RT --> P["Prompt + tools"]
    P --> M["模型流式采样"]
    M -->|"FunctionCall"| X["ToolRouter / runtime"]
    X --> E["exec / sandbox / other tools"]
    E -->|"ToolOutput"| H["Session history"]
    H --> RT
    H --> RO["rollout JSONL(增量)"]
    M -->|"无工具调用"| D["pending input + stop hook"]
    D -->|"continue"| RT
    D -->|"finish"| C["turn completion"]
    C --> RO
    RO -->|"JSONL flush 后 metadata sync"| DB["SQLite metadata projection(增量)"]
    C --> N["core Event"]
    N --> AS["app-server typed notification"]
    AS --> UI["TUI 更新 transcript 与状态"]

图中的 rollout 和 SQLite 不是等到 turn completion 才开始工作。工具结果等 conversation items 在 turn 内就会进入 history 和 rollout;本地 LiveThread 接受这些 items 时,会先等 JSONL writer flush,再从已追加内容派生 metadata patch 写入 SQLite。TurnComplete 只是后续写入的终态 item。

这条链里最重要的回路是 run_turn -> 模型 -> 工具 -> history -> run_turn。一次 sampling 结束后,run_turn 会把模型侧的 needs_follow_up 与 active turn 的 pending input 合并判断;两者都为 false 时,还要运行 stop hook。只有没有模型 follow-up、没有 pending input,且 stop hook 没要求 continuation,run_turn 才会收尾。模型返回 FunctionCall 时,Codex 执行工具,把输出转成新的输入项,再发起下一次 sampling。于是“一次 task”首先是一个控制循环,不是一个 HTTP 请求的别名。

RegularTask::run 外面还有一层循环:run_turn 返回后,如果当前 active turn 仍有排队输入,它会清空 next_input 再跑一次。run_turn 内部又有 sampling 循环,既处理工具 follow-up,也处理 turn 运行期间送进来的 steer。把这两层循环压成“请求一次模型,显示一次答案”,会直接漏掉工具和中途输入。

控制流为什么要跨这么多层

TUI 并不直接调用 run_turn。它先判断该 thread 是否有 active turn:有就调用 turn/steer,没有才调用 turn/start。这两条 RPC 到 core 的路径不同:turn/start 把协议输入转换成 Op::UserInput,作为 Submission 进入有界队列;turn/steer 则直接调用 Session::steer_input,把输入追加到当前 active regular turn,并返回原 turn id。

submission_loop 只消费前一条 start 路径。它收到 Op::UserInput 时仍会重新检查 active task:若 start 入队到实际消费之间出现竞态,输入可能转为 steer;确认空闲后才创建新的 RegularTask。因此,队列里的 UserInput 仍可能续接 active turn,但不能反过来把 turn/steer 画成一次 submission。

这几层不是无意义的转发。

  • TUI 负责交互语义,例如当前输入是新 turn 还是 steer,以及 RPC 竞态后的重试或回退。
  • app-server 负责协议边界,包括输入大小、类型转换、thread 查找,以及把 start 与 steer 送到各自的 core 入口。
  • core submission queue 负责 turn/start、审批、打断等异步控制操作;turn/steer 不经过它。
  • RegularTask 负责 turn 级生命周期;run_turn 负责上下文、采样和 follow-up。

这也解释了为什么不能在 TUI 的 Enter handler 里寻找“完整 Agent loop”:那里只有输入语义,没有模型循环。反过来,只读 run_turn 也看不到 active turn 时为什么没有创建新 turn,因为这个选择已经在上游完成了。

crate 边界应该怎样看

沿生命周期回看目录,每个区域的职责会清楚很多。

区域负责什么不负责什么
protocolcore 的 SubmissionOpEventEventMsg 等跨边界数据结构不决定 TUI 如何展示,也不执行工具
core/sessioncore/tasks长期 session、active turn、提交循环、历史、turn task 生命周期不承担 JSON-RPC 的兼容和客户端路由
core/session/turn.rs装配 prompt、流式 sampling、工具 follow-up、compaction 与结束条件不直接实现每一种工具
core/toolstools工具规划、模型可见 spec、registry、调用分发、输出协议不等于 shell 进程执行器本身
core/src/exec.rscore/unified_execsandboxing命令转换、进程、输出、取消、平台沙箱转换不决定一条用户输入是否创建 turn
rollout把 canonical items 持久化为可恢复的记录不是页面搜索索引,也不直接渲染 transcript
state / SQLitethread 检索、列表和元数据投影,以及由 rollout 重建/修复这些投影不是送给模型的 conversation history
app-serverthread/turn RPC、协议校验、core event 到 typed notification 的翻译不拥有模型循环
tui输入、弹层、在途状态、通知消费和终端渲染不应绕过协议去猜 core 状态

这张表不是说 crate 之间绝对没有交叉依赖。它标的是阅读时的职责归属:某个行为最终由谁做决定。比如 shell spec 在工具规划中出现,真实进程却在执行 runtime 中启动;只看 spec 只能证明模型可能提出调用,不能证明命令已经执行。

rollout、内存历史和 SQLite 不是一份东西

这是总览里最容易误判的状态边界。

Session::record_conversation_items 会先把 ResponseItem 写入当前内存 history,再持久化到 rollout,并向观察者发送 raw response items。下一次模型请求取的是 Session 克隆出的 history,经 for_prompt 按模型输入模态整理。也就是说,模型上下文的直接来源是 turn 运行时看到的 conversation history。

rollout 负责 durable record。recorder 接收 canonical RolloutItem,通过后台 writer 持久化到 JSONL。实时运行的本地主路径不是反复调用 reconcile_rolloutLiveThread::append_items 先把 items 交给 local live writer,local writer 在返回前等待 JSONL flush;随后 ThreadMetadataSync 观察这批已追加内容,派生 metadata patch,再由 LocalThreadStore 更新 SQLite。

reconcile_rollout 主要出现在文件扫描、搜索补全、读修复和显式兼容更新等路径。它可以从传入的增量 items 或现有 rollout 文件重建 metadata,但这是投影缺失或陈旧时的修复手段,不是 live session 每次追加时的默认投影链。

所以,“Codex 把上下文存在 SQLite 里”这个表面解释不够准确。SQLite 可以保存 thread 元数据、rollout path、标题、时间等可检索投影,但不能据此把它当作每次模型请求的 prompt。二者相关,却不在同一条读取链上。

源码依据

本章的证据分成四组:协议层用 SubmissionOpEvent;控制流用 TUI routing、app-server turn_start_inner、core submission_loop;模型循环用 RegularTaskrun_turn;状态边界用 record_conversation_items、LiveThread metadata sync,以及 rollout 扫描修复。

还需要保留一个限制:图中把命令执行压成了一个节点,尚未展开 approval policy、sandbox policy、远端 environment 和平台差异。它们会在《一条 Shell 命令如何穿过审批、策略与沙箱》中单独拆开。总览图也没有覆盖 resume、fork、compaction 重建的全部细节,不能拿它当作所有 thread 恢复路径的时序图。

app-server 到客户端的最后一段同样是异步的。thread listener 从 core next_event() 读取事件,先更新本地 thread state,再调用 bespoke handling 翻译为 typed notification。TUI 消费的是通知流,不是对 run_turn 栈帧的同步回调。

失败边界:这张地图不能替你证明什么

第一,看到 ToolSpec 不能证明对应 runtime 一定注册。当前实现努力从同一份规划生成两者,但仍有 hidden、deferred 和 hosted tool 等有意例外,《模型为什么能调用工具:从 ToolSpec 到真正执行》会精确说明。

第二,看到 TurnStarted 不能证明模型已经返回第一个 token。RegularTask 在进入 run_turn 前就发这个事件,它表示生命周期开始,不表示采样完成。

第三,看到 rollout 已排队也不能立即推断磁盘 flush 成功。recorder 有后台 writer、缓冲和显式 flush;I/O 失败还有重试边界。

第四,看到 TUI 已显示一条消息也不能反推 SQLite 已经同步。客户端通知与持久化不在同一条同步链上;本地持久化内部虽然保证 accepted live append 先 flush JSONL、后更新 metadata projection,但界面通知不会替你证明这两步已经完成。

这些限制看起来琐碎,却决定了排查顺序。状态系统里最常见的误诊,就是拿一个较早层的“已接收”事件,替代较晚层真正需要证明的“已执行”或“已持久化”。

动手改一个地方

如果想验证这张地图,最小改造不是给每个 crate 都加日志。可以在 run_sampling_request 调用 built_tools 之后增加一个结构化 trace,只记录:

turn_id
model
model_visible_tool_count
registered_tool_count

位置在 codex-rs/core/src/session/turn.rsbuilt_tools(...) 返回之后、build_prompt(...) 之前。不要记录用户正文、工具参数、环境变量或完整 prompt。这样一次 turn 每次 sampling 都会留下工具面快照,又不会把敏感输入复制进日志。

这个改动还能验证“一次 task 不是一次模型请求”:当工具触发 follow-up 时,同一 turn_id 会出现多次 sampling trace。需要注意,ToolRouter 目前公开的是模型可见 specs;若要统计 registry 数量,应加一个只返回计数的受控方法,而不是把完整 handler 或 spec dump 出来。

这一章建立了什么

读 Codex 时,先追控制权,再看目录。每看到一个事件,都要继续问:它只证明“已接收”,还是已经到了“已执行”“已持久化”。

接下来三章沿这条控制链继续走:先看 Enter 的分支和 turn 终态,再看工具从 spec 到执行,最后拆开一条 Shell 命令经过的审批、策略和沙箱。