青雲的博客
拆开 Codex 第二部:一次 Turn 怎样进入模型 第 11 章

Codex 为什么有两层循环

沿 rust-v0.144.6 的 RegularTask、run_turn 与真实 Stop hook 测试,拆开同一用户可见 turn 里的 task owner loop 和 sampling/action loop。

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

第 10 章把一份 Responses 请求交给 ResponseStream。接下来最容易出现的误读,是看到 response.completed 就认为“一次 turn 已经结束”,或者看到下一次 request 就认为“系统新建了一个 turn”。这两种判断都跳过了真正的控制所有者。

rust-v0.144.6 里,同一条用户提交可以经历多次模型采样、工具结果回填、steer、mailbox 消息和 Stop hook 阻断。用户界面仍只看到一个 turn id。理解这段代码,需要先把“哪一层决定再采样一次”和“哪一层决定整个 task 还能不能继续”分开。

先把“两层”钉在业务语义上

本文说的两层,是两层业务控制范围:RegularTask::run 只拥有 task-level continuation,具体是 run_turn 成功返回后的 pending-input 重入;run_turn 管一次进入模型后的 sampling/action continuation。Session 仍然拥有 task lifecycle 和 terminal event。这个命名并不表示整个 Codex 只有两个 Rust loop;同一调用链里还有 transport retry 和 ResponseStream event consumption,只是它们回答的是另外两类问题。

控制范围代码所有者每次迭代处理什么继续条件退出后交给谁
RegularTask owner loopRegularTask::run调用一次完整的 run_turnrun_turn 成功返回后仍有 pending inputnormal task result;显式 abort 走 Session abort path
sampling/action looprun_turn记录本 step 输入、发起一次 sampling、等待 action 结果stream/action signal 或 Stop hook block返回 CodexResult<Option<String>>RegularTask

源码里另外两个显眼的循环不并入这张表:run_sampling_request 的 retry loop 负责可重试 stream error,完整恢复矩阵属于第 13 章:错误、重试与恢复try_run_sampling_request 的 event loop 逐条消费 ResponseEvent,事件怎样落成 item、tool future 和 action 属于第 12 章:一条 Responses 流怎样变成下一步行动。这里称“两层”,是在划业务所有权,不是在数 loop {} 的数量。

flowchart TB
  accTitle: RegularTask 与 run_turn 的两级控制
  accDescr: RegularTask 在同一 TurnContext 内按 pending input 重入 run_turn;run_turn 在工具、provider continuation、steer 或 mailbox、Stop hook block 时继续采样,只有 Stop hook 放行并且外层确认没有 pending input 后才完成 task
  START[one submitted user turn] --> TASK[RegularTask continuation owner loop]
  TASK --> TURN[run_turn sampling action loop]
  TURN --> SAMPLE[one sampling request]
  SAMPLE --> RESULT[SamplingRequestResult]
  RESULT --> FOLLOWUP{normal continuation needed}
  FOLLOWUP -->|tool provider pending input| TURN
  FOLLOWUP -->|no| STOP{Stop hook decision}
  STOP -->|block plus continuation prompt| TURN
  STOP -->|allow or stop| RETURN[run_turn returns]
  RETURN --> PENDING{successful return plus pending input}
  PENDING -->|yes same TurnContext| TURN
  PENDING -->|no| COMPLETE[normal on_task_finished terminalization]

图里两条回边的含义不同。FOLLOWUP -> TURN 发生在一次 run_turn 调用内部;PENDING -> TURN 发生在 run_turn 已经成功返回之后,用来接住返回边界上新到的输入。两条边复用同一个 TurnContext,却不属于同一个检查点。这张图只画 normal return;显式 abort 会让 spawn closure 跳过 on_task_finished,再由 Session abort path 终结。

外层 loop 持有同 turn 重入权

TurnStarted 的发送逻辑位于 RegularTask 的 owner loop 外,其中的 turn_id 直接取 ctx.sub_id。所以无论后面调用多少次 run_turnTurnStarted 都只在这里执行一次。prewarm resolution 也只做一次;进入循环后,prewarmed_client_session.take() 保证预热 session 最多交给第一次调用。

循环体每次都 clone 同一个 sess、同一个 ctx 和同一份 turn extension data。换句话说,同一个用户可见 turn 始终复用同一个 TurnContext 和同一个 sub_id。外层回边没有创建新 context,也没有再次走 submission 或 task spawn。

