Skip to content

B1|主循环与事件总线:智能在 Session,不在 UI ​

先别把 submit 当成一次函数调用 ​

A2 已经把一次任务拆成 Thread / Turn / Step。读到 CodexThread::submit 的人,下一步几乎都会把它理解成 runAgent(prompt) -> answer:调用返回,答案就在返回值里。

返回值是 String——一个 submission id。答案不在那里。答案、工具输出、审批请求、中断通知,全部从另一头的事件队列流出来。protocol/src/protocol.rs:1-4 把这件事写成协议声明:客户端和 agent 之间是 Submission Queue / Event Queue,不是 call stack。

没有这层总线,A1 里的六个入口就做不成「前端只适配」。TUI 会去调模型,IDE 会自己开 HTTP,CI 会自己 Command::new。B 章从这里开始抄:先抄总线,再抄工具和沙箱。


1. 一对队列,用同一个 id 对上 ​

先把 A2 §0 那个玩具版本里的 input()/print(),换成这张图:

关键是那条 oneshot 箭头单独画在最上面:它是 submit() 调用本身的返回值,跟下面循环里流出来的 EventMsg 走的不是同一条线,先后顺序也不一样——oneshot 几乎立即回,Event 要等真正跑起来才有。这也是为什么 A2 玩具版本里"call_model 返回就是答案"这个直觉在这里不成立:submit() 返回的从来不是答案,答案在 next_event() 那条线上,可能好几个 EventMsg 之后才出现。

一条 Submission 很瘦(protocol/src/protocol.rs:192-205):id、op、可选 trace,以及给多 agent 用的 parent_turn_id / root_turn_id。对应的 Event 更瘦(:1339-1343):同一个 id,加上 EventMsg。

Op 是控制面,不是 chat API。用户话只是其中一种——Op::TurnInput 带着 TurnInputMode 和一只 oneshot,用来立刻回答「开了 / 注入了 / 拒了」(:627-632)。中断、清后台终端、恢复被打断的 turn、realtime、elicitation,全是并列的 Op。EventMsg 是给所有客户端的投影:文本增量、TurnStarted / TurnComplete / TurnAborted、审批、compact、警告。协议还特意写了 task_started 与 turn_started 的 rename(A2 提过兼容痕迹)——总线比任何一个 UI 都长寿。

oneshot 回包 不是 Event。turn_input.rs 文件头说得很狠:Core 在这里决定 start / steer / reject,立刻回包,不等 hooks、不等写入历史、不等采样(core/src/session/turn_input.rs:1-6)。调用方先拿到 turn_id 或 NotSubmittedReason,再去 next_event() 听后续。把这两个通道揉成一个 Result<Answer>,中断和审批都会堵在调用栈上。

没有 SQ/EQ 会怎样?审批变成「函数在等用户点按钮」;IDE 和 TUI 无法共享同一套语义;一次插话必须取消当前 HTTP 再重发。总线存在,是因为用户随时会插话,模型随时会要工具,磁盘随时要记一笔。


2. ThreadManager 造 Session,submission_loop 才是心脏 ​

三层所有权不要反着读:

层干什么不干什么
ThreadManager创建、resume、fork不跑 turn
CodexThread对外句柄:submit / next_event不碰模型
Session + submission_loop消化 Op,最多一个 running task不画 UI

SessionIo 把两头队列接到句柄上(core/src/session/mod.rs:399-407):tx_sub 进、rx_event 出,外加 agent_status watch。Session 造好之后,tokio::spawn 一条 submission_loop,直到收到 Op::Shutdown(:877-883)。循环本体在 handlers.rs:411:从 rx_sub 取包,对 Op 穷尽 match。CodexThread::next_event 只是 io.next_event()(codex_thread.rs:595-596)。

所以「主循环」不在 TUI 的 event loop 里,也不在 app-server 的 JSON-RPC dispatcher 里。那两处是适配器。Session 的 loop 才是 agent 活着的证据:thread 还在、loop 还在,才能 interrupt、steer、把 Event 送到任何一个听着的客户端。

