夜雨聆风学习资料网

ARTICLE · 1289719

Codex 源码-Code Mode 运行时——V8 里的 JS 执行单元

Codex 源码-Code Mode 运行时——V8 里的 JS 执行单元

Codex 源码解析系列

第 38 讲:Code Mode 运行时——V8 里的 JS 执行单元

基于 OpenAI Codex 源码 · 2026-11-01

💡 本讲一句话:Code Mode 让模型不再"一条命令一个结果",而是写一段 JavaScript、在 V8 里循环调用工具——本讲拆穿它背后的 actor + 状态机:JS 一 await 线程就冻结(零 CPU),10 秒窗口一到就把中间输出交回模型,store()/load() 让状态跨 cell 存活。

一、Code Mode:从"执行命令"到"执行程序"

前几讲我们拆过 shell exec(第 17 讲):模型发一条命令,Codex 在 bwrap/Landlock 沙箱里跑完,把 stdout 交回去。这个模式有个天花板——一次调用只能做一件事。"先查日志、再根据错误码改配置、最后验证"这种多步任务,模型得自己当调度器,一轮轮往返。

Code Mode(codex-rs/code-mode*,约 17K LOC)把这件事反过来:模型写一段 JavaScript,Codex 在一个叫 cell(执行单元)的 V8 isolate 里跑它。JS 代码可以循环、分支、await tools.shell({...}) 连续调多个工具,中间用 text()/notify() 随时产出内容,用 store()/load() 跨 cell 传状态。

代价是复杂度:JS 跑在进程内的 V8 里,没有沙箱进程隔离兜底;它可能死循环、可能 await 一个永远不返回的工具。所以整个 code-mode-runtime crate(约 5K LOC)的核心工作就一句话——把"一段不受控的 JS"约束成一台可暂停、可恢复、可强杀、交付边界清晰的状态机。

维度
shell exec(第 17 讲)
Code Mode cell(本讲)
执行体
沙箱里的子进程
V8 isolate 里的一段 JS(exec_main.mjs)
控制粒度
一条命令一个结果
一段程序:循环、分支、await 多个工具
中间输出
命令结束才有 stdout
text()/notify() 随时产出,窗口内多次交付
状态共享
靠文件系统/环境变量
store()/load() 会话级 JSON,跨 cell 存活
终止方式
kill 进程树
terminate_execution() 强杀 V8 + CancellationToken

四个 crate 分工明确:code-mode-protocol(协议与 JS API 描述,含 parse_exec_source pragma 解析)、code-mode-runtime(本讲主角:cell actor + V8 线程 + CellState 状态机)、code-mode-host(gRPC 宿主,把 cell 暴露给远端)、code-mode(remote_session/grpc_session 传输层)。core 侧的 tools/code_mode/ 负责把 exec 工具调用接进来、把嵌套工具派发到真实工具运行时。

二、Cell:一个 actor + 一条 V8 线程

每个 cell 的骨架在 cell_actor/mod.rs:V8 跑在一条裸 OS 线程上(不能跑 tokio),actor 主循环跑在 tokio 里,两者之间只有三条通道——事件、命令、控制。创建 cell 的 CellActor::prepare()(第 63-92 行)把这套接线一次做完:

📄 codex-rs/code-mode-runtime/src/cell_actor/mod.rs (第 63-92 行,节选)

let (event_tx, event_rx) = mpsc::unbounded_channel(); // 事件通道:V8 线程 → actor,Started/ToolCall/Result 都从这里来let (command_tx, command_rx) = mpsc::unbounded_channel(); // 命令通道:actor → V8 线程,工具结果回填、终止指令走这里let (initial_response_tx, initial_response_rx) = oneshot::channel(); // 首个观察结果专用一次性通道,execute() 的调用方直接 await 它let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = spawn_runtime( // 起 V8 线程,拿回三样东西:命令口、控制口、可强杀的执行句柄    stored_values, // 会话级 store/load 共享值快照——跨 cell 的"全局变量"    runtime_request(request), // JS 源码 + 启用工具清单打包成 ExecuteRequest    event_tx, // 事件出口接上面那条 unbounded channel    PendingRuntimeMode::PauseUntilResumed, // 关键策略:JS 一 await 就冻结线程,等 actor 显式 Resume——不为空转烧 CPU    task_failure_handler.clone(), // panic/任务失败上报钩子)?; // V8 初始化失败直接返回 Err,cell 根本不会创建let handle = CellHandle::new(command_tx, Arc::clone(&cell_state)); // 对外句柄:observe()/terminate() 都通过它和 actor 对话let task = run_cell( // actor 主循环本体,作为 tokio 任务跑在异步运行时上    host, // CellHost:会话回调(invoke_tool/notify/commit_completion)    CellContext { runtime_tx, runtime_control_tx, runtime_terminate_handle, cell_state }, // 对 V8 线程的全部控制面 + 共享状态    event_rx, command_rx, // actor 的两个输入口    Observer { mode: initial_observe_mode, response_tx: initial_response_tx }, // 首个等待者:execute() 的调用方    task_failure_handler, // 失败上报钩子透传);

