雨天小六

读懂 Codex(3.5):Session 输入主循环怎样串行分派 Op

· 更新于 2026-08-02 · 专栏:读懂 Codex

#Codex#Agent Runtime#生命周期#软件架构

Session 的输入主循环保证的是“控制操作按 Submission 到达顺序分派”,不是“整个 Agent 一次只能做一件异步工作”。用户 Turn 会被放到独立 Tokio 任务里执行,主循环立即回来继续接收 Interrupt、审批结果和 Steer。

Submission 是控制信封

每条 Submission 除 Op 外还有 id、可选 client user message id、W3C trace context 和 parent turn id。主循环从有界 channel 逐项 recv,为每个操作建立 dispatch span,然后按 Op 分类调用 handler。

Submission loop 串行分派与 Turn Task 并发运行
图 3.5-1:分派轴是串行的,执行轴可以持续运行。Interrupt 和审批答复因此能在模型流未结束时进入 Session。

操作大致分成五组:

  • Turn 控制:UserInput、Interrupt、Compact、Review、UserShell;
  • 同步回填:Exec/MCP 审批、RequestUserInput、Permissions、DynamicToolResponse;
  • 配置维护:ThreadSettings、Reload、RefreshMcpServers、技能和插件刷新;
  • Realtime:Start、Audio、Text、Speech、Close、ListVoices;
  • 终止:Shutdown 及连接关闭后的隐式 teardown。
async def submission_loop(session, rx):
    while submission := await rx.recv():
        with dispatch_span(submission.trace, submission.op):
            match submission.op:
                case UserInput(data):
                    await user_input_or_turn(session, submission.id, data)
                case Interrupt():
                    await session.abort_all_tasks(INTERRUPTED)
                case Approval(reply):
                    await resolve_turn_waiter(session, reply.call_id, reply)
                case Shutdown():
                    await shutdown_session_runtime(session)
                    break
                case op:
                    await dispatch_other_op(session, submission, op)
    else:
        await shutdown_session_runtime(session)

    await emit_thread_stop_and_close_live_thread(session)

Rust 的 Op 标记为 non-exhaustive,循环对未来未知变体保留忽略分支。这不是网络协议的普遍兼容保证,而是 Core 内部在版本演进中避免旧分派器崩溃的防御;App Server 的 JSON-RPC 层仍有自己的 method/version 校验。

为什么不能在主循环内 await 整个 Turn

如果 UserInput handler 直接等待 run_turn 返回,模型请求、工具执行或用户审批可能持续数分钟。此时同一 receiver 中的 Interrupt 永远无法被处理,形成“只有任务自己结束才能中断任务”的死锁式设计。

当前实现只在 handler 中完成 TurnContext 构造和 start_task 登记:

async def user_input_or_turn(session, submission):
    candidate = await session.new_turn_with_sub_id(submission.id, submission.settings)
    steered = await session.steer_input(candidate.input, submission.expected_turn_id)
    if steered.ok:
        return
    if steered.reason == NO_ACTIVE_TURN:
        await session.start_task(candidate, RegularTask())

start_task 内部 tokio::spawn 后便返回。任务结果由统一完成处理器收口,主循环不参与采样细节。

不同 Op 路由到 Task、TurnState、服务或 teardown
图 3.5-2:不是每个 Op 都创建 Turn。审批答复写回当前 TurnState,刷新操作调用长期服务,只有任务类操作进入 Task 生命周期。

串行分派仍可能启动并发工作

刷新 MCP、网络代理预热、工具执行和 Realtime fanout 都可能派生后台任务。主循环保证 handler 的入口顺序,却不自动保证这些后台结果按提交顺序完成。需要严格次序的写入必须由 LiveThread、Rollout recorder 或具体服务另设队列和屏障。

这也是为什么“Submission A 先到,所以它产生的所有 Event 都先于 B”并不成立。成立的是 A 的 handler 先被调用;若 A 启动异步任务,B 的同步错误事件完全可能先出现。协议消费者应按 Turn/Submission/Call ID 关联,而不是只凭全局墙钟直觉。

通道关闭与显式 Shutdown 的共同收尾

显式 Shutdown handler 返回停止标志;receive 得到 channel closed 也跳出循环。两条路径最终都运行 session runtime teardown、ThreadStop lifecycle 和 LiveThread shutdown。测试确保隐式关闭不会漏掉活动 Turn。

评论


← 返回文章列表