没有「Session 拥有 loop」会怎样?每个前端自己 while。A2 的 TurnContext 冻结、B3 的审批缓存、B4 的 rollout 都会按表面复制一份。AGENTS.md 禁止 library 代码 println!,就是在强迫所有用户可见输出走这条总线。


3. Task 是总线上看不见的司机,不是四种产品 ​

SessionTask trait 把「这一轮干什么」收成四个方法:kind、span_name、run,以及取消令牌(core/src/tasks/mod.rs:178-202)。文档写明:实现应当看 cancellation_token,尽快停;返回 TurnAborted 走中断生命周期,而不是自己发明一种结束方式。

四种司机:

任务TaskKind是否采样模型典型入口
RegularTaskRegular是,run_turn用户对话
CompactTaskCompact压缩专用路径/compact、自动压缩
ReviewTaskReview是,但是审查会话/review
UserShellCommandTask也报 Regular否,直接 exec用户在 composer 里跑的本地 shell

最后一行是陷阱。user_shell.rs:75-98 实现 SessionTask,kind() 返回 TaskKind::Regular,span_name 却是 session_task.user_shell。它走独立 turn 生命周期(UserShellCommandMode::StandaloneTurn),发 TurnStarted / TurnComplete,但 不进模型循环。TaskKind 枚举只有 Regular / Review / Compact 三个值(state/turn.rs:68-72),所以「user_shell」是 Regular 这个桶里的另一种司机,不是第四个 kind。

这对总线很重要:steer 只允许 Regular(turn_input.rs:603-614)。Review 和 Compact 直接 ActiveTurnNotSteerable。User shell 因为挂了 Regular 这个 kind,生命周期上像一轮对话,内容上却是「用户自己敲的命令」。不要用 kind 去猜「这轮会不会打模型」。

没有 Task 这层会怎样?submission_loop 里会堆起 if compact { ... } else if review。每加一种工作,总线就胖一圈。现在 loop 只分发 Op,司机自己决定要不要采样。


4. InputQueue:steer、排队、拒绝入队,是三种不同的命运 ​

A2 讲过 StartOrSteer / StartIfIdle / Steer。B1 要看队列本身怎么存。

InputQueue 不是一个 VecDeque<用户话>。一句新输入进来,落在哪条轨、还是被直接拒绝,取决于 Session 当前在干什么:

它分两条轨(core/src/session/input_queue.rs:69-88):

  • Steer 轨:挂在当前 TurnState.pending_input 上。正在跑的 Regular turn 才能收。run_turn 在两次采样之间 drain(A2 的内层 loop)。
  • Mailbox 轨:mailbox_pending_mails,装 InterAgentCommunication。子代理回信、带 trigger_turn 的邮件走这里。Session idle 时,maybe_start_turn_for_pending_work 才决定要不要开一轮(tasks/mod.rs:420-440)。

活动信号是 InputQueueActivity::{Steer, Mailbox}(input_queue.rs:69-72),不是「有没有字符串」。steer 和 mailbox 抢的不是同一个锁语义:一个注入当前采样循环,一个可能在空闲时合成新 Turn。

第三条命运是 拒绝入队。NotSubmittedReason(protocol/src/turn_input.rs:217-245)列得很全:host 在 draining、非 idle、turn_id 对不上、Compact/Review 不能 steer、schema 不一致、空输入、Plan 模式下的自动输入……被拒绝的输入 不写历史、不进 mailbox、不进 pending。turn_input.rs 的职责就是先做这个决定再回 oneshot。

没有「三种命运」会怎样?要么所有插话都开新 Turn(两个司机抢工作区),要么所有插话都静默丢弃(用户以为发出去了),要么 Compact 进行到一半被一句闲聊 steer 进压缩摘要。队列的价值不是缓冲,是 把「没被接受」变成可观察的协议结果。

