青雲的博客

第二部:一次 Turn 怎样进入模型

这一部沿一次 Turn 的所有权与数据交接,追踪输入怎样成为 task,instructions 与 history 怎样汇入 Prompt,并继续到 Responses 请求、两层循环与流式行动;未解决的错误集合交给第 13 章。

输入经过 task 所有权分岔,指令与 history 汇入 Prompt,再进入双层 Turn 循环。
展开阅读路线与实验入口

读这一部之前,需要知道什么

第一部已经把入口、配置、Requirements、认证和模型能力收束成一组运行前合同。第二部从这些合同汇入 TurnContext 之后开始,沿一次真实输入追到模型请求,再从响应流追到下一步行动。读者不需要先理解所有工具,也不用先弄清持久化、恢复或 app-server 的完整协议。

第 9 章会用一个最小恢复 fixture 说明 rollout(可重放的会话记录)和 resume(从记录重建会话)这两个词;这里先把它们当作 history 投影的输入,不把第五部的完整恢复语义提前搬过来。UUIDv5 只在那个 fixture 中用来解释为什么合成的 call-output id 能稳定重算,不是本部新增的身份系统。

这里先固定三个观察层次。Submission 是进入 Session 队列的请求;RunningTask 才持有当前 task、cancellation token、Tokio handle 和 TurnContext;用户看到的一次 turn 还可能包含多次 sampling。三者的 id、生命周期和完成条件不能互换。

第二个前提是把 instructions 与 history 分开。Promptbase_instructions 保存在独立字段,把 conversation context 放在 input 中;developer、user 和运行时上下文片段还会按各自规则成为 ResponseItem。所以 instructions 与 history 是两条汇入路径,不表示所有 instructions 都写入 history。

最后仍是同一条证据规则:本部只讨论 rust-v0.144.6,commit 固定为 5d1fbf26c43abc65a203928b2e31561cb039e06d。类型名和目录名用于定位,行为结论必须落到固定源码的调用、状态字段、分支或命名测试。

这一部负责讲清什么

这一部追一条纵向链,但不把它写成“输入直接变成请求”。Session 先决定谁接管输入;task 运行后,模型可见指令与 conversation history 分别准备,再在 Prompt 汇合。request builder 把它变成 Responses 请求,transport 返回 ResponseStream,业务循环才根据流内事件、action 结果和 pending input 判断是否继续。

flowchart TB
  accTitle: Turn 进入模型的所有权与数据交接
  accDescr: 输入经 Submission 与 task 进入 instructions、history 两条汇入路径,再由 Prompt 形成 Responses 请求;ResponseStream 交给两层业务循环与行动处理
  INPUT["input"]
  SUBMISSION["Submission"]
  DECISION{"Session steer decision"}
  NEW_TASK["spawn RegularTask / RunningTask"]
  PENDING["existing active task / pending input queue"]
  TASK["current task / TurnContext"]
  INSTRUCTIONS["base instructions path"]
  RECORDED["record developer / user / runtime fragments"]
  HISTORY["live history"]
  NORMALIZED["clone / for_prompt"]
  PROMPT["Prompt"]
  REQUEST["Responses request"]
  STREAM["ResponseStream"]
  EVENTS["try_run_sampling_request\nevent consumption loop"]
  ACTIONS["ResponseEvent / action"]
  RESULT["SamplingRequestResult"]
  INNER["run_turn\nsampling / action loop"]
  OUTER["RegularTask\ntask-owner loop"]
  INPUT --> SUBMISSION
  SUBMISSION --> DECISION
  DECISION -->|NoActiveTurn| NEW_TASK
  DECISION -->|active task| PENDING
  NEW_TASK --> TASK
  PENDING --> TASK
  TASK --> INSTRUCTIONS
  TASK --> RECORDED
  RECORDED --> HISTORY
  HISTORY --> NORMALIZED
  INSTRUCTIONS --> PROMPT
  NORMALIZED --> PROMPT
  PROMPT --> REQUEST
  REQUEST --> STREAM
  STREAM --> EVENTS
  EVENTS --> ACTIONS
  ACTIONS --> RESULT
  RESULT --> INNER
  INNER -->|needs follow-up| PROMPT
  INNER -->|run_turn returns Ok| OUTER
  OUTER -->|pending input remains| INNER

