青雲的博客

第三部:模型输出怎样改变机器

这一部从第二部留下的 unresolved error set 开始,追踪模型调用怎样恢复、工具怎样被发现并路由、补丁与命令怎样受审批和沙箱约束,最后收束到 Unified Exec 进程生命周期。

工具规格与执行注册表分开,补丁和 Shell 经治理边界进入沙箱与长进程。
展开阅读路线与实验入口

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

第二部停在模型输出已经成为 message、reasoning 或 tool call 的位置,同时留下了一组没有解释完的输入:401、retry、fallback、early EOF、tool failure 和 cancellation。第三部先处理这份 unresolved error set,再追一条工具调用怎样取得本地执行权。顺序很重要,因为一次失败采样可能根本没有产生工具调用;一次已经执行的工具也不会因为后续采样重试自动撤销副作用。

这里需要继续区分三件事。模型看见的是 ToolSpec;runtime 能执行的是 registry 中的 runtime;用户最后看见的 TurnDiff、tool item、process event 或 transcript 只是观察投影。规格存在不等于 handler 一定注册,handler 返回不等于副作用已经完整记录,turn 结束也不等于后台进程退出。

固定源码里,成功的 tool output 会进入 conversation;tool future 返回 Err 时,当前 drain 分支只调用 error_or_panic,不会自动补一条 durable output。这个缺口正好说明为什么不能把“模型输出”“机器状态”和“后续 Prompt”折成一份状态。

本部仍只讨论 openai/codexrust-v0.144.6,commit 固定为 5d1fbf26c43abc65a203928b2e31561cb039e06d。章节里的命令、类型和事件名必须回到这个 checkout;后续版本即使保留同名字段,也不能直接继承这里的行为结论。

这一部负责讲清什么

六章沿一组责任交接推进,但各章回答的不是同一种问题:

当前 owner 要交出的证据本章不代替谁做决定
13recovery owner、预算、sticky transport state 与局部退出不替 tool router 选择 handler
14model-visible ToolSpec、runtime registry、call id 与 tool output不替 handler 执行文件或命令
15parsed patch、committed delta、FileChange 与 TurnDiff不把普通 shell 写文件纳入 patch tracker
16handler argv、policy projection、approval identity 与 launch 边界不决定最终审批结果或平台 sandbox
17policy requirement、approval、PermissionProfile、sandbox attempt不拥有进程 session 与 transcript
18process id、PTY/pipe、yield、stored session、End 与清理边界不把 process exit 反推成完整 turn 或机器状态回滚
flowchart TB
  accTitle: 从模型响应恢复到机器副作用的责任边界
  accDescr: 六章按阅读交接连接 recovery、ToolSpec 与 Registry、patch 与 shell、approval 与 permission 与 sandbox、process lifecycle;这不是所有命令必经的单一运行时序
  RECOVERY["第 13 章\nrecovery owner"]
  SPEC["第 14 章\nToolSpec / model-visible"]
  REGISTRY["第 14 章\nRegistry / runtime route"]
  PATCH["第 15 章\napply_patch / file effect"]
  SHELL["第 16 章\nshell inputs / policy projections"]
  GOVERNANCE["第 17 章\napproval / permission / sandbox"]
  PROCESS["第 18 章\nUnified Exec / PTY / process"]
  OBSERVER["observer\nTurnDiff / events / transcript"]
  RECOVERY --> SPEC
  SPEC --> REGISTRY
  REGISTRY --> PATCH
  REGISTRY --> SHELL
  PATCH --> GOVERNANCE
  SHELL --> GOVERNANCE
  GOVERNANCE --> PROCESS
  PATCH -.-> OBSERVER
  PROCESS -.-> OBSERVER

图中实线是章节之间要交出的阅读材料,不是所有命令必经的 runtime pipeline。apply_patch 有自己的 parse、permission 与 runtime attempt;普通 shell 也可能被识别为 apply-patch heredoc 后提前分流。第 17 章在目录上位于 patch 与 shell 之后,是为了集中比较治理类型,不表示执行总是“先改文件,再审批”。两条虚线只表示 observer 从已发生或拟议的工作中取得投影,不能反向证明副作用完整。

四类角色也要分开:owner 持有可以跨 await 或跨调用继续使用的状态;executor 真正调用文件系统、shell、PTY、pipe 或远端 backend;governor 决定本次请求是否需要审批、允许哪些 permission、使用哪种 sandbox attempt;observer 生成 item、diff、event 与 transcript。一个类型可以在不同局部承担两种角色,但不能因此把四种责任合并。

固定版本的 ToolRouter 同时保存 cloned model-visible specs 与可执行 ToolRegistry。进入 dispatch inner 后,router 再把 Session、step context、cancellation、turn-diff tracker、call id、source 与 payload 组装成 ToolInvocation,然后交给 registry。这条边界把 model-visible capability 与 runtime-executable capability 明确拆开。

六章共用哪套实验

本部固定一套 shared model-output experiment:在同一个 source identity 下,始终记录 owner -> input -> decision -> effect -> observer -> uncovered boundary。它从第二部的 unresolved error set 开始,先确认请求是否恢复并留下可继续的 sampling;然后观察一个 model-emitted tool call 怎样由 spec 对上 registry、怎样产生 output;再分别把文件 patch、shell policy、approval/sandbox 和 process lifecycle 接到这份记录上。

这不是要求六章共享同一个进程或 fixture。六条命名测试提供六个局部证据锚点,不是一条端到端 fixture,也不能拼成一次真实模型会话的完整日志。它们共享的是提问格式、固定 checkout 和证据边界:每一章只接收上一章已经交出的对象,再用自己的 exact test 校准一个关键分支。

