青雲的博客
拆开 Codex 第三部:模型输出怎样改变机器 第 18 章

命令返回以后,进程为什么还活着

从 Unified Exec 的首轮 yield 出发,拆开 tool future、session ProcessStore、PTY 或 pipe、output streamer 与 stored lifecycle watcher,以及 interrupt、显式清理和 session shutdown 各自拥有的生命周期。

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

一次 exec_command 的输出可能先停在这一行:

Process running with session ID ...

这里的省略号是示意,不是 unified_exec_full_lifecycle_with_background_end_event 的逐字 fixture 输出。固定版本的 formatter 会在仍有逻辑 session id 时打印 Process running with session ID {process_id};同一个结构化结果可以同时有 process_id: Some(...)exit_code: None。所以“工具已经返回”与“命令已经退出”从数据模型上就不是同一个判断。

本章的核心结论只有一句:tool future、session ProcessStore、OS process 及其后台任务是三条不同的时间线。它们会在 spawn 处相遇,却不会在同一个 await 点一起结束。tool future 只负责本次 function call 何时交回模型;ProcessStore 负责 session 里仍可被寻址的逻辑进程;底层 process、output streamer 与 stored lifecycle watcher 负责继续读输出、观察 lifecycle 收口、发最终事件。理解这三条线,才不会把一个短文本误读成“shell 已经结束”。

从 approved SandboxAttempt 到 manager

第 17 章已经把 approval、Permission Profile 和 sandbox 选择走完了。本章不重复审批链,只从那个已经批准的 SandboxAttempt 接手。调用链可以压缩成三层:ExecCommandHandler 解析 function arguments、分配逻辑 process id 并构造 ExecCommandRequestUnifiedExecRuntime::run 在 attempt 上准备 shell snapshot、环境、network context、PowerShell 或 zsh-fork 包装;UnifiedExecProcessManager 决定 remote exec-server、local PTY 或 local pipe,然后真正 spawn。

handler 在进入 manager 前先保留原始 call_id、hook command、cwd、tty、yield 和权限请求。它只在请求进入 manager 后才拿到 ExecCommandToolOutput。因此 handler 的返回值不是 waitpid 的结果,而是 manager 在一个观察窗口内拼出的快照。

spawn 后,manager 发出 ExecCommandBegin,启动 output streamer,然后检查进程是否已经退出。只要它仍然 live,就必须在首轮 yield deadline 前把 entry 放进 session 的 ProcessStore。这个顺序看起来像实现细节,实际上是生命周期契约:如果先等输出、后入库,turn cancellation 可能在两步之间 abort handler;栈上的最后一个 Arc 一旦丢掉,process 的 drop 路径会 terminate。入库先于观察窗口,才让 session owner 接住了 handler future 之外的那条线。

已入库的保证也有边界。spawn 成功到 ProcessStore 插入之间仍是一小段窗口;源码没有把这段 race 变成跨 turn 保活保证。Begin 甚至在存储前就可能发出,所以看到 Begin 不能单独证明后台 session 已经稳定归 session manager 所有。保证的准确表述是:成功入库的 live process 可以脱离当前 tool future 继续存在。

flowchart TB
  accTitle: Unified Exec 的三条并行时间线
  accDescr: spawn 后发送 Begin 并启动 output streamer;在 initial alive check 时已完成的进程由 manager inline 发 End,仍未观察到退出的进程先进入 ProcessStore 并启动 stored lifecycle watcher;initial collection 后 refresh 若仍未观察到退出,tool future 返回 process_id Some,若已观察到退出则 remove entry 并返回 process_id None,但 End 仍由 stored lifecycle watcher 发出;turn interrupt 只结束当前 turn,不终止 stored process,CleanBackgroundTerminals 或 session shutdown 才会终止它
  SPAWN["spawn"] --> BEGIN["Begin"]
  SPAWN --> STREAM["output streamer"]
  BEGIN --> ALIVE{"initial alive check"}
  ALIVE -->|not observed exited| STORE["ProcessStore<br/>entry present"]
  ALIVE -->|already finished| INLINE["manager inline<br/>ExecCommandEnd"]
  STORE --> COLLECT["initial collection"]
  COLLECT --> REFRESH{"refresh observed exited?"}
  REFRESH -->|no| YIELD["tool future yielded<br/>process_id Some"]
  REFRESH -->|yes| REMOVE["remove store entry<br/>tool future returned<br/>process_id None"]
  YIELD --> INTERACT["write_stdin / poll"]
  STORE --> WATCH["stored lifecycle watcher"]
  REMOVE -. "End remains watcher-owned" .-> WATCH
  STORE -. "exit / close / failure" .-> CLOSE
  CLOSE["lifecycle close<br/>local exit / remote Closed<br/>failure / terminate"] --> TOKEN["lifecycle token"]
  TOKEN --> STREAM
  STREAM --> DRAIN["output drained"]
  TOKEN --> WATCH
  DRAIN --> WATCH
  WATCH --> BACKGROUND_END["background<br/>ExecCommandEnd"]
  CLEAN["independent cleanup owner"] --> CLOSE

