OpenAI Codex · Turn 循环

中途插话:这句话进本轮、下一轮,还是被拒

Agent 正在改第三个文件,你看见方向偏了,补了一句:配置用 YAML。回车之后,这句话是开工、插进当前轮,还是当场被拒,Core 当场拍板,不等模型开口。

课程目标读完能说清三件事。第一,为什么提交必须立刻回 Started、Steered 或 NotSubmitted。第二,审查和压缩为什么拒收插话,并且不会因此新开一轮。第三,最终答案上屏之后,迟到的子邮件为什么默认躺到下一轮,用户再插一句又为什么能把门打开。
先玩一遍 · 同一句话,四个时机
信箱分拣台:同一句约束,投递时刻不同,落点就不同
时机
四个时机共用同一句输入。空闲会开工,采样中会插话,答案上屏后子邮件先躺着,审查中会被挡在门口。
时刻 · 空闲 任务 · 无 相位 · —

当前轮

pending_input,相位 CurrentTurn 时会被掏走

下一轮

会话信箱,相位 NextTurn 时只排队不掏

门口拒收

NotSubmitted,设置也不会落地
等待投递。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 按 TurnInputMode 分发,默认走 StartOrSteerturn_input.rs L141
  2. 先试 steer_input,只有 NoActiveTurn 才开工turn_input.rs L195
  3. Regular 才收,Review 和 Compact 当场拒turn_input.rs L507
  4. 空闲则 apply_started,再 spawn_taskturn_input.rs L242
  5. 插话写入 pending,并把相位打回 CurrentTurnturn_input.rs L558
  6. 最终答案把投递相位 defer 到 NextTurninput_queue.rs L206
  7. 工具项把相位 accept 回 CurrentTurnstream_events_utils.rs L302
  8. get_pending_input 看相位,再决定掏不掏信箱input_queue.rs L297
选一个时机,点播放。看同一句话进当前轮、躺到下一轮,还是被挡在门口。
用户插话的落点空闲是 Started,采样中是 Steered。审查中是 NotSubmitted,不会偷偷新开一轮普通对话。
子邮件的落点答案已经上屏时,迟到的子邮件先躺在会话信箱。本轮认为没有待处理输入。
门什么时候重开用户再插一句,或模型再发出工具调用,相位翻回 CurrentTurn,积压的子邮件跟着进下一次采样。
教学示意:筐与纸条是课程化隐喻,对应 turn 内 pending_input 与会话级 mailbox。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 提交立刻回判定
它解决什么问题

三种日常结局都不好受。立刻打断,前面两个文件的改动可能半成品留在磁盘上。排到下一轮,你只能看着它把剩下的文件按旧方向改完。塞进当前上下文却不叫醒循环,模型要到下一次自己开口才看得到,你补的约束等于迟到。

思路是什么

Codex 把这件事收成一个入口、三种模式。调用方选 StartOrSteerStartIfIdleSteer,不直接喊 start。Core 按现场忙闲和任务种类判定,立刻回 StartedSteeredNotSubmitted。回完决定就结束,不等 user-prompt hook,不等历史落盘,不等模型开始采样。

出处:codex-rs/core/src/session/turn_input.rs 第 1 至 9 行;codex-rs/protocol/src/turn_input.rs 第 127 至 136 行

StartOrSteer 的顺序和函数名一致。先试插话。只有返回 NoActiveTurn,才 apply_startedspawn_task。其他拒绝原因原样包装成 NotSubmitted,不会偷偷开工。TUI 实时语音也走这条,和默认入口共用同一套判定。

出处:codex-rs/core/src/session/turn_input.rs 第 141 至 156 行、第 195 至 249 行

设置不能先改再判定。prepare 先预览线程设置,预览失败直接 InvalidRequest。真正写入发生在 apply_startedapply_steered。被拒绝的输入连设置都不改。插话成功后只落持久设置,当前轮的 TurnContext 不换。开工专用的选项,比如结构化输出 schema,只在 Started 上用。

出处:codex-rs/core/src/session/turn_input.rs 第 58 至 80 行

用户提交 TurnInputMode handle 先试 steer,空闲再开工 Started · 新开 Regular 轮 Steered · 插进当前 Regular 轮 NotSubmitted · 线程保持原样
教学化结构图:入口只做判定,hook、落盘和采样都还没开始。
为什么长期成立

开工和插话必须在同一把锁里做完。先看有没有活动轮,再看 kind,再写入 pending,中间不能让另一次提交把轮次换掉。设置却要先预览后写入,因为拒绝路径必须保证线程不变。判定和入队拆成两次加锁,用户回车和子邮件同时到达时,可能出现判定时还空闲、入队时已经有人占坑的窗口。

思路二 · 只有 Regular 收插话
它解决什么问题

审查任务自己再开一条 one-shot 子对话,压缩任务在换窗口。用户在这时候补一句「用 YAML」,没有当前轮的工具循环可以接住它。如果因此新开一轮普通对话,审查结果和压缩摘要会跟新对话抢同一个 active_turn

思路是什么