检查点exact test只把什么交给下一章
恢复websocket_fallback_is_sticky_across_turns同一存活 session 的 WS fallback state 可跨 turn 影响 transport 选择
路由current_time_tool_returns_the_latest_time受控 feature、deterministic provider、call id、tool output 与第二次 request 的闭环
补丁apply_patch_emits_turn_diff_event_with_unified_diff成功 Add 路径至少产生一份带 Git-style header 的 TurnDiff
命令evaluates_heredoc_script_against_prefix_rulesheredoc 的 executable prefix 投影可命中显式 rule,并形成 exec approval requirement
治理approval_matrix_covers_group四组稳定 matrix 在受控前提下校验;Unified Exec 留作时序诊断
进程unified_exec_full_lifecycle_with_background_end_event一次 delayed exec 的 tool result、background End 与 TurnComplete 边界

表里的“只把什么交给下一章”不是对 fixture 输出的逐字摘录。每条命名测试仍要回到对应章节查看 setup、filter、skip 门禁和负向边界;本部导读不另造一组终端输出,也不把局部断言扩大成平台通用结论。

哪些机制暂时不讲

network 的 attribution、deterministic rule、私网限制、approval 与 session cache 交给第 19 章;extensions 包含 Skills、MCP、Dynamic Tools、Code Mode 与 Plugin,交给第 20–24 章;state 的 rollout、SQLite、resume、compaction、Memory 与 Goal 留在第 25 章之后。这些后续机制可能复用第三部的 tool router、permission 或 process primitive,但不属于本部六章的验收范围。

本部也不把 TurnDiff 当成完整审计日志。普通 shell、外部进程、目录与权限变化可能没有 AppliedPatchDelta;patch move 与失败前缀也没有事务回滚。observer 没看到某种变化,只能说明这条 projection 没有记录,不能证明机器没有改变。

app-server 怎样把 tool item 和 process event 投影给不同 client、hook 怎样改变 action、远端 executor 怎样维护 host-specific lifecycle,也暂时只保留接口边界。这里会说明 local 与 remote 的 ownership 差异,但协议消费与宿主状态留给后面的章节。

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

读完后,面对“模型明明返回了调用,机器为什么没有按预期改变”这类问题,应该能先定位停点,而不是直接归因于模型:

  • 从 transport、ModelClientSession、sampling loop 与 Session 判断失败由谁观察、预算由谁消耗,以及 retry 是否会重做完整 Prompt;
  • 比较 ToolSpecToolRegistry,确认模型是否看得见、runtime 是否能执行、payload 与 call id 是否仍匹配;
  • 把 patch 的 planned view、committed delta、FileChange lifecycle 与 TurnDiff snapshot 分开;
  • 区分 handler argv、exec-policy projection、approval cache identity 与最终 launch argv;
  • 依次检查 policy requirement、user approval、effective PermissionProfile、sandbox selection 与 second attempt,不用其中一个词替代其余四层;
  • 用 logical process id、PTY/pipe backend、initial yield、ProcessStore、End event 和 cleanup 判断“工具已返回”与“进程已退出”是否成立。

到这里可以解释一条模型行动怎样取得执行能力、怎样被治理、怎样留下局部观察证据。仍不能声称副作用具备 transaction、所有平台执行相同,或 turn completion 会清理每一种外部资源。

源码工作台

核心目录

目录 / 文件本部只用它回答什么
codex-rs/core/src/toolsspec/registry 规划、router、handler、orchestrator、sandbox attempt 与事件
codex-rs/apply-patchpatch parser、ordered hunks、filesystem mutation 与 committed delta
codex-rs/shell-command人类可读 command metadata;它不生成执行 argv 或 policy decision
codex-rs/execpolicyprojected command segments、rule match 与 aggregate decision
codex-rs/sandboxingportable permission 怎样变成 macOS、Linux 或 Windows backend
codex-rs/core/src/unified_execlogical process id、store、yield、watcher、transcript 与 cleanup
codex-rs/core/src/tools/handlers/unified_execexec_command / write_stdin 参数怎样进入 process manager
codex-rs/utils/ptyPTY/pipe spawn、stdin、output reader、process group 与平台终止语义

先抓住 owner、executor、governor 与 observer

角色核心类型 / 函数先问的问题
ownerModelClientSessionToolCallRuntimeUnifiedExecProcessManagerProcessStore状态能活多久,谁能重试、取消、lookup 或清理?
executorToolRegistry、handler/runtime、apply-patch filesystem、PTY/pipe backend哪一层真正触碰文件、启动进程或形成 tool output?
governorExecPolicyManager、approval requirement、PermissionProfileSandboxManager谁判断 allow/prompt/forbidden,谁只物化一次 attempt?
observerTurnDiffTracker、FileChange/ExecCommand event、transcript、ExecCommandToolOutput观察的是计划、已提交 delta、进程输出,还是 lifecycle terminal?

ToolRouterToolSpec 横跨可见性和 dispatch,但不直接执行机器副作用。ToolRegistry 能找到 runtime,也不代表审批已经通过。ExecCommandToolOutput 从类型上允许 process_idexit_code 独立出现;实际的 Unified Exec initial-yield 路径还会先确认进程未退出且没有 exit code,再保存 background session。刷新后若状态仍为 Alive,manager 会把 Some(process_id) 与当时的可选 exit_code 写入 output。对应 lifecycle 测试也要求运行中的初始结果有 process id、没有 exit code。因此工具调用成功返回不等于操作系统进程已经退出。

实验准备与六个验证锚点

命令示例使用支持 pipefail 的 bash 或 zsh,以及 Git、just、Cargo 与 cargo-nextest。先做一轮不会创建副本的 source preflight;真正的 archive、lockfile 校准、编译产物和测试日志都由后面的完整脚本负责:

set -euo pipefail

SOURCE_ROOT="${SOURCE_ROOT:-${CODEX_SOURCE_DIR:-/tmp/codex-handbook-final-rust-v0.144.6}}"
SOURCE_DIR="$SOURCE_ROOT/codex-rs"
COMMIT=5d1fbf26c43abc65a203928b2e31561cb039e06d