run_turn(...).await? 的问号很关键。只有 run_turn 成功返回,RegularTask 才会调用 has_pending_input;错误会直接离开外层。若队列为空,它把 last_agent_message 作为 task result 返回。若队列仍有内容,它把下一次显式参数设为 Vec::new(),再调用同一个 run_turn。空参数不是丢掉 pending input:新调用以 input.is_empty() 初始化 drain 权限,真正的数据仍由共享 InputQueue 取出并记录。

因此,run_turn 成功返回之后才检查 has_pending_input,而且这是 RegularTask 重入 run_turn 的唯一正常条件。它解决的是一个真实竞态:内层刚决定结束、准备返回时,steer 或可接收的 mailbox input 仍可能到达。没有外层复查,这批输入只能等到另一个 task;有了复查,它仍能进入当前用户可见 turn。

整个 RegularTask 被 spawn site 包在一个 Tokio task 里。spawn site 保存 RunningTask、cancellation token、handle 和 TurnContext;normal task return 之后,它先 flush rollout,再调用 on_task_finished。这说明 RegularTask 决定“还要不要再跑一次工作”,Session 决定“如何把整个工作收束成 lifecycle event”。

显式 abort 走另一条 Session 路径:先 cancel task token,等待短暂的 graceful exit,必要时 abort handle,再调用 task-specific abort hook,最后发送 TurnAbortedRegularTask 没有 override 这个 hook,因此继承 SessionTask::abort 的 default no-op 实现。这条路径与 spawn closure 因 token 已取消而跳过 on_task_finished 配合,避免同一次 abort 产生两份 terminal event。

last_agent_message 在这里是上行 payload,也是给 Stop hook 和正常 TurnComplete 使用的候选文本,它不是停止条件。None 可以随 normal task result 到达 TurnComplete;反过来,已经得到一条 assistant message 后,pending input 或 Stop hook 仍能让同一 turn 继续。normal return 的终态来自 task result、abort 分类和 on_task_finished;显式 abort 则由 Session abort path 终结,两条路都不靠“有没有最后一句话”判断。

WebSocket 连接只是 transport resource

ModelClientSession 管理的 WebSocket connection、preconnect、reuse、reconnect 与 fallback 都只是 transport resource。它们不创建新的 TurnContext,不发送 TurnStarted,也不产生 TurnComplete;这些 lifecycle 仍由 RegularTask 与 Session 路径负责。

内层 loop 决定一次 response 后是否继续 sampling

run_turn 在进入循环前完成当前调用的 context、skill/plugin input 与初始用户输入记录。进入 loop 后,每个 step 才执行以下顺序:

  1. 在允许 drain 时从 InputQueue 取 pending input,并运行提交钩子、记录 input;
  2. 捕获 request-scoped StepContext,从 history projection 构造 sampling input;
  3. 调用 run_sampling_request,得到 SamplingRequestResult
  4. 再检查 pending input 和 token status;
  5. 必要时先 compact,或依据 stream/action signal 回到下一次 sampling;
  6. 没有 continuation 时运行 Stop hook,最后才 break 并返回。

这个顺序解释了为什么 ResponseEvent::Completed 不等于 TurnComplete。前者只让一次 try_run_sampling_request 产出结果,后者要等 run_turn 内层结束、RegularTask 外层也确认不再重入、spawn site 收到 task result 后才会发出。

成功结果被拆成 SamplingRequestResult 的两个字段:needs_follow_up 表达是否还需要一次模型请求,last_agent_message 保存本次 response 里最后一条非空 assistant 文本。run_turn 随后把 model_needs_follow_uphas_pending_input 做或运算。这里没有用消息文本的存在与否推导控制流。

三类 stream/action signal 与 Stop hook

前三组 signal 来自 stream 或 action 处理,都会让同一个用户可见 turn 再发一次模型请求。Stop hook 则在这些 signal 都消失后才运行,是 run_turn 的本地结束控制。它们按来源分组,不按代码里出现了几个 continue 关键字分组。

来源源码信号回到哪里所有权边界
tool call / RespondToModelOutputItemResult.needs_follow_up = true当前 run_turn 的下一次 sampling这里只确认需要 follow-up;tool registry、handler 选择和执行合同留给第 14 章
provider continuationCompleted { end_turn: Some(false) }当前 run_turn 的下一次 samplingcompleted 结束一条 response stream,不结束 task
pending steer / mailboxpost-sampling has_pending_input,或 stream 中的 mailbox preemption优先回当前 run_turn;返回竞态由 RegularTask 再接一次mailbox 是否可并入当前 turn 还受 delivery phase 约束
Stop hook blockshould_block 且能构造 continuation prompt记录 prompt 后回当前 run_turn完整 Hook 发现、信任、target 和生命周期留给第 35 章

