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

展开阅读路线与实验入口
读这一部之前,需要知道什么
第一部已经把入口、配置、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 分开。Prompt 把 base_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 和
ResponseStreamhandle 判断 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/protocol | Submission、Op、ResponseItem 等跨边界数据形状 |
codex-rs/app-server | turn/start 与 turn/steer fixture 怎样观察 active turn id |
codex-rs/core/src/session | Session decision、TurnContext、run_turn 与 sampling/action continuation |
codex-rs/core/src/tasks | RegularTask 和 task-level pending re-entry(RunningTask 定义在 core/src/state/turn.rs) |
codex-rs/core/src/context_manager | live history 怎样 clone、normalize 并投影为 prompt input |
codex-rs/core/src/client.rs | Prompt 怎样成为 request,HTTP / WebSocket 怎样返回 core ResponseStream |
codex-rs/core/src/client_common.rs | Prompt 与 core ResponseStream 的 consumer-side 类型边界 |
codex-rs/codex-api | ResponsesApiRequest、wire ResponseEvent、HTTP Server-Sent Events(SSE)与 Responses WebSocket mapper |
codex-rs/features | Stage 枚举、Feature::stage()、默认值与 legacy rollout flag 的元数据 |
先抓住这些控制类型
| 边界 | 类型 / 函数 | 先问的问题 |
|---|---|---|
| 输入相关 | Submission、Op::UserInput | id 只关联提交与事件,还是已经成为 active turn id? |
| task ownership | RegularTask、RunningTask、TurnContext | 谁持有 handle/token/context,谁只执行一次调用? |
| 指令与历史 | BaseInstructions、ResponseItem、ContextManager::for_prompt | 文本留在专用字段、进入 history,还是只存在于 detached projection? |
| 模型输入 | Prompt、build_prompt | input、tools、base instructions 与 output schema 在哪里汇合? |
| request/transport | ResponsesApiRequest、ResponseStream、ResponseEvent | request 参数由谁构造,stream handle 由谁消费? |
| continuation | SamplingRequestResult、run_turn、RegularTask::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、ToolCallRuntime 与 ToolRouter;这里先固定所有权,不展开第 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_DIR 或 EXIT 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_id | steer response 沿用 active turn id,输入被该 active turn 接受 | 所有客户端都经过 app-server;steer 等于新建 task |
instruction_sources_include_global_before_agents_md_docs | global instruction source 与 project AGENTS.md source 的顺序和分离结构 | 最终 request 中只有一个统一 instructions 字符串 |
normalize_adds_missing_output_for_function_call | filter 命中的一条或两条 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 成熟度
Feature 的 Stage 描述 feature flag 的生命周期,不是对应 runtime path 的存在性证明。本部最直接的反例是 Responses transport:legacy Feature::ResponsesWebsockets 与 ResponsesWebsocketsV2 都是 Removed、默认关闭;当前 ModelClient::responses_websocket_enabled 实际检查 provider capability 与 session-scoped disable state。不能从 legacy flag 的 Stage 推出 Responses WebSocket 代码已经消失,也不能从 provider 支持推出本次连接一定成功。
第二个例子来自本部第 12 章的验证锚点。Feature::CurrentTimeReminder 是 UnderDevelopment、默认关闭;current_time_tool_returns_the_latest_time 的 fixture 会显式 enable 该 feature,并注入 external time provider。测试通过只证明这个受控配置,不证明默认 turn 会暴露 current-time tool。
平台与 transport 限制
| 观察面 | 固定版本或命名测试能证明什么 | 仍需单独验证什么 |
|---|---|---|
| checkout 与 Cargo 命令 | bash/zsh 示例能固定 commit 并运行 test filter | Windows 需要 PowerShell/cmd 的变量、续行与路径转换 |
turn_steer fixture | Unix 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 脚本连续阻断同一个 turn | module 在 Windows 不编译;缺少 python3 也不能形成本锚点 |
| HTTP SSE | local mock 能捕获普通 Responses request,并返回 typed stream event | 公网认证、代理、服务端路由与 401/retry 行为 |
| Responses WebSocket | provider capability 和 session fallback state 参与 transport 选择 | handshake、reuse、断线与 fallback 的完整恢复矩阵 |
| Responses Lite | request builder 会改变 instructions、tools 与 input 的 wire layout | 它不是第三种 transport,也不代表所有 provider 都支持 |
| Realtime | fixed registry 中有独立的 Realtime feature 与 endpoint code | Realtime 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 开始:进入《你按下回车后,谁接管这次任务》。
你按下回车后,谁接管这次任务
从 TUI 的一次提交追到 Submission、active turn、RegularTask、pending queue 与 cancellation token,画清一轮任务的所有权交接。
第 1 章只在系统地图上标出了 Submission -> Session -> RegularTask -> run_turn,没有解释任务怎样被接管和收回。现在把镜头推近:我在 TUI 里输入一句“把这个测试修掉”,按下回车。屏幕上的光标消失、状态变成运行中,看起来像是同一个动作;源码里却至少发生了两次所有权交接:先由界面决定把输入送到哪一个 thread,再由 Session 决定是否有资格创建一个新的 turn。中间还隔着一个容易被忽略的 Submission 队列。
这次先不展开 prompt 怎样拼出来,也不讨论工具循环。只回答一个更窄的问题:回车之后,谁保存这份输入,谁拥有 active turn,谁负责取消,以及完成时谁把所有权收回来。app-server 在这里是入口黑盒;我们只用它暴露出来的 turn/start、turn/steer 和 turn/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_id 调 new_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_input 或 record_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,都集中在那里展开。