test "$(git -C "$SOURCE_ROOT" rev-parse HEAD)" = "$COMMIT"
test "$(git -C "$SOURCE_ROOT" describe --tags --exact-match HEAD)" = rust-v0.144.6
test -f "$SOURCE_DIR/Cargo.toml"
source_status="$(git -C "$SOURCE_ROOT" status --porcelain)"
test -z "$source_status"
test -z "${CODEX_SANDBOX_NETWORK_DISABLED+x}"
test -z "${CODEX_SANDBOX+x}"

该 commit 的 workspace manifest 已是 0.144.6,固定 Cargo.lock 中 132 个 local workspace package 仍写 0.0.0。下面是本部唯一的 archive/lock 校准入口:它从同一 commit 创建副本,再让 Cargo 只在副本内更新 workspace package。更新后的 metadata 必须仍是 132 个 0.144.6 package;校准前 lockfile 的 132 个无 source / checksum 条目必须与 workspace name set 完全一致;diff 也只允许 132 组 local version replacement。任一 postcondition 不成立,测试不会启动。

六条测试统一经过固定源码要求的 just test,由仓库的 justfile 设置 RUST_MIN_STACK 并调用 nextest。run_checked_test 同时检查命令 exit code、nextest 实际运行与通过的数量,并拒绝 fixture 打出的 Skipping test...。nextest 摘要里的 skipped 还包含没有被 filter 选中的测试,不能据此判断环境 skip;真正的 fail-closed 门禁是预先拒绝 sandbox marker,再检查未捕获的 fixture 输出。zero-match 或环境短路都不能成为通过结果。

运行还需要可用的 jq、awk、tar、cmp 与 grep。RUST_MIN_STACK 只消除这组 Tokio fixture 的线程栈噪声,不是 runtime 行为证据。

共享实现放在本仓库的 scripts/codex-handbook-part3.sh。它是 sourceable helper:只定义 prepare_codex_part3run_checked_testcleanup_codex_part3,source 时不会注册 EXIT trap,也不会自动删除 archive。每个调用方都要在自己的 shell 中 prepare_codex_part3,再显式注册 trap 'cleanup_codex_part3 "$?"' EXIT;这样第 14–18 章可以单独复制运行,不依赖另一个已经退出的 shell。没有博客 checkout 时,可把这个 helper 文件保存到任意路径,再通过 CODEX_HANDBOOK_PART3_HELPER 指向它;代码块里的 $PWD/scripts/... 只是从本仓库根目录运行时的默认值。

完整复现脚本:隔离 checkout、校准 Cargo.lock、验证六个局部锚点

先把下面这段完整的 sourceable helper 保存为 CODEX_HANDBOOK_PART3_HELPER 指向的文件(默认可保存为当前目录的 scripts/codex-handbook-part3.sh)。它只定义准备、校验与清理函数;不会在 source 时自动注册 trap:

#!/usr/bin/env bash

# Shared, sourceable preparation for the Codex handbook Part 3 experiments.
# The caller owns the shell lifecycle: source this file, call
# prepare_codex_part3, and register cleanup_codex_part3 with its own trap.