为什么这样设计:注意 runtime_tx(命令)和 runtime_control_tx(控制)是两条独立通道。这不是洁癖——V8 线程的取命令循环里,数据命令用 try_recv()(非阻塞),控制指令用 recv()(阻塞挂起)。如果混在一条通道里,线程冻结时无法区分"该睡了"还是"有活来了"。分道之后语义干净:没数据命令 = 睡觉;控制口来消息 = 醒来。

💡 记住这张图:V8 线程(同步、单线程、跑 JS)⇄ actor(异步、tokio、管生命周期)。事件向上、命令向下,第三个角色 v8::IsolateHandle 是"电闸"——terminate_execution() 一拉,死循环的 JS 立刻停。

run_cell()(第 108-495 行)是 actor 主循环,一个 tokio::select! 同时盯六个方向:取消信号、新的 Observe 命令(wait 工具)、yield 定时器到期、V8 事件、notify 任务完成、tool 任务完成。其中两个细节值得放大:

🔹 biased + 条件分支:select 按书写顺序优先匹配,且事件分支带 if !yield_deadline_elapsed——yield 定时器一旦到期,本轮 select 故意屏蔽 V8 事件,保证"交付给模型"优先于"继续消费事件"。这是把"观察窗口"做成硬语义的关键一行。

🔹 未送达回滚:每次向 observer 发事件(Yielded/Pending)都用 std::mem::take() 先搬走内容,发送失败再把未送达的部分拼回 content_items(见第 534-545 行 restore_undelivered_yield)。模型侧 drop 了接收端,内容也不丢——下次观察还能取到。

三、V8 线程与"暂停—恢复"协议

runtime/mod.rs(515 行)是 V8 线程的全部。先看 spawn_runtime()(第 74-122 行)怎么把线程生出来:

📄 codex-rs/code-mode-runtime/src/runtime/mod.rs (第 88-121 行,节选)

ensure_v8_initialized()?; // V8 进程级初始化只做一次(平台/沙箱配置),失败直接拒绝创建 celllet (command_tx, command_rx) = std_mpsc::channel(); // 注意是 std 同步通道:V8 线程是裸 OS 线程,不能跑 tokiolet (control_tx, control_rx) = std_mpsc::channel(); // 控制口单独一条:Continue/Resume/Terminate 与数据命令分流let runtime_command_tx = command_tx.clone(); // 再克隆一份送进线程——JS 侧 setTimeout 到期也要走这条命令通道let (isolate_handle_tx, isolate_handle_rx) = std_mpsc::sync_channel(1); // 容量 1 的同步通道:等 V8 isolate 建好才放行let enabled_tools = request.enabled_tools.iter().map(enabled_tool_metadata).collect::<Vec<_>>(); // 工具定义转成运行时元数据(名字/描述/kind)let config = RuntimeConfig { tool_call_id: request.tool_call_id, enabled_tools, source: request.source, stored_values }; // 打包全部启动参数,move 进线程闭包spawn_supervised_runtime_thread(event_tx.clone(), task_failure_handler, move || { run_runtime(config, event_tx, command_rx, control_rx, pending_mode, isolate_handle_tx, runtime_command_tx); }); // catch_unwind 包住整个线程体:panic 也能上报而不是静默死掉let isolate_handle = isolate_handle_rx.recv().map_err(|_| "failed to initialize code mode runtime".to_string())?; // 阻塞等 isolate 就绪——调用方拿到句柄即可随时 terminate_execution() 强杀 CPU 死循环

为什么这样设计:spawn_supervised_runtime_thread(第 124-137 行)用 catch_unwind(AssertUnwindSafe(runtime)) 包住整个线程体——V8 里的 JS panic、Rust 侧的断言失败,统统变成 RuntimeEvent::ThreadPanicked + task_failure_handler 上报,而不是让进程直接崩。测试里甚至专门验证了"panic 前初始化未完成也能直报"(第 372-391 行)。