这张图只表达 owner/data handoff,不展开 recovery。Submission 与 task 也不是一对一转换:Session 先尝试 steer;没有 active turn 才创建 RegularTask,已有 task 时输入先进入 pending queue。两条分支最后都由当前 task/TurnContext 继续处理。

TASK 后的两条存储/汇入路径并不把所有 instruction source 画成直达 Prompt:只有 base instructions 留在 Prompt 的专用字段;developer、user 与 runtime fragments 先记录成 ResponseItem,进入 Session-owned live history,再经 clone 和 for_prompt 形成 detached input。

图尾按真实返回顺序放置:try_run_sampling_request 的事件消费 loop 先把 ResponseEvent 消费成 item、tool future 与 SamplingRequestResult,结果再交给 run_turn。这个事件消费 loop 不属于第 11 章的两层业务循环。两层业务控制仍是 run_turn 的 sampling/action continuation,以及 RegularTask::run 在一次 run_turn 成功返回后执行的 pending-input 复查;后者才决定是否以同一个 TurnContext 重入。

run_turn 内部的 handoff 也能直接定位:它从 Session clone history,在 detached copy 上调用 for_prompt,再把结果交给 run_sampling_request。后者取得 base instructions、工具规格和输出 schema,构造 Prompt。这里有可检查的值传递,不需要从函数名猜一条“prompt pipeline”。

六章共用哪套实验

六章共用同一个 source identity 和一条连续证据链,但测试不在固定 checkout 中直接运行。每一章都要接住上一个产物,并保留它没有证明的部分;不能各自跑完一个测试,再用标题把六份局部结果拼成全链路动态 trace。

交接位置必须留下的证据产物下一步只能据此继续什么
第 7 章结束ownership timeline确认 input、active task 与 cancellation 的 owner
第 8 章结束instruction ledger区分来源、role、注入时机和是否进入 history
第 9 章结束normalized prompt input先交给 build_prompt 汇入 Prompt,再由 request builder 编码
第 10 章结束captured request + transport params + ResponseStream handle只确认 wire 形状、transport 选择输入和流句柄
第 11 章结束continuation conditions区分 task-owner loop 与 sampling/action loop
第 12 章结束unresolved error set,交给第 13 章不在正常流章节里补写恢复结论

“连续”指对象和边界能对上:timeline 里的 owner 交出本轮输入,ledger 说明哪些值以什么角色可见,normalized input 与 base instructions 汇成 Prompt,captured request 返回 stream,continuation conditions 再消费 action 结果。它不要求六个命名测试共享同一个进程、账号或网络连接。

每条命令只记录三项:实际运行的测试全名、直接断言、未覆盖边界。Cargo filter 是子串匹配;这里的 normalize filter 在 debug build 命中 always-built 的 _inserts_output 测试,在 release build 还会命中一个 #[cfg(not(debug_assertions))] 的短名测试,测试数不是跨 profile 常量。若出现 zero match、skip 或环境门禁,当前机器没有产出对应动态证据,不能用源码里“存在这个测试”代替通过结果。

哪些机制暂时不讲

本部在正常 owner/data handoff 上停住。401、stream retry、Responses WebSocket fallback 与断线后的恢复顺序统一留给第 13 章:错误、重试与恢复;这里只记录 unresolved error set,不重述恢复矩阵。

工具为什么被注册、如何选 handler、审批与沙箱怎样包住执行,交给第 14 章:工具规格与注册表及第三部。第 12 章只观察完成 item、in-flight future 与 tool output 怎样影响下一次 sampling。

app-server 在本部只提供 Submission/steer 的入口测试,不展开 JSON-RPC 生命周期、event projection 或 client contract;这些边界留给第 30 章:app-server 协议。conversation history 在本部只读到模型投影,durable state、resume 与 compaction mechanics 分别留给第 25 章第 26 章

