Codex 正在修复资料页的保存按钮。它已经找到组件代码,准备运行测试。此时用户又补充了一句:
不要改接口,只修前端。
这句话应该加入正在执行的任务,还是创建一项新任务?如果用户紧接着点击中断,正在等待的模型请求、已经启动的测试进程和尚未答复的审批又该由谁停止?测试停下以后,之前的对话历史是否还保留?
这些问题不能只靠一个反复调用模型的循环解决。Runtime 必须知道哪些状态属于整段会话,哪些只属于当前请求,哪些在一次模型采样结束后就应失效。Codex 用 Thread、Session、Turn、Task 和 Step 把这些时间尺度分开。
第二章沿着 turn/start 追踪了完整执行路径。本章把这条路径拆开,重点检查状态所有权、生命周期切换、运行中追加输入以及协作式取消。
本章细节导航
本篇负责建立全局地图,源码级细节已继续拆成 22 个单元:
- 3.1 ThreadId、ConversationId 与 Session 身份
- 3.2 ThreadManager 的 Live Thread 注册表
- 3.3 CodexThread 的 Channel 与关闭协议
- 3.4 SessionServices、SessionState 与可变状态
- 3.5 Submission 输入分派循环
- 3.6 运行时 ID 的作用域
- 3.7 SessionTask 策略抽象
- 3.8 RegularTask 进入 run_turn
- 3.9 TurnContext 的字段来源与冻结
- 3.10 StepContext 的捕获与刷新
- 3.11 TurnEnvironmentSnapshot
- 3.12 run_turn 的三个阶段
- 3.13 多次模型采样的循环条件
- 3.14 ResponseItem 的控制流
- 3.15 InputQueue 的两级存储
- 3.16 Steer 的原子校验与生效边界
- 3.17 Interrupt 的传播链
- 3.18 CancellationToken 与 Tokio abort
- 3.19 后台资源清理
- 3.20 Turn 的单一终态
- 3.21 Rollout Flush 屏障
- 3.22 Realtime、Review 与普通 Turn
五个名称对应五种时间尺度
先把最容易混淆的几个名称放在同一张表里。
| 名称 | 它表示什么 | 典型生命周期 |
|---|---|---|
| Thread | 一段可以继续、恢复和持久化的会话身份 | 从创建开始,可跨多次 Turn,停止后还可以恢复 |
| Session | 一个已加载 Thread 的内存 Runtime | 从 Thread 被加载到关闭或进程退出 |
| Turn | 一次用户请求及为完成它开展的全部工作 | 从 TurnStarted 到完成、中断或失败 |
| Task | 执行某类 Turn 的运行策略 | 从 Session 启动后台任务到清理结束 |
| Step | 模型观察当前状态并采样一次的内部边界 | 从捕获 StepContext 到本次模型输出处理完成 |
这五层不是五种写法不同的“任务”。Thread 回答“这段对话是谁”,Session 回答“现在由哪个活体 Runtime 管理它”,Turn 回答“用户这次要求做什么”,Task 回答“Runtime 用哪种策略执行”,Step 回答“模型这一次实际看到了什么”。
它们的所有权关系如下。
Thread 是身份,Session 是活体 Runtime
ThreadManager 负责创建、恢复和查找 Thread。它在进程内维护一张从 Thread ID 到 CodexThread 的表,并共享模型管理器、环境管理器、MCP、Skills、Plugins 和存储等服务。
CodexThread 是外部调用者接触 Core 的双向接口。它包装一个 Session 和一组输入输出通道:调用者通过它提交 Op,再读取 Core 发出的 Event。它本身不运行模型循环,真正持有会话状态的是 Session。
Session 中既有跨 Turn 保留的状态,也有只在运行期间存在的资源。前者包括当前配置、对话历史、Token 统计和附加上下文;后者包括事件发送端、输入队列、工具与扩展服务以及当前活跃 Turn。
Thread 和 Session 的区别在恢复场景中最清楚。一个正在运行的 Thread 已经有 Session,再次请求恢复时可以直接复用。如果 Thread 只剩持久化记录,Runtime 会从历史重新构造一个 Session。Thread ID 和历史仍然延续,但原来的内存对象、异步任务和网络连接不会被“复活”。
因此,不能把 Session 当成 Thread 的另一个名字。Thread 是可恢复的会话身份,Session 是该身份当前加载出来的运行实例。
Session 串行处理控制操作
Session 启动时会创建一条 Submission 通道,并运行长期存在的 submission_loop。用户输入、中断、审批答复、设置变更、上下文压缩和关闭请求都会先成为 Submission,再由这条循环按接收顺序分派。
串行分派解决的是控制顺序,不代表模型和工具只能串行执行。循环收到用户输入后,会把实际工作放进独立的异步 Task,自己继续接收后续操作。因此,模型仍在流式返回内容时,Session 依然能够处理审批答复或 Interrupt。
这里还有另一条容易与 Submission 通道混淆的队列:InputQueue。
| 队列 | 保存什么 | 什么时候消费 |
|---|---|---|
| Submission 通道 | UserInput、Interrupt、审批答复、设置变更等控制操作 | Session 主循环逐项分派 |
| Turn InputQueue | 运行中追加的用户输入、附加上下文和 Agent Mailbox 消息 | 当前 RegularTask 在模型采样边界读取 |
前者决定 Runtime 先处理哪个操作,后者决定哪些新信息应进入当前 Turn 的下一次模型请求。把它们合并成一条队列,会让“中断当前任务”和“把一句补充要求交给模型”失去清晰边界。
没有活跃任务时,UserInput 创建新 Turn
Session 收到 Op::UserInput 后,先根据 Thread 当前配置和本次覆盖项构造 TurnContext。它包含 Turn ID、模型、推理设置、协作模式、权限、环境选择、Skills 快照和终态元数据等信息。
TurnContext 是一次 Turn 的主要配置快照。这里的“主要”很重要:模型和权限等决策需要在本次任务中保持一致,但 MCP 连接、环境就绪状态和项目指令仍可能在后续采样前刷新。Codex 没有把所有运行状态一次复制后永久冻结。
如果 Session 此时没有活跃 Task,它会创建 RegularTask,并把两类对象登记进 ActiveTurn:
RunningTask保存 Task 类型、取消令牌、后台 Tokio Handle 和 TurnContext;TurnState保存待处理输入、审批等待者、动态工具响应、临时权限和本 Turn 统计。
Session 的 active_turn 是可选值,而且一个 Session 同时至多运行一个主 Task。这个约束让审批、追加输入和终态事件都能明确关联到当前 Turn。工具执行仍然可以在 Task 内并发,这与“只有一个主 Task”并不冲突。
Task 决定 Turn 怎样运行
Task 不是操作系统进程,也不是 Thread 的子会话。它是 Session 对一类工作的执行策略。统一接口的核心职责是报告任务类型、执行 run,并在需要时通过 abort 完成取消后的专用清理;追踪名称等运行信息也由 Task 提供。
Codex 当前把主要任务分成三类:
| Task | 用途 | 是否接受运行中追加输入 |
|---|---|---|
| RegularTask | 普通对话、代码修改和工具反馈循环 | 接受 |
| ReviewTask | 运行受限制的代码审查流程并投影结果 | 不接受 |
| CompactTask | 压缩上下文,重建可继续使用的历史 | 不接受 |
普通用户请求由 RegularTask 执行。它先发出 TurnStarted,取得启动阶段预热的模型传输会话(如果可用),然后进入 run_turn;同一次 run_turn 内的后续采样会复用这份会话。ReviewTask 和 CompactTask 仍然使用同一套 Session 生命周期设施,但它们各自实现不同的执行逻辑与清理动作。
把 Task 单独抽象出来有两个直接作用。第一,Session 不需要在主循环里堆叠普通对话、审查和压缩的全部分支。第二,所有 Task 共享启动、取消、统计、持久化和终态事件规则,客户端不必为每种内部实现重新理解生命周期。
代价也很明确:Task 的业务结束不等于生命周期已经结束。Runtime 仍要刷新执行记录、计算用量、发送终态事件并安全地清除 ActiveTurn。把这些动作散落到每个 Task 中,很容易产生重复完成事件或残留状态,因此统一收口在 Session 的完成处理里。
一个 Turn 为什么需要多个 StepContext
RegularTask 进入 run_turn 后,才开始第一次模型采样。采样前,Session 捕获一份 StepContext。它把这一次请求实际使用的动态状态绑定在一起,包括:
- 当前环境是否已经就绪;
- 此刻加载到的
AGENTS.md; - 执行环境提供的能力发现结果;
- 当前 MCP Binding 和模型可见工具列表;
- 与工具列表对应的 ToolRouter。
模型看到的工具规格和 Runtime 随后使用的路由来自同一份 StepContext。即使 MCP 配置在采样期间刷新,已经发出的工具调用仍按产生它的那份执行视图解释,不会在半途中换到另一套路由。
当工具结果进入历史、用户追加输入到达或上下文发生变化时,下一次采样会捕获新的 StepContext。新的快照可以看见已经刷新的环境和工具状态,旧快照则保持不变。
因此,Step 不是一个长期保存的业务对象,也没有独立的客户端生命周期。它是一次模型采样的内部一致性边界。一个 Turn 可以只有一个 Step,也可以经历“搜索—读取—测试—修改—再测试—总结”等多次 Step。
把 RegularTask 和 run_turn 抽掉实现细节后,可以写成下面的伪代码:
async def run_regular_task(turn: TurnContext) -> str | None:
while True:
result = await run_turn(turn)
if not input_queue.has_pending_input(turn.id):
return result
async def run_turn(turn: TurnContext) -> str | None:
while True:
step = await capture_step_context(turn)
outcome = await sample_model(step)
await execute_and_record_tool_calls(outcome, step)
if not outcome.needs_follow_up and not input_queue.has_pending_input(turn.id):
return outcome.last_message
内层循环处理模型与工具的反馈,外层检查完成边界是否又出现了输入。两层检查不是重复劳动:用户可能恰好在最后一次采样结束、run_turn 准备返回时补充要求,外层检查可以避免这条输入被遗漏。
运行中输入会 Steer 当前 Regular Turn
回到开头的场景。Codex 正在运行测试时,用户补充“不要改接口,只修前端”。Session 不会立即再启动一个并行 Turn,而是先检查当前 ActiveTurn。
如果活跃 Task 是 RegularTask,这条输入会作为 pending input 加入当前 TurnState。steer_input 返回当前 Turn ID,表明它属于原来的工作边界。run_turn 在下一次模型采样前把输入写入历史,模型随后可以根据新要求调整方案。
Steer 不是把一句话硬塞进正在传输的模型响应。已经发出的采样使用原 StepContext 完成;追加要求在安全的采样边界生效。这避免了同一请求的 Prompt 和工具视图在传输中途变化。
Runtime 还会检查调用者提供的 expected Turn ID。如果界面以为自己正在调整 Turn A,而 Session 实际已经进入 Turn B,输入会被拒绝,不会误写到新任务。ReviewTask 和 CompactTask 也不接受 Steer,因为把普通用户要求混入审查或压缩流程会破坏它们的输入契约。
如果根本没有 ActiveTurn,steer_input 会把输入原样退回给调用路径,Session 再用已经构造的 TurnContext 启动新的 RegularTask。同一种 UserInput 因此可以根据 Session 当前状态表现为“创建 Turn”或“调整当前 Turn”。
完成和中断都必须经过状态收口
下面的状态图把普通 Turn、追加输入和中断放在一起。
正常完成时,Task 的 run 先返回结果,Runtime 刷新执行记录,再由 Session 统一完成以下动作:处理完成边界残留的输入,计算本 Turn 的 Token 和工具调用统计,发出 TurnComplete,清除 RunningTask,最后把 ActiveTurn 设回空闲。
清理 ActiveTurn 时还要确认它仍然对应刚完成的 TurnState。这个检查防止一个已经结束的异步任务误删后来建立的新状态。只有清理成功后,Session 才发出 Thread Idle 生命周期,并检查是否有排队工作需要唤醒新的 Turn。
中断走另一条路径。Op::Interrupt 到达 Session 主循环后,Runtime 先原子地取走 ActiveTurn,使新的 Steer 不再写入旧任务。随后取消 RunningTask 的 CancellationToken,给模型流、工具执行和审批等待一个协作退出的窗口;如果 Task 没有及时结束,再中止它的后台 Handle,并调用 Task 自己的 abort 清理。
对于用户中断,Codex 会先把模型可见的中断标记写入历史并完成持久化刷新,再发出 TurnAborted。这样恢复后的模型知道上一轮不是自然结束,客户端收到中断事件后重新读取记录时也不会看见缺失的历史。待审批者、待输入和其他 Turn 级等待项随后被清除。
无论正常完成还是中断,Session 和 Thread 都继续存在。用户下一句话会在同一个 Thread 中启动新 Turn,并继承此前已经提交到历史的内容。只有 Shutdown 或 Thread 被卸载,当前 Session 的生命周期才结束。
生命周期边界换来可控的并发
这套设计维持了几条关键不变量:
- 一个 Session 同时至多有一个主 Task,但 Task 内部可以运行并发工具和流式模型请求。
- TurnContext 固定一次 Turn 的主要决策,StepContext 固定一次采样真正使用的动态视图。
- 运行中的普通 Turn 可以吸收 Steer,特殊 Task 明确拒绝不兼容输入。
- 取消先切断 ActiveTurn,再通知异步工作退出,旧任务不能继续接收新输入。
- Turn 的完成、中断和持久化状态最终都由 Session 收口。
这些边界让 Codex 能在同一 Thread 中持续工作,同时避免多个主任务争抢历史、权限和终态事件。它们也增加了实现成本:输入可能落在采样、工具执行或完成检查之间;取消依赖每个异步组件配合;Turn 级配置和 Step 级动态状态必须维护清晰的刷新规则。
下一章将沿着 StepContext 继续向模型请求内部移动。重点不再是谁拥有状态,而是基础指令、对话历史、World State、项目规则和工具规格怎样在每次采样前组成模型真正看到的上下文。
评论
登录后即可评论