真正的心脏是 next_runtime_command()(第 281-305 行)——V8 线程"取下一条命令"的原语,暂停—恢复协议全在这里:

📄 codex-rs/code-mode-runtime/src/runtime/mod.rs (第 281-305 行)

fn next_runtime_command( // V8 线程的"取下一条命令"原语——暂停-恢复协议的核心    event_tx: &mpsc::UnboundedSender<RuntimeEvent>, // 事件出口:要告诉 actor "我卡住了"    command_rx: &std_mpsc::Receiver<RuntimeCommand>, // 数据命令口(工具结果/超时)    control_rx: &std_mpsc::Receiver<RuntimeControlCommand>, // 控制口(Continue/Resume/Terminate)    pending_mode: PendingRuntimeMode, // 卡住时是"冻结等恢复"还是"测试模式直接阻塞") -> Option<RuntimeCommand> { // None = actor 已断开,线程该退出了    loop {        match command_rx.try_recv() { // 非阻塞取命令:有活就干,没有才考虑挂起            Ok(command) => return Some(command), // 拿到工具结果/超时事件 → 回主循环执行            Err(std_mpsc::TryRecvError::Disconnected) => return None, // actor drop 了发送端 = cell 被销毁,线程退出            Err(std_mpsc::TryRecvError::Empty) => {} // 暂无命令:走下面的挂起流程        }        let _ = event_tx.send(RuntimeEvent::Pending); // 先广播"我卡在 await 了"——actor 据此暂停计时/交付 Pending 事件        match pending_mode {            #[cfg(test)] Continue => return command_rx.recv().ok(), // 测试专用:不冻结,直接阻塞等下一条命令            PendingRuntimeMode::PauseUntilResumed => match control_rx.recv().ok()? { // 生产路径:线程在此冻结,零 CPU 消耗                RuntimeControlCommand::Continue => return command_rx.recv().ok(), // actor 回填了工具结果 → 取回命令继续跑                RuntimeControlCommand::Resume => continue, // 观察窗口结束 → 回循环顶部重新 try_recv                RuntimeControlCommand::Terminate => return Some(RuntimeCommand::Terminate), // 终止指令优先级最高,直接上抛            },        }    }}

为什么这样设计:JS 里 await tools.shell(...) 挂起后,V8 线程没有任何事可做。朴素做法是忙等或 sleep 轮询——浪费 CPU 且延迟高。这里的方案是把"等待"变成"阻塞在控制通道上":先广播 Pending(让 actor 知道可以交付中间状态了),然后 control_rx.recv() 直接睡死。唤醒只有三种方式——工具结果到了(Continue)、观察窗口结束要接着跑(Resume)、被终止(Terminate)。线程的每一次醒来都对应一个明确的语义事件,没有一次空转。

V8 线程与 actor 之间的事件/命令对照如下(事件定义见第 51-72 行 RuntimeEvent):

V8 线程事件
actor 处理(run_cell)
模型看到什么
Started
启动/重置 yield 定时器
(无,内部信号)
Pending
标记暂停;观察模式=PendingFrontier 时交付 Pending,否则发 Continue 放行
wait_to_pending 拿到 pending_tool_call_ids
ContentItem
累积进 content_items,等交付窗口
Yielded/Completed 里的内容项
ToolCall
spawn 工具任务,登记 pending id
(后台执行中)
Notify
spawn notify 任务 → broker 注入会话消息
对话流里多一条 exec 输出
Result
commit_completion + deliver_completion
最终结果(含 error_text)

四、JS API 表面:白名单 + Promise 桥

runtime/globals.rs(163 行)定义 cell 里能用什么。策略是先删后加——先把危险全局对象删光,再逐个挂白名单函数(第 15-49 行 install_globals):

📄 codex-rs/code-mode-runtime/src/runtime/globals.rs (第 15-49 行,节选)

pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), String> { // 给 cell 装 API:先删危险全局,再挂白名单函数    let global = scope.get_current_context().global(scope); // 拿到全局对象,接下来在上面增删属性    delete_global(scope, global, "console")?; // 删 console:cell 里不该有浏览器式调试输出    delete_global(scope, global, "Atomics")?; // 删 Atomics:跨线程同步原语对单 cell 无意义,还放大攻击面    delete_global(scope, global, "SharedArrayBuffer")?; // 删共享内存:同上,且是 Spectre 类漏洞的常见入口    delete_global(scope, global, "WebAssembly")?; // 禁 WASM:模型代码只能跑纯 JS,不能加载二进制模块    let tools = build_tools_object(scope)?; // 按 enabled_tools 逐个生成函数挂到 tools.* 下——没启用的工具根本不存在    let all_tools = build_all_tools_value(scope)?; // [{name, description}] 元数据数组,供 JS 代码自省"我能调什么"    let clear_timeout = helper_function(scope, "clearTimeout", clear_timeout_callback)?; // clearTimeout:取消 JS 侧定时器    let set_timeout = helper_function(scope, "setTimeout", set_timeout_callback)?; // setTimeout:到期经命令通道唤醒 V8 线程(不是真异步)    let text = helper_function(scope, "text", text_callback)?; // text():产出文本内容项(模型可见的输出)    let image = helper_function(scope, "image", image_callback)?; // image():产出图片内容项    let audio = helper_function(scope, "audio", audio_callback)?; // audio():产出音频内容项    let generated_image = helper_function(scope, "generatedImage", generated_image_callback)?; // generatedImage():带 output_hint 的生成图结果    let store = helper_function(scope, "store", store_callback)?; // store(key, value):写入会话级共享值,跨 cell 存活    let load = helper_function(scope, "load", load_callback)?; // load(key):读回之前存的值(JSON 往返)    let notify = helper_function(scope, "notify", notify_callback)?; // notify(text):向会话注入一条消息,如"还在跑,已完成 X%"    let yield_control = helper_function(scope, "yield_control", yield_control_callback)?; // yield_control():主动让出——立即把当前输出交回模型    let exit = helper_function(scope, "exit", exit_callback)?; // exit():抛哨兵异常终止 cell,已产出的内容保留    set_global(scope, global, "tools", tools.into())?; // 工具入口:await tools.shell({...}) 这类调用都从这里进    set_global(scope, global, "ALL_TOOLS", all_tools)?; // 元数据数组挂全局    set_global(scope, global, "text", text.into())?; // 其余 helper(image/audio/store/load/notify/yield_control/exit/setTimeout/clearTimeout)同样逐个挂载    Ok(()) // 全部就绪,JS 代码可以开始执行了

为什么这样设计:删 WebAssembly/SharedArrayBuffer 是安全决策——cell 没有进程隔离兜底,能加载的二进制越少越好;删 console 则是语义决策:cell 的输出通道只有 text()/notify(),不允许"偷偷打印到别处"。工具函数用下标而非名字绑定(tool_function 第 122-134 行把 tool_index.to_string() 塞进函数模板 data)——回调里查表即可,避免字符串匹配出错。

JS API
作用
备注
tools.*
调用宿主工具(shell/apply_patch…)
返回 Promise,await 挂起 V8 线程
text(v)
产出文本内容项
随 Yielded/Completed 交付给模型
image(url[,detail])
产出图片内容项
detail 可选 auto/low/high/original
store(k,v) / load(k)
会话级 JSON 值存取
cell 结束时才合并进会话存储
notify(text)
向会话注入一条消息
空文本直接抛 TypeError
yield_control()
主动让出执行权
立即触发 Yielded 交付
exit()
终止 cell
抛 __codex_code_mode_exit__ 哨兵异常
setTimeout(fn,ms)
JS 侧定时器
到期发 TimeoutFired 命令唤醒线程

最关键的桥是 tool_callback(callbacks.rs 第 14-73 行)——JS 调 tools.xxx() 时 V8 回调到这里,把"调用宿主工具"翻译成"一个挂起的 Promise + 一条出线程的事件":

📄 codex-rs/code-mode-runtime/src/runtime/callbacks.rs (第 14-73 行,节选)

pub(super) fn tool_callback(scope: &mut v8::PinScope<'_, '_>, args: v8::FunctionCallbackArguments, mut retval: v8::ReturnValue<v8::Value>) { // JS 调 tools.xxx(...) 时 V8 回调到这里——工具桥的入口    let tool_index = match args.data().to_rust_string_lossy(scope).parse::<usize>() { // 函数模板 data 里存的是工具下标(字符串形式),这里还原成 usize        Ok(tool_index) => tool_index, // 正常路径:拿到下标去查 enabled_tools        Err(_) => { throw_type_error(scope, "invalid tool callback data"); return; } // 数据损坏 → JS 侧抛 TypeError,fail loud    };    let input = if args.length() == 0 { Ok(None) } else { v8_value_to_json(scope, args.get(0)) }; // 参数序列化成 JSON——跨语言边界的统一格式;无参工具传 None    let input = match input { // 序列化结果分派:成功继续,失败在 JS 侧抛错        Ok(input) => input, // JSON 化成功(或 None)        Err(error_text) => { throw_type_error(scope, &error_text); return; } // 参数不可序列化 → TypeError,工具调用不会发生    };    let Some(resolver) = v8::PromiseResolver::new(scope) else { throw_type_error(scope, "failed to create tool promise"); return; }; // 每个工具调用对应一个 Promise:JS 侧 await 的就是它    let promise = resolver.get_promise(scope);    let resolver = v8::Global::new(scope, resolver); // 提升为全局引用——resolver 要跨 scope 存活到结果回填那一刻    let (tool_name, tool_kind) = { // 查表拿工具名与 kind(Function/Freeform)        let Some(state) = scope.get_slot::<RuntimeState>() else { throw_type_error(scope, "runtime state unavailable"); return; }; // 运行时状态不可用属于内部错误,直接抛        let Some(tool) = state.enabled_tools.get(tool_index) else { throw_type_error(scope, "tool callback data is out of range"); return; }; // 越界防护:data 与 enabled_tools 必须一致        (tool.tool_name.clone(), tool.kind) // 克隆出来,后面发事件要用    };    let Some(state) = scope.get_slot_mut::<RuntimeState>() else { throw_type_error(scope, "runtime state unavailable"); return; }; // 取可变状态:要改计数器、登记 pending    let id = format!("tool-{}", state.next_tool_call_id); // 运行时内部 ID:tool-1、tool-2……与模型侧 call_id 区分开    state.next_tool_call_id = state.next_tool_call_id.saturating_add(1); // 自增(饱和加法,防溢出回绕)    let event_tx = state.event_tx.clone();    state.pending_tool_calls.insert(id.clone(), resolver); // 登记 pending:actor 把工具结果送回来时按 id resolve/reject    let _ = event_tx.send(RuntimeEvent::ToolCall { id, name: tool_name, kind: tool_kind, input }); // 事件出线程——actor 收到后才会真正执行这个工具    retval.set(promise.into()); // JS 侧拿到 Promise,await 挂起;V8 线程随后进入 Pending 冻结

为什么这样设计:注意顺序——先登记 pending、再发事件、最后才把 Promise 交给 JS。如果先发事件,actor 可能在 resolver 还没入表时就回填结果,按 id 查不到就丢了。这个"先挂账后通知"的顺序是整条工具链不丢结果的保证。而 v8::Global 提升引用是因为 resolver 必须活过当前 scope——它要等宿主工具跑完(可能几秒)才被 resolve。

结果回填在 module_loader.rs 第 66-103 行 resolve_tool_response():按 id 从 pending_tool_calls 取出 resolver,Ok(result) → resolver.resolve(),Err(text) → resolver.reject()——JS 侧的 try/catch 就能接住工具失败,模型代码可以自己做降级。

五、CellState:一台三态状态机

actor 循环里到处是"先检查再动作"的竞态风险:结果提交了一半、observer 又来取、终止信号同时到达。Codex 没有用一堆布尔标志硬扛,而是把 cell 生命周期收敛成一台显式状态机 CellPhase(cell_actor/types.rs):Running → Completed{event} → Tombstone,外加 Terminating/CompletionClaimed 两个过渡态。所有跨线程的"读-改-写"都在这把锁内原子完成。

看两个核心方法——commit_completion(第 199-218 行)和 deliver_completion(第 220-262 行):

📄 codex-rs/code-mode-runtime/src/cell_actor/types.rs (第 199-262 行,节选)

pub(crate) fn commit_completion(&self, event: CellEvent, pending_initial_yield_items: Option<Vec<OutputItem>>, commit: impl FnOnce()) -> CompletionCommit { // 提交完成结果:先落账、再改阶段,两步都在同一把锁内    let mut phase = self.phase.lock().unwrap_or_else(std::sync::PoisonError::into_inner); // 阶段锁:commit/deliver/route_observation 三个入口共用一把锁串行化    if !matches!(*phase, CellPhase::Running) || self.cancellation_token.is_cancelled() { return CompletionCommit::Rejected(event); } // 非 Running(已被终止/已交付)或已取消 → 拒绝提交,事件原样退回给 actor    commit(); // 回调里把 store() 的写入合并进会话级 stored_values——先落账再改阶段,保证不丢写    *phase = CellPhase::Completed { pending_initial_yield_items, event }; // 原子地进入 Completed:结果在此"冻结",等待交付    CompletionCommit::Committed}pub(crate) fn deliver_completion(&self, response_tx: Option<oneshot::Sender<Result<CellEvent, CellError>>>) -> CompletionDelivery { // 交付完成结果:抢锁 → 取事件 → 发送,任何一步失败都有明确回退    let mut phase = self.phase.lock().unwrap_or_else(std::sync::PoisonError::into_inner);    let (pending_initial_yield_items, event) = match std::mem::replace(&mut *phase, CellPhase::Tombstone) { // 先抢成 Tombstone:保证全局只有一个交付者能拿到结果        CellPhase::Completed { pending_initial_yield_items, event } => (pending_initial_yield_items, event), // 正常路径:取出冻结的结果        previous => { *phase = previous; return CompletionDelivery::Rejected(response_tx); } // 不是 Completed(比如已被终止)→ 恢复原状并拒绝交付    };    let Some(response_tx) = response_tx else { *phase = CellPhase::Completed { pending_initial_yield_items, event }; return CompletionDelivery::Buffered; }; // 没有等待者:退回 Completed 缓冲,等下一次 observe() 来取    match response_tx.send(Ok(event)) {        Ok(()) => { self.cancellation_token.cancel(); CompletionDelivery::Delivered } // 交付成功 → 取消 token,cell 生命周期正式结束        Err(Ok(event)) => { *phase = CellPhase::Completed { pending_initial_yield_items, event }; CompletionDelivery::Buffered } // 接收端已 drop:再缓冲一次,不丢结果        Err(Err(error)) => panic!("completion delivery unexpectedly carried an actor error: {error:?}") // 逻辑上不可能:oneshot 里只装过 Ok(event)    }

为什么这样设计:std::mem::replace(&mut *phase, Tombstone) 是教科书级的"先占坑再干活"——交付权是一次性的,谁抢到 Tombstone 谁就是唯一交付者;抢不到的人(比如并发来的 observe)走 route_observation(第 264-347 行)看到 Completed 阶段,直接把缓冲的事件取走。三种交付结果(Delivered/Buffered/Rejected)把"模型侧到底收没收到"变成显式状态,actor 据此决定是 break、继续等还是转 Terminated——没有任何一条路径靠"大概收到了吧"来推进。

💡 对比直觉方案:"mutex + done 标志位 + 结果字段"三件套也能跑,但每个调用方都要自己保证检查顺序,漏一处就是竞态。状态机把合法迁移写死在类型里:Tombstone 之后任何操作都返回 Closed——非法路径根本不存在,而不是"存在但被 if 拦住"。

六、Yield 策略:10 秒窗口 + pragma 覆盖

cell 不是"跑完才说话"。模型发起 exec 时,初始观察模式是 ObserveMode::YieldAfter(duration)(service.rs 第 77-99 行):跑满窗口就交付一次中间结果,cell 继续活着;模型再发 wait(ObserveMode::PendingFrontier)就能接着取。窗口时长怎么定?看 resolve_yield_timeout()(第 198-209 行):

📄 codex-rs/code-mode-runtime/src/service.rs (第 77-99、198-209 行,节选)

pub async fn execute(&self, request: ExecuteRequest) -> Result<StartedCell, String> { // 模型侧入口:一次 exec 调用 = 创建一个 cell    let yield_time_ms = request.yield_time_ms.unwrap_or(DEFAULT_EXEC_YIELD_TIME_MS); // 模型没写 pragma 就用默认 10s(DEFAULT_EXEC_YIELD_TIME_MS = 10_000)    let started = self.runtime.execute(runtime_request(request), runtime::ObserveMode::YieldAfter(self.resolve_yield_timeout(yield_time_ms))).await.map_err(|error| error.to_string())?; // 初始观察模式 = YieldAfter:跑满窗口就交付一次中间结果,cell 继续活着    let cell_id = protocol_cell_id(&started.cell_id);    let (response_tx, response_rx) = oneshot::channel(); // 首个事件异步转交:execute() 立即返回 StartedCell,不阻塞调用方    tokio::spawn(async move { let response = started.initial_event().await.map_err(|error| error.to_string()).and_then(|event| runtime_response(&response_cell_id, event)); let _ = response_tx.send(response); }); // 后台任务等首个观察结果并投递    Ok(StartedCell::from_result_receiver(cell_id, response_rx)) // 返回"带接收端"的 cell:模型侧 await initial_response() 拿第一次交付}fn resolve_yield_timeout(&self, yield_time_ms: u64) -> Duration { // 把模型的 yield_time_ms 折算成 actor 用的定时器时长    let yield_time = Duration::from_millis(yield_time_ms);    let timeout = if yield_time >= MIN_YIELD_TIME_FOR_GRACE { // ≥10s(MIN_YIELD_TIME_FOR_GRACE)的长任务额外加宽限        yield_time.saturating_add(YIELD_GRACE_PERIOD) // +1s 宽限:防止"刚好在交付边界上"的工具调用被误判超时    } else { yield_time }; // 短窗口不加——小任务要的是快,不是稳    self.cell_execution_limits.max_yield_time_ms.map(Duration::from_millis).map_or(timeout, |limit| timeout.min(limit)) // 会话级上限兜底:模型不能无限拉长单次观察窗口

为什么这样设计:宽限期的直觉是"边界抖动"——一个 9.9s 的工具调用,如果窗口恰好 10s,交付和完成会撞车;给长任务 +1s 缓冲,让"刚好跑完"的调用有空间正常收尾。而 max_yield_time_ms 上限是资源治理:模型可以请求长窗口,但会话策略能封顶——防止一个 cell 长期霸占观察权。

模型怎么传这些参数?不是 JSON 字段,而是 JS 源码首行的 pragma(code-mode-protocol/src/description.rs 第 167-249 行 parse_exec_source):

📄 codex-rs/code-mode-protocol/src/description.rs (第 180-247 行,节选)

let mut lines = input.splitn(2, '\n'); // 只切第一行:pragma 必须独占首行,其余全部是 JS 源码let first_line = lines.next().unwrap_or_default();let rest = lines.next().unwrap_or_default(); // rest = pragma 之后的所有行(真正的代码)let trimmed = first_line.trim_start();let Some(pragma) = trimmed.strip_prefix(CODE_MODE_PRAGMA_PREFIX) else { return Ok(args); }; // 没有 `// @exec:` 前缀 → 整段当源码,零开销直通if rest.trim().is_empty() { return Err("exec pragma must be followed by JavaScript source on subsequent lines".to_string()); } // pragma 后面必须有真代码——防止"只写 pragma 不干活"的空执行let directive = pragma.trim();if directive.is_empty() { return Err("exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`".to_string()); } // 空 pragma 直接报错,不留模糊空间let value: serde_json::Value = serde_json::from_str(directive).map_err(|err| format!("exec pragma must be valid JSON ...: {err}"))?; // 严格 JSON 解析:不是对象/字段非法都会带上下文报错for key in object.keys() { match key.as_str() { "yield_time_ms" | "max_output_tokens" => {}, _ => return Err(format!("exec pragma only supports `yield_time_ms` and `max_output_tokens`; got `{key}`")) } } // 字段白名单:未知字段直接报错——fail loud,不静默忽略args.code = rest.to_string(); // pragma 行被剥掉,V8 里跑的是纯 JSargs.yield_time_ms = pragma.yield_time_ms; // 两个参数原样透传给运行时

为什么这样设计:pragma 走"首行注释"而不是独立 JSON 字段,是因为 exec 工具的输入本来就是一段 JS 文本——模型不需要学新的参数结构,写一行 // @exec: {"yield_time_ms": 30000} 就行。解析器对未知字段直接报错(而不是忽略),配合"pragma 后必须有代码"的校验,把模型写错 pragma 的情况全部变成可被模型读到的错误信息——下一轮它自己就会改对。

七、嵌套工具:从 cell 回到真实世界

JS 里 await tools.shell(...) 最终要落到 core 的真实工具运行时(沙箱、审批流那套)。中间隔着 actor → CellHost → SessionRuntimeDelegate 三层抽象,core 侧的实现是 tools/code_mode/delegate.rs(442 行)里的 CodeModeDispatchBroker:一个 per-turn 的 worker 任务 + 一条 dispatch 队列。看 invoke_tool()(第 289-330 行):

📄 codex-rs/core/src/tools/code_mode/delegate.rs (第 294-329 行,节选)

Box::pin(async move { // invoke_tool 的异步体:cell actor 调 host.invoke_tool() 时进入这里    let call_id = format!("{PUBLIC_TOOL_NAME}-{}", uuid::Uuid::new_v4()); // 嵌套调用生成独立 call_id:exec-<uuid>,和模型侧的 tool_call_id 区分开    if cancellation_token.is_cancelled() { dispatch_trace.interruption = Some(DispatchInterruption::Cancelled); return Err("code mode nested tool call cancelled".to_string()); } // 入队前已取消 → 直接短路,不打扰 worker    let (response_tx, response_rx) = oneshot::channel(); // 结果回传通道:worker 执行完把 JSON 结果塞回来    self.dispatch_tx.send(DispatchMessage::InvokeTool { invocation, dispatch_trace, cancellation_token: cancellation_token.clone(), response_tx }).await.map_err(|_| "code mode nested tool dispatcher is unavailable".to_string())?; // 投递给 turn worker——只有它能判断"派发是否跑赢取消"    tokio::select! {        response = response_rx => response.map_err(|_| "code mode nested tool dispatcher stopped".to_string())?, // 正常路径:worker 执行完回传结果        _ = cancellation_token.cancelled() => Err("code mode nested tool call cancelled".to_string()), // cell 被终止 → 立即返回错误,V8 侧 Promise 会被 reject    }

为什么这样设计:worker 循环(第 106-221 行 start_turn_worker)里有一段注释点破了要害——"Only the worker can tell whether dispatch beats cancellation once the call is queued"(调用入队后,只有 worker 能判断派发是否跑赢了取消)。因为"提交工具"和"cell 关闭"共享同一把 gate:worker 在 submit_tool 前再查一次 cancellation_token.is_cancelled() || !gates.contains_key(&cell_id)(第 182-193 行),被取消的调用标记 DispatchInterruption::Cancelled 后直接丢弃,不会有一个"已经提交到沙箱、但 cell 已死"的孤儿工具调用。而 notify()(第 420-437 行)走的是另一条路:把文本包装成 CustomToolCallOutput 直接注入会话——模型在对话流里就能看到"还在跑,已完成 X%"。

core 侧的入口 execute_handler.rs(第 34-154 行)则展示了完整接线:解析 pragma → 收集嵌套工具定义(enabled_tools.sort_by + dedup,保证 JS 侧下标稳定)→ code_mode_service.execute() → 记录 cell.id 到 tracing span、analytics(CellStarted/CellClosed)、rollout trace。注意第 127-141 行:Yielded 的 cell 不记 CellClosed——它还在跑,生命周期事件要等最终交付才发。

八、一次 exec 调用的完整数据流

exec 调用全链路(模型 → V8 → 宿主工具 → 模型)

① 发起:CodeModeExecuteHandler::execute

解析 // @exec: pragma,收集嵌套工具定义(排序去重),创建 cell。

▼

② 启动:CellActor::prepare → spawn_runtime

V8 线程执行 JS(exec_main.mjs),install_globals 装好白名单 API。

▼

③ 让出:YieldAfter(10s+宽限) 到期或 yield_control()

content_items 打包成 Yielded 交回模型;cell 继续活着,V8 线程冻结。

▼

④ 嵌套工具:await tools.shell(...) → ToolCall 事件

🔹 actor spawn 工具任务 → broker worker 派发到真实工具运行时(沙箱/审批)🔹 JS 侧 Promise 挂起,V8 线程阻塞在控制通道上

▼

⑤ 回填:RuntimeCommand::ToolResponse → resolve Promise

按 tool-N id 取出 resolver,resolve/reject;JS 的 try/catch 接住结果继续跑。

▼

⑥ 收尾:Result → commit_completion → deliver_completion

store() 写入合并进会话存储,Completed 事件交付模型;Tombstone 关门。

本讲小结:Code Mode 的复杂度不在"跑 JS"(V8 干了),而在边界治理——三条通道把 V8 线程和 actor 隔开,暂停—恢复协议让冻结零成本,CellState 状态机把交付竞态变成类型问题,dispatch broker 保证嵌套工具不产生孤儿调用。模型获得的是"写程序"的表达力,Codex 付出的是一套可审计的并发纪律。

📚 系列导航

← 第 37 讲:Multi-Agent Spawn 与角色

→ 第 39 讲:Realtime / Voice 实时对话

关注公众号「AI技术推荐官」获取更多源码解析内容

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料