1. tool call 与 RespondToModel

stream 中一个 output item 完成后,真实 tool call 会把 needs_follow_up 设为 true;FunctionCallError::RespondToModel 虽然不进入同一条执行路径,也会请求 follow-up。两者都只说明当前 response 之后还要再问模型一次,具体 item 怎样识别、记录和变成 action 留到第 12 章。

因此,tool call 与 RespondToModel 都通过 needs_follow_up 请求下一次 sampling,但二者的 action 不一样。本文只追踪这个布尔结果,不展开工具规格怎样注册、路由怎样匹配、审批和 sandbox 怎样包围 handler;那是第 14 章:工具规格、注册表与执行入口的责任。

in-flight tool action 还必须在 sampling result 返回前 drain。这个门槛说明 needs_follow_up=true 并不等于立即发下一次 HTTP/WS request;action 结果就绪后,run_turn 才能决定下一次 sampling。

2. provider 明确要求继续

ResponseEvent::Completed 带有可选的 end_turn。当值是 Some(false) 时,Codex 把 needs_follow_up 设为 true,再返回 SamplingRequestResult。也就是说,Completed 遇到 end_turn = false 时仍会继续当前 turn;provider 只结束了当前 response,没有替 runtime 决定 task 已经完成。

若 response 早些时候已产生 tool call,needs_follow_up 也可能早已为 true;Completed 会保留这个聚合结果。若 end_turnNone 或 true,也不能单独推出 turn 完成,因为 post-sampling pending input 和 Stop hook 还没检查。

3. pending steer 与 mailbox

pending input 有两个观察点。第一处在每次 sampling 成功后:run_turn 重新调用 has_pending_input,再把它与 model follow-up 合并。第二处在 run_turn 成功返回之后:RegularTask 再检查一次,用来覆盖返回边界上的竞态。两处检查属于同一组输入 continuation,不应被算成两种业务原因。

InputQueue 又把 turn-local steer 和 session-scoped mailbox 分开。get_pending_input 总会先取 turn-local items;只有当前 turn 仍接受 mailbox delivery 时,才会 drain mailbox 并接到后面。has_pending_input 使用相同准入逻辑:本地 pending 优先,delivery phase 不允许时直接忽略 mailbox 对当前 turn 的续跑作用。

还有一条更早的 mailbox preemption,而且它不读取 mailbox delivery phase:当 stream 完成一条 reasoning 或 commentary item,且 has_pending_mailbox_items() 为 true 时,event loop 直接返回 SamplingRequestResult { needs_follow_up: true, ... }。这个检查看到的是 raw mailbox presence;它不会为 mailbox 创建新 turn,也不会把 event loop 升格成第三层业务 owner。

mailbox 是否真正进入下一次请求,是后面的第二个判断。NextTurn 会让 late child mail 保持 queued,交给 later turn;get_pending_inputhas_pending_input 在这个 phase 下都不把 mailbox 接纳进当前 turn。显式 steer 或后续 tool work 可以重新打开 CurrentTurn

NextTurn 只控制 mail 的消费,不能阻止 mailbox preemption 已经触发的额外 sampling。于是源码能保证的是“late mail 不被当前 follow-up drain”,不能仅凭 phase 推出“当前 turn 绝不会多发一次 request”。

Stop hook 是 run_turn 的退出控制

只有当 model follow-up 与 pending input 都为 false,run_turn 才把当前 last_agent_message 交给 Stop hook。Stop hook 返回 should_block 后,还必须提供能构造成消息的 continuation fragments。Codex 把这条 continuation prompt 记录进 conversation 和 turn item,设置 stop_hook_active = true,然后 continue 当前 run_turn

这条路径说明 Stop hook 的 should_block 配合 continuation prompt 可以在同一个 turn 内反复阻止结束。正常 parser 已要求 blocking response 带非空 reason,并据此构造 continuation fragment,因此合法 hook 输出不会产生“要求 block 却没有 prompt”的状态;runtime 对空 prompt 的 warning 属于更外层的防御分支,不能写成常见结果。若 should_stop 为 true,则直接 break。Hook 如何发现、建立信任并选择执行目标放到第 35 章:Hook 生命周期,本章只保留“block 如何变成同 turn continuation”。

