Codex 为什么有两层循环
沿 rust-v0.144.6 的 RegularTask、run_turn 与真实 Stop hook 测试,拆开同一用户可见 turn 里的 task owner loop 和 sampling/action loop。
第 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 loop | RegularTask::run | 调用一次完整的 run_turn | run_turn 成功返回后仍有 pending input | normal task result;显式 abort 走 Session abort path |
| sampling/action loop | run_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_turn,TurnStarted 都只在这里执行一次。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,最后发送 TurnAborted。RegularTask 没有 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 才执行以下顺序:
- 在允许 drain 时从
InputQueue取 pending input,并运行提交钩子、记录 input; - 捕获 request-scoped
StepContext,从 history projection 构造 sampling input; - 调用
run_sampling_request,得到SamplingRequestResult; - 再检查 pending input 和 token status;
- 必要时先 compact,或依据 stream/action signal 回到下一次 sampling;
- 没有 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_up 与 has_pending_input 做或运算。这里没有用消息文本的存在与否推导控制流。
三类 stream/action signal 与 Stop hook
前三组 signal 来自 stream 或 action 处理,都会让同一个用户可见 turn 再发一次模型请求。Stop hook 则在这些 signal 都消失后才运行,是 run_turn 的本地结束控制。它们按来源分组,不按代码里出现了几个 continue 关键字分组。
| 来源 | 源码信号 | 回到哪里 | 所有权边界 |
|---|---|---|---|
tool call / RespondToModel | OutputItemResult.needs_follow_up = true | 当前 run_turn 的下一次 sampling | 这里只确认需要 follow-up;tool registry、handler 选择和执行合同留给第 14 章 |
| provider continuation | Completed { end_turn: Some(false) } | 当前 run_turn 的下一次 sampling | completed 结束一条 response stream,不结束 task |
| pending steer / mailbox | post-sampling has_pending_input,或 stream 中的 mailbox preemption | 优先回当前 run_turn;返回竞态由 RegularTask 再接一次 | mailbox 是否可并入当前 turn 还受 delivery phase 约束 |
| Stop hook block | should_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_turn 为 None 或 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_input 与 has_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。它仍复用当前 StepContext、TurnContext、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 one、draft two、final 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 event | TurnComplete 已发出 |
| assistant message 已记录 | last_agent_message 有了候选 payload | queue 与 Stop hook 已同意停止 |
run_sampling_request 再次调用 client | retry/recovery 仍在当前 sampling 合同里 | RegularTask 重启或 history 被 durable rewrite |
mid-turn compact 后 continue | 已存在的 follow-up 在新上下文上恢复 | compaction 自己制造了第五类 continuation |
tool item 让 needs_follow_up=true | action 结果要回给模型 | 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_id、continuation_reason 与 continuation_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:
try_run_sampling_request消费ResponseStream,最后产生SamplingRequestResult { needs_follow_up, last_agent_message };- tool call /
RespondToModel是第一类 stream/action signal,会请求 follow-up; - provider
Completed { end_turn: Some(false) }是第二类 stream/action signal,会请求 follow-up; - 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 就能同时成立。