A2|一次任务的生命周期:Thread 活着,Turn 干活,Step 采样
先别把一次回车当成一次 HTTP
「用户敲了一句」不是「对模型 POST 一次」。resume 也不是重发同一段 messages,steer 不是再开一个请求,中断更不是 kill -9。
一次用户可见的工作是 Turn:里面可以多次采样、多次跑工具。每一次采样是 Step。把这些 Turn 串起来、能关掉再打开的那条命,叫 Thread。客户端从不直接调模型;它丢一个 Op,听一串 Event,磁盘上留下 rollout。
下面先用一段玩具循环对齐这三个词,再分清 Turn 上冻住什么、Step 上重算什么。
0. 先看 30 行玩具版本,再进正题
如果你还没写过 agent 循环,下面这段伪代码是最小可能的版本——能跑,但撑不住任何真实需求:
messages = [system_prompt]
tools = {"exec_command": run_shell, "apply_patch": apply_patch}
while True:
user_text = input("你: ")
messages.append({"role": "user", "content": user_text})
while True: # 一轮里可能要好几次工具调用才能给出最终答案
response = call_model(messages, tools=list(tools.keys()))
if response.tool_call is None:
print(response.text)
messages.append({"role": "assistant", "content": response.text})
break # 这一轮结束,等下一句用户输入
result = tools[response.tool_call.name](**response.tool_call.args)
messages.append({"role": "assistant", "tool_call": response.tool_call})
messages.append({"role": "tool", "content": result})对应关系很直接:外层 while True 等用户输入,是一条 Thread 的寿命;中间那次"用户说一句到给出最终答案",是一个 Turn;内层每次 call_model,是一个 Step;messages 这份列表,就是 Rollout 想要做到能重建的东西。
这版本能跑,但立刻会撑不住这些真实需求:
- 用户想在模型还在跑的时候插一句话 —— 这个脚本只能等
input()返回,插不进去(B1 的 steer)。 run_shell卡住了,想取消但不想杀掉正在测的 dev server —— 这个脚本只有Ctrl+C一种粒度(B1 的 CancelToken vs 后台进程)。- 想关掉终端明天接着聊 ——
messages在内存里,进程一退就没了(Rollout / resume)。 - 要不要执行
exec_command之前该不该问一下人 —— 这个脚本要么全自动、要么每次都问,没有"能力"和"同意"两个独立旋钮(B3)。 - 同时想接 TUI、CI、IDE 三种前端 —— 这个脚本的
input()/print()换成任何一种前端都要重写一遍循环本体。
B1–B7 要抄的骨架,就是把这 20 行拆成能扛住上面 5 条需求的形状:while True 变成 Session 的 submission_loop,input()/print() 变成 Op/Event 两条队列,messages 变成可重建的 Rollout。
1. Thread / Turn / Step 不是三个同义词
CodexThread 的注释把自己叫「组成一条 thread 的双向消息管道」,还注明它 formerly called a conversation(core/src/codex_thread.rs:206-207)。对外句柄是它:submit(Op)(同文件 :227-229)。对内干活的是 Session。Session 的硬约束写在结构体上头:同时最多一个 running task,可被用户输入打断(core/src/session/session.rs:48-51)。
ThreadManager 不跑 turn。它负责创建、从 rollout resume、fork 出新 id(core/src/thread_manager.rs)。活着的运行时是 Session;Thread 是你能拿着走的把。
Turn 是用户看见的「这一轮」。普通对话走 RegularTask(core/src/tasks/regular.rs:29-44)。同一文件里还有 Compact / Review;TaskKind 只认这三种(core/src/state/turn.rs:68-72)。用户在 TUI 里敲的那句,默认不是 Compact,也不是 Review。
Step 是 Turn 里面的一次采样。StepContext 的模块文档写得很直:request-scoped,两次模型请求之间可以变(core/src/session/step_context.rs:1-18)。它带着这一次真正广告出去的 tool_router、MCP 绑定、AGENTS.md 快照。Turn 可以有很多个 Step;每个 Step 必须能回答「模型这次看见了哪套工具」。
三个名字混用会怎样?resume 会去重放 UI,steer 会再开一条 Session,中断会把后台 PTY 一起杀掉。后面第四节全是为了阻止这三件事。
2. TurnContext 冻结,StepContext 每步重算
TurnContext 是一轮的壳。注释要求:service tier、approvals reviewer 这类 step-specific 字段,不要从 TurnContext 读,从对应的 StepContext 读(core/src/session/turn_context.rs:287-288)。同一结构里有两份 settings:
initial_settings:构造 Turn 时冻住。注释写明 legacy 消费者即使后面几步 settings 变了,仍看这一份(:295-297)。current_settings:ArcSwap,给 下一步 用;真正执行采样的代码必须用已经 capture 下来的 StepContext(:300-301)。
token_budget 相关的两个字段也是同一哲学:用户偏好在 turn 开始时 capture 一次,后面几步不再回头重读 config 层(:290-293)。
Step 怎么来的?Session::capture_step_context 的文档把调用纪律写死了:这会刷新从文件系统推出来的状态;普通 turn 只该从 run_turn 调用,然后把结果往下传(core/src/session/mod.rs:3642-3647)。run_turn 在第一次采样前就 capture(core/src/session/turn.rs:257-264),并且立刻 record_context_updates_and_set_reference_context_item(:283)——把「这一步模型可见的世界」冻成参考点。
内层循环里,没有新的 pending input 就复用上一个 StepContext;有 steer 进来的用户话,才重新 capture,因为 mention 的 plugin / MCP 可能变了(turn.rs:456-468)。采样侧还有一句配套注释:capture 一次,是为了让 上下文、广告出去的工具、真正执行的 tool call 共用一个 request view(:456)。
没有「冻结 / 重算」这层会怎样?模型在 Step 1 看见工具 A,Step 2 你热更新了 config,工具调用却按新列表分发——call_id 对不上,审批缓存错位,rollout 也无法重建当时模型看见的那一套。Turn 冻的是「这一轮用户要什么」;Step 重算的是「这一枪打出去时世界长什么样」。
3. 一条任务怎么走:Op → Session → Model → Tools → Event → Rollout
把心跳画成一条单向的管道。反向箭头只有 Event 和磁盘。
入口是 CodexThread::submit。活 Session 的心脏是 submission_loop(core/src/session/handlers.rs:411-427):从队列里取 Submission,对 Op 穷尽分发。Op::TurnInput 不在这里采样;它把盒子交给 turn_input::handle。
turn_input.rs 文件头是这一章最重要的设计声明之一(:1-6):Core 在这里决定输入是开新 Turn、steer 进正在跑的 Turn、还是拒绝。决定立刻回包。 它不等 prompt hooks,不等把消息写进模型上下文,不等 rollout,更不等采样。客户端能先拿到 Started { turn_id } / Steered { turn_id } / NotSubmitted { reason },再去听事件。
开新 Turn 之后,RegularTask::run 先发 TurnStarted,再进外层 loop(regular.rs:46-96):每次调用 run_turn;若 input_queue 还有货,清空 next_input 再跑一轮——所以用户可以在模型还在跑时继续说话,说完的话不会丢,但也不会偷偷另开一条 Session。
run_turn 内层顺序(turn.rs:163 起)是后面 B 章全部要挂上去的时间线:
- Guardian 挂起检查、drain 上一轮迟到的 hook
- 采样前 compact。失败也必须先
run_hooks_and_record_inputs,因为新用户话还没入历史(:183-198) - 解析 mention,决定这一步需要哪些 MCP / plugin
capture_step_context,记录 world_state- 注入 skills / plugins,跑 pending session-start hooks
- 采样:
ModelClientSession是 turn-scoped,WS 和 sticky routing 在这一轮里复用(:414-415)。这一枪的Prompt长什么样、失败怎么分流,见 附录 F - 工具:
ToolCallRuntime按 StepContext 里那份 router 分发(采样函数:1551-1555) - 工具结果变成下一枪的 prompt;有 pending steer 就 drain 进历史(
:416-434)
Event 从 Session.tx_event 出去,所有表面——TUI、exec、IDE——听的是同一类 EventMsg。磁盘真相是 rollout:turn 过程中的 item 往 recorder 里追加,resume 读的是它,不是当时屏幕上的 widget。B4 展开 compact 和 JSONL;这里只要记住:内存里的 history 是投影,rollout 才是可以重建 Session 的那份。
没有这条管道会怎样?UI 会去调模型,exec 会去自己跑 shell,IDE 会自己记一份 transcript。六个入口在 A1 里已经证明必须共用 harness;共用的前提就是这条 Op → Event → Rollout 管道,而不是共用一个 React 组件。
4. 中断、steer、resume、fork:四个容易被当成同义词的操作
它们全是生命周期上的不同切口,不是四个按钮皮肤。
Interrupt:取消这一轮采样和工具,不动后台 PTY。Op::Interrupt 的注释写明:abort current task without terminating background terminal processes,回 TurnAborted(protocol/src/protocol.rs:601-603)。要杀长进程,另有 CleanBackgroundTerminals(:605-607)。实现是 interrupt_task → abort_all_tasks(session.rs 一侧转到 tasks/mod.rs:512-536)。真正发出的信号是 cancellation_token.cancel()(tasks/mod.rs:890),不是 kill。工具、采样、MCP 启动都看这张令牌。后台 unified_exec 进程活着,直到有人显式 CleanBackgroundTerminals。中断还会在历史上留下可识别的 fragment(InterruptedTurnHistoryMarker,tasks/mod.rs:76-114),fork 中断过的会话时用同一份标记,避免模型以为上一轮正常说完了。
Steer:话扔进正在跑的 Turn,不开新的。TurnInputMode 三种(protocol/src/turn_input.rs:133-139):
| 模式 | 空闲时 | 正在跑时 |
|---|---|---|
StartOrSteer | 开新 Turn | 注入当前 Turn |
StartIfIdle | 开新 Turn | 拒绝入队,不记录 |
Steer { expected_turn_id } | 拒绝 | 仅当 turn_id 对得上才注入 |
CodexThread::start_or_steer_turn 把「调用方不用先探状态」写成 API(codex_thread.rs:323-333)。steer 只接受用户输入(turn_input.rs:486-489)。持久 thread settings 在 Started 和 Steered 时都生效;TurnStartOptions 只在 Started 时生效(turn_input.rs:7-9)。被拒绝的输入 不写历史、不入队——这是 NotSubmitted 存在的理由。
Resume thread ≠ Recover turn。resume_thread_from_rollout 从磁盘重建一条 Thread(thread_manager.rs:1118-1134)。同一 thread id 若还活着,warm resume 直接把现成句柄还你,不另开一份 runtime(:1985-2011)。这是「关掉终端再打开」。RecoverTurn / recover_turn_if_idle 是另一件事:thread 已 idle,没有新的用户消息,用已经记过的 turn_id 把被打断的那一轮采样续上(codex_thread.rs:360-363,turn_input.rs:460-462)。这是「中断之后接着干」,不是「重放整段聊天」。
Fork:新 id,拷一份历史快照。fork_thread 按 snapshot 切 rollout,新 thread 新 id,配置默认相同(thread_manager.rs:1337-1345)。冷 resume / 根 fork 会重新装说明书;warm resume 不走这条(:1680-1685)。Fork 不是 resume 的别名:resume 还是你这条命,fork 是分出去的平行宇宙。历史从哪切、是否拷贝,会影响后面 compact 和审批上下文——B4 再讲拷贝策略。落盘之后的 lineage / revert 指针见 G2。
没有这四个切口会怎样?中断变成杀进程,后台测试挂掉;用户插话变成并行两个 Turn 抢同一工作区;resume 变成「把 transcript 贴回对话框」;fork 变成「另存为」却共用 rollout 文件,两个 Session 写穿。生命周期操作必须是协议,不能是 UI 手势。
5. /side /btw:不污染主线的 ephemeral fork,不是 fork_thread
TUI 里 /side 和别名 /btw 看起来像「再 fork 一次」。它们不是上一节那个协议切口。
判断先说清楚:旁路对话是 TUI 自己开的 ephemeral fork。 模块头写得很直:给主线留一个快速问题,fork 出去,继承的历史只当参考,默认别改工作区(tui/src/app/side.rs:1-8)。分发在 slash_dispatch.rs:339-341:空命令走 request_empty_side_conversation。这不是 ThreadManager::fork_thread 的别名,也不是 D5 的 spawn_agent。
和协议 fork 的差别:
/fork(上一节) | /side /btw | |
|---|---|---|
| 谁拥有生命周期 | Session / ThreadManager | TUI app/side.rs |
| 新 thread | 持久、可 rename、可挂 Goal | ephemeral,不能改名(:19) |
| 继承历史 | 从 snapshot 切一份继续干 | 历史只是参考;边界之后才是现行指令(SIDE_BOUNDARY_PROMPT,:28-40) |
| 默认动手 | 和父 thread 同一套政策 | 默认只读探索;用户在边界之后点名才许最小改动(SIDE_DEVELOPER_INSTRUCTIONS,:42-56) |
| 子代理 | 可以再 spawn | off-limits(同段) |
| 怎么回去 | 你现在就在新宇宙里 | Ctrl+C 回主 thread;已经在 side 里再 /side 会被拒(chatwidget/tests/side.rs:121-137) |
side 里 /review 也不可用,测试文案就是「先回主 thread」(:109)。C5 已经钉过:ephemeral thread 不能挂 Goal。旁路对话是那种 thread,不是另一条可 resume 的命。
没有这一层会怎样?你会把 /side 当成「另存为」,主线的 Goal、审批和子代理被一句闲聊带跑;或者反过来,给 side 再发明一套 Op,六个入口每端各写一份「旁路」。TUI 把它收成投影,内核仍然只有 Thread / Turn / Step。
6. 结语:带走一句话
Thread 保命,Turn 干这一轮活,Step 是每一枪采样;Turn 冻住用户要什么,Step 在开枪前重算模型看见的世界。