Compaction 只拦截已经需要的 follow-up

mid-turn compaction 的条件先检查 needs_follow_up,再检查新的 context-window request 或 token limit。这里的布尔值聚合了 tool / RespondToModel、provider continuation 和 pending input;Stop hook 此时还没有运行。前三组都不要求 follow-up 时,代码不会因为 token status 单独走这个 mid-turn 分支。compact 成功后执行 continue,只是把原本就要发生的下一次 sampling 放到压缩之后。

这也解释了 can_drain_pending_input = !model_needs_follow_up。如果模型或工具链本来就需要 follow-up,compact 后先恢复这条链,暂缓把 steer 混入;如果只有 pending input 让循环继续,则允许下一轮 drain。compaction 不是第五类 continuation,它只在已经需要 continuation 时改变下一次请求前的上下文维护顺序。算法、replacement history、remote/local compact 和失败恢复全部留给第 26 章:Context compaction

此外,run_turn 在 invalid image sanitation 等恢复分支里也可能出现 continue。那是错误处置,不属于上表的 stream/action signal 或 Stop hook 控制。按关键字统计循环原因,会把业务控制、数据修复和 transport recovery 混在一起。

两个内嵌 loop 为什么不算第三、第四层

run_sampling_request 会在 retryable error 后重建 prompt、执行 shared backoff/recovery,再重试 try_run_sampling_request。它仍复用当前 StepContextTurnContext、client session 和 cancellation subtree;成功后只返回一次 SamplingRequestResult。因此 retry 没有新增 user input,也没有经过 RegularTask 的 pending check,更不会再发 TurnStarted

try_run_sampling_request 则先取得第 10 章交来的 stream,再在 loop 中等待 stream.next()。stream error、过早 EOF 和 cancellation 在这里形成 sampling error;ResponseEvent 的具体分支才负责更新 active item、排队工具和汇总结果。这是 ResponseStream/event loop boundary:本文只借它定位 continuation 信号,不展开每种 delta、item 和 durable record 的映射。

所以本章的两层模型没有否认内部循环。它只给每个循环一个明确问题:RegularTask 处理 task 级 pending race;run_turn 处理正常 action continuation;run_sampling_request 处理 transport recovery;try_run_sampling_request 处理 stream events。四者的退出值和下游消费者不同。

固定版本实验:一个 turn 内连续阻断两次

最直接的可运行证据不是数函数里的 continue,而是让同一 turn 真的发出三次 request。固定 checkout 为 rust-v0.144.6,commit 为 5d1fbf26c43abc65a203928b2e31561cb039e06d

fixture 开头调用了 skip_if_no_network!。如果 sandbox network-disable 环境变量存在,测试会提前返回 Ok(()),所以 1 passed 本身不足以证明下面的 request 和 hook 断言真的执行。实验命令必须把这个环境条件做成 fail-closed precondition,而不是只打印后继续:

set -euo pipefail
: "${ARCHIVE_CODEX_RS:?先执行第二部导读的 archive 准备脚本}"
test -z "${CODEX_SANDBOX_NETWORK_DISABLED+x}"
cd "$ARCHIVE_CODEX_RS"
CODEX_TEST_ENVIRONMENT=local \
  just --set rust_min_stack 16777216 test --locked \
  -p codex-core --test all stop_hook_can_block_multiple_times_in_same_turn

只有 precondition 通过,nextest 才会启动目标 fixture。guard 的实现只在变量存在时打印 skip 信息并提前 return;上面的 shell 在到达测试 runner 之前就把同一条件变成非零退出:

本机实测的关键结果整理为:

PASS codex-core::all suite::hooks::stop_hook_can_block_multiple_times_in_same_turn
Summary: 1 test run, 1 passed

nextest 报告的 filtered out / skipped 无关 test 数量只是 suite inventory 快照,不是业务合同。same-turn 行为证据来自下面的 request、prompt、turn id 和 hook-state 断言,不来自这个计数。

fixture 准备三条 SSE response:draft onedraft twofinal draft。Stop hook 前两次分别返回一条 continuation prompt,于是测试断言共有三次 provider request;第二次请求带第一条 prompt,第三次请求同时保留前两条 prompt。