Initial yield 只是观察窗口

exec_commandyield_time_ms 默认是 10 秒。manager 不把它当作进程 timeout,而是把它当作首次观察窗口:输入值先在 Windows 上抬到至少 2 秒,再限制到 250 毫秒至 30 秒。deadline 到达不会 kill process;它只决定当前 tool future 何时交回一份快照。

首轮收集结束时,manager 再看一次 store entry。这个时刻可能是 deadline,也可能在 lifecycle token 到达后,因为 output 同时关闭,或 50 毫秒 close wait 到期而更早发生;仅有 output closed 并不会单独结束收集。如果 process 仍 live,输出结构保留 process_id: Some(logical_id);如果已经完成,refresh 会移除 entry 并返回 process_id: None,但 exit_code 仍是 Option,只有 backend 已提供时才是已知值。这里的 Some 只说明“manager 还持有一个可寻址的 session”,不说明 OS PID,也不说明下一秒一定成功。

process_id 是 manager 的逻辑 ID。它是 ProcessStore 的 key,属于 session scope;child PID 和 Unix process group ID 都是 backend 细节,不能由这个数字反推。测试模式可能从 1000 顺序分配,生产模式则在 manager 的保留范围里寻找未占用值,这种分配策略也不改变它是逻辑 ID 的事实。

write_stdin 不是第二次 spawn

write_stdin handler 只把 session_id 对应的逻辑 id、chars 与 yield 参数交给 manager。manager 才在 ProcessStore 中 lookup entry、更新 last_used,并取出原始 call_id、buffer 与 process handle。它不会创建新命令,也不会重新走第 17 章的审批路径。输入规则由 backend 的形态决定:TTY session 接受普通字节;tty: false 的 local pipe 从一开始就没有普通 stdin,非空输入只允许 Ctrl-C (0x03) 这一种中断请求,其余字节返回 stdin closed。空 chars 是 poll,只观察输出和进程状态,不写入任何字节。

空 poll 的等待上限与初次 exec 不同:默认提示是 5 秒,manager 还会套用 poll 的最小值与最大值;普通 write 则使用另一组 250 毫秒至 30 秒的约束。两者都只是观察窗口。最容易漏掉的是 call identity:TerminalInteraction 复用 store 里原始 exec_commandevent_call_id,不是当前 write_stdin function call 的 id。这样 UI 才能把输入与原命令 item 关联起来。

running session 没有普通的第二次 PostToolUse payload。write_stdinpre_tool_use_payload 固定为空;当结果仍有 process_id 时,ExecCommandToolOutput::post_tool_use_response 也返回 None。只有某次交互观察到原进程已完成,才可能用原始 call id 和 hook command 补发那一条 completion-time PostToolUse。

PTY 与 pipe 都进入统一 transcript

tty: true 的 local path 打开 PTY。Unix PTY 把 stdin、stdout、stderr 接到同一个 slave;portable reader 从 master 只产生一条输出通道,另建的 stderr channel 没有 producer。UnifiedExecProcess 再把可用的 stdout 与 stderr receiver 合成统一的 output receiver。因此 delta 统一标记为 ExecOutputStream::Stdout,这不是说每个字节原本都来自 fd 1,而是协议层已经丢掉了 PTY 中可恢复的 fd 边界。

local tty: false 走 regular pipe backend;remote environment 无论 tty 都由 exec-server 接管。local pipe 会用两个并发 reader 分别 drain stdout 和 stderr,避免某一侧阻塞,但进入统一 process 后仍写进同一份 transcript。最终成功的 ExecCommandEnd 把 aggregated output 放进 stdoutstderr 为空。需要严格区分两个原始 stream 的调用者,不能从 Unified Exec 的这层事件恢复它。

ExecCommandEnd 有 inline 与 stored watcher 两条路径

