Thread与Turn概念模型
Codex 源码中的 Thread、Session、Turn 和 Task 不是四个彼此嵌套的业务实体。CodexThread 是运行 handle,Session 是一个已加载 thread 的运行时所有者,session_id 却标识整棵 agent tree;Core 没有保存历史 Turn 列表的 Turn 聚合对象,一次活动 Turn 由 context、active slot、task 和 mutable state 协作表达;客户端看到的 Thread 与 Turn 又是 App Server reducer 生成的快照。
如果忽略这些层次,很容易产生几种错误实现:从 manager map 删除 thread 就认为 Session 已销毁, 用 session_id 查找具体运行 handle,把历史 Turn 当成仍存在的 Tokio task,或者为了修改一个客户端 Turn 字段直接把 Core 内部状态暴露到协议层。
阅读前先用 Core运行时架构总览 建立职责域;需要完整对象地图时可对照 核心数据对象关系。本文只解释概念语义,不逐字段展开锁和 同步原语;进入具体 Session 字段后应转到 Session核心数据结构。
1. 实体投影
| 名称 | 具体类型 | 所属层 | 生命周期 | 是否直接持久化 |
|---|---|---|---|---|
| Thread 身份 | ThreadId | protocol | 一个具体 thread 的整个寿命 | 写入 metadata/rollout |
| Thread 运行 handle | CodexThread | Core | thread 加载到关闭 | 否 |
| Thread 运行时 | Session | Core | 与已加载 thread 基本同寿命 | 否 |
| Thread 客户端快照 | v2::Thread | App Server protocol | 一次响应/通知 | 从 store/live state 重建 |
| Agent-tree 身份 | SessionId | protocol / AgentControl | root 与 descendants 共享 | 写入 SessionMeta |
| Turn 身份 | submission ID / TurnContext.sub_id | Core | 一次 Turn | 写入 TurnContext/event |
| Turn 活动槽位 | ActiveTurn | Core | Session 当前活动阶段 | 否 |
| Turn 执行 | RunningTask + SessionTask | Core | task spawn 到 terminal | 否 |
| Turn 可变交互 | TurnState | Core | active Turn 内 | 否 |
| Turn 客户端快照 | v2::Turn | App Server protocol | 一次投影 | 从 events/items 重建 |
这里最反直觉的关系是:一个 Core Session 只服务一个 loaded Thread,但一个 SessionId 可以对应多 个 Core Session。Rust 类型名与协议字段名相似,语义却不在同一轴上。
2. Thread形态
2.1 ThreadId
ThreadId 包装 Uuid,new() 使用 UUIDv7。新建、清空和 fork history 会分配新 ID;resume 使用 原 history 中的 conversation/thread ID。它是 manager map、rollout metadata、App Server thread ID 和 agent graph 的关联键。
ThreadId 不是进程 ID,也不是模型 response ID。一个 thread 内可以产生多个 Turn、多个模型 response 和多个命令进程,这些 ID 不能互换。
2.2 CodexThread
ThreadManagerState 的 active map 保存 ThreadId → Arc<CodexThread>。CodexThread 本身组合:
Arc<Session>:运行状态和 thread-scoped services;SessionIo:submission sender、event receiver、status receiver 和 termination signal;SessionSource、首次SessionConfiguredEvent与 rollout path;- 少量 handle 级 out-of-band elicitation 状态。
它的核心职责是让宿主提交 Op、读取 Event、查询运行状态和关闭 Session。它不是可序列化的完整 Thread,也不保存历史 Turn 列表。
源码位置:codex-rs/core/src/codex_thread.rs :: CodexThread
pub struct CodexThread {
// Session 拥有状态与服务,SessionIo 拥有双向消息端点。
pub(crate) session: Arc<Session>,
pub(crate) io: SessionIo,
pub(crate) session_source: SessionSource,
// 创建握手快照保存在 handle 上,宿主无需重放首事件才能读取。
session_configured: SessionConfiguredEvent,
rollout_path: Option<PathBuf>,
out_of_band_elicitations: Mutex<OutOfBandElicitations>,
_diagnostics_guard: GaugeGuard,
}
impl CodexThread {
pub(crate) fn new(
session: Arc<Session>,
io: SessionIo,
session_configured: SessionConfiguredEvent,
rollout_path: Option<PathBuf>,
session_source: SessionSource,
) -> Self {
Self {
session,
io,
session_source,
session_configured,
rollout_path,
// 带外 elicitation 独立加锁,不污染 SessionState 或历史模型。
out_of_band_elicitations: Mutex::new(OutOfBandElicitations::default()),
}
}
// handle 只委托消息与关闭操作,不复制 Session 的控制状态。
pub async fn submit(&self, op: Op) -> CodexResult<String> {
self.io.submit(op).await
}
pub async fn shutdown_and_wait(&self) -> CodexResult<()> {
self.io.shutdown_and_wait().await
}
pub fn session_telemetry(&self) -> SessionTelemetry {
self.session.services.session_telemetry.clone()
}
pub async fn wait_until_terminated(&self) {
self.io.session_loop_termination.clone().await;
}
}session_configured 和 rollout_path 是 Thread 级快照,活动 Turn 的 task、waiter 与状态不在这个结构中; 这进一步说明 Thread 不是 Turn 集合容器。
2.3 StoredThread
Thread 从 manager map 移除或进程退出后,rollout、SQLite metadata 和 ThreadStore 仍可描述该 thread。 App Server 的 thread/list、thread/read 可以返回未加载记录,其 ThreadStatus 为 NotLoaded。
因此:
- loaded thread 一定有
Arc<CodexThread>; - stored thread 不一定 loaded;
- ephemeral thread 可能 loaded 却不 materialize 到磁盘;
- map remove 只解除 manager 所有权,其他
Arc持有者仍可使用 handle。
2.4 Thread 产品快照
App Server Thread 包含字符串 id/session_id、fork/parent 关系、preview、ephemeral、section、model provider、时间戳、status、path、cwd、source、Git metadata 和可选 turns。大多数 notification 中 turns 为空;只有 resume、rollback、fork、read 且请求 includeTurns 等路径才填充历史。
它不是 CodexThread 的 serde 版本。协议快照可以由 live state、ThreadStore metadata 和 rollout history 合成,而运行 handle 包含 channel、锁、Tokio task 和不可序列化 service。
3. Session
Core Session 的注释称其为 initialized model agent context,并声明同一时刻最多一个 running task。 它持有一个具体 thread_id、SessionState、ActiveTurn、InputQueue、SessionServices、realtime manager 和 预热/刷新控制。
SessionId 则是 agent control session 的身份。AgentControl 注释明确要求:每个 root thread/session tree 最多创建一份 control,它随后被所有 sub-agent 共享,所以共同 root 的 descendants 使用相同 session_id。
活动 Turn 在 Core 中是一个很小的拥有关系,而不是历史集合:ActiveTurn 只保存可选的运行任务和共享 的 TurnState。因此“当前有 Turn”与“当前已经 spawn 了 task”是两个可分别判断的事实;Steer、取消和 空闲恢复都必须在持有 active_turn 锁时观察这两个字段。
源码位置:codex-rs/core/src/state/turn.rs 的 ActiveTurn。
pub(crate) struct ActiveTurn {
pub(crate) task: Option<RunningTask>,
pub(crate) turn_state: Arc<Mutex<TurnState>>,
}源码位置:codex-rs/core/src/state/turn.rs 的 MailboxDeliveryPhase。 同一文件还用 MailboxDeliveryPhase 区分当前 Turn 是否仍可吸收 mailbox 消息:
/// Mailbox 是否还能并入当前 Turn 的下一次模型请求。
/// CurrentTurn 允许消费排队消息;NextTurn 把迟到消息留给下一 Turn。
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum MailboxDeliveryPhase {
/// 当前 Turn 仍可消费 mailbox。
#[default]
CurrentTurn,
/// 已产生可见终态,迟到消息不得延长答案。
NextTurn,
}
impl Default for ActiveTurn {
fn default() -> Self {
Self {
task: None,
turn_state: Arc::new(Mutex::new(TurnState::default())),
}
}
}这个结构证明的是活动槽位的所有权边界,不证明 TurnState 内部字段的语义;后者由 Session 状态与 TurnContext 专题分别展开。
3.1 ID层级
Session 初始化按下面的优先级解析 session_id:
- resume history 若有
RolloutItem::SessionMeta,优先恢复其中的session_id; - non-root agent 没有可用 persisted ID 时,继承传入
AgentControl.session_id(); - root thread 使用
SessionId::from(thread_id); - legacy subagent rollout 中等于自身 thread ID 的 session ID 会被过滤,再改为继承 agent tree ID。
SessionId 和 ThreadId 都包装 UUID,并提供双向 From 转换。这只表示 root 可以复用相同 UUID 值, 不表示二者语义相同。
源码位置:codex-rs/protocol/src/thread_id.rs :: ThreadId
pub struct ThreadId {
pub(crate) uuid: Uuid,
}
impl ThreadId {
// 新 Thread 使用 UUIDv7,使身份唯一且保留时间有序特征。
pub fn new() -> Self {
Self { uuid: Uuid::now_v7() }
}
pub fn from_string(s: &str) -> Result<Self, uuid::Error> {
Ok(Self { uuid: Uuid::parse_str(s)? })
}
}
pub struct SessionId {
pub(crate) uuid: Uuid,
}
// 转换只复用底层 UUID;Rust 类型仍阻止调用方混淆两种作用域。
impl From<ThreadId> for SessionId {
fn from(value: ThreadId) -> Self {
Self { uuid: value.uuid }
}
}
impl From<SessionId> for ThreadId {
fn from(value: SessionId) -> Self {
ThreadId { uuid: value.uuid }
}
}两种 ID 可以无损转换,是因为底层表示相同;类型仍分开,是为了让函数签名表达“一个 Thread”还是 “整棵 agent tree”。如果只把它们当字符串使用,编译器就无法阻止把 child ThreadId 误当 tree ID。
图中的 SessionId → AgentControl 是身份归属,不是内存容器关系。AgentControl 通过 weak manager handle 和自己的 registry 管理 agent tree,避免形成 ThreadManagerState → CodexThread → Session → AgentControl → ThreadManagerState 强引用环。
4. Turn聚合
App Server 定义了真正的客户端 Turn struct:id、items、items_view、status、error、 started/completed 时间和 duration。Core 运行时没有与它一一对应、长期保存的 struct。
一次活动 Turn 由四个对象协作表达:
| 对象 | 回答的问题 | 是否跨 Turn |
|---|---|---|
TurnContext | 本 Turn 使用什么模型、权限、环境、指令和 metadata? | 否 |
ActiveTurn | Session 当前是否有活动工作? | 否 |
RunningTask | 哪个算法正在运行,如何取消和等待? | 否 |
TurnState | 哪些审批、输入、elicitation 和动态工具 response 尚未返回? | 否 |
4.1 Turn身份
SessionIo::submit() 生成 submission ID。启动用户 Turn 时,该 ID 进入 TurnContext.sub_id,随后成为 TurnStartedEvent.turn_id、Event.id 关联值和 App Server Turn ID。 因此源码中经常把 sub_id 当作当前 Turn 的身份。
不是每个 submission 都创建 Turn。审批回复、MCP response、配置更新和 shutdown 也有 submission ID,但只是 Session 控制消息。只有进入 task/Turn lifecycle 的操作才形成客户端 Turn。
Core 的双向信封直接保留这条关联,而不是把它隐藏在 channel 元数据中:
源码位置:codex-rs/protocol/src/protocol.rs :: Submission, Event
pub struct Submission {
// 用户 Turn 和控制提交都用该 ID;是否形成 Turn 由 Op 分支决定。
pub id: String,
pub op: Op,
pub client_user_message_id: Option<String>,
pub trace: Option<W3cTraceContext>,
pub parent_turn_id: Option<String>,
}
pub struct Event {
// 事件通过同一 ID 回到所属 submission/Turn。
pub id: String,
pub msg: EventMsg,
}
pub enum EventMsg {
Error(ErrorEvent),
Warning(WarningEvent),
GuardianWarning(WarningEvent),
RealtimeConversationStarted(RealtimeConversationStartedEvent),
RealtimeConversationRealtime(RealtimeConversationRealtimeEvent),
RealtimeConversationClosed(RealtimeConversationClosedEvent),
// ...
}EventMsg 是开放增长的协议面,Event.id 才是跨事件类型稳定的关联键。调试时若只按 event variant 聚合,会把同一 Turn 的 warning、item 和 terminal event 拆散。
4.2 TurnContext
TurnContext 保存模型、provider、reasoning、SessionSource、history mode、parent thread、originator、 environment、cwd、日期时区、developer instructions、mode、network、sandbox level、dynamic tools、输出 schema,以及 timing/metadata state handles。
task、模型 step 和工具通过 Arc<TurnContext> 共享同一 Turn 语义。下一次 Turn 可以改变 model 或 permission,但当前 Turn 不应在执行中读取一份不断变化的全局 Config 来重解释已广告的工具和策略。
4.3 ActiveTurn单活
Session.active_turn 是 Mutex<Option<ActiveTurn>>。None 表示空闲;Some 表示已有 Turn state, 其中 task: Option<RunningTask> 可以在生命周期切换中暂时为空。判断“是否正在执行”时应检查具体 方法的语义,不能看到 Some(ActiveTurn) 就推断模型正在采样。
4.4 TurnState
TurnState 维护以 call/request ID 为键的 oneshot sender:approval、request permissions、 request_user_input、MCP elicitation 和 dynamic tool response。它还保存 pending input、当前 Turn 授予 的环境权限、tool-call count 与 token baseline。
这些 sender 只对当前进程中的 waiter 有意义。Turn 终止时必须 clear;resume 只能从 rollout 重建 用户可见 item 和上下文,不能恢复原 oneshot 或继续旧 Tokio future。
5. Task 执行策略
SessionTask trait 负责四件事:返回粗粒度 TaskKind、给 tracing 提供 span name、执行 async run(),以及可选的 abort() 清理。Session::spawn_task() 统一安装 cancellation token、Tokio handle、完成通知、TurnContext 与 terminal handling。
当前生产 task 的关系如下:
| Task 实现 | TaskKind | 是否走普通模型循环 | 主要终态语义 |
|---|---|---|---|
RegularTask | Regular | 是,调用 run_turn() | assistant complete/error/abort |
CompactTask | Compact | 专用 compaction 流程 | 更新 history/window |
ReviewTask | Review | 启动受限 review conversation | exited review item |
UserShellCommandTask | Regular | 否,直接 exec | command item + TurnComplete |
UserShellCommandTask 返回 TaskKind::Regular 是理解边界的关键:TaskKind 不是 task 实现的一一枚举, 而是状态查询、取消处理和遥测使用的粗粒度类别。若业务逻辑用 TaskKind 反推具体 Rust 类型,会把 user shell 当作普通模型 Turn。
同一个 Turn 通常由一个 task 驱动,但两个概念仍不可互换:
- Turn 有对外 ID,task 没有独立业务 ID;
- TurnContext 描述语义快照,task 描述执行算法;
- task 返回后,
on_task_finished()才产生 completed/failed/aborted 终态; - interrupt/replace 可以终止 task,但必须另行记录 TurnAborted;
- 历史重放重建 Turn,不会重新实例化原 task。
6. Turn生命周期
客户端 Turn 状态不是通过读取 RunningTask 生成的。Core 发出 lifecycle event,App Server 的 ThreadHistoryBuilder 归约事件和 rollout item:
TurnStatus 只有 Completed、Interrupted、Failed、InProgress。ThreadHistoryBuilder 收到 TurnStarted 时打开 PendingTurn,item event 更新其中的 items,TurnComplete 或 TurnAborted 闭合。 Error 可以记录到 current Turn,最终 status 还要结合 terminal event。
缺少 terminal event 的 rollout 可能留下 incomplete Turn,reducer 在 finish 或恢复逻辑中需要给出 可显示快照。这也是为什么持久化顺序重要:interrupt path 会先把 aborted marker/相关 item flush,再 发 TurnAborted,尽量保证客户端实时结果与恢复结果一致。
同一生命周期按参与者展开后,可以看出 Turn ID 如何从 submission 一直传到客户端 reducer。下面的 时序图只画身份与终态,不展开模型和工具内部循环。
SessionTask 没有自己的业务 ID;它借用 TurnContext.sub_id 产生 lifecycle event。reducer 依据该 ID 归并 item 和终态,所以控制类 submission 即使也有 ID,也不会自动形成一个 Turn。
7. Thread与Turn状态
这两个枚举位于 App Server v2 协议中,源码没有建立一一转换:
源码位置:codex-rs/app-server-protocol/src/protocol/v2/turn.rs :: TurnStatus
// Turn 状态只描述一轮工作的闭合结果。
pub enum TurnStatus {
Completed,
Interrupted,
Failed,
InProgress,
}
// Thread 状态描述 live runtime,并可携带等待外部交互的细分标记。
pub enum ThreadStatus {
NotLoaded,
Idle,
SystemError,
Active { active_flags: Vec<ThreadActiveFlag> },
}
pub enum ThreadActiveFlag {
WaitingOnApproval,
WaitingOnUserInput,
}ThreadStatus::Active 还能表达等待哪类外部交互,而 TurnStatus::InProgress 只说明 Turn 尚未闭合;这正是 源码中的状态来源也说明它们不能相互推导。
| 状态 | 含义 | 来源 |
|---|---|---|
ThreadStatus::NotLoaded | 只有 stored metadata,没有 live handle | ThreadStore/list/read |
ThreadStatus::Idle | loaded,但无活动工作 | live Session 状态 |
ThreadStatus::Active | 当前有活动行为,可带 waiting flags | active task / pending interaction |
ThreadStatus::SystemError | thread runtime 进入系统错误状态 | App Server live state |
TurnStatus::InProgress | 某个 Turn 已开始未闭合 | ThreadHistoryBuilder |
TurnStatus::Completed | 收到正常终态 | TurnComplete |
TurnStatus::Interrupted | 收到中断终态 | TurnAborted |
TurnStatus::Failed | Turn 有失败结果 | error/terminal projection |
一个 Thread 可以在历史中拥有多个 completed Turn,同时当前为 Idle;也可以 Thread Active,但历史 最后一个 Turn 的 items 尚未完整 materialize。Thread 状态不能通过 turns.last().status 简单计算, 因为 loaded runtime 还有 approval/user-input wait 和后台状态。
8. ID 关系
| ID | 作用域 | 典型载体 | 用途 |
|---|---|---|---|
SessionId | agent tree | SessionMeta、Thread.sessionId | 归并 root 与 descendants |
ThreadId | 一个 thread | manager map、rollout、Thread.id | 查 handle、store 与 agent node |
| Turn/submission ID | 一次 Turn/控制提交 | TurnContext.sub_id、Event.id、Turn.id | 串联 task 与客户端事件 |
| model response ID | 一次采样 | ResponseEvent/metadata | 模型连续性与遥测 |
| tool call ID | 一次工具调用 | call + output item | 配对模型请求和工具结果 |
| process ID | 一个执行进程 | exec begin/end/stdin | 写入、resize、signal、terminate |
排查 child agent 未显示时,先比对 session_id 和 parent ThreadId;排查一个 Turn 卡住时,查 Turn/submission ID 与 active task;排查工具结果未回灌时,查 call ID;排查终端命令残留时,查 process ID。只拿 ThreadId 搜索所有日志会得到大量同 thread 的无关采样和工具事件。
9. 常见错误推断
| 错误推断 | 为什么不成立 | 正确判断 |
|---|---|---|
一个 SessionId 对应一个 Session | descendants 共享 tree identity | 用 ThreadId 找具体 Session |
一个 Thread 永远有 CodexThread | stored thread 可以 NotLoaded | 区分 live map 与 ThreadStore |
Core Session 保存 Vec<Turn> | history 保存 ResponseItem,active_turn 只有一个 | 历史 Turn 由 reducer 重建 |
ActiveTurn::Some 必然正在模型采样 | task 可暂为空,也可能等待审批 | 检查 RunningTask 和 wait flags |
| 一个 task kind 对应一个 task 类 | UserShell 复用 Regular | 看具体 SessionTask 实现/span |
| task 结束等于历史已持久化 | flush 可失败并重试 | 同时检查 terminal event 和 rollout |
| TurnComplete 包含全部 UI item | delta/item 由独立事件产生 | 用 ThreadHistoryBuilder 归约完整序列 |
| remove thread 等于 shutdown | 其他 Arc 仍可持有 | 显式关闭并等待 loop 终止 |
10. 概念模型测试入口
概念关系应由跨层测试反证,而不是只看 struct:
core/src/session/tests.rs:root/resumed/subagentsession_id、Session 初始化和 active Turn;core/src/tasks/mod_tests.rs:task spawn、abort、terminal event 与 lifecycle;core/tests/suite/turn_state.rs:活动 Turn 的交互状态;core/tests/suite/pending_input.rs:同一 Turn 的 queued/steered input;core/tests/suite/resume.rs、fork_thread.rs:loaded/stored Thread 和新旧 ID;app-server-protocol/src/protocol/thread_history.rstests:事件/rollout 到 v2 Turn 的 reducer;app-server/tests/suite/thread_*.rs与 turn tests:公共 JSON-RPC 快照和 status。
一个完整验证案例至少应同时断言两个层次。例如 resume subagent 不仅要检查 Core session.session_id(),还应检查 SessionConfiguredEvent.session_id 或 App Server Thread.sessionId; interrupt 不仅要看到 task token 取消,还要看到 TurnAborted 和恢复后的 TurnStatus::Interrupted。
概念层的几个关键反例已经有直接 arrange/assert:steer_input_requires_active_turn 在没有 active Turn 时提交输入,断言 NoActiveTurn 且不创建 reservation; try_start_turn_if_idle_rejects_pending_trigger_turn_without_injecting 先放入 trigger-turn mailbox, 断言返回 PendingTriggerTurn、原输入原样退回且 active slot 仍为空; queue_only_mailbox_mail_waits_for_next_turn_after_answer_boundary 先把 mailbox 相位切到 NextTurn, 断言 queue-only mail 不会污染当前 Turn;tool_calls_reopen_mailbox_delivery_for_current_turn 则断言 工具调用重新打开当前 Turn 的投递相位。这些测试证明对象层级和 mailbox 相位,不证明任意调度交错都具有相同顺序。
同一个 ThreadId 是否仍是同一个运行对象,应由指针身份而不是 ID 值推断:
源码位置:codex-rs/core/src/thread_manager_tests.rs
// :: resume_stopped_thread_from_rollout_spawns_new_thread(核心断言)
assert_eq!(resumed.thread_id, source.thread_id);
// stopped resume保留业务身份,但创建新的CodexThread/Session owner。
assert!(!Arc::ptr_eq(&resumed.thread, &source.thread));最终可将概念模型压缩为四句话:ThreadId 标识具体 thread,CodexThread/Session 表达其 loaded runtime;SessionId 标识共享 root 的 agent tree;一次活动 Turn 由 context、slot、task 和 state 协作完成;客户端 Thread/Turn 是 live events 与 persisted history 的投影,而不是 Core 对象的直接 序列化。
继续追创建与恢复生命周期时,分别阅读 ThreadManager创建、 ThreadManager恢复 和 ThreadManager分叉。
可以用下面的只读搜索把本文的 Thread and Turn state 主线落回源码:
rg -n "struct Thread|struct Turn|TurnContext|TurnState|ActiveTurn" codex-rs/core/src