这三条都活在 当前进程的 Session 里。关掉终端,pending steer 和 mailbox 一起没。跨进程、可 resume 的那条队是另一层。


5. 持久队列:空闲才开新 Turn,不进 pending_input ​

ext/queue 的模块文档写的是「Durable, storage-neutral user-message queue and idle dispatch」(ext/queue/src/lib.rs:1)。条目落在 sqlite(QueueStore),上限 100 条/thread(state/src/lib.rs:99-100)。只接受用户输入;别的 payload 入队直接 InvalidInput(service.rs:57-58)。

派发走 StartIfIdle,不是 StartOrSteer(service.rs:367-402)。thread 空闲才开新 Turn,turn_trigger 标成 "queue";开成功才从队里删。正在跑就留在磁盘上等。on_thread_idle 会接着派;Interrupted 故意不派(:549-553),避免刚取消又被队里下一句立刻拉起来。

对外:

  • CLI codex queue --thread … --message …(cli/src/queue_cmd.rs:13-21)
  • app-server 实验 RPC:thread/queue/add|list|update|delete|reorder|start(app-server-protocol/.../common.rs:631-665)
  • 通知 thread/queue/changed(:1917-1918)

10 秒轮询 sqlite 版本发现外部写入(service.rs:89-96),所以 IDE 和 CLI 可以往同一条 thread 的队里塞话,不必共享内存里的 InputQueue。

寿命正在跑时进程重启
Steer当前 Turn注入这一轮丢
Mailbox当前 Session空闲才合成 Turn丢
持久队列sqlite等空闲,不开第二司机还在
NotSubmitted无当场拒绝无

没有这一层会怎样?CI 往活着的 TUI session 塞话只能 steer(改正在跑的那一轮)或拒收;关掉窗口等于把排队的需求清掉。持久队列是给「这条 thread 忙完再做下一句」用的,不是第四条 pending 轨。


6. 取消用 CancelToken,100ms 之后才 abort 任务,仍然不杀进程 ​

SessionTask::run 的契约:令牌 cancelled 就尽快返回(tasks/mod.rs:186-195)。handle_task_abort 先 cancellation_token.cancel()(:890),然后 select:任务自己结束,或等到 GRACEFULL_INTERRUPTION_TIMEOUT_MS(100ms,:69)。超时才 task.handle.abort()(:908-916)。abort 的是 tokio 任务,不是 OS 进程。

这和 A2 的 Op::Interrupt 对得上:中断当前 task,不终止后台 terminal(protocol.rs:601-607)。杀 PTY 是另一条 Op。工具执行、采样、MCP 启动都接同一张令牌;run_turn 把 cancellation_token.child_token() 传下去(regular.rs:83),子步骤取消不会误伤下一轮新开的 token。

100ms 宽限是为了让 in-flight 审批不要先被理解成「模型可见的拒绝」,再冒出 TurnAborted(abort_all_tasks 附近的注释,:534-535)。先 cancel,再清 pending,顺序是协议,不是性能彩蛋。

没有令牌、直接 kill 会怎样?沙箱里的测试、用户自己起的 dev server、unified_exec 的长进程一起死。下一轮 resume 时工作区处于半写状态,还没有 TurnAborted fragment 告诉模型发生了什么。取消必须是总线消息,不能是进程信号。


7. ModelClient 长在 Session 上,ModelClientSession 短在 Turn 上 ​

打模型不是 reqwest.post 一次了事。core/src/client.rs 模块文档把寿命切开(:1-26):

  • ModelClient 活在 Session 上:鉴权、provider、thread id、传输回退状态。
  • ModelClientSession 每个 Turn 新建:懒开 Responses WebSocket,缓存 x-codex-turn-state sticky routing,以及「上一枪完整请求」以便增量续传。