这些停止线也约束图的读法:Responses request 之后仍可能发生认证或 transport recovery,action 之后仍可能进入工具内部。图没有画出的机制不等于不存在,只表示它们不属于本部要验收的 owner/data handoff。

读完这一部,你应该能做什么

读完后,面对“为什么这次输入没有按预期进入模型”这类问题,应该能先选对证据面:

  • 用 submission id、active turn id、RunningTask 和 cancellation token 判断输入由谁持有;
  • 用 instruction ledger 区分 base、developer、user、project document 与运行时片段,不从最终 request 的顶层 instructions 反推全部来源;
  • 区分 live history、normalized prompt input、Prompt 与 captured request,不把 detached projection 当成会话真相;
  • 从 provider capability、request params 和 ResponseStream handle 判断 transport 交接,不把 response.completed 当成 TurnComplete
  • 把 event consumption、sampling/action continuation 与 task-level pending re-entry 放回各自 owner;
  • 将尚未解释的 401、retry、fallback、early EOF、tool failure 和 cancellation 形成 unresolved error set,交给第 13 章继续验证。

到这里可以解释正常路径怎样继续或停止,还不能声称恢复策略、工具执行和持久化已经讲完。这个边界会直接决定后续章节需要补哪一种实验。

源码工作台

核心目录

