TurnTiming与Metadata
一次工具调用持续了两秒,为什么 tool_blocking_ms 只有几百毫秒?同一请求的 JSON 正文有工具清单, HTTP 头却没有,是否丢了字段?这两类现象都需要先找出数据的产生位置、采样时刻和接收者。 仅凭字段名称,既无法解释延迟,也无法判断元数据是否完整。
本文面向了解 Rust 的 Arc、锁和 async、准备沿源码定位这些现象的读者。 Thread与Turn概念模型 说明会话与工作轮次的关系, TurnContext字段 说明每轮上下文的来源, Turn主循环与退出条件 说明一轮为什么可以多次请求模型。 这里进一步追踪计时算法与元数据投影,不展开全部工具实现或遥测后端。
先固定对象层次:Thread 是长期会话,Session 是它的 Core 运行时;Turn 是一次工作轮次, Task 是执行该轮工作的异步任务;一轮中的 Step 持有当次采样使用的模型等上下文。 TurnTimingState 记录轮次内的时间,TurnMetadataState 管理身份、来源、策略和工作区信息。 读完应能从公开输入入口找到这两份状态,解释哪些数据会进入事件、指标、Responses 请求和 MCP 请求, 并用上游测试验证一次性记录、互斥计时与字段裁剪。
1. 任务起点
1.1 输入路由
时间起点首先是一个调用位置问题。客户端按下发送、输入进入队列、创建 TurnContext、任务正式开始, 发生在不同位置。Turn 的计时从 Session::start_task 取 Instant::now() 开始,不能覆盖之前所有排队和准备时间。
源码文件:codex-rs/core/src/codex_thread.rs
相关函数/类型:CodexThread::start_or_steer_turn(L338–L344,摘录)
// 作者注:返回值描述 Core 的输入路由结果,不等待整轮模型输出。
pub async fn start_or_steer_turn(
&self,
request: TurnInputRequest,
) -> CodexResult<TurnInputSubmission> {
self.submit_turn_input_with_mode(request, TurnInputMode::StartOrSteer)
.await
}StartOrSteer 表示 Core 可以新建一轮,也可以把输入送入已经运行的一轮。这里返回的 Started 或 Steered 是路由结果;模型回答由后续事件送出。submit_turn_input_with_mode 在需要开始工作时检查执行容量, 随后调用 self.io.submit_turn_input。
源码文件:codex-rs/core/src/session/mod.rs
相关函数/类型:SessionIo::submit_turn_input(L858–L879,摘录)
// 作者注:oneshot 等待路由结果;进入队列后丢弃等待端不会撤回输入。
pub(crate) async fn submit_turn_input(
&self,
mut request: TurnInputRequest,
mode: TurnInputMode,
) -> CodexResult<TurnInputSubmission> {
let id = new_submission_id();
let (reply_tx, reply_rx) = oneshot::channel();
let trace = request.trace.take();
self.submit_with_id(Submission {
id,
// 作者注:公开请求在这里封装为带 reply 的内部操作。
op: Op::TurnInput {
request: Box::new(request),
mode,
reply: reply_tx,
},
trace,
parent_turn_id: None,
root_turn_id: None,
})
.await?;
reply_rx.await.unwrap_or(Err(CodexErr::InternalAgentDied))
}这段代码说明了两条不同的 channel:Submission 进入会话的操作队列,reply_rx 只等待处理该操作的答复。 session/handlers.rs 的 Op::TurnInput 分支调用 turn_input::handle,把结果送回 reply。 等待端被丢弃不会撤回已经入队的操作;会话在答复前结束,则等待端得到 InternalAgentDied。 因此请求调用方的生命周期也不能直接当成 Turn 的生命周期。
源码文件:codex-rs/core/src/session/turn_input.rs
相关函数/类型:start_or_steer(L206–L228、L240–L248,局部节选)
// 作者注:只在没有活动 Turn 时创建上下文;其余成功路径继续使用当前 Turn。
// ...
Ok(turn_id) => {
settings.apply_steered(session, submission_id).await?;
Ok(TurnInputSubmission::Steered { turn_id })
}
Err(NotSubmittedReason::NoActiveTurn) => {
let turn_context = settings
.apply_started(session, submission_id.clone())
.await?;
if can_start_root_turn
&& !items.is_empty()
&& turn_context
.turn_metadata_state
.can_start_root_turn(&turn_context.session_source)
{
turn_context
.turn_metadata_state
.set_root_turn_id(submission_id.clone());
}
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata {
turn_context
.turn_metadata_state
.set_responsesapi_client_metadata(responsesapi_client_metadata);
}
// ...
session
.spawn_task(turn_context, task_input, RegularTask::new())
.await;
Ok(TurnInputSubmission::Started {
turn_id: submission_id,
})
}
Err(reason) => Ok(TurnInputSubmission::NotSubmitted { reason }),
}
// ...这是 start_or_steer 匹配 steer_input 结果的局部。已经有可接收输入的 Turn 时,沿 Steered 返回; 只有 NoActiveTurn 分支才创建新上下文、写入客户端元数据并启动 RegularTask。 其他拒绝原因保留为 NotSubmitted,不会借机新建一个 Turn。 spawn_task 还会先清理被替换的任务,再进入 start_task。
1.2 创建与开始
PreparedTurnInputSettings::apply_started 经 new_turn_with_sub_id 走到上下文构造。 两个状态在这里分配,但创建时间并不自动成为计时起点。
源码文件:codex-rs/core/src/session/turn_context.rs
相关函数/类型:Session::make_turn_context(L676–L692、L732–L735,局部节选)
// 作者注:身份、配置与策略先写入元数据;计时对象初建时尚未开始。
// ...
let turn_metadata_state = Arc::new(TurnMetadataState::new(
session_id.to_string(),
thread_id.to_string(),
session_configuration.forked_from_thread_id,
session_configuration.parent_thread_id,
&session_configuration.session_source,
session_configuration.thread_source.clone(),
sub_id.clone(),
cwd.clone(),
&permission_profile,
session_configuration.windows_sandbox_level,
network.is_some(),
auto_review_enabled,
&model_info,
));
turn_metadata_state
.set_responses_api_metadata(per_turn_config.responses_api_metadata.clone());
// ...
turn_metadata_state,
extension_data,
turn_timing_state: Arc::new(TurnTimingState::default()),
terminal_error: Arc::new(Mutex::new(None)),
// ...sub_id 成为 Turn ID;Session 的 fork/parent 信息、有效权限配置和模型能力进入元数据初始状态。 model_info 在这里提供的是与 Node REPL 有关的能力位,并不意味着整个实际模型选择被存进 metadata。 另一个 Arc<TurnTimingState> 初始没有开始时间。构造上下文期间发生的配置检查、环境装配等工作, 不能从之后的 duration_ms 推算出来。
源码文件:codex-rs/core/src/tasks/mod.rs
相关函数/类型:Session::start_task(L285–L303,局部节选)
// 作者注:同一次任务启动把墙钟毫秒写入 metadata,把 Instant 留给持续时间计算。
// ...
pub(crate) async fn start_task<T: SessionTask>(
self: &Arc<Self>,
turn_context: Arc<TurnContext>,
input: Vec<TurnInput>,
task: T,
mailbox_parent_provenance: MailboxParentProvenance,
) {
let task: Arc<dyn AnySessionTask> = Arc::new(task);
let task_kind = task.kind();
let span_name = task.span_name();
let started_at = Instant::now();
let turn_started_at_unix_ms = turn_context
.turn_timing_state
.mark_turn_started(started_at)
.await;
turn_context
.turn_metadata_state
.set_turn_started_at_unix_ms(turn_started_at_unix_ms);
let token_usage_at_turn_start = self.total_token_usage().await.unwrap_or_default();
// ...任务启动把一个单调时钟起点交给 timing,再将它返回的墙钟毫秒写入 metadata。 这个开始时间可在后续模型或工具请求中用来关联同一轮工作;耗时计算仍使用 Instant。 token_usage_at_turn_start 在后面读取,所以该读取已经处于计时区间内。
下图只展开“输入被接纳到开始记录”的顺序。参与者拥有不同的状态;框的分组不表示额外进程。
Steered复用当前 timing;它没有再次调用mark_turn_started。- 新上下文与
Started之间还经过任务接纳逻辑;开始时间由具体调用点定义。 - 开始字段写入 metadata 后,请求构造方才有机会读到它。它不是创建
TurnMetadataState时自动生成的字段。
2. 两种时钟
2.1 状态与锁
不要将所有包含 ms 的字段放在同一条时间轴上。计时对象同时保存耗时用的单调时间、事件关联用的 Unix 时间, 还持有一份独立的阶段剖面。下面是这些核心类型的完整字段定义。
源码文件:codex-rs/core/src/turn_timing.rs
相关函数/类型:TurnTimingState / TurnProfileState / TurnProfileTimingGuard(L43–L87,摘录)
// 作者注:首次信号与条目表使用异步锁;phase 使用同步锁,Drop 不需要 await。
#[derive(Debug, Default)]
pub(crate) struct TurnTimingState {
state: Mutex<TurnTimingStateInner>,
profile: StdMutex<TurnProfileState>,
}
#[derive(Debug, Default)]
struct TurnTimingStateInner {
started_at: Option<Instant>,
started_at_unix_secs: Option<i64>,
// 作者注:条目 ID 映射到墙钟毫秒,不是 phase 的耗时表。
item_started_at_ms: HashMap<String, i64>,
first_token_at: Option<Instant>,
first_message_at: Option<Instant>,
}
#[derive(Debug, Default)]
struct TurnProfileState {
started_at: Option<Instant>,
last_transition_at: Option<Instant>,
// 作者注:只有三种互斥活动 phase;空闲阶段由 seen_sampling 再分类。
active_phase: Option<TurnProfilePhase>,
seen_sampling: bool,
before_first_sampling: Duration,
sampling: Duration,
compaction: Duration,
between_sampling_overhead: Duration,
tool_blocking: Duration,
// 作者注:先暂存采样后的空闲,到下一次采样或完成时才决定归属。
pending_idle_after_sampling: Duration,
sampling_request_count: u32,
sampling_retry_count: u32,
// 作者注:完成后保存快照,后续 phase 操作不能继续修改这份结果。
completed_profile: Option<TurnProfile>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TurnProfilePhase {
Sampling,
Compaction,
ToolBlocking,
}
#[must_use]
pub(crate) struct TurnProfileTimingGuard {
timing: Arc<TurnTimingState>,
phase: TurnProfilePhase,
active: bool,
}state 的 Tokio 互斥锁保护首次信号和条目表;短小的 phase 更新使用标准库 Mutex。 后者使 TurnProfileTimingGuard::drop 可以同步完成记账,不需要在析构函数里等待一个异步锁。 两把锁承担不同职责,不能因为一个类型名里有 Timing 就认为一次读取能得到所有字段的联合快照。 profile_state() 在锁中毒时取回内部值,但这也不等于能恢复任意被破坏的业务不变量。
下图区分所有者、状态和交付结果。TurnProfile 是完成时输出的数据,guard 则持有原状态的强引用。
| 字段 | 时钟或单位 | 用途 |
|---|---|---|
started_at、两个 first 时间 | Instant | 当前进程内计算耗时 |
started_at_unix_secs | Unix 秒 | Turn 完成/中断事件的开始时间 |
turn_started_at_unix_ms | Unix 毫秒 | 请求元数据中的开始时间 |
item_started_at_ms | Unix 毫秒 | 与 item 完成时间配对 |
各 phase 的 Duration | 单调时钟差 | 最后转换为整数毫秒 |
2.2 重置范围
源码文件:codex-rs/core/src/turn_timing.rs
相关函数/类型:TurnTimingState::mark_turn_started / now_unix_timestamp_ms(L90–L100、L216–L221,摘录)
// 作者注:重置本轮首次信号与条目表;墙钟与单调时钟独立取样。
pub(crate) async fn mark_turn_started(&self, started_at: Instant) -> i64 {
let started_at_unix_ms = now_unix_timestamp_ms();
let mut state = self.state.lock().await;
state.started_at = Some(started_at);
state.started_at_unix_secs = Some(started_at_unix_ms / 1000);
state.item_started_at_ms.clear();
state.first_token_at = None;
state.first_message_at = None;
self.profile_state().start(started_at);
started_at_unix_ms
}
// ...
pub(crate) fn now_unix_timestamp_ms() -> i64 {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
}mark_turn_started 清空条目表和两个 first 记录,随后 TurnProfileState::start 用默认值重建 phase 状态。 因此类本身允许重新开始;正常构造路径通常为新 TurnContext 分配新 timing,不能将这个使用惯例 写成“completed profile 永远不能重置”。重置前也不能仍把旧 guard 当作新轮次的有效计时器。
SystemTime 早于 Unix epoch 时兜底为零,过大值转换时饱和到 i64::MAX。 这类容错只保证表示可用;系统墙钟的跳变仍可能影响 item 时间戳之间的差值。 Turn 总耗时不由两个 Unix 时间戳相减,而由 Instant 得到。
3. 首个输出
3.1 信号筛选
TTFT(Time to First Token)在这里是“从任务开始到首个满足筛选规则的输出信号”。 它不是网络收到第一个字节的时间,也不是终端画出第一个字符的时间。 先看事件层,再看事件携带的 item 层,才能还原这个指标的含义。
源码文件:codex-rs/core/src/turn_timing.rs
相关函数/类型:response_event_records_turn_ttft(L379–L399,摘录)
// 作者注:这里只判断事件种类;四种 delta/done 信号没有再次检查字符串长度。
fn response_event_records_turn_ttft(event: &ResponseEvent) -> bool {
match event {
ResponseEvent::OutputItemDone(item) | ResponseEvent::OutputItemAdded(item) => {
response_item_records_turn_ttft(item)
}
ResponseEvent::OutputTextDelta(_)
| ResponseEvent::ReasoningSummaryDelta { .. }
| ResponseEvent::ReasoningSummaryDone { .. }
| ResponseEvent::ReasoningContentDelta { .. } => true,
ResponseEvent::Created
| ResponseEvent::ServerModel(_)
| ResponseEvent::ModelVerifications(_)
| ResponseEvent::TurnModerationMetadata(_)
| ResponseEvent::SafetyBuffering(_)
| ResponseEvent::ServerReasoningIncluded(_)
| ResponseEvent::ToolCallInputDelta { .. }
| ResponseEvent::Completed { .. }
| ResponseEvent::ReasoningSummaryPartAdded { .. }
| ResponseEvent::RateLimits(_)
| ResponseEvent::ModelsEtag(_) => false,
}OutputTextDelta、三种 reasoning delta/done 信号直接返回 true,这里没有检查其字符串是否为空。 OutputItemAdded 和 OutputItemDone 则委托另一个函数判断内容。 生命周期、限流、模型信息和 ToolCallInputDelta 都不触发记录。 把这张分支表概括为“任意事件”或“首个非空文本”都会漏掉真实条件。
源码文件:codex-rs/core/src/turn_timing.rs
相关函数/类型:response_item_records_turn_ttft(L402–L439,摘录)
// 作者注:完成项与新增项走同一筛选函数;工具调用是有效输出,工具返回值不是。
fn response_item_records_turn_ttft(item: &ResponseItem) -> bool {
match item {
ResponseItem::Message { .. } => {
raw_assistant_output_text_from_item(item).is_some_and(|text| !text.is_empty())
}
ResponseItem::Reasoning {
summary, content, ..
} => {
summary.iter().any(|entry| match entry {
codex_protocol::models::ReasoningItemReasoningSummary::SummaryText { text } => {
!text.is_empty()
}
}) || content.as_ref().is_some_and(|entries| {
entries.iter().any(|entry| match entry {
codex_protocol::models::ReasoningItemContent::ReasoningText { text }
| codex_protocol::models::ReasoningItemContent::Text { text } => {
!text.is_empty()
}
})
})
}
ResponseItem::AgentMessage { .. } => false,
ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::Compaction { .. }
| ResponseItem::ContextCompaction { .. } => true,
ResponseItem::CompactionTrigger { .. } => false,
ResponseItem::AdditionalTools { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::Other => false,
}
}普通 Message 必须能提取非空的 assistant 输出,reasoning item 必须有非空 summary 或 content。 但函数调用、工具搜索、Web 搜索、图像生成以及两类压缩 item 只依据变体即可触发。 反过来,FunctionCallOutput 等是工具返回值,不作为这里的模型首次输出。
还要区分两个同名概念:这里的 ResponseItem::AgentMessage 明确返回 false;后面的 TTFM 检查的是 归一化后的 TurnItem::AgentMessage。它们来自不同枚举,不能交换着解释。 这也意味着 TTFT 有值时,用户界面可能还没有可展示的回答文本。
3.2 一次性记录
源码文件:codex-rs/core/src/turn_timing.rs
相关函数/类型:TurnTimingStateInner::record_turn_ttft / record_turn_ttfm(L354–L377,摘录)
// 作者注:两个 Option 分别锁存首次信号;未设置 started_at 时直接返回 None。
impl TurnTimingStateInner {
fn time_to_first_token(&self) -> Option<Duration> {
Some(self.first_token_at?.duration_since(self.started_at?))
}
fn record_turn_ttft(&mut self) -> Option<Duration> {
if self.first_token_at.is_some() {
return None;
}
self.started_at?;
self.first_token_at = Some(Instant::now());
self.time_to_first_token()
}
fn record_turn_ttfm(&mut self) -> Option<Duration> {
if self.first_message_at.is_some() {
return None;
}
let started_at = self.started_at?;
let first_message_at = Instant::now();
self.first_message_at = Some(first_message_at);
Some(first_message_at.duration_since(started_at))
}
}事件满足条件后,还必须已有 started_at,且对应 first 字段为空。 “第一次”由共享状态与互斥锁共同保证;后续采样继续共享本 Turn 的状态,不会每请求重记一次。 first_token_at 和 first_message_at 独立,不能将它们画成只能先后进入的两个互斥状态。 仅由这个结构本身,也不能推出所有调用者都保证 TTFM ≥ TTFT。
源码文件:codex-rs/core/src/session/turn.rs
相关函数/类型:try_run_sampling_request(L2291–L2304,局部节选)
// 作者注:只有成功解码出的 ResponseEvent 才进入 Turn 级计时;流错误直接走失败路径。
// ...
let event = match event {
Some(Ok(event)) => event,
Some(Err(err)) => break Err(err),
None => {
break Err(CodexErr::Stream(
"stream closed before response.completed".into(),
));
}
};
sess.services
.session_telemetry
.record_responses(&handle_responses, &event);
record_turn_ttft_metric(&turn_context, &event).await;
// ...真正的 TTFT 调用位于 try_run_sampling_request 的响应循环中:流必须先交出一个 Ok(ResponseEvent)。 流提前关闭或解码失败会先退出,无法凭空补一个 first 时间。 同一个循环还记录 response 级遥测,那些请求级字段有自己的起点,不应与 Turn TTFT 混算。
源码文件:codex-rs/core/src/turn_timing.rs
相关函数/类型:record_turn_ttft_metric / record_turn_ttfm_metric(L19–L41,摘录)
// 作者注:一次性状态返回 Some 时才发射指标;TTFT 还由 SessionTelemetry 记录生产事件。
pub(crate) async fn record_turn_ttft_metric(turn_context: &TurnContext, event: &ResponseEvent) {
let Some(duration) = turn_context
.turn_timing_state
.record_ttft_for_response_event(event)
.await
else {
return;
};
turn_context.session_telemetry.record_turn_ttft(duration);
}
pub(crate) async fn record_turn_ttfm_metric(turn_context: &TurnContext, item: &TurnItem) {
let Some(duration) = turn_context
.turn_timing_state
.record_ttfm_for_turn_item(item)
.await
else {
return;
};
turn_context
.session_telemetry
.record_duration(TURN_TTFM_DURATION_METRIC, duration, &[]);
}记录成功才调用 telemetry。SessionTelemetry::record_turn_ttft 位于 codex-rs/otel/src/events/session_telemetry.rs,同时记录 codex.turn.ttft.duration_ms 和 codex.turn_ttft 生产事件;TTFM 在这里记录 codex.turn.ttfm.duration_ms。 消费这两个指标的 runtime summary 是累计视图,不能代替单个 Turn 的开始/完成事件。
3.3 完整消息
TTFM(Time to First Message)的入口是 Session::emit_turn_item_completed。 该函数在生成 ItemCompleted 前调用 record_turn_ttfm_metric;状态层只接受 TurnItem::AgentMessage。 所以它记录的是 Core 发布第一条完成消息时的时间,既不在 item started 处,也不在 UI 真正显示后。 条目配对的完整代码在后文“条目时间”中展开。
源码文件:codex-rs/core/src/turn_timing_tests.rs
相关函数/类型:turn_timing_state_records_ttfm_independently_of_ttft(L51–L85,摘录)
// 作者注:测试故意使用空 content 的 AgentMessage;它检验类型与一次性边界,不检验非空消息。
async fn turn_timing_state_records_ttfm_independently_of_ttft() {
let state = TurnTimingState::default();
state.mark_turn_started(Instant::now()).await;
assert!(
state
.record_ttft_for_response_event(&ResponseEvent::OutputTextDelta("hi".to_string()))
.await
.is_some()
);
assert!(
state
.record_ttfm_for_turn_item(&TurnItem::AgentMessage(AgentMessageItem {
id: "msg-1".to_string(),
content: Vec::new(),
phase: None,
memory_citation: None,
delivery: None,
}))
.await
.is_some()
);
assert_eq!(
state
.record_ttfm_for_turn_item(&TurnItem::AgentMessage(AgentMessageItem {
id: "msg-2".to_string(),
content: Vec::new(),
phase: None,
memory_citation: None,
delivery: None,
}))
.await,
None
);
}测试先写一个输出 delta,再完成一个空 content 的 AgentMessage,最后重复完成另一条 AgentMessage。 前两次分别得到有效 TTFT/TTFM,最后一次返回 None。它直接验证两份 first 状态独立, 同时揭示 TTFM 状态层并不重新校验消息正文。测试没有调用网络或 UI,不能证明传输或绘制延迟。 另一个 turn_timing_state_records_ttft_only_once_per_turn 还覆盖了开始前输出、Created 和重复 delta。
4. 采样剖面
4.1 计时区间
单一 TTFT 不能回答“首个输出之后慢在哪里”。TurnProfile 将轮次拆成多个互斥时间桶; 要理解每个桶,先看 guard 放在调用链的什么地方。
源码文件:codex-rs/core/src/session/turn.rs
相关函数/类型:try_run_sampling_request(L2217–L2236,局部节选)
// 作者注:guard 在 stream 建立前创建,因此包括该 await 和随后的流消费。
// ...
let sampling_timing_guard = turn_context.turn_timing_state.begin_sampling();
let uses_sequential_cutoff_reasoning_summaries = turn_context
.config
.features
.enabled(Feature::ConcurrentReasoningSummaries)
&& turn_context.provider.info().is_openai();
let mut stream = client_session
.stream(
prompt,
&step_context.model_info,
&step_context.session_telemetry,
step_context.reasoning_effort.clone(),
step_context.reasoning_summary,
step_context.service_tier.clone(),
responses_metadata,
&inference_trace,
)
.instrument(trace_span!("stream_request"))
.or_cancel(&cancellation_token)
.await??;
// ...Sampling 从 stream 建立之前开始,涵盖请求准备之后的连接/发送等待以及响应循环。 .or_cancel(...).await?? 提前返回时,局部 guard 同样析构。 所以 sampling_ms 是 Core 观察到的这段请求与流处理时间,不能解读为服务端纯推理时间。
源码文件:codex-rs/core/src/session/turn.rs
相关函数/类型:try_run_sampling_request(L2747–L2763,局部节选)
// 作者注:先结束 Sampling,再刷新文本,再单独计算等待剩余工具的时间。
// ...
drop(sampling_timing_guard);
flush_assistant_text_segments_all(
&sess,
&turn_context,
plan_mode_state.as_mut(),
&mut assistant_message_stream_parsers,
)
.await;
let tool_blocking_timing_guard = if in_flight.is_empty() {
None
} else {
Some(turn_context.turn_timing_state.begin_tool_blocking())
};
drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?;
drop(tool_blocking_timing_guard);
// ...模型仍在输出时,工具 future 可以已经运行;这段重叠仍归 Sampling。 响应循环退出后显式释放 sampling guard,再刷新文本,最后对 in_flight 的剩余等待开启 ToolBlocking。 由此可以解释两秒工具只产生几百毫秒阻塞的现象:profile 衡量主链在哪个阶段花时间,并不求每个工具耗时之和。 刷新文本发生在两个 guard 之间,它归入空闲时间,再根据是否还有下一次采样决定最终桶。
源码文件:codex-rs/core/src/session/turn.rs
相关函数/类型:run_sampling_request(L1437–L1451,局部节选)
// 作者注:只有允许继续并完成重试处理后才增加 retry 计数,不能等同于 HTTP 请求次数。
// ...
if !err.is_retryable() {
return Err(err);
}
handle_retryable_response_stream_error(
&mut retry_state,
max_retries,
err,
client_session,
&sess,
&turn_context,
ResponsesStreamRequest::Sampling,
)
.await?;
turn_context.turn_timing_state.record_sampling_retry();
// ...这是采样重试循环的尾部。不可重试错误直接返回;允许继续的错误先完成重试处理,再增加 sampling_retry_count。 下一轮成功开启 Sampling 才增加 sampling_request_count。低层 transport 内部的 HTTP 重试未必经过这些调用点, 因此这两个计数也不是网络抓包中的请求数。
源码文件:codex-rs/core/src/tasks/compact.rs
相关函数/类型:CompactTask::run(L28–L39,局部节选)
// 作者注:手动压缩入口先持有 Compaction guard;TokenBudget 分支提前返回也会释放它。
// ...
async fn run(
self: Arc<Self>,
session: Arc<Session>,
ctx: Arc<TurnContext>,
_input: Vec<TurnInput>,
_cancellation_token: CancellationToken,
) -> SessionTaskResult {
let _profile_guard = ctx.turn_timing_state.begin_compaction();
if ctx.config.features.enabled(Feature::TokenBudget) {
crate::compact_token_budget::run_manual_compact_task(session, ctx).await?;
return Ok(None);
}
// ...手动压缩在自己的任务入口持有 Compaction guard。自动压缩则在 session/turn.rs 的 run_auto_compact 开始处持有同类 guard,再选择 TokenBudget、远程或本地实现。阶段归属由 guard 范围决定, 不会因为内部也访问模型就自动叠加到 Sampling。
4.2 互斥与析构
源码文件:codex-rs/core/src/turn_timing.rs
相关函数/类型:TurnTimingState::begin_sampling / TurnProfileTimingGuard::drop(L145–L152、L202–L210,摘录)
// 作者注:inactive guard 只维持生命周期,不得在 Drop 时关闭已有的外层 phase。
pub(crate) fn begin_sampling(self: &Arc<Self>) -> TurnProfileTimingGuard {
let active = self.profile_state().begin_sampling(Instant::now());
TurnProfileTimingGuard {
timing: Arc::clone(self),
phase: TurnProfilePhase::Sampling,
active,
}
}
// ...
impl Drop for TurnProfileTimingGuard {
fn drop(&mut self) {
if self.active {
self.timing
.profile_state()
.end_phase(Instant::now(), self.phase);
}
}
}guard 里的 active 记录本次申请是否真正获得了 phase。重入申请可以返回一个 inactive guard; 它析构时什么也不做,避免关闭外层已经运行的阶段。 Drop 只调用状态记账,不代表任务成功,也不会自己发送完成事件。
源码文件:codex-rs/core/src/turn_timing.rs
相关函数/类型:TurnProfileState::begin_sampling / begin_tool_blocking / begin_compaction(L236–L281,摘录)
// 作者注:所有 phase 都拒绝重入;再次采样才把暂存空闲转入 between_sampling_overhead。
fn begin_sampling(&mut self, now: Instant) -> bool {
if self.completed_profile.is_some()
|| self.started_at.is_none()
|| self.active_phase.is_some()
{
return false;
}
self.advance(now);
if self.seen_sampling {
// 作者注:用 take 搬移,避免同一段空闲在完成时再次计数。
self.between_sampling_overhead += std::mem::take(&mut self.pending_idle_after_sampling);
}
self.seen_sampling = true;
self.active_phase = Some(TurnProfilePhase::Sampling);
self.sampling_request_count = self.sampling_request_count.saturating_add(1);
true
}
fn record_sampling_retry(&mut self) {
if self.completed_profile.is_none() && self.started_at.is_some() {
self.sampling_retry_count = self.sampling_retry_count.saturating_add(1);
}
}
fn begin_tool_blocking(&mut self, now: Instant) -> bool {
if self.completed_profile.is_some()
|| self.started_at.is_none()
|| self.active_phase.is_some()
{
return false;
}
self.advance(now);
self.active_phase = Some(TurnProfilePhase::ToolBlocking);
true
}
fn begin_compaction(&mut self, now: Instant) -> bool {
if self.completed_profile.is_some()
|| self.started_at.is_none()
|| self.active_phase.is_some()
{
return false;
}
self.advance(now);
self.active_phase = Some(TurnProfilePhase::Compaction);
true
}三个 begin 都检查:未开始、已经完成、已有活动 phase,任一成立就拒绝。 begin_sampling 额外做两件事:把暂存空闲搬到采样间开销,增加一次采样请求数。 一段采样结束时无法知道后面是否还有采样,所以当下不能直接将空闲定为“最后一次采样之后”。
下面画的是 active_phase 与完成快照的真实转换;Idle 对应 active_phase == None,并非额外枚举值。
| 转换/条件 | 记账结果 |
|---|---|
Idle 且 seen_sampling == false | 空闲累加到 before_first_sampling |
Idle 且 seen_sampling == true | 空闲暂存到 pending_idle_after_sampling |
| 再次 begin Sampling | 将暂存值搬到 between_sampling_overhead |
| complete | 剩余暂存值成为 after_last_sampling |
| 活动 phase 内再 begin | 拒绝,已有 phase 继续计时 |
| 已完成后 drop 或 begin | 不再改变已保存的 profile |
4.3 延迟归类
源码文件:codex-rs/core/src/turn_timing.rs
相关函数/类型:TurnProfileState::end_phase / advance(L283–L303,摘录)
// 作者注:先把上次转换到 now 的差值记入旧 phase,再改变 active_phase。
fn end_phase(&mut self, now: Instant, phase: TurnProfilePhase) {
if self.completed_profile.is_some() || self.active_phase != Some(phase) {
return;
}
self.advance(now);
self.active_phase = None;
}
fn advance(&mut self, now: Instant) {
let Some(previous) = self.last_transition_at.replace(now) else {
return;
};
let elapsed = now.saturating_duration_since(previous);
match self.active_phase {
Some(TurnProfilePhase::Sampling) => self.sampling += elapsed,
Some(TurnProfilePhase::Compaction) => self.compaction += elapsed,
Some(TurnProfilePhase::ToolBlocking) => self.tool_blocking += elapsed,
None if self.seen_sampling => self.pending_idle_after_sampling += elapsed,
None => self.before_first_sampling += elapsed,
}
}advance 总是先记旧状态从上次转换到 now 的差值。end_phase 然后才清空活动 phase。 反过来先清状态,就会把刚结束的 Sampling 错记到 idle 桶。 saturating_duration_since 对反向时间输入兜底为零;正常路径使用同一进程单调时钟。
源码文件:codex-rs/core/src/turn_timing.rs
相关函数/类型:TurnProfileState::complete(L305–L351,摘录)
// 作者注:统一完成时刻、最后一次归类、毫秒舍入补偿和结果冻结。
fn complete(&mut self, now: Instant) -> TurnProfile {
if let Some(profile) = self.completed_profile.as_ref() {
return profile.clone();
}
// 作者注:记录最后 phase,后面用它接收毫秒截断产生的余数。
let final_phase = self.active_phase;
self.advance(now);
let after_last_sampling = if self.seen_sampling {
std::mem::take(&mut self.pending_idle_after_sampling)
} else {
Duration::ZERO
};
let mut profile = TurnProfile {
before_first_sampling_ms: duration_to_u64_ms(self.before_first_sampling),
sampling_ms: duration_to_u64_ms(self.sampling),
compaction_ms: duration_to_u64_ms(self.compaction),
between_sampling_overhead_ms: duration_to_u64_ms(self.between_sampling_overhead),
tool_blocking_ms: duration_to_u64_ms(self.tool_blocking),
after_last_sampling_ms: duration_to_u64_ms(after_last_sampling),
sampling_request_count: self.sampling_request_count,
sampling_retry_count: self.sampling_retry_count,
};
let total_ms = self
.started_at
.map(|started_at| duration_to_u64_ms(now.saturating_duration_since(started_at)))
.unwrap_or_default();
let classified_ms = profile
.before_first_sampling_ms
.saturating_add(profile.sampling_ms)
.saturating_add(profile.compaction_ms)
.saturating_add(profile.between_sampling_overhead_ms)
.saturating_add(profile.tool_blocking_ms)
.saturating_add(profile.after_last_sampling_ms);
// 作者注:每一桶分别转毫秒会向下取整,总时长与分桶之差只加一次。
let rounding_ms = total_ms.saturating_sub(classified_ms);
match final_phase {
Some(TurnProfilePhase::Sampling) => profile.sampling_ms += rounding_ms,
Some(TurnProfilePhase::Compaction) => profile.compaction_ms += rounding_ms,
Some(TurnProfilePhase::ToolBlocking) => profile.tool_blocking_ms += rounding_ms,
None if self.seen_sampling => profile.after_last_sampling_ms += rounding_ms,
None => profile.before_first_sampling_ms += rounding_ms,
}
self.active_phase = None;
self.completed_profile = Some(profile.clone());
profile
}完成分为三步:把最后一段记完,将未归类的空闲归入尾部,最后解决整数毫秒舍入。 例如两段各 0.6 ms 的时间分别转换只得到 0 + 0,但总区间转换为 1 ms;rounding_ms 补回差额,归给完成时的活动 phase,或者最后的空闲桶。 这样常规范围内的六个毫秒桶之和与同一完成时刻的总耗时一致。
completed_profile 使重复调用 complete 返回原快照。注意缓存的只是 profile; 外层 complete_profile_and_duration_ms 还会重新取结束时间。不能把内部幂等性扩大为 “任何时候重复调用外层方法都会得到相同的全部时间字段”。
输出结构 TurnProfile 定义在 codex-rs/analytics/src/facts.rs,包含六个毫秒桶和两个采样计数。 它记录的是各段累计值,没有保存每次转换的完整时间线;要重建更细的事件顺序还需要 trace 或日志。
4.4 数值推演
源码文件:codex-rs/core/src/turn_timing_tests.rs
相关函数/类型:turn_profile_breaks_down_sampling_blocking_and_retry_overhead(L205–L240,摘录)
// 作者注:用人为推进的 Instant 验证 1300 ms 如何拆分,不依赖调度器 sleep。
fn turn_profile_breaks_down_sampling_blocking_and_retry_overhead() {
let started_at = Instant::now();
let mut state = TurnProfileState::default();
state.start(started_at);
let _ = state.begin_sampling(started_at + Duration::from_millis(100));
state.end_phase(
started_at + Duration::from_millis(600),
TurnProfilePhase::Sampling,
);
let _ = state.begin_tool_blocking(started_at + Duration::from_millis(600));
state.end_phase(
started_at + Duration::from_millis(900),
TurnProfilePhase::ToolBlocking,
);
state.record_sampling_retry();
let _ = state.begin_sampling(started_at + Duration::from_millis(1_000));
state.end_phase(
started_at + Duration::from_millis(1_200),
TurnProfilePhase::Sampling,
);
assert_eq!(
state.complete(started_at + Duration::from_millis(1_300)),
TurnProfile {
before_first_sampling_ms: 100,
sampling_ms: 700,
compaction_ms: 0,
between_sampling_overhead_ms: 100,
tool_blocking_ms: 300,
after_last_sampling_ms: 100,
sampling_request_count: 2,
sampling_retry_count: 1,
}
);
}这项测试给定了一个完整的 1300 ms 区间,读者可以按调用顺序自行分桶:
| 区间 | 状态 | 最终毫秒桶 |
|---|---|---|
| 0–100 | 首次 Sampling 前的 Idle | before_first_sampling_ms = 100 |
| 100–600 与 1000–1200 | 两次 Sampling | sampling_ms = 700 |
| 600–900 | 等待工具 | tool_blocking_ms = 300 |
| 900–1000 | 暂存 Idle,随后又采样 | between_sampling_overhead_ms = 100 |
| 1200–1300 | 暂存 Idle,随后完成 | after_last_sampling_ms = 100 |
这说明 duration - sampling 包含工具等待、压缩和多种框架开销,不能直接命名为工具耗时。 turn_profile_counts_compaction_as_an_exclusive_phase 进一步在采样前后插入压缩,断言压缩只进入独立桶。 这两项测试验证分类算法;使用人工 Instant 推进,不能据此宣称真实调度的性能基准。
5. 结束与条目
5.1 完成快照
源码文件:codex-rs/core/src/turn_timing.rs
相关函数/类型:TurnTimingState::complete_profile_and_duration_ms(L120–L136,摘录)
// 作者注:duration 与 profile 共享同一 Instant;completed_at 单独读取墙钟秒。
pub(crate) async fn complete_profile_and_duration_ms(
&self,
) -> (Option<i64>, Option<i64>, TurnProfile) {
let completed_at_instant = Instant::now();
let state = self.state.lock().await;
let completed_at = Some(now_unix_timestamp_secs());
let duration_ms = state.started_at.map(|started_at| {
i64::try_from(
completed_at_instant
.saturating_duration_since(started_at)
.as_millis(),
)
.unwrap_or(i64::MAX)
});
let profile = self.profile_state().complete(completed_at_instant);
(completed_at, duration_ms, profile)
}外层先取一个 completed_at_instant,同时用于总耗时与 profile。 turn_profile_and_duration_share_a_completion_instant 验证六桶之和等于 duration_ms, 避免分别取两次结束时间而产生无法解释的差值。 没有开始时间时,总耗时为 None;墙钟完成时间仍可存在,不能凭结束字段反推正常开始过。
源码文件:codex-rs/core/src/tasks/mod.rs
相关函数/类型:Session::on_task_finished(L773–L783、L794–L825,局部节选)
// 作者注:profile 送 analytics;事件只携带完成字段,普通完成额外携带 TTFT 和 error。
// ...
let started_at = turn_context.turn_timing_state.started_at_unix_secs().await;
let (completed_at, duration_ms, profile) = turn_context
.turn_timing_state
.complete_profile_and_duration_ms()
.await;
self.services
.analytics_events_client
.track_turn_profile(TurnProfileFact {
turn_id: turn_context.sub_id.clone(),
profile,
});
// ...
let event = if let Some(reason) = abort_reason {
if reason == TurnAbortReason::Interrupted {
run_turn_interrupt_hooks(self, &turn_context).await;
}
self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref())
.await;
EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some(turn_context.sub_id.clone()),
reason,
started_at,
completed_at,
duration_ms,
})
} else {
let time_to_first_token_ms = turn_context
.turn_timing_state
.time_to_first_token_ms()
.await;
let error = turn_context.terminal_error.lock().await.clone();
self.emit_turn_stop_lifecycle(turn_context.extension_data.as_ref())
.await;
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: turn_context.sub_id.clone(),
last_agent_message,
error,
started_at,
completed_at,
duration_ms,
time_to_first_token_ms,
})
};
self.send_event(turn_context.as_ref(), event).await;
// ...on_task_finished 把详细 profile 发给 analytics_events_client.track_turn_profile, 以 turn_id 关联到轮次;随后构造公开的完成/中断事件。 TurnComplete 带有 error 和 time_to_first_token_ms,TurnAborted 带有原因和公共时间字段。 所以计时状态的消费者既包括观测链路,也包括用户可见协议事件。
同一个完成事件携带 error 时不能按名称直接判定为业务成功。 测量位置前还有任务结果处理、Git 任务取消和清理工作;位置后则还有生命周期 hook、事件传送等动作。 它表示 Core 选择的轮次区间,不是从用户点击到客户端呈现的端到端 SLA。
5.2 中断与暂停
源码文件:codex-rs/core/src/tasks/mod.rs
相关函数/类型:Session::handle_task_abort(L909–L925、L952–L975,局部节选)
// 作者注:终止先取消 Git 补充与任务,再生成中断事件的耗时快照。
// ...
task.turn_context
.turn_metadata_state
.cancel_git_enrichment_task();
let session_task = task.task;
select! {
_ = task.done.notified() => {
},
_ = tokio::time::sleep(Duration::from_millis(GRACEFULL_INTERRUPTION_TIMEOUT_MS)) => {
warn!("task {sub_id} didn't complete gracefully after {}ms", GRACEFULL_INTERRUPTION_TIMEOUT_MS);
}
}
task.handle.abort();
session_task
.abort(Arc::clone(self), Arc::clone(&task.turn_context))
// ...
let started_at = task
.turn_context
.turn_timing_state
.started_at_unix_secs()
.await;
let (completed_at, duration_ms, profile) = task
.turn_context
.turn_timing_state
.complete_profile_and_duration_ms()
.await;
self.services
.analytics_events_client
.track_turn_profile(TurnProfileFact {
turn_id: task.turn_context.sub_id.clone(),
profile,
});
let event = EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some(task.turn_context.sub_id.clone()),
reason,
started_at,
completed_at,
duration_ms,
});
self.send_event(task.turn_context.as_ref(), event).await;
// ...中断路径取走 Git enrichment 的句柄,等待任务的短暂优雅退出,再 abort 并调用任务自己的清理逻辑。 计时快照在这些操作之后生成,所以 duration_ms 可以包含退出与清理等待。 局部 phase guard 的析构结束对应区间,但仅凭析构无法知道最后会得到 TurnComplete 还是 TurnAborted。
暂停还存在一个不同分支:codex-rs/core/src/session/turn_suspension.rs 的 suspend_turn_and_shutdown 在接纳暂停后取消任务和 Git 补充,刻意不记录正常的 terminal turn event,供另一个 worker 恢复原 Turn ID。 因此不能把“任何停止都会发送带 profile 的结束通知”写成统一保证。 恢复沿 handle_recovery → start_if_idle → apply_started 创建运行时上下文,这里的 Instant 状态并不会 作为持久化对象跨进程续接。
5.3 条目时间
一个 Turn 可有多个同时在途的 item,不能仅用一个 current_item_started_at 保存开始时间。 状态层以 item ID 配对,重复开始保留第一次记录。
源码文件:codex-rs/core/src/turn_timing.rs
相关函数/类型:TurnTimingState::record_item_started / take_item_started(L106–L118,摘录)
// 作者注:entry 保留第一次开始时间;完成消费会删除对应 ID。
pub(crate) async fn record_item_started(&self, item_id: String, started_at_ms: i64) -> i64 {
*self
.state
.lock()
.await
.item_started_at_ms
.entry(item_id)
.or_insert(started_at_ms)
}
pub(crate) async fn take_item_started(&self, item_id: &str) -> Option<i64> {
self.state.lock().await.item_started_at_ms.remove(item_id)
}发送 ItemStarted 的 Session::emit_turn_item_started 先调用此方法,再把得到的时间放进事件。 完成时使用 remove 消费,保证相同 ID 不会无限留在表里。它存的是墙钟毫秒, 不能替代 profile 的单调时间区间。
源码文件:codex-rs/core/src/session/mod.rs
相关函数/类型:Session::emit_turn_item_completed(L2254–L2286,摘录)
// 作者注:TTFM 的真实调用点在条目完成;缺失开始记录时用完成时间兜底并发出 warning。
pub(crate) async fn emit_turn_item_completed(
&self,
turn_context: &TurnContext,
item: TurnItem,
) {
record_turn_ttfm_metric(turn_context, &item).await;
let completed_at_ms = now_unix_timestamp_ms();
let item_id = item.id();
let started_at_ms = turn_context
.turn_timing_state
.take_item_started(&item_id)
.await
.unwrap_or_else(|| {
warn!(
thread_id = %self.thread_id,
turn_id = %turn_context.sub_id,
item_id = %item_id,
"item completed without a recorded start timestamp"
);
completed_at_ms
});
self.send_event(
turn_context,
EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id: self.thread_id,
turn_id: turn_context.sub_id.clone(),
item,
started_at_ms: Some(started_at_ms),
completed_at_ms,
}),
)
.await;
}这也是前面 TTFM 的真实消费入口。先尝试记录首个 AgentMessage 完成时间,再取 item 的开始记录。 如果缺失,日志会留下 item completed without a recorded start timestamp,并用完成时间兜底。 因此观察到零长度 item 不一定意味着工具瞬时完成,也可能是开始/完成不配对;应连同 warning 检查。
源码文件:codex-rs/core/src/turn_timing_tests.rs
相关函数/类型:turn_timing_state_preserves_in_flight_items_after_turn_completion(L130–L144,摘录)
// 作者注:新一轮开始清空旧条目,完成 profile 则保留仍在途条目的开始记录。
async fn turn_timing_state_preserves_in_flight_items_after_turn_completion() {
let state = TurnTimingState::default();
state
.record_item_started("first".to_string(), /*started_at_ms*/ 100)
.await;
state.mark_turn_started(Instant::now()).await;
assert_eq!(state.take_item_started("first").await, None);
state
.record_item_started("second".to_string(), /*started_at_ms*/ 200)
.await;
let _ = state.complete_profile_and_duration_ms().await;
assert_eq!(state.take_item_started("second").await, Some(200));
}测试先证明新开始清空旧 item,再证明完成 profile 保留新的在途 item。 这使稍后到达的完成事件仍有机会消费原来的开始时间。 它只保证状态容器的保留行为,并不承诺任意取消、进程退出或客户端断连后所有 item 都一定送达。
6. 元数据来源
6.1 身份与可变值
元数据回答“这是谁的哪次工作、用了什么策略、来自什么环境”。它与 profile 共用 Turn 生命周期, 但既不使用同一把锁,也不在 Turn 完成时才生成。请求方在需要发送请求时读取它。
源码文件:codex-rs/core/src/turn_metadata.rs
相关函数/类型:TurnMetadataState(L101–L133,摘录)
// 作者注:按身份、策略、可变请求上下文和后台任务分组阅读字段。
#[derive(Debug)]
pub(crate) struct TurnMetadataState {
cwd: AbsolutePathBuf,
repo_root: Option<PathBuf>,
session_id: String,
thread_id: String,
agent_name: String,
forked_from_thread_id: Option<ThreadId>,
parent_thread_id: Option<ThreadId>,
// 作者注:血缘只接受第一次有效写入;root 另有歧义屏蔽标记。
parent_turn_id: OnceLock<String>,
initiating_agent_path: OnceLock<AgentPath>,
root_turn_id: OnceLock<String>,
subagent_header: Option<String>,
subagent_kind: Option<String>,
thread_source: Option<ThreadSource>,
turn_id: String,
// TODO(anp): Derive this cached tag from TurnEnvironment::sandbox_context
// so metadata reflects the selected environment's backend.
sandbox: Option<String>,
sandbox_mode: Option<String>,
auto_review_enabled: bool,
node_repl_auto_review_required: bool,
node_repl_disabled: bool,
// 作者注:这些 RwLock 存储可更新值,请求构造时分别克隆。
enriched_workspaces: RwLock<Option<BTreeMap<String, TurnMetadataWorkspace>>>,
tool_namespaces_info: RwLock<Option<TurnToolNamespacesInfo>>,
turn_started_at_unix_ms: RwLock<Option<i64>>,
responses_api_metadata: RwLock<BTreeMap<String, String>>,
responsesapi_client_metadata: RwLock<BTreeMap<String, String>>,
root_turn_ambiguous: AtomicBool,
user_input_requested_during_turn: AtomicBool,
// 作者注:句柄控制启动与取消,watch 负责唤醒等待者。
enrichment_task: Mutex<Option<JoinHandle<()>>>,
git_enrichment_complete: watch::Sender<bool>,
}| 字段组 | 来源与生效时间 | 并发约束 |
|---|---|---|
| session/thread/turn、fork、parent thread、agent name、source | 构造时来自 Session 与本轮 sub_id | 普通不可变字段 |
| parent/root turn、initiating agent | 开始选项或被接纳的消息血缘 | OnceLock 首次写入 |
| sandbox、审批与模型能力位 | 创建上下文时的有效配置/模型 | 本轮初始快照,不是安全执行器 |
| workspace、工具清单、开始时间 | Git 补充、工具规划、任务开始 | 各自 RwLock |
| 两张 extra 表 | 配置与客户端输入 | setter 整表替换 |
| root 歧义、请求过用户输入 | 已发生的运行时信号 | 原子标志 |
| enrichment handle/watch | 后台补充生命周期 | 句柄互斥,watch 唤醒 |
sandbox 附近的上游 TODO 还明确指出缓存标签需要进一步与所选环境的 sandbox context 对齐。 因此标签可以帮助解释请求上下文,却不能作为操作已经受到某个后端沙箱强制的证明。 平台差异进入 permission_profile_sandbox_tag 等标签生成函数,计时算法本身没有按平台替换的分支。
源码文件:codex-rs/core/src/turn_metadata.rs
相关函数/类型:TurnMetadataState::set_parent_turn_id / root_turn_id(L274–L308,摘录)
// 作者注:拒绝空白 ID;OnceLock 不覆盖既有值,歧义标志隐藏 root 而不重写血缘。
pub(crate) fn set_parent_turn_id(&self, parent_turn_id: String) {
if parent_turn_id.trim().is_empty() {
return;
}
let _ = self.parent_turn_id.set(parent_turn_id);
}
pub(crate) fn parent_turn_id(&self) -> Option<String> {
self.parent_turn_id.get().cloned()
}
pub(crate) fn set_initiating_agent_path(&self, initiating_agent_path: AgentPath) {
let _ = self.initiating_agent_path.set(initiating_agent_path);
}
pub(crate) fn initiating_agent_path(&self) -> Option<&AgentPath> {
self.initiating_agent_path.get()
}
pub(crate) fn set_root_turn_id(&self, root_turn_id: String) {
if root_turn_id.trim().is_empty() {
return;
}
let _ = self.root_turn_id.set(root_turn_id);
}
pub(crate) fn root_turn_id(&self) -> Option<String> {
self.root_turn_id
.get()
.filter(|_| !self.root_turn_ambiguous.load(Ordering::Relaxed))
.cloned()
}
pub(crate) fn mark_root_turn_ambiguous(&self) {
self.root_turn_ambiguous.store(true, Ordering::Relaxed);空白 parent/root ID 被拒绝,但非空 ID 不是先 trim 再存储。 后续重复 set 不覆盖已有值;当消息来源令 root 不再唯一时,mark_root_turn_ambiguous 使读取返回 None。丢失 root 字段可能是有意抑制错误归因,并不说明 Turn 自己的 ID 消失。 它也不会修改已经发出的请求快照。
6.2 两张扩展表
responses_api_metadata 来自配置,responsesapi_client_metadata 来自客户端输入。 名称只差几个字符,入口约束却不同。
源码文件:codex-rs/core/src/turn_metadata.rs
相关函数/类型:TurnMetadataState::set_responsesapi_client_metadata / set_responses_api_metadata(L329–L348,摘录)
// 作者注:两个 setter 都替换整张表;客户端字段先过滤,配置字段依赖上游验证。
pub(crate) fn set_responsesapi_client_metadata(
&self,
responsesapi_client_metadata: HashMap<String, String>,
) {
*self
.responsesapi_client_metadata
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
filter_extra_metadata(responsesapi_client_metadata);
}
pub(crate) fn set_responses_api_metadata(
&self,
responses_api_metadata: BTreeMap<String, String>,
) {
*self
.responses_api_metadata
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = responses_api_metadata;
}两个 setter 都使用赋值,语义是替换整张表。 当 steer 携带新客户端 metadata 时,当前 Turn 的这张表会被替换;未传该参数则不执行 setter。 不能把它当作逐字段补丁,也不能认为新值会自动修改正在重试的旧请求对象。
源码文件:codex-rs/core/src/responses_metadata.rs
相关函数/类型:validate_extra_metadata / filter_extra_metadata / valid_extra_metadata_key(L437–L472,摘录)
// 作者注:配置验证与客户端过滤是两套算法,只有前者实施数量、字节长度与 ASCII 标识符限制。
pub(crate) fn validate_extra_metadata<'a>(
extra: impl IntoIterator<Item = (&'a String, &'a String)>,
) -> Result<(), &'static str> {
let mut count = 0;
for (key, value) in extra {
count += 1;
if count > MAX_EXTRA_METADATA_ENTRIES {
return Err("responses_api_metadata may contain at most 16 entries");
}
if key.len() > MAX_EXTRA_METADATA_KEY_BYTES || !valid_extra_metadata_key(key) {
return Err("responses_api_metadata keys must be short ASCII identifiers");
}
if RESERVED_METADATA_KEYS.contains(&key.as_str()) {
return Err("responses_api_metadata contains a reserved key");
}
if value.len() > MAX_EXTRA_METADATA_VALUE_BYTES {
return Err("responses_api_metadata values may contain at most 128 bytes");
}
}
Ok(())
}
pub(crate) fn filter_extra_metadata(
extra: impl IntoIterator<Item = (String, String)>,
) -> BTreeMap<String, String> {
extra
.into_iter()
.filter(|(key, _)| !RESERVED_METADATA_KEYS.contains(&key.as_str()))
.collect()
}
fn valid_extra_metadata_key(key: &str) -> bool {
let mut bytes = key.bytes();
bytes.next().is_some_and(|byte| byte.is_ascii_alphabetic())
&& bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
}validate_extra_metadata 对配置执行最多 16 项、key 最多 64 字节、value 最多 128 字节的限制, 并要求 key 以 ASCII 字母开头,后续只允许字母、数字、下划线、点、连字符。 Rust 字符串的 len() 在这里表示字节数,所以中文 value 的限制并非 128 个汉字。 任何不合格项都使整个配置校验失败。
filter_extra_metadata 则只删除 Core 保留键,然后收集成有序表;它没有数量、长度或 key 字符规则。 保留键表包含身份、用途、策略、工具清单以及对应兼容 header 名,已移除的 code_mode_tool_names 也继续保留,防止客户端把旧清单重新塞回来。 model 与 reasoning_effort 不在这份保留表中,后面还要依据接收者解释其值。
源码文件:codex-rs/core/src/config/mod.rs
相关函数/类型:Config::load_config_with_layer_stack(L3129–L3133,局部节选)
// 作者注:配置不合法时返回 InvalidInput;不能把这个限制移植为客户端参数过滤语义。
// ...
if let Some(responses_api_metadata) = cfg.responses_api_metadata.as_ref() {
validate_extra_metadata(responses_api_metadata.iter()).map_err(|message| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, message)
})?;
}
// ...配置加载器在接受 responses_api_metadata 时调用严格验证,把失败转为 InvalidInput。 这才是 16/64/128 限制的真实生效位置。 客户端参数进入 Turn 状态时走过滤函数,不能仅看到文件顶部的三个常量就宣称它也受相同限制。
6.3 合并与快照
源码文件:codex-rs/core/src/turn_metadata.rs
相关函数/类型:responses_metadata_template / mcp_metadata_template(L358–L382,局部节选)
// 作者注:先从 MCP 模板删掉配置同名键,再仅为 Responses 覆盖配置值。
// ...
fn responses_metadata_template(&self) -> CodexResponsesMetadata {
let mut metadata = self.mcp_metadata_template();
metadata.extra.extend(
self.responses_api_metadata
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone(),
);
metadata
}
fn mcp_metadata_template(&self) -> CodexResponsesMetadata {
let mut extra = self
.responsesapi_client_metadata
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
for key in self
.responses_api_metadata
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.keys()
{
extra.remove(key);
}
// ...合并顺序有两个目的。Responses 使用配置值覆盖客户端同名值;MCP 不向外发送这批配置字段, 也不会因删除配置值后又回退到客户端伪造的同名值。于是 MCP 模板先把所有配置同名键删掉, Responses 模板才把配置表补进去。
源码文件:codex-rs/core/src/turn_metadata.rs
相关函数/类型:TurnMetadataState::mcp_metadata_template(L383–L412,局部节选)
// 作者注:公共身份与策略来自 Turn 快照,Git、工具清单、开始时间则读取当前值。
// ...
CodexResponsesMetadata {
turn_id: Some(self.turn_id.clone()),
agent_name: Some(self.agent_name.clone()),
forked_from_thread_id: self.forked_from_thread_id,
parent_thread_id: self.parent_thread_id,
parent_turn_id: self.parent_turn_id.get().cloned(),
root_turn_id: self.root_turn_id(),
subagent_header: self.subagent_header.clone(),
subagent_kind: self.subagent_kind.clone(),
thread_source: self.thread_source.clone(),
sandbox: self.sandbox.clone(),
sandbox_mode: self.sandbox_mode.clone(),
auto_review_enabled: Some(self.auto_review_enabled),
node_repl_auto_review_required: Some(self.node_repl_auto_review_required),
node_repl_disabled: Some(self.node_repl_disabled),
workspaces: self.current_workspaces(),
tool_namespaces_info: self
.tool_namespaces_info
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone(),
turn_started_at_unix_ms: self.current_turn_started_at_unix_ms(),
extra,
..CodexResponsesMetadata::new(
String::new(),
self.session_id.clone(),
self.thread_id.clone(),
String::new(),
)
}
// ...类型化字段继续从状态读取:身份保留创建期值,root 经过歧义判断,workspace 和工具清单取当前克隆。 各个锁分别读取,不能将一次模板构造称作覆盖所有可变字段的原子快照。 这些 extra 值最终通过 payload 的 #[serde(flatten)] 进入 blob, 并不会逐个成为顶层 client_metadata 键。
源码文件:codex-rs/core/src/responses_metadata.rs
相关函数/类型:CodexResponsesMetadata(L199–L232,摘录)
// 作者注:每个调用方持有自己的值快照,后续 TurnMetadataState 的更新不会回写到它。
/// Caller-owned snapshot of Codex metadata sent to ResponsesAPI.
///
/// The full Codex turn metadata blob is transported canonically as
/// `client_metadata["x-codex-turn-metadata"]`. Flat `client_metadata` keys and direct HTTP/ws
/// headers are generated compatibility projections of this snapshot, not separate sources of
/// truth.
#[derive(Clone, Debug)]
pub struct CodexResponsesMetadata {
pub(crate) installation_id: String,
pub(crate) session_id: String,
pub(crate) thread_id: String,
pub(crate) agent_name: Option<String>,
pub(crate) turn_id: Option<String>,
pub(crate) routing_hint: Option<HeaderValue>,
pub(crate) window_id: String,
pub(crate) context_window_id: Option<Uuid>,
pub(crate) request_kind: Option<CodexResponsesRequestKind>,
pub(crate) forked_from_thread_id: Option<ThreadId>,
pub(crate) parent_thread_id: Option<ThreadId>,
pub(crate) parent_turn_id: Option<String>,
pub(crate) root_turn_id: Option<String>,
pub(crate) subagent_header: Option<String>,
pub(crate) subagent_kind: Option<String>,
pub(crate) thread_source: Option<ThreadSource>,
pub(crate) sandbox: Option<String>,
pub(crate) sandbox_mode: Option<String>,
pub(crate) auto_review_enabled: Option<bool>,
pub(crate) node_repl_auto_review_required: Option<bool>,
pub(crate) node_repl_disabled: Option<bool>,
pub(crate) workspaces: BTreeMap<String, TurnMetadataWorkspace>,
pub(crate) tool_namespaces_info: Option<TurnToolNamespacesInfo>,
pub(crate) turn_started_at_unix_ms: Option<i64>,
pub(crate) extra: BTreeMap<String, String>,
}CodexResponsesMetadata 自身是调用方拥有的值对象。 例如 Git 查询在它创建后才完成,已经克隆到请求里的 workspaces 不会自动更新。 同样,routing_hint 是路由用 header 信息,与 JSON blob 中的观察字段有不同消费者。
源码文件:codex-rs/core/src/session/session.rs
相关函数/类型:Session::responses_metadata(L653–L667,摘录)
// 作者注:window 与 context_window 在取快照时由 Session 补入,不属于永久 Turn 身份。
pub(crate) async fn responses_metadata(
&self,
turn_context: &TurnContext,
request_kind: CodexResponsesRequestKind,
) -> CodexResponsesMetadata {
let (window_id, context_window_id) = self.current_window().await;
CodexResponsesMetadata {
context_window_id: Some(context_window_id),
..turn_context.turn_metadata_state.to_responses_metadata(
self.installation_id.clone(),
window_id,
request_kind,
)
}
}Session 在构造快照时补当前 window_id 与 context_window_id, run_turn 在 session/turn.rs 中把这个对象借给 run_sampling_request。 后者的内部重试循环仍使用这份借用。要观察 steer 或 Git 更新,必须分清下一轮重新构造的采样快照, 与同一次采样内部的重试请求。
7. 请求投影
7.1 用途与身份
源码文件:codex-rs/core/src/responses_metadata.rs
相关函数/类型:CodexResponsesRequestKind(L138–L159,摘录)
// 作者注:用途决定 blob 中身份字段的投影;压缩描述仅由 Compaction 变体携带。
#[derive(Clone, Copy, Debug)]
pub(crate) enum CodexResponsesRequestKind {
Turn,
Prewarm,
Compaction(CompactionTurnMetadata),
Memory,
}
impl CodexResponsesRequestKind {
fn metadata(self) -> (&'static str, Option<CompactionTurnMetadata>) {
match self {
CodexResponsesRequestKind::Turn => ("turn", None),
CodexResponsesRequestKind::Prewarm => ("prewarm", None),
CodexResponsesRequestKind::Compaction(metadata) => ("compaction", Some(metadata)),
CodexResponsesRequestKind::Memory => ("memory", None),
}
}
fn has_turn_identity(self) -> bool {
!matches!(self, CodexResponsesRequestKind::Memory)
}
}Turn、Prewarm、Compaction、Memory 描述请求用途,不是四种互斥的 Thread 状态。 压缩变体随身带着触发原因、实现、阶段与策略信息;它只描述发送时的操作背景, 不是后续压缩是否成功的结果记录。
源码文件:codex-rs/core/src/responses_metadata.rs
相关函数/类型:CodexResponsesMetadata::turn_metadata_payload(L351–L396,摘录)
// 作者注:has_turn_identity 与 has_request_identity 控制的字段不同;血缘字段并未全部受这两个布尔值控制。
fn turn_metadata_payload(&self) -> CodexTurnMetadataPayload<'_> {
let request_kind = self.request_kind;
let (request_kind_value, compaction) = request_kind.map_or((None, None), |request_kind| {
let (request_kind, compaction) = request_kind.metadata();
(Some(request_kind), compaction)
});
let has_turn_identity =
request_kind.is_none_or(CodexResponsesRequestKind::has_turn_identity);
let has_request_identity =
request_kind.is_some_and(CodexResponsesRequestKind::has_turn_identity);
CodexTurnMetadataPayload {
installation_id: has_request_identity.then_some(self.installation_id.as_str()),
session_id: has_turn_identity.then_some(self.session_id.as_str()),
thread_id: has_turn_identity.then_some(self.thread_id.as_str()),
agent_name: has_turn_identity
.then_some(self.agent_name.as_deref())
.flatten(),
turn_id: has_turn_identity
.then_some(self.turn_id.as_deref())
.flatten(),
window_id: has_request_identity.then_some(self.window_id.as_str()),
context_window_id: has_request_identity
.then_some(self.context_window_id)
.flatten(),
request_kind: request_kind_value,
forked_from_thread_id: self.forked_from_thread_id,
parent_thread_id: self.parent_thread_id,
parent_turn_id: self.parent_turn_id.as_deref(),
root_turn_id: self.root_turn_id.as_deref(),
subagent_kind: self.subagent_kind.as_deref(),
thread_source: self.thread_source.as_ref(),
sandbox: self.sandbox.as_deref(),
sandbox_mode: self.sandbox_mode.as_deref(),
auto_review_enabled: self.auto_review_enabled,
node_repl_auto_review_required: self.node_repl_auto_review_required,
node_repl_disabled: self.node_repl_disabled,
workspaces: non_empty_workspaces(&self.workspaces),
tool_namespaces_info: self.tool_namespaces_info.as_ref(),
turn_started_at_unix_ms: self.turn_started_at_unix_ms,
compaction,
// Extra metadata enriches the Codex turn metadata blob, not literal top-level
// Responses client_metadata. Product metadata is validated while loading config;
// app-server metadata has reserved Codex-owned keys filtered when it enters turn state.
extra: &self.extra,
}
}has_turn_identity 在 kind 缺省时仍为真,has_request_identity 则要求 kind 存在且不是 Memory。 这让内部/MCP 模板可以带 Turn 身份而没有完整的请求身份。 表中“可携带”仍受对应 Option 是否有值影响。
| kind | session/thread/turn/agent | installation/window/context window | request kind | compaction |
|---|---|---|---|---|
None | 可携带 | 不携带 | 缺省 | 缺省 |
Turn | 可携带 | 可携带 | turn | 缺省 |
Prewarm | 可携带 | 可携带 | prewarm | 缺省 |
Compaction | 可携带 | 可携带 | compaction | 本次压缩描述 |
Memory | 不携带 | 不携带 | memory | 缺省 |
这个表只适用于 turn_metadata_payload 管理的对应字段。 fork/parent/root 等血缘字段在代码中直接投影,并未统一受上述布尔条件裁剪。 实际 detached_memory_responses_metadata 从新对象构造,未设置这些血缘值,所以其输出没有这些字段; 这与“Memory 自动清除对象上的所有血缘”是不同的结论。
源码文件:codex-rs/core/src/turn_metadata_tests.rs
相关函数/类型:turn_metadata_state_overlays_compaction_only_on_compaction_requests(L920–L951,局部节选)
// 作者注:同一状态先创建压缩快照,再创建普通快照;压缩字段不粘在 Turn 上。
// ...
let compact_header = test_compaction_responses_metadata_json(
&state,
"thread-a:2",
CompactionTurnMetadata::new(
CompactionTrigger::Auto,
CompactionReason::ContextLimit,
CompactionImplementation::ResponsesCompactionV2,
CompactionPhase::MidTurn,
),
);
let compact_json: Value = serde_json::from_str(&compact_header).expect("json");
assert_eq!(compact_json["request_kind"].as_str(), Some("compaction"));
assert_eq!(compact_json["turn_id"].as_str(), Some("turn-a"));
assert_eq!(compact_json[WINDOW_ID_KEY].as_str(), Some("thread-a:2"));
assert_eq!(compact_json["codex_security_surface"].as_str(), Some("sdk"));
assert_eq!(
compact_json["compaction"],
serde_json::json!({
"trigger": "auto",
"reason": "context_limit",
"implementation": "responses_compaction_v2",
"phase": "mid_turn",
"strategy": "memento",
})
);
let regular_header = test_turn_responses_metadata_json(&state, "thread-a:3");
let regular_json: Value = serde_json::from_str(®ular_header).expect("json");
assert_eq!(regular_json["request_kind"].as_str(), Some("turn"));
assert_eq!(regular_json[WINDOW_ID_KEY].as_str(), Some("thread-a:3"));
assert_eq!(regular_json["codex_security_surface"].as_str(), Some("sdk"));
assert!(regular_json.get("compaction").is_none());
// ...测试用同一份 Turn 状态依次生成压缩和普通请求。第一份包含 context_limit、mid_turn、memento 等压缩信息,第二份没有 compaction,且采用传入的另一个 window。 这样验证用途是每个请求的选择,不会粘在共享 Turn 状态上污染后面的普通采样。
源码文件:codex-rs/core/src/turn_metadata_tests.rs
相关函数/类型:detached_memory_responses_metadata_omits_turn_identity(L139–L166,局部节选)
// 作者注:这里断言的是 turn_metadata_json 的内容,不能外推所有 header 与平铺键。
// ...
let header = detached_memory_responses_metadata(
String::new(),
String::new(),
String::new(),
String::new(),
&SessionSource::Unknown,
&repo_path,
&PermissionProfile::read_only(),
Some("none"),
)
.await
.turn_metadata_json()
.expect("header");
assert!(header.is_ascii());
assert!(!header.contains("東京"));
let parsed: Value = serde_json::from_str(&header).expect("valid json");
assert_eq!(parsed["request_kind"].as_str(), Some("memory"));
assert_eq!(
parsed["thread_source"].as_str(),
Some("memory_consolidation")
);
assert_eq!(parsed[SANDBOX_MODE_KEY].as_str(), Some("read-only"));
assert!(parsed.get("session_id").is_none());
assert!(parsed.get("thread_id").is_none());
assert!(parsed.get("forked_from_thread_id").is_none());
assert!(parsed.get("turn_id").is_none());
assert!(parsed.get(ROOT_TURN_ID_KEY).is_none());
assert!(parsed.get(WINDOW_ID_KEY).is_none());
// ...该测试通过真正的 Git 临时仓库构造 detached memory metadata,断言 JSON 的 ASCII 编码和 blob 身份省略。 它调用的是 turn_metadata_json,没有检查平铺兼容字段。只读测试名就把结论扩展到全部传输面, 会漏掉下一节的差异。
7.2 正文与兼容头
同一快照会被三条路径消费。下图的分支表示接收者需要的字段投影,箭头不表示新建三个 Turn。
client_metadata["x-codex-turn-metadata"]保存完整 blob 的 JSON 字符串。- 同名直接 header 保存兼容投影,工具清单会被拿掉。
- MCP 的
_meta下保存 JSON 对象,还会进一步删除内部字段并加入实际模型信息。
源码文件:codex-rs/core/src/responses_metadata.rs
相关函数/类型:CodexResponsesMetadata::client_metadata(L282–L319,摘录)
// 作者注:平铺兼容键直接读取快照,随后才附加完整 JSON 字符串;不会统一应用 Memory 的 blob 裁剪。
pub(crate) fn client_metadata(&self) -> HashMap<String, String> {
let mut client_metadata = HashMap::from([
(
X_CODEX_INSTALLATION_ID_HEADER.to_string(),
self.installation_id.clone(),
),
(SESSION_ID_KEY.to_string(), self.session_id.clone()),
(THREAD_ID_KEY.to_string(), self.thread_id.clone()),
(X_CODEX_WINDOW_ID_HEADER.to_string(), self.window_id.clone()),
]);
if let Some(turn_id) = &self.turn_id {
client_metadata.insert(TURN_ID_KEY.to_string(), turn_id.clone());
}
if let Some(subagent_header) = &self.subagent_header {
client_metadata.insert(
X_OPENAI_SUBAGENT_HEADER.to_string(),
subagent_header.clone(),
);
}
if let Some(parent_thread_id) = self.parent_thread_id {
client_metadata.insert(
X_CODEX_PARENT_THREAD_ID_HEADER.to_string(),
parent_thread_id.to_string(),
);
}
if let Some(parent_turn_id) = &self.parent_turn_id {
client_metadata.insert(PARENT_TURN_ID_KEY.to_string(), parent_turn_id.clone());
}
if let Some(root_turn_id) = &self.root_turn_id {
client_metadata.insert(ROOT_TURN_ID_KEY.to_string(), root_turn_id.clone());
}
if self.has_turn_metadata()
&& let Some(turn_metadata_json) = self.turn_metadata_json()
{
client_metadata.insert(X_CODEX_TURN_METADATA_HEADER.to_string(), turn_metadata_json);
}
client_metadata
}平铺的 installation、session、thread、window 直接从快照读取,之后才按 has_turn_metadata 附加完整 blob。这段函数没有调用 has_turn_identity 来裁剪平铺键。 因此 Memory 的 blob 省略 session/thread,并不表示顶层兼容键也消失;它们甚至可以只是构造方传入的空字符串。 先辨认当前检查的是 blob 内层还是外层表,才能解释“身份仍然出现”。
源码文件:codex-rs/core/src/responses_metadata.rs
相关函数/类型:CodexResponsesMetadata::compatibility_headers(L321–L349,摘录)
// 作者注:兼容头复制 blob 投影时明确移除工具清单;其余数据仍受各自来源约束。
pub(crate) fn compatibility_headers(&self) -> ApiHeaderMap {
let mut headers = ApiHeaderMap::new();
insert_header(&mut headers, X_CODEX_WINDOW_ID_HEADER, &self.window_id);
// Direct x-codex-turn-metadata is compatibility output. Keep the unbounded tool inventory
// in client_metadata only so HTTP and WebSocket compatibility headers remain bounded.
if self.has_turn_metadata()
&& let Ok(turn_metadata_json) = to_ascii_json_string(&CodexTurnMetadataPayload {
tool_namespaces_info: None,
..self.turn_metadata_payload()
})
{
insert_header(
&mut headers,
X_CODEX_TURN_METADATA_HEADER,
&turn_metadata_json,
);
}
if let Some(parent_thread_id) = self.parent_thread_id {
insert_header(
&mut headers,
X_CODEX_PARENT_THREAD_ID_HEADER,
&parent_thread_id.to_string(),
);
}
if let Some(subagent_header) = &self.subagent_header {
insert_header(&mut headers, X_OPENAI_SUBAGENT_HEADER, subagent_header);
}
headers
}这里显式把 tool_namespaces_info 设为 None,避免把可增长的工具清单复制进直接 header。 它不是整张元数据的字节上限检查:其他字段仍由来源约束,客户端扩展表也没有前述配置长度限制。 x-codex-window-id 作为独立兼容 header 仍会尝试插入,与 blob 里的 window 投影并非同一次判断。
源码文件:codex-rs/core/src/responses_metadata.rs
相关函数/类型:turn_metadata_json / turn_metadata_value / insert_header(L274–L280、L431–L435,摘录)
// 作者注:JSON 字符串转换为 ASCII 以适配 header;无效 header 值会被跳过。
pub(crate) fn turn_metadata_json(&self) -> Option<String> {
to_ascii_json_string(&self.turn_metadata_payload()).ok()
}
pub(crate) fn turn_metadata_value(&self) -> Option<Value> {
serde_json::to_value(self.turn_metadata_payload()).ok()
}
// ...
fn insert_header(headers: &mut ApiHeaderMap, name: &'static str, value: &str) {
if let Ok(header_value) = HeaderValue::from_str(value) {
headers.insert(name, header_value);
}
}字符串形式使用 to_ascii_json_string,非 ASCII 内容被 JSON 转义,解析后仍能恢复原值。 MCP 值形式走 serde_json::to_value。直接 header 值如果不能构造为 HeaderValue,插入被跳过, 此处没有返回一个让整个 Turn 失败的错误。 所以排查时应分别检查对象值、序列化结果和 header,不能以 header 缺失推断原状态一定为空。
源码文件:codex-rs/core/src/client.rs
相关函数/类型:ModelClient::build_responses_request(L944–L960,局部节选)
// 作者注:ResponsesApiRequest 把 client_metadata 放在请求体中;model 是独立的真实模型选择。
// ...
let request = ResponsesApiRequest {
model: model_info.slug.clone(),
instructions,
input,
tools,
tool_choice: "auto".to_string(),
parallel_tool_calls: prompt.parallel_tool_calls && !model_info.use_responses_lite,
reasoning: Some(reasoning),
store: false,
stream: true,
stream_options,
include,
service_tier,
prompt_cache_key,
text,
client_metadata: Some(responses_metadata.client_metadata()),
};
// ...这个真实请求构造点将完整 client_metadata 装入 Responses API 的请求体。 顶层 model 来自当次 model_info.slug;用户自己放入 blob 的同名扩展字段不会选择模型。 HTTP 与 WebSocket 的兼容头也来自这套快照方法;WebSocket 另外有逐请求与连接路由字段, 不能因为连接复用就认为所有 metadata 永远固定在握手时。
7.3 MCP 投影
源码文件:codex-rs/core/src/turn_metadata.rs
相关函数/类型:TurnMetadataState::current_meta_value_for_mcp_request(L205–L245,摘录)
// 作者注:外部 MCP 接收不同投影:移除内部工具清单和部分血缘,再写入实际 model/effort。
pub(crate) fn current_meta_value_for_mcp_request(
&self,
context: McpTurnMetadataContext<'_>,
) -> Option<serde_json::Value> {
let mut responses_metadata = self.mcp_metadata_template();
// Never serialize harness-owned tool inventory for external MCP servers.
responses_metadata.tool_namespaces_info = None;
let Value::Object(mut metadata) = responses_metadata.turn_metadata_value()? else {
return None;
};
metadata.remove(AGENT_NAME_KEY);
metadata.remove(PARENT_TURN_ID_KEY);
metadata.remove(ROOT_TURN_ID_KEY);
metadata.insert(
MODEL_KEY.to_string(),
Value::String(context.model.to_string()),
);
match context.reasoning_effort {
Some(reasoning_effort) => {
metadata.insert(
REASONING_EFFORT_KEY.to_string(),
Value::String(reasoning_effort.to_string()),
);
}
None => {
metadata.remove(REASONING_EFFORT_KEY);
}
}
if self
.user_input_requested_during_turn
.load(Ordering::Relaxed)
{
metadata.insert(
USER_INPUT_REQUESTED_DURING_TURN_KEY.to_string(),
Value::Bool(true),
);
} else {
metadata.remove(USER_INPUT_REQUESTED_DURING_TURN_KEY);
}
Some(Value::Object(metadata))
}MCP 先以自己的模板生成对象,再移除工具清单、agent_name、parent_turn_id、root_turn_id。 model 使用调用点传来的模型名覆盖,reasoning_effort == None 时直接删除该键, 而不是回退到客户端自行填写的值。 user_input_requested_during_turn 也根据内部原子标志决定出现或删除,不信任 extra 表中的同名字符串。
源码文件:codex-rs/core/src/mcp_tool_call.rs
相关函数/类型:build_mcp_tool_call_request_meta(L1181–L1204,局部节选)
// 作者注:这里把 JSON 对象放入 MCP _meta,不是把 HTTP header 字符串原样搬过去。
// ...
fn build_mcp_tool_call_request_meta(
turn_context: &TurnContext,
server: &str,
call_id: &str,
metadata: Option<&McpToolApprovalMetadata>,
) -> Option<serde_json::Value> {
let mut request_meta = serde_json::Map::new();
request_meta.insert(
"callId".to_string(),
serde_json::Value::String(call_id.to_string()),
);
if let Some(turn_metadata) = turn_context
.turn_metadata_state
.current_meta_value_for_mcp_request(McpTurnMetadataContext {
model: turn_context.model_info.slug.as_str(),
reasoning_effort: turn_context.effective_reasoning_effort(),
})
{
request_meta.insert(
crate::X_CODEX_TURN_METADATA_HEADER.to_string(),
turn_metadata,
);
}
// ...工具调用将投影放到 request_meta 的 x-codex-turn-metadata 键。 外层 _meta 还会经过线程 ID、沙箱状态、Apps 和 trace 等独立补充,不能把本函数的投影视为 整个 MCP 请求的全部元数据。这里的 model/effort 来自 TurnContext 的调用参数; 与 Responses 采样的顶层模型字段分别取证才可靠。
源码文件:codex-rs/core/src/turn_metadata_tests.rs
相关函数/类型:turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(L771–L780、L851–L888,局部节选)
// 作者注:相同输入分别检查内部 blob、兼容 header 与 MCP,验证它们不是同一张平铺表。
// ...
assert_eq!(json["fiber_run_id"].as_str(), Some("fiber-123"));
assert_eq!(json["origin"].as_str(), Some("東京"));
assert_eq!(json["workspace_kind"].as_str(), Some("projectless"));
assert_eq!(json["codex_security_surface"].as_str(), Some("sdk"));
assert_eq!(json["model"].as_str(), Some("client-supplied"));
assert_eq!(json["reasoning_effort"].as_str(), Some("client-supplied"));
assert_eq!(json["session_id"].as_str(), Some("session-a"));
assert_eq!(json["thread_id"].as_str(), Some("thread-a"));
assert!(json.get(LEGACY_CODE_MODE_TOOL_NAMES_KEY).is_none());
assert_eq!(json["agent_name"].as_str(), Some("/root"));
// ...
let compatibility_headers = state
.to_responses_metadata(
"installation-a".to_string(),
"thread-a:1".to_string(),
CodexResponsesRequestKind::Turn,
)
.compatibility_headers();
let compatibility_metadata: Value = serde_json::from_str(
compatibility_headers
.get("x-codex-turn-metadata")
.expect("compatibility turn metadata header")
.to_str()
.expect("valid compatibility header"),
)
.expect("compatibility metadata json");
assert!(
compatibility_metadata
.get(LEGACY_CODE_MODE_TOOL_NAMES_KEY)
.is_none()
);
assert!(
compatibility_metadata
.get(TOOL_NAMESPACES_INFO_KEY)
.is_none()
);
let meta = state
.current_meta_value_for_mcp_request(test_mcp_turn_metadata_context())
.expect("turn metadata should be present");
assert_eq!(meta["model"].as_str(), Some("gpt-5.4"));
assert_eq!(meta["reasoning_effort"].as_str(), Some("high"));
assert!(meta.get(LEGACY_CODE_MODE_TOOL_NAMES_KEY).is_none());
assert!(meta.get(TOOL_NAMESPACES_INFO_KEY).is_none());
assert!(meta.get(PARENT_TURN_ID_KEY).is_none());
assert!(meta.get(ROOT_TURN_ID_KEY).is_none());
assert!(meta.get(WINDOW_ID_KEY).is_none());
assert!(meta.get("codex_security_surface").is_none());
assert_eq!(state.workspace_kind().as_deref(), Some("projectless"));
// ...测试输入同时有配置值、客户端同名值、伪造的保留字段和工具清单。 断言显示:Responses 采用配置的 codex_security_surface = sdk,真实身份不被客户端覆盖; 兼容头没有工具清单;MCP 又移除配置同名键和内部血缘,并把模型/effort 替换为调用参数。 这些断言验证三类投影的差异,不证明远端 provider 对自定义字段提供任何业务保证。
7.4 工具字段开关
源码文件:codex-rs/core/src/tools/spec_plan.rs
相关函数/类型:build_tool_router(L372–L376、L434–L444,局部节选)
// 作者注:工具清单需要配置开关与 Responses Lite 同时成立;外部 MCP 仍不会接收该清单。
// ...
let include_tool_namespaces_info = turn_context
.config
.tool_registry
.turn_metadata_includes_tool_info
&& turn_context.model_info.use_responses_lite;
// ...
let model_visible_specs =
build_model_visible_specs(turn_context, ®istry, &code_mode_tool_names, hosted_specs);
if include_tool_namespaces_info {
turn_context
.turn_metadata_state
.set_tool_namespaces_info(collect_tool_namespaces_info(
®istry,
&code_mode_tool_names,
&model_visible_specs,
));
}
// ...工具清单要求 tool_registry.turn_metadata_includes_tool_info 与模型的 use_responses_lite 同时成立。 写入时使用模型可见工具的有效名称与 exposure 信息,而非把所有注册项原样导出。 是否写入共享状态、是否进入完整 blob、是否进入兼容头、是否进入 MCP,是四个不同判断。 看到某个面的字段缺省时,应按这个顺序定位。
源码文件:codex-rs/core/tests/suite/responses_lite.rs
相关函数/类型:responses_lite_includes_tool_namespaces_info_when_enabled(L185–L207、L224–L232,局部节选)
// 作者注:启用工具清单后捕获实际请求体和兼容头,验证工具字段的分流。
// ...
let mut builder = apps_enabled_builder(apps_server.chatgpt_base_url)
.with_model_info_override("gpt-5.4", |model_info| {
model_info.use_responses_lite = true;
model_info.tool_mode = Some(ToolMode::CodeMode);
model_info.supports_search_tool = false;
})
.with_config(|config| {
config.code_mode.disable_in_process_fallback = true;
config.tool_registry.turn_metadata_includes_tool_info = true;
});
let test = builder.build_with_auto_env(&server).await?;
wait_for_mcp_server(&test.codex, CODEX_APPS_MCP_SERVER_NAME).await?;
test.submit_turn("hello").await?;
let request = response_mock.single_request();
let body = request.body_json();
let turn_metadata: Value = serde_json::from_str(
body["client_metadata"]["x-codex-turn-metadata"]
.as_str()
.context("Responses request should include turn metadata")?,
)?;
let calendar = &turn_metadata["tool_namespaces_info"][SEARCH_CALENDAR_NAMESPACE];
// ...
let compatibility_metadata: Value = serde_json::from_str(
request
.header("x-codex-turn-metadata")
.as_deref()
.context("Responses request should include compatibility turn metadata")?,
)?;
assert!(compatibility_metadata.get("tool_namespaces_info").is_none());
Ok(())
// ...这项集成测试启用 Responses Lite 和工具字段配置,发送真正经过 Core 的请求,再从 mock server 捕获 JSON 正文与 header。正文能解出 tool_namespaces_info,同名兼容 header 中没有它。 它比只调用 serializer 多覆盖了工具规划与请求装配,但 mock 仍不验证线上服务端如何使用这些字段。
8. Git 补充
8.1 启动条件
Git 补充在后台读取 remote、HEAD 与 dirty 状态,填充 workspaces。 模型第一次请求与 Git 查询可以竞争,因此“首个请求没有 workspace,后续请求有了”有明确的实现依据。
源码文件:codex-rs/core/src/session/turn_context.rs
相关函数/类型:new_turn_context_from_configuration(L963–L972,局部节选)
// 作者注:只有 Fresh 策略和单一本地环境才启动本条 Git 补充路径。
// ...
let turn_context = Arc::new(turn_context);
if git_enrichment_policy == GitEnrichmentPolicy::Fresh
&& turn_context
.environments
.single_local_environment_cwd()
.is_some()
{
turn_context.turn_metadata_state.spawn_git_enrichment_task();
}
turn_context
// ...构造普通 Turn 时的 Fresh 策略还要求单一本地执行环境;启动预热上下文走 Skip。 TurnMetadataState::new 先找 repository root,没有根目录时 spawn_git_enrichment_task 直接返回。 Git 缺失、非仓库目录、远端环境与禁用本条路径,都不能当成“空 Git 仓库”这个同一种状态。
源码文件:codex-rs/core/src/turn_metadata.rs
相关函数/类型:TurnMetadataState::spawn_git_enrichment_task(L437–L469,摘录)
// 作者注:句柄存在即抑制再次启动;正常完成设置 watch,但不自动清空句柄。
pub(crate) fn spawn_git_enrichment_task(self: &Arc<Self>) {
let Some(repo_root) = self.repo_root.clone() else {
return;
};
let mut task_guard = self
.enrichment_task
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if task_guard.is_some() {
return;
}
self.git_enrichment_complete.send_replace(/*value*/ false);
// 作者注:后台任务持有状态的强引用,清理需要显式取消。
let state = Arc::clone(self);
*task_guard = Some(tokio::spawn(async move {
let workspace_git_metadata = state.fetch_workspace_git_metadata(&repo_root).await;
if !workspace_git_metadata.is_empty() {
let mut workspaces = BTreeMap::new();
workspaces.insert(
repo_root.to_string_lossy().into_owned(),
workspace_git_metadata.into(),
);
*state
.enriched_workspaces
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(workspaces);
}
state.git_enrichment_complete.send_replace(/*value*/ true);
}));
}互斥锁保护“检查句柄并写入句柄”这一步,使并发调用合并为一个后台任务。 注意判断的是 task_guard.is_some(),没有检查 JoinHandle::is_finished: 正常结束甚至空结果结束后,句柄仍留在槽内;直接再调 spawn 不会自动刷新。 任务写入工作区映射后发出完成信号,但并不改变已经确定的 Thread/Turn 血缘。
8.2 查询与未知值
源码文件:codex-rs/core/src/turn_metadata.rs
相关函数/类型:TurnMetadataState::fetch_workspace_git_metadata(L487–L500,摘录)
// 作者注:三项查询并发返回 Option,允许部分成功;它们不是 Git 的事务快照。
async fn fetch_workspace_git_metadata(&self, repo_root: &Path) -> WorkspaceGitMetadata {
let (head_commit_hash, associated_remote_urls, has_changes) = tokio::join!(
get_head_commit_hash(&self.cwd),
get_git_remote_urls_assume_git_repo(&self.cwd),
get_has_changes_in_repo(&self.cwd, repo_root),
);
let latest_git_commit_hash = head_commit_hash.map(|sha| sha.0);
WorkspaceGitMetadata {
associated_remote_urls,
latest_git_commit_hash,
has_changes,
}
}三次 Git 查询并发执行并分别返回 Option。只要有一个字段成功,WorkspaceGitMetadata::is_empty 就为假,映射可以保留部分结果。Some(false) 是“查到了干净状态”,None 是“没有得到这项结果”, 不能用默认 false 抹平二者。 HEAD、remote 和工作树状态并非在 Git 的联合事务中读取,查询期间改动仓库可能得到不同瞬间的观察值。
源码文件:codex-rs/git-utils/src/status.rs
相关函数/类型:get_has_changes_in_repo(L29–L41,摘录)
// 作者注:status 成功且有输出才是 true;失败返回 None,不会伪造干净仓库。
pub async fn get_has_changes_in_repo(cwd: &Path, repo_root: &Path) -> Option<bool> {
let git = PathBuf::from("git");
let cwd = cwd.to_path_buf();
let key = git_status_key(git.clone(), repo_root).await;
share_git_status_run(key, move || async move {
let fsmonitor = detect_local_fsmonitor_override(&git, &cwd).await;
let output =
run_git_command_with_timeout_from(&git, &["status", "--porcelain"], &cwd, fsmonitor)
.await?;
output.status.success().then_some(!output.stdout.is_empty())
})
.await
}dirty 判断来自 git status --porcelain 成功后的输出是否为空,包含未跟踪文件。 git-utils 还按 Git 程序与规范化 repo root 合并同时在途的 status 请求,完成后不把结果当长期缓存。 其命令助手带超时与平台相关进程处理;这些机制不意味着整次 enrichment 一定得到全部三项结果。
8.3 取消与等待
源码文件:codex-rs/core/src/turn_metadata.rs
相关函数/类型:wait_for_git_enrichment / cancel_git_enrichment_task(L471–L485,摘录)
// 作者注:取消取走句柄并唤醒等待者;true 表示不再等待,不等价于 Git 查询成功。
pub(crate) async fn wait_for_git_enrichment(&self) {
let mut completion = self.git_enrichment_complete.subscribe();
let _ = completion.wait_for(|complete| *complete).await;
}
pub(crate) fn cancel_git_enrichment_task(&self) {
let mut task_guard = self
.enrichment_task
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(task) = task_guard.take() {
task.abort();
self.git_enrichment_complete.send_replace(/*value*/ true);
}
}watch 初始为 true,启动改为 false,正常返回或显式取消改回 true。 因此 wait_for_git_enrichment 等待的是“不再被这个信号阻塞”,不是成功证书。 取消用 take 清空句柄,允许之后显式重启;它没有清空已经写入的 enriched_workspaces, 也没有 await 被 abort 的 JoinHandle。不能由这一小段函数推出跨多次启动的严格 generation 屏障。
下图分开表示后台写入与请求取快照。正常采样构造不会先调用 wait;测试同步工具才可以主动等待补充。
- 写入完成之前的请求可以缺少 workspace,不能回头改写已经发送的 JSON。
true同时覆盖初始、完成与取消;要解释字段必须同时检查值与调用路径。- 正常 task 完成、显式中断和暂停均有取消调用点,防止 Turn 结束后继续无目的补充。
8.4 并发与重启
源码文件:codex-rs/core/src/turn_metadata_tests.rs
相关函数/类型:turn_metadata_state_coalesces_concurrent_git_enrichment(L1038–L1071,局部节选)
// 作者注:八个调用者同时启动并比较同一个 JoinHandle ID,再检查已提交仓库的字段。
// ...
let barrier = Arc::new(tokio::sync::Barrier::new(8));
let tasks = (0..8)
.map(|_| {
let state = Arc::clone(&state);
let barrier = Arc::clone(&barrier);
tokio::spawn(async move {
barrier.wait().await;
state.spawn_git_enrichment_task();
state
.enrichment_task
.lock()
.expect("enrichment task lock")
.as_ref()
.expect("enrichment task")
.id()
})
})
.collect::<Vec<_>>();
let mut task_ids = Vec::new();
for task in tasks {
task_ids.push(task.await.expect("spawn task"));
}
assert!(task_ids.iter().all(|task_id| *task_id == task_ids[0]));
let json = wait_for_git_enrichment(state.as_ref()).await;
assert_eq!(
json["workspaces"],
serde_json::json!({
repo_path.to_string_lossy().as_ref(): {
"latest_git_commit_hash": head,
"has_changes": false,
}
})
);
// ...测试让八个调用者经过 barrier 同时申请 enrichment,比较它们读到的任务 ID,然后检查实际仓库的 HEAD 与 dirty 值。 第一组断言验证句柄槽的并发合并;第二组才验证 Git 查询结果。 两者是独立能力,不能因为拿到同一个任务 ID 就断言数据必定完整。
源码文件:codex-rs/core/src/turn_metadata_tests.rs
相关函数/类型:turn_metadata_state_git_enrichment_cancellation_is_retryable_and_errors_stay_empty(L1093–L1112,局部节选)
// 作者注:明确验证取消后的句柄为空、等待者醒来,以及显式重新启动可取得结果。
// ...
state.spawn_git_enrichment_task();
state.cancel_git_enrichment_task();
assert!(
state
.enrichment_task
.lock()
.expect("enrichment task lock")
.is_none()
);
tokio::time::timeout(Duration::from_secs(2), state.wait_for_git_enrichment())
.await
.expect("cancelled git enrichment should unblock waiters");
assert!(state.current_workspaces().is_empty());
state.spawn_git_enrichment_task();
let json = wait_for_git_enrichment(&state).await;
assert_eq!(
json["workspaces"].as_object().map(serde_json::Map::len),
Some(1)
);
// ...取消测试先证明句柄被清空且 waiter 醒来,再显式 spawn 并等待得到一条 workspace 记录。 同一测试还构造残缺 .git 并断言失败后为空。它验证了这组受控输入下的重启与空结果, 不承诺任意文件系统、权限或并发取消时序都相同。
core/tests/suite/git_enrichment.rs 则把范围扩展到实际请求:启动 prewarm 没有 workspace; 改动临时仓库后,用户 Turn 的后续请求看到新 dirty 状态;Guardian 的预热/审查不重复补充; 两个同时运行的工作树与仓库仍保留各自的元数据。 其中 Guardian 测试有非 Windows 条件,不能将它的执行范围写成所有平台。
9. 观测取证
排查时先明确手里的数据来自哪里。下表把本篇几个容易混淆的现象对应到第一处应该查看的实现。
| 现象 | 先定位什么 | 能排除的错误解释 |
|---|---|---|
| TTFT 有值但没有回答文本 | response_event_records_turn_ttft 与 item 变体 | 工具/reasoning 信号也可触发 |
| TTFM 晚于最后一个网络 delta | emit_turn_item_completed 的调用时间 | TTFM 测量 Core 完整条目发布 |
| 工具总运行时间大于阻塞桶 | Sampling guard 与 drain_in_flight | 重叠区间不会重复相加 |
| 分桶和抓包耗时不一致 | 起点、结束点、重试所属层 | phase 不是服务端推理计时 |
| item 出现零耗时 | 缺失 start 的 warning 与 item ID | 零值可能来自兜底 |
| body 有工具清单,header 没有 | compatibility_headers | 有意移除字段 |
| MCP 没有配置扩展键 | mcp_metadata_template | 配置值与客户端同名值都被排除 |
| Git waiter 结束但字段缺失 | watch 状态、句柄与各项 Option | 取消/空结果也会结束等待 |
| steer 新字段没有进入正在重试的请求 | setter 与快照创建位置 | 重试可沿用已构造快照 |
在 Codex 仓库根目录运行下面两组测试,分别观察状态算法与实际请求装配。just test 使用仓库指定的 Nextest 本地配置;第二组依赖本地 socket 和 Git 临时仓库,其中部分用例有平台条件。
just test --locked -p codex-core --lib \
-E 'test(turn_timing::tests::) | test(turn_metadata::tests::)'
just test --locked -p codex-core --test all \
-E 'test(suite::git_enrichment::) | test(responses_lite_includes_tool_namespaces_info_when_enabled)'第一组要检查一次性记录、输入过滤、阶段分桶和取消后的状态;第二组要看 mock 实际收到的 client_metadata 与兼容 header。临时仓库夹具必须真正完成初次 Git 提交, 否则“干净目录”和“存在 HEAD”的前置条件并不成立。 这些测试不验证线上 provider 的字段使用、终端绘制时间或所有平台的进程行为。
还可以把数值测试中第二次 begin_sampling 改为直接完成,先在纸上推演 900 ms 之后的空闲应进入哪个桶, 再沿 pending_idle_after_sampling 的两处 take 验证答案。 对元数据则追踪同一 workspace_kind 从客户端输入到 setter、blob 和 MCP 的路径, 再加入一个配置同名键,判断三处输出为何会分岔。
要继续追踪 phase 包住的采样与退出分支,可回到 Turn主循环与退出条件; 要理解压缩为何有独立请求用途与阶段字段,可阅读 CompactTask完整流程。 修改这两条主链时,应同时检查 guard 的作用域和元数据快照的创建位置:它们分别决定时间归属与字段生效时间。