文档写得很凶:跨 turn 复用 ModelClientSession 会把上一轮的 sticky token 带到下一轮,破坏客户端/服务端契约(:266-268)。WebSocket prewarm 是 v2 的 response.create 且 generate=false;失败算作这一轮第一次 WS 尝试,吃 retry budget(:17-26)。HTTP fallback 一旦在某一轮激活,整段 Session 剩下的 turn 都走 HTTP(:240-241)。这是 Session 级状态,不是单次请求的临时决定。

采样循环在 turn.rs 里复用同一个 ModelClientSession 做多枪 tool-call(A2 引用过 :414-415)。总线的含义是:取消令牌取消的是这一轮的 session,不是把 ModelClient 拆掉。下一轮 new_session(),sticky 从头来。

没有这层寿命切分会怎样?每枪重建连接,prompt cache 和 sticky routing 全废;或者一个 WS 用整条 thread 的寿命,中断无法对准「这一轮」,鉴权切换后旧连接打到错误账号。


8. 内部 Event 不是 App Server 的 JSON-RPC ​

Core 只说 Event / EventMsg。IDE、桌面、Python SDK 看见的是 app-server v2 的 ServerNotification 和 thread/* RPC。翻译发生在 app-server/src/bespoke_event_handling.rs:例如 EventMsg::TurnStarted 变成 ServerNotification::TurnStarted(:153-180),MCP 启动、环境连接、Guardian、模型改道各有一条映射。这是适配,不是第二条总线。

TUI 可以两条路:嵌 in-process app-server(A1),或历史上直接 CodexThread::next_event。Python SDK 只走 codex app-server --listen stdio://。TypeScript SDK 走 codex exec 的 JSONL——那是 exec 表面把 Event 投影成另一套 ThreadEvent,仍然不是 Core 协议换了皮。

三套「事件」容易混:

你看见的实际是
protocol::Event / EventMsgSession 总线,唯一内部真相
ServerNotification + thread/startapp-server 对外 JSON-RPC
codex exec --json 的一行exec 给 CI 的投影

往 Core 里加一个只给 IDE 的字段,或让 TUI 直接调 ModelClient,都会把适配器做成第二套智能。E1 会把「前端只适配」写完;这里先钉死:harness 的事件总线止于 EventMsg。

没有这层翻译边界会怎样?app-server v2 的 camelCase 会漏进 rollout;exec 的 JSONL 一变,Session 要跟着改;内部 oneshot 回包会被误当成要持久化的 Event。总线内部形状必须比所有产品表面稳定。

适配器不必实现 EventMsg 的每一个变体。按 职 分组,P0 前端至少要投影下面这几类;其余可以忽略或记日志。

职典型 EventMsg前端干什么不要当成
文本AgentMessage / AgentMessageContentDelta画答案历史真相(rollout 才是)
reasoningAgentReasoning 及 delta可折叠展示用户原话;另一种 Turn
工具生命周期ExecCommandBegin/End、PatchApply*、McpToolCall*进度、diff、输出自己再 Command::new
审批exec / patch / elicitation 请求overlay 回 RPCGuardian 的 Allow/Deny;/review 的 findings(E6、附录 A)
turn 生命周期TurnStarted / TurnComplete / TurnAborted一轮的边界杀 OS 进程(Interrupt 不杀 PTY,附录 B)
compactContextCompacted提示窗口被换了起点改已经发出去的过去(B4)

oneshot 的 Started / Steered / NotSubmitted 不是 上表里的任何一行,也不写 rollout。三种「再看一眼」不要焊成一个按钮:审批 Event 问的是这次 tool call;Guardian 是同意链上的评审员;/review 产出 findings。


9. 结语:带走一句话 ​

Codex 的主循环不是「调用模型的 while」,而是 Session 上那对 SQ/EQ——Op 进、EventMsg 按职流出、oneshot 不是 Event、取消走令牌、采样寿命按 Turn 切开;插话是 steer / mailbox / 拒绝,跨进程再排队才走 sqlite——UI 和 JSON-RPC 只是这条总线的耳机。