prepare_codex_part3() {
  if [ -n "${ARCHIVE_DIR:-}" ]; then
    test -f "$ARCHIVE_DIR/.codex-handbook-part3"
    test "${ARCHIVE_CODEX_RS:-}" = "$ARCHIVE_DIR/codex-rs"
    test "${CARGO_TARGET_DIR:-}" = "$ARCHIVE_DIR/target"
    test -f "$ARCHIVE_CODEX_RS/workspace.names"
    test -f "$ARCHIVE_CODEX_RS/lock.names"
    cmp -s "$ARCHIVE_CODEX_RS/workspace.names" "$ARCHIVE_CODEX_RS/lock.names"
    return 0
  fi

  SOURCE_ROOT="${SOURCE_ROOT:-${CODEX_SOURCE_DIR:-/tmp/codex-handbook-final-rust-v0.144.6}}"
  SOURCE_DIR="${SOURCE_DIR:-$SOURCE_ROOT/codex-rs}"
  COMMIT="${COMMIT:-5d1fbf26c43abc65a203928b2e31561cb039e06d}"
  EXPECTED_LOCAL_PACKAGES="${EXPECTED_LOCAL_PACKAGES:-132}"
  export SOURCE_ROOT SOURCE_DIR COMMIT EXPECTED_LOCAL_PACKAGES

  test "$(git -C "$SOURCE_ROOT" rev-parse HEAD)" = "$COMMIT"
  test "$(git -C "$SOURCE_ROOT" describe --tags --exact-match HEAD)" = rust-v0.144.6
  test -f "$SOURCE_DIR/Cargo.toml"
  source_status="$(git -C "$SOURCE_ROOT" status --porcelain)"
  test -z "$source_status"
  test -z "${CODEX_SANDBOX_NETWORK_DISABLED+x}"
  test -z "${CODEX_SANDBOX+x}"

  ARCHIVE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/codex-part3.XXXXXX")"
  export ARCHIVE_DIR
  ARCHIVE_CODEX_RS="$ARCHIVE_DIR/codex-rs"
  export ARCHIVE_CODEX_RS
  export CARGO_TARGET_DIR="$ARCHIVE_DIR/target"
  export CARGO_TERM_COLOR=never

  if git -C "$SOURCE_ROOT" archive "$COMMIT" | tar -x -C "$ARCHIVE_DIR"; then
    :
  else
    archive_status=$?
    cleanup_codex_part3 "$archive_status"
    return "$archive_status"
  fi

  if (
    cd "$ARCHIVE_CODEX_RS"
    cp Cargo.lock Cargo.lock.before
    cargo update --workspace --offline
    cargo metadata --locked --format-version 1 --no-deps >workspace-metadata.json

    jq -e --argjson expected "$EXPECTED_LOCAL_PACKAGES" '
      (.packages | length) == $expected and
      (.workspace_members | length) == $expected and
      all(.packages[]; .version == "0.144.6")
    ' workspace-metadata.json >/dev/null
    jq -r '.packages[].name' workspace-metadata.json | LC_ALL=C sort >workspace.names

    awk -v expected="$EXPECTED_LOCAL_PACKAGES" '
      BEGIN { RS = "\\[\\[package\\]\\]"; count = 0; bad = 0 }
      /version = "0.0.0"/ {
        count++
        if ($0 ~ /source = / || $0 ~ /checksum = /) bad++
        name = ""
        fields = split($0, field, "\\n")
        for (i = 1; i <= fields; i++) {
          if (field[i] ~ /^name = "/) {
            name = field[i]
            sub(/^name = "/, "", name)
            sub(/"$/, "", name)
          }
        }
        if (name == "") bad++
        else print name
      }
      END { exit !(count == expected && bad == 0) }
    ' Cargo.lock.before | LC_ALL=C sort >lock.names
    cmp -s workspace.names lock.names

    diff_code=0
    diff -U0 Cargo.lock.before Cargo.lock >Cargo.lock.diff || diff_code=$?
    test "$diff_code" -eq 1
    awk -v expected="$EXPECTED_LOCAL_PACKAGES" '
      /^--- / || /^\+\+\+ / || /^@@ / { next }
      /^-version = "0.0.0"$/ { removed++; next }
      /^\+version = "0.144.6"$/ { added++; next }
      /^[+-]/ { unexpected++ }
      END { exit !(removed == expected && added == expected && unexpected == 0) }
    ' Cargo.lock.diff
    : > "$ARCHIVE_DIR/.codex-handbook-part3"
  ); then
    :
  else
    preparation_status=$?
    cleanup_codex_part3 "$preparation_status"
    return "$preparation_status"
  fi
}

run_checked_test() {
  label=$1
  expected=$2
  shift 2
  test -n "${ARCHIVE_DIR:-}"
  test -d "$ARCHIVE_DIR"
  test -f "$ARCHIVE_DIR/.codex-handbook-part3"
  test "${ARCHIVE_CODEX_RS:-}" = "$ARCHIVE_DIR/codex-rs"
  test "${CARGO_TARGET_DIR:-}" = "$ARCHIVE_DIR/target"
  log="$ARCHIVE_DIR/$label.log"
  if ! "$@" >"$log" 2>&1; then
    sed -n '1,240p' "$log"
    return 1
  fi
  if ! grep -Eq "Summary \\[[^]]+\\][[:space:]]+${expected} tests? run:[[:space:]]+${expected} passed" "$log"; then
    sed -n '1,240p' "$log"
    return 1
  fi
  if grep -Eqi 'Skipping test|skip_if_' "$log"; then
    sed -n '1,240p' "$log"
    return 1
  fi
  sed -n '1,240p' "$log"
}

cleanup_codex_part3() {
  cleanup_status="${1:-$?}"
  archive_dir="${ARCHIVE_DIR:-}"
  if [ -n "$archive_dir" ] && [ -e "$archive_dir" ]; then
    if ! rm -rf "$archive_dir"; then
      [ "$cleanup_status" -ne 0 ] || cleanup_status=1
    fi
  fi
  unset ARCHIVE_DIR ARCHIVE_CODEX_RS CARGO_TARGET_DIR CARGO_TERM_COLOR
  return "$cleanup_status"
}

把 helper 保存好后,在同一个或全新的 shell 中运行下面的 selector;CODEX_HANDBOOK_PART3_HELPER 可以显式指向任意保存位置:

set -euo pipefail

SOURCE_ROOT="${SOURCE_ROOT:-${CODEX_SOURCE_DIR:-/tmp/codex-handbook-final-rust-v0.144.6}}"
SOURCE_DIR="$SOURCE_ROOT/codex-rs"
COMMIT=5d1fbf26c43abc65a203928b2e31561cb039e06d

test "$(git -C "$SOURCE_ROOT" rev-parse HEAD)" = "$COMMIT"
test "$(git -C "$SOURCE_ROOT" describe --tags --exact-match HEAD)" = rust-v0.144.6
test -f "$SOURCE_DIR/Cargo.toml"
source_status="$(git -C "$SOURCE_ROOT" status --porcelain)"
test -z "$source_status"
test -z "${CODEX_SANDBOX_NETWORK_DISABLED+x}"
test -z "${CODEX_SANDBOX+x}"

CODEX_HANDBOOK_PART3_HELPER="${CODEX_HANDBOOK_PART3_HELPER:-$PWD/scripts/codex-handbook-part3.sh}"
test -f "$CODEX_HANDBOOK_PART3_HELPER"
source "$CODEX_HANDBOOK_PART3_HELPER"
prepare_codex_part3
trap 'cleanup_codex_part3 "$?"' EXIT
cd "$ARCHIVE_CODEX_RS"

run_checked_test websocket 1 \
  just test --locked -p codex-core --test all --no-capture \
  -E 'test(=suite::websocket_fallback::websocket_fallback_is_sticky_across_turns)'

run_checked_test current-time 1 \
  just --set rust_min_stack 16777216 test --locked -p codex-core --test all --no-capture \
  -E 'test(=suite::current_time_reminder::current_time_tool_returns_the_latest_time)'

run_checked_test apply-patch 1 \
  just --set rust_min_stack 16777216 test --locked -p codex-core --test all --no-capture \
  -E 'test(=suite::apply_patch_cli::apply_patch_emits_turn_diff_event_with_unified_diff)'

run_checked_test exec-policy 1 \
  just test --locked -p codex-core --lib --no-capture \
  -E 'test(=exec_policy::tests::evaluates_heredoc_script_against_prefix_rules)'

run_checked_test approval 4 \
  just test --locked -p codex-core --test all --no-capture \
  -E 'test(approval_matrix_covers_group)' -- --skip unified_exec

run_checked_test unified-exec 1 \
  just test --locked -p codex-core --test all --no-capture \
  -E 'test(=suite::unified_exec::unified_exec_full_lifecycle_with_background_end_event)'

source_status="$(git -C "$SOURCE_ROOT" status --porcelain)"
test -z "$source_status"

Feature 成熟度

FeatureStage 只描述 feature flag catalog 的生命周期,不证明对应 runtime path 存在、被选中或已完成执行。固定版本里 ShellToolUnifiedExec 都标为 Stable,但前者默认开启,后者只在非 Windows 默认开启;ShellZshForkUnifiedExecZshFork 仍是 UnderDevelopment 且默认关闭。Stage 相同也不能推出默认值、平台门禁和 handler wiring 相同。

固定 catalog 中,CurrentTimeReminder 也标为 UnderDevelopment、默认关闭;本部 current-time 锚点的 helper 只显式启用 feature,并写入 interval 与 clock source 配置。命名测试随后单独注入 TestTimeProviderApplyPatchStreamingEventsExecPermissionApprovals 同样是 UnderDevelopment、默认关闭。这些证据分别证明 catalog 默认值与受控测试 setup,不证明普通会话默认暴露该工具、流式 patch 预览或 additional-permission request。

平台与沙箱限制

平台固定版本能证明的 backend本部不能外推什么
macOShost selection 返回 MacosSeatbelt,命令再由 sandbox manager 包装Seatbelt profile 等于 Linux permission 实现;所有命令都必然 sandboxed
LinuxLinuxSeccomp helper 默认用 bubblewrap filesystem view 加 seccompvariant 名只表示 seccomp;legacy Landlock 支持 managed proxy-only network
Windows只有启用 Windows sandbox 时才选 WindowsRestrictedTokenUnifiedExec 默认开启;Unix PTY/process-group、POSIX shell 命令可原样复现

shared SandboxType 还允许 None。Windows sandbox 被禁用或 host 不受支持时,platform lookup 可以没有 concrete backend。

SandboxManager::select_initial 另外接收 filesystem policy、network policy、sandbox preference、Windows level 与 managed-network requirement;Forbid 或不需要 sandbox 的 Auto 分支会返回 None,缺少 platform backend 时也会落到 None。审批结果不是这个选择函数的输入,因此仅凭“审批通过”仍不能确定最终 backend。

Linux 的 use_legacy_landlock 是 helper flag,不是第五种 shared SandboxType。managed proxy-only networking 需要 bubblewrap 的 isolated network namespace,所以打开 legacy flag 也不能保证实际选择 Landlock。Windows 还需要单独验证 restricted-token launcher、ConPTY、PowerShell encoding 与 termination;本部的 POSIX shell 和 Unix process-group 命令不是 Windows 原样操作指南。

工作台到此只提供定位、实验入口和停止线。下一步从第二部交出的错误集合开始:进入《断流、限流和 401:Codex 分别从哪里恢复》

拆开 Codex 第三部:模型输出怎样改变机器 第 13 章

断流、限流和 401:Codex 分别从哪里恢复

沿 rust-v0.144.6 的 request retry、401 recovery、sampling retry、WebSocket fallback 与 Session terminalization,画出失败信号的首个观察者、恢复所有者、预算和退出边界。

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

第 12 章已经把正常路径推进到一个明确边界:added 和 delta 只维护当前投影,done 才可能写入完整 item、启动 tool future,Completed 只结束一次 provider response。它留下的 unresolved error set 却来自好几层:HTTP 请求尚未打开、401、已经打开的 SSE 或 WebSocket 中断、typed event 映射失败、core 收不到 response.completed、工具 future 自己返回 Err

如果只问“会不会 retry”,这些分支很容易被揉成一个循环。更有效的读法是先把每个信号放回首个观察者,再沿下面这条责任链向外走:

flowchart TB
  accTitle: Codex 失败恢复责任链
  accDescr: 失败信号先由 transport、ModelClientSession、sampling event loop、run_turn 或 Session 观察,再由对应 owner 持有预算或状态,决定重试目标、局部退出以及最终 TurnComplete 或 TurnAborted
  SIGNAL[error or signal] --> OBSERVER[first observer]
  OBSERVER --> OWNER[recovery owner]
  OWNER --> STATE[budget or sticky state]
  STATE --> TARGET[retry target]
  TARGET --> EXIT[local return]
  EXIT --> NORMAL[EventMsg Error then TurnComplete]
  EXIT --> ABORT[TurnAborted lifecycle]
  EXIT --> CONTINUE[continue current task]

这张图没有重画第 10 章的 request shape、HTTP/WS full 与 delta,也不重复第 11 章的正常两层业务循环。本文只回答失败以后控制权停在哪里。

失败先归 owner,再判断能不能恢复

CodexErr::is_retryable 是 sampling 层的一道分类门,但它并不是整套恢复策略。Stream、timeout、UnexpectedStatusIo 等会通过这道门;TurnAbortedInvalidImageRequestUsageLimitReachedRetryLimitRefreshTokenFailed 等不会。401 在进入这里之前可能已经由 client call 内的 auth state machine 消化;HTTP request-open retry 更早发生在 codex-client;WS fallback 还会改 session-scoped transport state。

还要把 local return 与 task lifecycle 分开。run_turn 对一般 sampling error 会发 EventMsg::Error,然后 break 并返回 Ok(last_agent_message);Session 的 on_task_finished 因而仍走 TurnComplete。只有 CodexErr::TurnAborted 被原样向外传播时,Session 才选择 TurnAborted。因此用户看到 Error 后又看到 TurnComplete,在这版源码里并不矛盾:前者报告工作失败,后者关闭 task 生命周期。

401 的恢复权停在当前 client call

HTTP 和 WebSocket 各自在 ModelClientSession 的调用内部创建一个 UnauthorizedRecovery。HTTP stream_responses_api 捕获 request-open 的 401,WS stream_responses_websocket 捕获 handshake 的 401;两边都调用 handle_unauthorized,成功后 continue 当前 transport loop。调用者没有拿到 stream error,run_sampling_request 的 counter 也没有递增。

managed auth 的状态顺序是 Reload -> RefreshToken -> Done。第一次 401 先按 account id guarded reload;无论磁盘 auth 是否改变,成功后下一次 401 才进入 authority refresh。external auth 从 ExternalRefresh 开始,只给一次 refresh,然后进入 Done。API key、不可 refresh 的 auth、account mismatch 或已经耗尽的 state 都可能没有下一步。

handle_unauthorized 还决定 recovery 自己失败以后用什么错误离开。永久 refresh failure 映射成不可 retry 的 RefreshTokenFailed;transient failure 映射成可 retry 的 Io。若没有 recovery 机会或步骤已经耗尽,原始 401 交给 provider mapper,成为 UnexpectedStatus,随后可以进入上层 sampling retry。这里的“可以”很重要:401 本身没有吃掉 request_max_retries,但映射后的错误可能消耗 stream_max_retries

主动刷新是另一条路径。第 5 章已经划过 auth owner;在这里需要保留一个窄边界:AuthManager::auth() 会在 token 接近过期时尝试 proactive refresh,失败后记录诊断并继续返回旧 auth。reactive recovery 只有服务端已经给出 401 才启动,不能把两者合成“请求前一定刷新”。

request retry 与 sampling retry 是两套预算

request-open 只重做 HTTP 建流

ModelProviderInfo::request_max_retries() 默认是 4,配置值被硬截到 100。它进入 API provider 时形成 policy:当前 Responses policy 对 5xx、timeout 和 network error 开启 retry,对 429 关闭。401 也没有对应开关,所以不在这层 retry。

HTTP Responses 先把 body 和 request 准备好,再把可 clone 的 request 交给 run_with_request_telemetry。后者只包 auth.apply_auth(req)transport.stream(req)。所以这一预算重试的是 HTTP 请求和建流,没有重建 history、Prompt 或已经开始消费的 SSE。

run_with_retry 遍历 0..=max_attempts。因此配置 N 次 retry 会产生最多 N+1 次 request attempt。最后一次 retryable error 到来时,should_retryattempt >= max_attempts 返回 false,原 TransportErrorErr(err) 分支上抛。函数尾保留了 TransportError::RetryLimit fallback,但正常的有限循环通常在最后一个 Err arm 已返回;不能把 request budget 耗尽一律写成新造 RetryLimit

sampling retry 重做完整 request

run_sampling_request 才持有 sampling counter。它读取 stream_max_retries(),默认 5、上限 100,然后用初始 input 建第一次 Prompt。后续 retry 不续读旧 stream:它从 Session 的最新 history 重新做 for_prompt,重建 Prompt,再调用一遍 try_run_sampling_request。这也接上第 9 章的边界:retry input 是新的 history projection。

responses_retry.rs 只是共享 decision helper。counter 的生存期仍在调用者。正常 tool follow-up 回到 run_turn 的下一轮,又调用一个新的 run_sampling_request,局部 retries 再从 0 开始;它不会继承上一轮 sampling 已用掉的次数。

RateLimits、HTTP 429、response.failed 各走一条路

ResponseEvent::RateLimits(snapshot) 是成功 stream 中的 metadata。core 把 snapshot 写进 Session rate-limit state,只把 should_emit_token_count 置起;真正的 TokenCount 延迟到 tool future drain 以后。这个 event 不递增任何 retry counter,也不让 sampling 退出。

HTTP 429 发生在 stream 打开前。request policy 已经明确 retry_429=false,provider mapper 再看 body:type=usage_limit_reached 变成不可 retry 的 UsageLimitReached,并可携带 reset、plan 和 snapshot;usage_not_included 另行映射;其他 429 直接变成不可 retry 的 RetryLimit。后一个名字描述 core error shape,不证明 run_with_retry 已经用完次数。

response.failedresponse.incomplete 已经发生在 stream 内。共享 mapper 会把 context window、quota、usage-not-included、cyber policy、invalid request 和 overloaded 等已知 failed code 映到各自错误;其余 failed error,包括 rate_limit_exceeded,成为带可选 server delay 的 ApiError::Retryable,随后映成 sampling 可 retry 的 CodexErr::Streamresponse.incomplete 也形成 ApiError::Stream。所以同样是“限流”,HTTP usage limit 可以直接结束 sampling,stream 内的 rate_limit_exceeded 却可能重做完整 sampling request。

WS fallback 分两种:426 不占预算,耗尽时清零;sticky 状态跨 turn 生效

WS handshake 返回 426 时,stream_responses_websocket 直接返回 FallbackToHttp。同一次 ModelClientSession::stream 随即调用 try_switch_fallback_transport,然后进入 HTTP branch。这个切换发生在 stream 成功返回给 sampling 层之前,不消耗 sampling retry counter。

另一条 fallback 发生在可 retry 的 WS stream error 已经吃完 stream_max_retries 时。shared helper 先尝试切换 transport;成功就发 Warning,把 counter 清成 0,并让 sampling loop 立即再来一次。下一次 Prompt 仍会重建,但 transport 已是 HTTP,而且 HTTP 得到一份完整的新 stream budget。若当前已经是 HTTP,或 fallback state 已经打开,helper 无法再次切换,最终返回原错误。

sticky 状态是 Arc<ModelClientState> 里的 disable_websockets: AtomicBoolforce_http_fallback 原子置位并清空 cached WS session;之后 responses_websocket_enabled() 对同一存活 Session 返回 false。这里没有修改 provider、Config 或配置文件,也没有给新进程留下 durable marker。

固定版本实验:同一存活 session 的 fallback 跨 turn 保持

实验前确认 CODEX_SANDBOX_NETWORK_DISABLED 完全未设置。fixture 的 skip macro 只要发现该变量存在就提前返回,因此不能用 env -u 把受限环境伪装成真实执行。本次变量为 unset,输出中也没有 Skipping test because...

固定 commit 还有一个实验准备问题:workspace manifest 已标成 0.144.6,同 commit 的 lockfile 中本地 workspace packages 仍是 0.0.0。直接在固定 checkout 执行 just test --locked,Cargo 会在编译前拒绝更新 lock。为了不污染证据 checkout,第三部的共享准备脚本从该 commit 创建 git archive,只在副本中校准 132 个本地 workspace version 条目;diff 必须确认所有变化都是 0.0.0 -> 0.144.6,没有外部 dependency 的 version、checksum 或 source 变化。

完整的 archive/lock 校准由本仓库的 scripts/codex-handbook-part3.sh 提供。跳读到本章时,下面代码块会在自己的 shell 中 source helper、调用 prepare_codex_part3 并注册 cleanup trap;没有博客 checkout 时,可把第三部导读展开区的完整 helper 保存到任意路径,再通过 CODEX_HANDBOOK_PART3_HELPER 指向它。它不会依赖另一个已经退出的 shell。断言说明测试启动前必须已经成立的合同。SOURCE_ROOT 是固定仓库根,SOURCE_DIR 是其中的 codex-rsARCHIVE_DIR 是一次性副本根,Cargo target 也只能写在这个目录。两份 132 行的 name set 必须完全相同,不能只核对数量:

SOURCE_ROOT="${SOURCE_ROOT:-/tmp/codex-handbook-final-rust-v0.144.6}"
SOURCE_DIR="$SOURCE_ROOT/codex-rs"
EXPECTED_LOCAL_PACKAGES=132
CODEX_HANDBOOK_PART3_HELPER="${CODEX_HANDBOOK_PART3_HELPER:-$PWD/scripts/codex-handbook-part3.sh}"
test -f "$CODEX_HANDBOOK_PART3_HELPER"
source "$CODEX_HANDBOOK_PART3_HELPER"
prepare_codex_part3
trap 'cleanup_codex_part3 "$?"' EXIT
ARCHIVE_CODEX_RS="$ARCHIVE_DIR/codex-rs"
export CARGO_TARGET_DIR="$ARCHIVE_DIR/target"

test -f "$SOURCE_DIR/Cargo.toml"
test -f "$ARCHIVE_CODEX_RS/Cargo.lock.before"
test "$(wc -l <"$ARCHIVE_CODEX_RS/workspace.names" | tr -d ' ')" \
  -eq "$EXPECTED_LOCAL_PACKAGES"
test "$(wc -l <"$ARCHIVE_CODEX_RS/lock.names" | tr -d ' ')" \
  -eq "$EXPECTED_LOCAL_PACKAGES"
cmp -s "$ARCHIVE_CODEX_RS/workspace.names" "$ARCHIVE_CODEX_RS/lock.names"
test "$CARGO_TARGET_DIR" = "$ARCHIVE_DIR/target"
cd "$ARCHIVE_CODEX_RS"

测试仍经共享的 run_checked_test 执行。它要求 nextest 确实运行并通过 1 项测试,同时拒绝 Skipping test... 输出;环境 marker 已存在时,导读里的前置断言会直接退出,不会把 fixture 的提前返回记成通过:

set -euo pipefail
SOURCE_ROOT="${SOURCE_ROOT:-/tmp/codex-handbook-final-rust-v0.144.6}"
SOURCE_DIR="$SOURCE_ROOT/codex-rs"
CODEX_HANDBOOK_PART3_HELPER="${CODEX_HANDBOOK_PART3_HELPER:-$PWD/scripts/codex-handbook-part3.sh}"
test -f "$CODEX_HANDBOOK_PART3_HELPER"
source "$CODEX_HANDBOOK_PART3_HELPER"
prepare_codex_part3
trap 'cleanup_codex_part3 "$?"' EXIT
cd "$ARCHIVE_CODEX_RS"

run_checked_test websocket 1 \
  just test --locked -p codex-core --test all --no-capture \
  -E 'test(=suite::websocket_fallback::websocket_fallback_is_sticky_across_turns)'

关键判定不再依赖旧 harness 的 filtered out 文案,而是 nextest 的实际运行数与通过数:

PASS [...] suite::websocket_fallback::websocket_fallback_is_sticky_across_turns
Summary [...] 1 test run: 1 passed

fixture 使用本机 Wiremock/loopback。startup deferred prewarm 产生 1 次 WS GET;第一 turn 再产生 initial + 2 retries,共 3 次 WS GET,随后 fallback;第一、第二 turn 各走 1 次 HTTP POST 并命中 1 次 SSE response。最终计数是 WS_GET=4 / HTTP_POST=2 / SSE_POST=2

下面这张既有截图记录的是另外两条 Responses WebSocket 恢复测试,不是上面的 sticky-fallback fixture。它只作为同一环境下的命令与输出样本;本节的跨 turn 结论仍以上面的 exact test 和 WS_GET / HTTP_POST / SSE_POST 计数为准。

macOS 上 Responses WebSocket 恢复定向测试结果

在 macOS 上,将源码固定到 commit 5d1fbf26c43abc65a203928b2e31561cb039e06d 后运行的完整命令为:

just test -p codex-core -E 'test(responses_websocket_streams_request) | test(responses_websocket_connection_limit_error_reconnects_and_completes)'

预期 responses_websocket_streams_requestresponses_websocket_connection_limit_error_reconnects_and_completes 两项定向测试通过;实际结果为 2 tests run: 2 passed, 2967 skipped。这张图只证明这两个 fixture 在该环境与版本下通过,不能覆盖所有断流、401、限流和 HTTP fallback 分支,也不能证明生产 OpenAI 服务、真实网络或其他平台上的恢复行为。

这项实验只证明同一存活 Session 中,WS retry exhaustion 触发的 HTTP fallback 会跨 turn 保持。它没有覆盖 426、401、中途断流、进程重启、resume、新 Session 或真实外网。

sampling 层还要区分 EOF、invalid image 与 cancellation

第 12 章留下的“解析失败”需要拆回实际 return path:

  • unknown typed event、字段不足的 event 与无法形成 item 的 added/done 可以由共享 mapper 返回 Ok(None);它们不会自动触发 retry。
  • malformed SSE envelope 在 SSE reader 里 debug 后继续;malformed WS envelope 也 debug 后继续。两者都没有进入 typed mapper。
  • typed mapper 的 error 在 SSE 中先存进 response_error,reader 继续取 event;若随后 EOF,才优先发保存的 error。WS 对相同 mapper error 立即返回。
  • SSE premature EOF 发保存的 mapper error 或通用 stream closed before response.completed;idle timeout 发自己的 stream error。WS premature EOF、idle timeout 和 socket read error 也各自在 WS reader 返回。
  • core event loop 收到 Some(Err(err)) 会原样结束;若 channel 直接为 None,才由 core 自己生成 stream closed before response.completed。这些路径最终都可能映成可 retry 的 sampling error,但 first observer 不同。

InvalidImageRequest 的 recovery owner 在更外层 run_turn。release build 先记录诊断,再把 last-turn image 替换成文本占位;替换成功就 continue sampling/action loop。它不调用 sampling retry helper,也不占 stream_max_retries。若没有可替换图片,则发 BadRequest Error 并正常离开 run_turn,之后 task 仍是 TurnComplete。debug assertions build 会在同一诊断 helper 处 panic,早于 sanitation;这条 build-mode 差异不能省掉。

显式 cancellation 则沿 or_cancel 或 loop 末尾的 token check 变成 CodexErr::TurnAborted,绕过普通 Error 分支,交给第 7 章已经固定的 Session abort lifecycle。只有这条错误类型会把自然 task return 映成 TurnAborted

side effect 不随 retry 回滚

sampling replay 没有 transaction boundary。第 12 章已经证明,完整 tool call item 在 future 创建前先更新当前 history;成功的 tool output 随后尝试写入 rollout,但 append 失败只记录错误,不会回滚 live history。若 stream 随后失败并重做 sampling,新的 Prompt 从最新 history 重建;已经写入的 completed item 不会自动回滚。

工具对机器产生的副作用更没有自动 undo。命令可能已经写文件、启动进程或调用外部系统,然后 future 才返回错误。retry 只重做模型采样,不能恢复副作用前的世界状态。是否应该再次执行、补一条失败 output 或要求人工确认,需要第 14 章继续从 handler failure 接手。

当前 drain_in_flight 还有一个不整齐的边界:future Ok 才把 ResponseInputItem 写进 conversation;future Err 调用 error_or_panic,不生成 durable tool output。debug build 在这里 panic;release build 只记录 error,继续 drain 后面的 future。源码没有一套统一的 tool failure recovery 可以替这段行为兜底。

恢复责任矩阵

表中的“local exit”指当前函数怎样交还控制权;最后一列才是用户可见 task lifecycle。成功恢复后,task 当然还可能在更晚阶段遇到另一种错误,因此这里不把“继续”写成完成保证。

signalfirst observerrecovery owner / budget or stateretry target / local exittask terminal event
HTTP / WS 401ModelClientSession transport loopUnauthorizedRecovery;managed 为 Reload、RefreshToken、Done,external 一次 refresh成功回到当前 HTTP/WS call;永久失败 RefreshTokenFailed;transient Io;无机会则原 401 映成 UnexpectedStatus成功时继续;最终普通错误先 Error,随后 TurnComplete
HTTP request-open 5xx / timeout / networkrun_with_retryrequest_max_retries,默认 4、上限 100,总 attempt 为 N+1clone HTTP request 并重新 transport.stream;最终通常返回原 TransportError上层若仍失败,普通 Error 后 TurnComplete
retryable sampling failurerun_sampling_request 收到 CodexErrfunction-local stream_max_retries,默认 5、上限 100从最新 history 重建 Prompt,重做完整 sampling request;不续旧 stream成功继续;耗尽后 Error,再 TurnComplete
WS handshake 426WS connect match arm当前 client call 内的 fallback state,不计 sampling retry同一次 stream() 立即走 HTTPtask 继续,最终按后续结果终结
WS stream budget exhaustedhandle_retryable_response_stream_errordisable_websockets=true,counter 清零,发 Warning下一次完整 sampling 改走 HTTP,并取得完整新 stream budgetHTTP 成功则继续;HTTP 也耗尽则 Error 后 TurnComplete
ResponseEvent::RateLimitscore event loopSession rate-limit snapshot;无 retry budget继续消费当前 stream;TokenCount 延后到 tool drain 后不改变 terminal 选择
HTTP 429 usage_limit_reachedprovider api_bridge无 retry;解析 reset、plan 与 limit snapshot返回不可 retry UsageLimitReachedrun_turn 发 Error,Session 发 TurnComplete
其他 HTTP 429provider api_bridgerequest policy 不 retry 429返回不可 retry RetryLimitError 后 TurnComplete;名字不证明 request retry 耗尽
response.failed / response.incompletetyped event mapper;SSE 延迟保存,WS 立即返回已知 failed code 各自分类;其余 failed 与 incomplete 进入 sampling budgetretryable error 重做完整 sampling;不可 retry error 离开 run_sampling_request普通 Error 后 TurnComplete
InvalidImageRequestouter run_turn match不占 sampling counter;release sanitation 修改 last-turn image替换成功 continue 新 sampling;无法替换则 Error / local normal return;debug panicrelease 普通退出为 TurnComplete;debug panic 无正常 terminal 保证
explicit cancellationor_cancel / cancellation token checkSession/task cancellation ownership,无 retry返回 CodexErr::TurnAbortedTurnAborted lifecycle 与 event
in-flight tool future Errdrain_in_flight没有统一 retry budget 或 rollbackdebug panic;release 记录 error 后继续 drain;不写 durable tool outputrelease 取决于已有 sampling outcome;debug 无正常 terminal 保证

到这里,第 12 章交来的 error set 已经按 owner 拆开:auth recovery 停在 client call,HTTP request-open 与完整 sampling 各有预算,WS fallback 修改当前 Session 的 transport state,run_turn 负责 invalid image 与普通 Error,Session 才负责 task terminalization。下一章只需要接住一个更窄的缺口:tool registry、handler 与执行 policy 内部失败以后,什么结果能被可靠地交回模型,什么副作用只能由更外层协调。