目录 / 文件本部只用它回答什么
codex-rs/protocolSubmissionOpResponseItem 等跨边界数据形状
codex-rs/app-serverturn/startturn/steer fixture 怎样观察 active turn id
codex-rs/core/src/sessionSession decision、TurnContextrun_turn 与 sampling/action continuation
codex-rs/core/src/tasksRegularTask 和 task-level pending re-entry(RunningTask 定义在 core/src/state/turn.rs
codex-rs/core/src/context_managerlive history 怎样 clone、normalize 并投影为 prompt input
codex-rs/core/src/client.rsPrompt 怎样成为 request,HTTP / WebSocket 怎样返回 core ResponseStream
codex-rs/core/src/client_common.rsPrompt 与 core ResponseStream 的 consumer-side 类型边界
codex-rs/codex-apiResponsesApiRequest、wire ResponseEvent、HTTP Server-Sent Events(SSE)与 Responses WebSocket mapper
codex-rs/featuresStage 枚举、Feature::stage()、默认值与 legacy rollout flag 的元数据

先抓住这些控制类型

边界类型 / 函数先问的问题
输入相关SubmissionOp::UserInputid 只关联提交与事件,还是已经成为 active turn id?
task ownershipRegularTaskRunningTaskTurnContext谁持有 handle/token/context,谁只执行一次调用?
指令与历史BaseInstructionsResponseItemContextManager::for_prompt文本留在专用字段、进入 history,还是只存在于 detached projection?
模型输入Promptbuild_promptinput、tools、base instructions 与 output schema 在哪里汇合?
request/transportResponsesApiRequestResponseStreamResponseEventrequest 参数由谁构造,stream handle 由谁消费?
continuationSamplingRequestResultrun_turnRegularTask::run这是 action follow-up、Stop hook、pending input,还是恢复重试?

ResponseStream 在两层代码里各有一个结构:codex-api 的 stream 包装 provider event receiver 与 upstream request id,core mapper 再交出自己的 stream,并用 cancellation token 感知 consumer 提前 drop。读调用链时要带上模块名,不能把两个同名类型当成一个 owner。

core ResponseStream 只承载并交付 ResponseEvent,不执行工具。action owner 是 event handler、ToolCallRuntimeToolRouter;这里先固定所有权,不展开第 14 章里的规格选择和 handler 合同。

实验准备与六个验证锚点

命令示例使用支持 pipefail 的 bash 或 zsh,以及 Git、just、Cargo、python3 与 cargo-nextest(固定仓库的 justfile 用 python3 作为 recipe shell,just test 调用 cargo nextest)。每一部都在自己的新 shell 中准备;固定 checkout 只导出源码,Rust 测试在一次性的 archive 中运行;先执行下面的准备脚本,并在本部 shell 会话保留 $ARCHIVE_CODEX_RS。不要把上一部的 ARCHIVE_DIREXIT trap 复用到这里:

set -euo pipefail
export SOURCE_ROOT="${CODEX_SOURCE_DIR:-/tmp/codex-handbook-final-rust-v0.144.6}"
export COMMIT=5d1fbf26c43abc65a203928b2e31561cb039e06d
export ARCHIVE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/codex-part2.XXXXXX")"
export CARGO_TARGET_DIR="$ARCHIVE_DIR/target"
trap 'rm -rf "$ARCHIVE_DIR"' EXIT

test "$(git -C "$SOURCE_ROOT" rev-parse HEAD)" = "$COMMIT"
test "$(git -C "$SOURCE_ROOT" describe --tags --exact-match HEAD)" = rust-v0.144.6
git -C "$SOURCE_ROOT" archive "$COMMIT" | tar -x -C "$ARCHIVE_DIR"
cd "$ARCHIVE_DIR/codex-rs"
cargo update --workspace --offline
cargo metadata --locked --format-version 1 --no-deps >/dev/null
export ARCHIVE_CODEX_RS="$PWD"
test -z "${CODEX_SANDBOX_NETWORK_DISABLED+x}"

固定仓库的 just test recipe 会设置默认测试栈并调用 nextest;个别深层 Tokio fixture 用 just --set rust_min_stack 16777216 test ... 显式提高栈容量。这只减少测试环境噪声,不能拿来证明 owner、request 或 continuation 结论。单独导出 RUST_MIN_STACK 不够,因为 just recipe 会按自己的变量值重新设置它。

随后按证据链运行六个锚点,不合并 filter,也不把 mock server 结果写成公开 endpoint 的行为:

: "${ARCHIVE_CODEX_RS:?先执行本部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
just test --locked -p codex-app-server --test all turn_steer_returns_active_turn_id
just test --locked -p codex-core instruction_sources_include_global_before_agents_md_docs
just test --locked -p codex-core normalize_adds_missing_output_for_function_call
just --set rust_min_stack 16777216 test --locked -p codex-core --test all chatgpt_auth_sends_correct_request
just --set rust_min_stack 16777216 test --locked -p codex-core --test all stop_hook_can_block_multiple_times_in_same_turn
just --set rust_min_stack 16777216 test --locked -p codex-core --test all current_time_tool_returns_the_latest_time
锚点直接断言不能外推什么
turn_steer_returns_active_turn_idsteer response 沿用 active turn id,输入被该 active turn 接受所有客户端都经过 app-server;steer 等于新建 task
instruction_sources_include_global_before_agents_md_docsglobal instruction source 与 project AGENTS.md source 的顺序和分离结构最终 request 中只有一个统一 instructions 字符串
normalize_adds_missing_output_for_function_callfilter 命中的一条或两条 normalize fixture 为缺失 call output 补配对项live history 被原地重写;测试数与断言在所有 profile 都相同
chatgpt_auth_sends_correct_request本地 mock 捕获 path、auth/account/session/thread headers、metadata 与 stream body真实 ChatGPT 服务接受请求;WebSocket 也使用相同 wire body
stop_hook_can_block_multiple_times_in_same_turn三次 request 复用同一 turn id,并累积两次 continuation prompt任意 hook 都可信;Stop hook 是 task-level outer loop
current_time_tool_returns_the_latest_time同一 call id 的工具输出进入第二次 request,值来自可控 external time provider系统时钟、跨平台 timezone 或所有工具失败处理已经被验证

第 7 章 fixture 文件顶层受 #![cfg(unix)] 限制,因此 Windows 上这条 filter 会 zero-match。函数里虽然还保留 PowerShell Start-Sleep 分支,但在当前编译门禁下不可达,不能当成 Windows 证据。动态验收仍要记录 host、target、build profile、是否 skip 和实际测试全名。

后三条 core integration test 都先执行 skip_if_no_network!。只要环境里存在 CODEX_SANDBOX_NETWORK_DISABLED,macro 就打印 skipping 信息并直接 return;Rust test harness 仍可能把它报告为 ok,但 request、hook 和 tool-output 断言一条都没跑。它们只连接本机 mock/loopback socket,不依赖公网。验收必须同时确认环境门禁未设置、filter 实际 running 1 test,输出没有 skipping 文本;turn_steer 在 remote fixture 下也会提前返回 Ok(()),同样不能只看最终绿色状态。

Feature 成熟度

FeatureStage 描述 feature flag 的生命周期,不是对应 runtime path 的存在性证明。本部最直接的反例是 Responses transport:legacy Feature::ResponsesWebsocketsResponsesWebsocketsV2 都是 Removed、默认关闭;当前 ModelClient::responses_websocket_enabled 实际检查 provider capability 与 session-scoped disable state。不能从 legacy flag 的 Stage 推出 Responses WebSocket 代码已经消失,也不能从 provider 支持推出本次连接一定成功。

第二个例子来自本部第 12 章的验证锚点。Feature::CurrentTimeReminderUnderDevelopment、默认关闭;current_time_tool_returns_the_latest_time 的 fixture 会显式 enable 该 feature,并注入 external time provider。测试通过只证明这个受控配置,不证明默认 turn 会暴露 current-time tool。

平台与 transport 限制

观察面固定版本或命名测试能证明什么仍需单独验证什么
checkout 与 Cargo 命令bash/zsh 示例能固定 commit 并运行 test filterWindows 需要 PowerShell/cmd 的变量、续行与路径转换
turn_steer fixtureUnix host-local sleep 路径能覆盖 active-turn steer整个文件受 cfg(unix) 门禁;Windows 会 zero-match,remote executor 也可能 skip
mock/loopback 网络门禁三条 core integration fixture 在本机 socket 可用时执行真实断言CODEX_SANDBOX_NETWORK_DISABLED 会提前 return,但 harness 仍可能显示 ok
Stop hook fixture非 Windows host 上可用 python3 脚本连续阻断同一个 turnmodule 在 Windows 不编译;缺少 python3 也不能形成本锚点
HTTP SSElocal mock 能捕获普通 Responses request,并返回 typed stream event公网认证、代理、服务端路由与 401/retry 行为
Responses WebSocketprovider capability 和 session fallback state 参与 transport 选择handshake、reuse、断线与 fallback 的完整恢复矩阵
Responses Literequest builder 会改变 instructions、tools 与 input 的 wire layout它不是第三种 transport,也不代表所有 provider 都支持
Realtimefixed registry 中有独立的 Realtime feature 与 endpoint codeRealtime WebSocket 不等于本部的 Responses WebSocket
wasm target本部 model WebSocket client 使用 Tokio TCP 与 Tungstenite native socket这条实现证据不能证明 wasm 支持,也不能外推成整个 workspace 都不支持 wasm

HTTP SSE 与 Responses WebSocket 最终都要交出可消费的 ResponseStream,但建立连接、编码 request 与错误处理仍是不同 transport 路径。第 10 章只捕获 request 与选择参数;WebSocket recovery 仍按本部边界交给第 13 章。

工作台只提供定位和验收入口。现在从 input 的第一个 owner 开始:进入《你按下回车后,谁接管这次任务》

拆开 Codex 第二部:一次 Turn 怎样进入模型 第 07 章

你按下回车后,谁接管这次任务

从 TUI 的一次提交追到 Submission、active turn、RegularTask、pending queue 与 cancellation token,画清一轮任务的所有权交接。

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

第 1 章只在系统地图上标出了 Submission -> Session -> RegularTask -> run_turn,没有解释任务怎样被接管和收回。现在把镜头推近:我在 TUI 里输入一句“把这个测试修掉”,按下回车。屏幕上的光标消失、状态变成运行中,看起来像是同一个动作;源码里却至少发生了两次所有权交接:先由界面决定把输入送到哪一个 thread,再由 Session 决定是否有资格创建一个新的 turn。中间还隔着一个容易被忽略的 Submission 队列。

这次先不展开 prompt 怎样拼出来,也不讨论工具循环。只回答一个更窄的问题:回车之后,谁保存这份输入,谁拥有 active turn,谁负责取消,以及完成时谁把所有权收回来。app-server 在这里是入口黑盒;我们只用它暴露出来的 turn/startturn/steerturn/interrupt 结果来定位边界。

一条 ownership timeline

flowchart LR
  accTitle: Turn ownership timeline
  accDescr: submission 经 Session decision 进入 existing 或 new task;normal return 走 TurnComplete,interrupt 经 cancellation_token.cancel 走 TurnAborted
  A[typed input] --> B[AppCommand::UserTurn]
  B -->|有 active turn| C[turn/steer]
  B -->|没有 active turn| D[turn/start]
  D --> E[Submission.id]
  E --> F[candidate current_context]
  F[candidate current_context] --> G{Session steer decision}
  G{Session steer decision} -->|existing active task| H[existing active turn]
  G{Session steer decision} -->|NoActiveTurn| I[spawn RegularTask]
  C[turn/steer] --> H[existing active turn]
  H[existing active turn] --> J[pending input queue]
  I --> K[RunningTask / new active turn]
  H --> N[task execution / RegularTask loop]
  J[pending input queue] --> N[task execution / RegularTask loop]
  K[RunningTask / new active turn] --> N[task execution / RegularTask loop]
  N[task execution / RegularTask loop] -->|normal return| O[on_task_finished / TurnComplete]
  N[task execution / RegularTask loop] -->|interrupt| P[cancellation_token.cancel]
  P[cancellation_token.cancel] --> Q[abort cleanup / TurnAborted]

这张图里的箭头不是“网络请求顺序”的简写,而是 owner 的转移。AppCommand::UserTurn 仍属于 TUI;Submission 进入 core 的 channel 后由 Session 消费;RunningTask 创建后,Session 的 active_turn 才有一个真正执行中的 task。steer 不绕过这层 owner,它只是把输入交给已有 task 的队列。

先有 submission,才谈 turn

Submission 是 submission queue 的条目,不是模型请求,也不是 TurnContext。它有一个由 Codex 生成的唯一 id、一个 Op、可选的客户端消息 id 和 trace carrier。Op::UserInput 只描述输入 items、额外上下文和 thread-settings overrides;它没有“模型已经开始”的承诺。

submit_user_input_with_client_user_message_id 先生成 new_submission_id(),再把 Submission 发送到 tx_sub。send 成功只说明 queue 接受了这条消息;channel 关闭才会返回 InternalAgentDied。因此,submission send success 只能证明 queue acceptance,不能证明 Session 已创建 active turn,更不能证明模型已收到 prompt。

app-server 的 turn_start_inner 把输入映射为 Op::UserInput,调用 thread 的 submit 方法,并把返回值放进 TurnStartResponse.turn.id。这里 API 字段叫 turn.id,但源码注释明确说它是 submission id。后续 core handler 只用这个 id 创建 candidate current_context。只有 steer_input 返回 NoActiveTurn,handler 才会 spawn RegularTask;start_task 把该 context 写入 active_turn.task 后,它的 context.sub_id 才成为新的 active turn id。若已有 active turn,handler 会把输入 steer 到已有 task;此时 turn/start response 里的 submission id 仍用于这次提交和事件关联,不等于已有 active turn id。

Session 决定谁真正接管

收到 Op::UserInput 后,user_input_or_turn_inner 先用 sub_idnew_turn_with_sub_id,然后尝试 Session::steer_input。这一步的顺序很重要:如果已经存在 active turn,输入可以进入它;如果没有,steer 返回 NoActiveTurn,handler 才会把输入整理成 TurnInput 并创建 RegularTask。因此“收到提交”与“创建任务”是两个状态。

RegularTask 是 Session 持有的工作对象。它的 run 收到 TurnContext、初始 TurnInput 和 cancellation token,先发出 TurnStarted,再循环调用 run_turn;如果 input queue 还有 pending input,就在同一个 task 里继续处理,而不是另起一个 turn。

start_task 把这个对象装进 RunningTask,同时创建新的 CancellationToken,将 task、turn context、handle 和 token 一起写入 active_turn.task。到这一步,Session 才拥有可查询、可取消、可等待完成的 active turn。

active turn、steer 与 pending queue

Session::steer_input 的第一道门是 active_turn。没有 active turn 或 task,就返回 NoActiveTurn;有 task 但 expected turn id 不匹配,就返回 ExpectedTurnMismatch;Review 和 Compact 类型也明确不可 steer。只有 Regular task 才会接受这条输入。

通过检查后,steer 把额外上下文和 TurnInput::UserInput 追加到当前 turn state 的 input queue,并返回原来的 active_turn_id。所以 steer 不创建新 turn;它只把“还没消费的输入”交给当前 RegularTask 的下一轮处理。

TUI 侧还会缓存每个 thread 的 active turn id,用它发起 steer 或 interrupt;遇到 race 时,服务器返回的实际 id 会修正缓存。这个缓存是路由辅助,不是 Session owner。真正的 active turn 仍由 core 的 active_turn 锁保护。

turn/steer 的 app-server 处理要求非空 expectedTurnId,调用 thread 的 steer,并把返回的 id 原样放进 TurnSteerResponse。固定测试正是验证这条 active-turn id contract;它没有证明完整 prompt 或工具循环。

cancel 是停止执行,不是撤销历史

中断发生时,Session 从 active_turn 取出 RunningTask,handle_task_abort 先取消它持有的 token,等待任务优雅退出一小段时间,超时后 abort handle,再执行 task 的 abort 清理。它随后记录 interruption marker(若该版本配置要求)、发送 TurnAborted 并 flush rollout。回到 abort_all_tasks 后,Session 才调用 extension 的 turn-abort lifecycle,最后清理这个 turn 的 pending input。事件、extension hook 和 queue cleanup 的先后不能互换。

取消只影响仍在执行中的协作,不会回滚已经发生的 side effects:已经写入的文件、已启动的 shell、已发出的网络请求或已经持久化的 rollout 不会因为 token 被 cancel 自动恢复原状。源码这里证明的是取消传播和清理顺序;它不提供一个 transaction rollback 机制。

正常的连续处理发生在 RegularTask::run:一次 run_turn 返回后,它检查 has_pending_input;仍有输入就清空 next_input,继续下一次 run_turn,整个过程仍属于同一个 task。

on_task_finished 处理的是 task 结束时残留的 pending input。它先调用 inspect_pending_input,再按 hook 结果调用 record_pending_inputrecord_additional_contexts;这段不调用 run_turn,也不负责继续当前 turn。取消路径则明确清理 pending。三条路径都不会把已发生的外部副作用倒放。

用一个本地实验校准结论

固定 checkout 上实际执行:

set -euo pipefail
: "${ARCHIVE_CODEX_RS:?先执行第二部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
CODEX_TEST_ENVIRONMENT=local just test --locked -p codex-app-server --test all turn_steer_returns_active_turn_id

本次输出为:

PASS codex-app-server::all suite::v2::turn_steer::turn_steer_returns_active_turn_id
Summary: 1 test run, 1 passed

这个实验把一次 turn/start 的 turn.id 交给 turn/steer,再断言 TurnSteerResponse.turn_id 等于原 active turn id。它证明的是 active-turn id contract,以及 steer 消息在该 id 下被接受;不证明完整的 end-to-end prompt assembly、模型推理或 side effect rollback。证据边界到这里就停。

交给下一章的未解析输入

到这一章结束,下一层可以拿到四个确定对象:submission id(最初由 queue 生成并用于事件关联)、active turn id(当前 TurnContext.sub_id)、RegularTask/Session owner,以及该 task 的 cancellation token

但 ownership 账本还不能回答模型看见了什么。首次提交与 steer 进来的追加输入由不同 owner 交付,后面还要经过上下文组装和 history 记录,才可能成为 sampling 输入。下一章《Instruction assembly:输入怎样变成模型可读的上下文》从这里接手;pending queue 的消费、skill mention 的提取边界,以及这些输入何时进入 history,都集中在那里展开。