steer_input 拿着 active_turn 锁做完全部检查。没有活动轮,或者有槽没有 task,都算 NoActiveTurnTaskKind 只有 Regular、Review、Compact 三个变体。审查和压缩返回 ActiveTurnNotSteerableStartOrSteer 也不会因此开工。

出处:codex-rs/core/src/session/turn_input.rs 第 507 至 519 行;codex-rs/core/src/state/turn.rs 第 67 至 72 行

检查过关之后,用户输入被推进 pending_input,同时把信箱相位打回 CurrentTurn。穷尽 match 在这里有业务后果:新加一种任务类型,编译器会逼你回答能不能插话。

出处:codex-rs/core/src/session/turn_input.rs 第 546 至 564 行

为什么长期成立

内部任务有自己的生命周期。把用户插话焊进审查轮,等于让两种工作抢同一条执行槽。调用方收到拒绝,这条输入不会被默默排进一条新对话。换个语言重写,该守的仍是:能接住插话的任务和不能接住的任务,必须在类型上分开。

思路三 · 一面翻牌决定能不能续写
它解决什么问题

主 agent 已经在屏幕上打出一段看起来像最终答案的话,子 agent 同时发回一条进度。并进去,用户已经看见的答案会被续写。一律等到下一轮,子 agent 的结果可能要隔一次采样才进模型。

思路是什么

用户插话进的是 turn 内的 pending_input。子 agent 的信走会话级 mailbox_pending_mails。两套存货能不能并进当前轮,由 MailboxDeliveryPhase 决定。相位从 CurrentTurn 起步。用户已经看见最终答案之后切到 NextTurn。用户再插一句,或者模型又发出工具调用,相位会重开。

出处:codex-rs/core/src/state/turn.rs 第 37 至 56 行

切到 NextTurn 有一条例外:pending_input 里只要还有一条不是排队不叫醒的子邮件,就保持当前相位。取信时也看这面牌。NextTurn 时 turn 内 pending 也不拿,会话信箱更不掏。CurrentTurn 时先拿走 turn 内 pending,再掏会话信箱,拼在后面。所以最终答案落地之后,子邮件可以躺在信箱里,循环却认为没有待处理输入,本轮会收束。

出处:codex-rs/core/src/session/input_queue.rs 第 206 至 227 行、第 284 至 336 行

CurrentTurn pending 加信箱,并进本轮 NextTurn 两套存货都不掏 最终答案上屏 用户插话,或工具调用,或模型还要 follow-up
教学化状态图:答案上屏后关闸,明确的同轮工作再开门。

什么算出用户可见的最终答案?助手正文,phase 是 Commentary 的不算,trim 之后为空的也不算。未打标的助手消息按最终答案处理。未打标的提供方默认走更安全的那条:先把信箱关到下一轮。审批、权限、提问、elicitation、动态工具是五张独立的 oneshot 表,不进这套信箱,各等各的回执。

出处:codex-rs/core/src/stream_events_utils.rs 第 486 至 501 行;codex-rs/core/src/state/turn.rs 第 87 至 103 行

答案已经给用户看过,迟到的信默认不续写。
为什么长期成立

「答案已经给用户看过」是一条产品边界,不依赖 Rust 或某个信箱实现。换语言重写,仍然需要一面翻牌:迟到的附属消息默认不续写已经上屏的答案;明确的同轮工作,比如用户再插一句或模型再调工具,再把门打开。

横向对比 · 同一道题的另一种答法

DSH:两个参数,两条轨道

DSH 对外是三个别名,底层共用 send。目标队列和唤不唤醒是两个正交参数。followup 自己独占一轮并叫醒,steer 插进下一站并叫醒,inject 上车不催司机。Inbox 是 next-turnnext-step 两条持久列表,claim 先掏空 next-step,目标是 next-turn 时再多取一条。

Codex 没有这三个公开函数。StartOrSteer 把空闲开工和忙时插话焊在一次判定里。相位这面翻牌,DSH 可以没有,因为它把等整轮和等下一站写成两条列表。代价是答案已经上屏之后,next-step 非空仍会续命当前 Turn。

两侧均已核对源码 · 2026-08-22 · DSH · Inbox

Claude Code:一条队列,用优先级补时机

还原源码里,用户输入、任务通知、孤儿权限走同一条 commandQueue。优先级是 now 大于 next 大于 later,同级 FIFO。用户命令默认 next,任务通知默认 later,用户输入不会被系统消息饿死。消费发生在当前流结束之后,没有步级插话。你补的那句 YAML 约束,要等当前生成器收尾才进模型。

已核对还原源码 · 2026-08-22 · messageQueueManager.ts
课堂练习
01

答案上屏之后,这封子邮件什么时候被看见

最终答案落地之后,先入队一封 trigger_turn: false 的子邮件,再喂一条 FunctionCall。先在纸上推演 get_pending_input 该返回什么。

对照路径:正文落盘时相位切到 NextTurn,这封信看不见;工具项到达后相位重开,下一圈才能把它掏出来,并进同轮的下一次模型请求。

Takeaway:提交立刻回判定,回的是收下还是拒绝,采样还没开始。只有 Regular 收插话,审查和压缩当场拒。答案上屏后默认不续写,用户再插或工具再调,才把门打开。