更重要的断言是所有 Stop hook input 使用同一个非空 turn_id,且 stop_hook_active 的序列为 [false, true, true]。第一次是普通结束候选,后两次已处于 hook 驱动的 same-turn continuation。这个实验同时证明“多次 request”和“一个 turn id”,没有把次数变化误报成新 turn。

这里不直接调用 cargo test。固定源码的 just test recipe 设置测试栈和 nextest profile,本章再通过 just 变量把深层 integration fixture 的栈提高到 16 MiB;这属于仓库测试合同,不是业务行为证据。lockfile 校准、target 与日志都留在 disposable archive,固定 checkout 只提供源码身份。

负边界:读完这一章仍不能推出什么

看到的现象本章能确认不能推出
同一提交出现三次 provider request某类 continuation 让同一 turn 再采样每次 request 都创建新 turn
ResponseEvent::Completed 到达当前 response stream 进入 terminal eventTurnComplete 已发出
assistant message 已记录last_agent_message 有了候选 payloadqueue 与 Stop hook 已同意停止
run_sampling_request 再次调用 clientretry/recovery 仍在当前 sampling 合同里RegularTask 重启或 history 被 durable rewrite
mid-turn compact 后 continue已存在的 follow-up 在新上下文上恢复compaction 自己制造了第五类 continuation
tool item 让 needs_follow_up=trueaction 结果要回给模型registry、审批、handler 与 sandbox 都由本章证明

还有一条实现边界值得保留:tool handler 的内部失败、Stop hook 的完整信任与 target 规则、compaction 的 replacement mechanics 都可能影响最终行为,但它们没有改变本文定义的两个业务 owner。把这些机制全塞进“两层循环”会让章节失去可验证的停止线。

局部改造建议:记录外层重入原因

这是一项 proposed 改造,固定版本 rust-v0.144.6 尚未实现。最小切口只放在 RegularTask 已经决定继续的分支,不改 sampling loop、InputQueue 存储或 terminal event。

regular.rs:84-87 的外层重入点记录 turn_idcontinuation_reasoncontinuation_source:trace 使用 ctx.sub_id,reason 固定为 pending_after_run_turn,source 固定为当前调用点确实知道的 input_queue。counter 只按 reason/source 聚合,避免把高基数 turn id 放进 metric label;不新增 event schema、持久化表或独立 observability pipeline。

验证只需一个 targeted same-turn fixture:提交一次 turn,在第一次 run_turn 返回边界前放入 pending input,随后断言只有一个 TurnStarted 和一个同 id 的 TurnComplete、发生两次 run_turn entry,并捕获一次带相同 turn id 与预期 reason/source 的 trace;counter 只增加一次。这个 fixture 锁外层回边,不重复第 12 章的 stream action 测试。

交给第 12 章

第 10 章 Prompt 怎样变成一份 Responses 请求 已经封口 request shape 与 transport;本文只接收它交出的 ResponseStream。本文向第 12 章:一条 Responses 流怎样变成下一步行动交付一个 result object 与三类 stream/action signal:

  1. try_run_sampling_request 消费 ResponseStream,最后产生 SamplingRequestResult { needs_follow_up, last_agent_message }
  2. tool call / RespondToModel 是第一类 stream/action signal,会请求 follow-up;
  3. provider Completed { end_turn: Some(false) } 是第二类 stream/action signal,会请求 follow-up;
  4. mailbox preemption 与 pending input 是第三类 stream/action signal,会在各自准入条件下请求 follow-up。

Stop hook 发生在 run_turn 收到 sampling result 之后,是本章必须收口的结束控制,不交给第 12 章重讲。第 12 章需要继续解释 ResponseEvent 怎样成为完整或已记录的 response item、tool future 和 client event,但不能重新定义 task completion。retry、auth 与 fallback 的统一恢复矩阵留在第 13 章;工具注册表和 handler 内部留在第 14 章;compaction mechanics 留在第 26 章;Hooks 的发现、信任、执行与完整生命周期留在第 35 章

最终的控制判断很窄:一次 sampling 返回后(可能来自 Completed,也可能由 mailbox preemption 提前返回),先看三类 stream/action signal,再运行 Stop hook;run_turn 返回后,再由 RegularTask 检查返回边界上的 pending input;整个 task 收束后,Session 才发 terminal event。只要这三个时点不混在一起,多次 sampling、一次 user-visible turn 和一次 TurnComplete 就能同时成立。