先统一术语:output streamer 负责 delta 与 transcript;源码里的 spawn_exit_watcher 下文称为 stored lifecycle watcher;未入库进程由 manager inline 发 End。stored lifecycle watcher 与 output streamer 都监听 UnifiedExecProcess 的 lifecycle/cancellation token。token 到达后,streamer 留一小段 trailing grace,再通知 output_drained;stored lifecycle watcher 等 token 与 drain 都完成,才按当前 failure/exit state 发 ExecCommandEnd

local 正常退出时,exit_rx 会更新 ProcessState 并取消 lifecycle token;如果 exit channel 关闭却没有 code,状态仍可退出,只是 exit code 保持未知。

remote 路径的边界不同:event stream closed、read failure、ClosedFailed 都会取消同一枚 token;单独收到 Exited 只先更新状态,还会继续等 close。于是 stored lifecycle watcher 发出 End 只能证明 lifecycle signal 与 output drain 已收口,不能单凭 End 断言 backend 自然退出。

显式 terminate 也会关闭输出并取消 token,随后 stored lifecycle watcher 仍可能形成 End。另一条是 manager inline End:如果进程在 initial alive check 与早期收尾窗口内已经完成,它不会进入 store,manager 会在当前 future 里直接调用 End helper,也不经过 stored lifecycle watcher 的 output_drained 等待。两条路径的事件格式相同,owner 和等待条件不同。

因此 ExecCommandEndTurnComplete 没有固定先后。End 属于 process/output lifecycle,TurnComplete 属于 turn loop;它们可以互相越过。exact lifecycle test 的事件循环明确同时等待两者,只有都看到才结束,而不是假设某个事件必然先到。

两枚 cancellation token,两个边界

turn cancellation token 只描述当前 turn 或 dispatch future 的生命周期;UnifiedExecProcess 自己拥有另一枚 process lifecycle token。output streamer、initial collector 与 stored lifecycle watcher 监听后者,但它不是“OS 已退出”的同义词。turn interrupt 会 abort 当前 task,并发 TurnAborted,却不会自动调用 terminate_all_processes;已经进入 ProcessStore 的进程仍由 session owner 持有。CleanBackgroundTerminals 是独立操作,才会调用 close/terminate 路径。

terminate trigger、store removal 与 End 是三件事。几条路径可以直接按所有权对照:

路径terminate 动作是否立即 remove store entry后续边界
natural exit / stored lifecycle watcher End不调用 terminate;只更新状态、取消 token、发 End否;stored lifecycle watcher 不操作 ProcessStoreinitial refresh、后续 poll refresh、prune 或 shutdown 才可能移除
managed network denialfail_and_terminate否;network-denial task 不操作 storeinitial failure cleanup、后续 poll、prune 或 shutdown
capacity prune先选 entry,移除后调用 terminate已无 store owner;process 进入 termination
explicit terminal terminate未退出时调用 terminate_confirmed有条件;initial exec_command active 时暂时保留initial refresh、另一次 terminate、poll、prune 或 shutdown
session shutdownabort active tasks 后 terminate_all_processes是;先 drain 整个 store,再逐个 terminatesession ownership 在这里结束

initial guard 的 drop 只把 initial_exec_command_active 改为 false,不会补做 remove。若显式 terminate 因 guard active 留下 entry,它仍要等表中的后续边界离开 store。

terminate 不是温和的 TERM 再 KILL

local PTY 的 terminate 语义要单独看。普通 portable Unix PTY 的 child 是新 session leader,PID 可作为 process group;它先对 group 发 SIGKILL,再尝试 direct child killer,避免缓存的 PGID 已失效时留下 child。保留 inherited fd 的 raw-PID 路径只走 process-group SIGKILL,没有同一个 direct-child fallback。随后 ProcessHandle::terminate abort reader、writer、wait 和额外 reader helper tasks。这里没有可以对外承诺的 TERM grace -> KILL 两阶段。

terminate_confirmed 这个名字也不能被读成 waitpid 确认。remote backend 会等待 exec-server 的 terminate RPC;local branch 是同步调用 handle terminate,再更新内部 exit state、关闭输出并 cancel lifecycle token,没有额外等待 OS reap。Windows portable PTY 选择 ConPTY,其 child killer 使用 TerminateProcess;local pipe 也对单个 PID 调用 TerminateProcess。两者都不能套用 Unix 的 PID=PGID 或进程组语义。

沙箱 denial heuristic 在这里的回指很短:只有当 process 已经退出,executor 的结果或输出形态命中 denial heuristic,orchestrator 才可能按第 17 章的策略重试或转成 denial。它不会把初始 yield 当成审批超时,也不会改变 ProcessStore 的 owner。

