中途插话:这句话进本轮、下一轮,还是被拒
Agent 正在改第三个文件,你看见方向偏了,补了一句:配置用 YAML。回车之后,这句话是开工、插进当前轮,还是当场被拒,Core 当场拍板,不等模型开口。
当前轮
下一轮
门口拒收
- 按 TurnInputMode 分发,默认走 StartOrSteerturn_input.rs L141
- 先试 steer_input,只有 NoActiveTurn 才开工turn_input.rs L195
- Regular 才收,Review 和 Compact 当场拒turn_input.rs L507
- 空闲则 apply_started,再 spawn_taskturn_input.rs L242
- 插话写入 pending,并把相位打回 CurrentTurnturn_input.rs L558
- 最终答案把投递相位 defer 到 NextTurninput_queue.rs L206
- 工具项把相位 accept 回 CurrentTurnstream_events_utils.rs L302
- get_pending_input 看相位,再决定掏不掏信箱input_queue.rs L297
三种日常结局都不好受。立刻打断,前面两个文件的改动可能半成品留在磁盘上。排到下一轮,你只能看着它把剩下的文件按旧方向改完。塞进当前上下文却不叫醒循环,模型要到下一次自己开口才看得到,你补的约束等于迟到。
Codex 把这件事收成一个入口、三种模式。调用方选 StartOrSteer、StartIfIdle 或 Steer,不直接喊 start。Core 按现场忙闲和任务种类判定,立刻回 Started、Steered 或 NotSubmitted。回完决定就结束,不等 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_started 再 spawn_task。其他拒绝原因原样包装成 NotSubmitted,不会偷偷开工。TUI 实时语音也走这条,和默认入口共用同一套判定。
出处:codex-rs/core/src/session/turn_input.rs 第 141 至 156 行、第 195 至 249 行
设置不能先改再判定。prepare 先预览线程设置,预览失败直接 InvalidRequest。真正写入发生在 apply_started 或 apply_steered。被拒绝的输入连设置都不改。插话成功后只落持久设置,当前轮的 TurnContext 不换。开工专用的选项,比如结构化输出 schema,只在 Started 上用。
出处:codex-rs/core/src/session/turn_input.rs 第 58 至 80 行
开工和插话必须在同一把锁里做完。先看有没有活动轮,再看 kind,再写入 pending,中间不能让另一次提交把轮次换掉。设置却要先预览后写入,因为拒绝路径必须保证线程不变。判定和入队拆成两次加锁,用户回车和子邮件同时到达时,可能出现判定时还空闲、入队时已经有人占坑的窗口。
审查任务自己再开一条 one-shot 子对话,压缩任务在换窗口。用户在这时候补一句「用 YAML」,没有当前轮的工具循环可以接住它。如果因此新开一轮普通对话,审查结果和压缩摘要会跟新对话抢同一个 active_turn。
steer_input 拿着 active_turn 锁做完全部检查。没有活动轮,或者有槽没有 task,都算 NoActiveTurn。TaskKind 只有 Regular、Review、Compact 三个变体。审查和压缩返回 ActiveTurnNotSteerable。StartOrSteer 也不会因此开工。
出处: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 行
什么算出用户可见的最终答案?助手正文,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-turn 和 next-step 两条持久列表,claim 先掏空 next-step,目标是 next-turn 时再多取一条。
Codex 没有这三个公开函数。StartOrSteer 把空闲开工和忙时插话焊在一次判定里。相位这面翻牌,DSH 可以没有,因为它把等整轮和等下一站写成两条列表。代价是答案已经上屏之后,next-step 非空仍会续命当前 Turn。
Claude Code:一条队列,用优先级补时机
还原源码里,用户输入、任务通知、孤儿权限走同一条 commandQueue。优先级是 now 大于 next 大于 later,同级 FIFO。用户命令默认 next,任务通知默认 later,用户输入不会被系统消息饿死。消费发生在当前流结束之后,没有步级插话。你补的那句 YAML 约束,要等当前生成器收尾才进模型。
答案上屏之后,这封子邮件什么时候被看见
最终答案落地之后,先入队一封 trigger_turn: false 的子邮件,再喂一条 FunctionCall。先在纸上推演 get_pending_input 该返回什么。
对照路径:正文落盘时相位切到 NextTurn,这封信看不见;工具项到达后相位重开,下一圈才能把它掏出来,并进同轮的下一次模型请求。