指定实验:一次 exec,两个结束事件

固定实验名是 unified_exec_full_lifecycle_with_background_end_event。它只发一次 exec_command,命令是 sleep 0.5; printf 'HELLO-FULL-LIFECYCLE'yield_time_ms 为 1000。启动检查时进程仍 live,因此先入 ProcessStore 并启动 stored lifecycle watcher;在首轮 yield 内它退出,output streamer 随后 drain transcript。测试没有调用 write_stdin

事件循环同时等 ExecCommandEndTurnComplete,允许任意先后;它只断言 Begin 和 End 的 process_id 都是 Some,不锁定逻辑 id 为 1000。最终 End 的 exit code 必须为 0,aggregated_output 必须包含 marker。这项实验只证明这次 full lifecycle 的并行关系,不能外推 turn-end 保活、interrupt 保活或 stdin 复用;这三项分别由相邻测试证明。

实验在 disposable git archive 中校准 local workspace package version,避免改动固定 checkout。编译产物也写入 $ARCHIVE_DIR/target,不复用固定源码中被 Git 忽略的 target。本章代码块先 source 本仓库的 scripts/codex-handbook-part3.sh,在自己的 shell 中调用 prepare_codex_part3 并注册 cleanup trap;没有博客 checkout 时,把该 helper 文件保存到任意路径,再通过 CODEX_HANDBOOK_PART3_HELPER 指向它,因此可以独立运行 exact selector:

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
ARCHIVE_CODEX_RS="$ARCHIVE_DIR/codex-rs"
export CARGO_TARGET_DIR="$ARCHIVE_DIR/target"

test -f "$SOURCE_DIR/Cargo.toml"
test "$CARGO_TARGET_DIR" = "$ARCHIVE_DIR/target"
cmp -s "$ARCHIVE_CODEX_RS/workspace.names" "$ARCHIVE_CODEX_RS/lock.names"
test -z "${CODEX_SANDBOX+x}"
test -z "${CODEX_SANDBOX_NETWORK_DISABLED+x}"
cd "$ARCHIVE_CODEX_RS"

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)'

通过判定包含四件事:just test exit 0;nextest 摘要确认实际运行并通过 1 项测试;目标不是 Windows;未捕获输出里没有 Skipping test...skip_if_。nextest 摘要中其余测试被 filter 排除后也会计入 skipped,所以不能粗暴要求这个数字为 0;环境短路由共享 runner 从 fixture 输出中单独拒绝。

最后按证据命名状态

状态最小定义可观察证据不可顺手推出
runningentry 仍在 store,且 manager 尚未观察到 exitedfunction output 的 process_id: Some,或 store refresh 返回 Alivebackend 此刻确实存活;remote transport 未 Closed/failed;仅凭 Begin 就已入库
yieldedinitial collection 结束,tool future 返回,entry 仍在 storeprocess_id: Some;常见但不保证 exit_code: Noneprocess 一定成功;End 已发出
exited已直接观察到 local exit channel 或 exec-server 的 backend exitlocal exit_rx 或 exec-server Exited/read response;code 可缺失仅凭 lifecycle token 或 End;entry 必然立即移除
terminatedcleanup owner 已请求 backend terminate;可以尚未观察到 backend exitexplicit terminate、live-entry prune、network denial 或 shutdown 路径local 已完成 waitpid;Windows 有 Unix PGID 语义

下面是抽象的 session transcript / owner ledger,不是 exact fixture 的原始 transcript,也刻意不含原命令和路径。它只记录谁拥有哪一步:

01  command-event owner   Begin(call_id=call-A, process_id=logical-1)
02  session-store owner   stored(process_id=logical-1, observed_exited=false)
03  tool-future owner     returned(process_id=logical-1, exit_code=None, state=yielded)
04  interaction owner     poll(original_call_id=call-A, process_id=logical-1)
05  process owner         backend_exit_observed, lifecycle_token(cancelled), exit_code=0
06  output owner          transcript_drained(marker_present=true)
07  lifecycle watcher     End(call_id=call-A, process_id=logical-1, status=completed)
08  store owner           entry_removed(process_id=logical-1)

命令的 function output 只回答“现在值得不值得继续等”;ProcessStore 回答“这个 session 还能不能找到它”;stored lifecycle watcher 回答“lifecycle 收口和尾部输出是否已经形成最终事件”。把这三句分开,Process running with session ID ... 就不再神秘。下一章再沿 network proxy、denial token 与 approval amendment 追问:一次网络访问为何继续,见第 19 章:网络决策链