English 书架

第 15 章:SDK、Daemon 与远程控制

阅读契约: 本章回答一个具体问题:当 core runtime 之外的代码访问 Codex 时,协议语义、进程生命周期、restart/reconnect 行为和恢复分别由哪一层负责?阅读时抓住三个 owner:SDK stream routing、daemon process supervision、remote-control cursor replay。读完后应该能分清一个“client”到底是 protocol client、process wrapper、lifecycle manager,还是 transport bridge。

Codex external reach 封面图:protocol client、process wrapper、daemon、remote bridge 共享同一份 app-server contract
SDK、daemon、in-process caller 和 remote-control bridge 的触达方式不同;真正有用的边界会保留同一份 turn contract,而不是各自发明语义。

源码边界: 本章只有在链接到固定 Codex commit 569ff6a1c400bd514ff79f5f1050a684dc3afde3 或本章 Source Map 时,才把 source-level 说法视为 verified source。像 runtime owner、transport bridge、semantic boundary、client reach 这类设计语言,是从可见源码锚点得出的 surrounding contract inference。本章不推断 OpenAI 服务内部实现。

第 14 章把 app-server 解释为 shared thread contract:多个 connection 可以 start turn、observe notification、回答 server request,并重新接入正在运行的 work,而不用直接拥有 core session loop。第 15 章往外走一层,看同一份 contract 怎样穿过 SDK ergonomics、process supervision 和网络间隙。真实用户通常不会手写每条 JSON-RPC message。他们会调用 SDK,让 daemon 找到本地 server,或者通过 remote-control bridge 连接。

容易误判的地方,是把这些都叫做“client”然后停住。这个词会遮住真正重要的工程分工:

  • Protocol client 必须保留 request、response、notification 和 server-request 语义。
  • Process wrapper 可能只是运行一个命令并解析 JSON event stream。
  • Daemon 不是 SDK;它拥有进程生命周期、pid 状态、健康探测、restart 和 update loop。
  • Remote-control bridge 不是普通 WebSocket;网络失败会把 cursor、buffering、replay 和 connection identity 变成正确性的一部分。

因此,本章的 mental model 不是“SDK 调 app-server”,而是“每个外部 surface 要么保留 app-server contract,要么明确暴露一个更窄的 contract”。

一、Client reach 是 taxonomy,不是一条路径

Codex 有几种被外部代码触达的方式,它们的边界刻意不对称。

SurfacePrimary boundaryVerified anchor本地 owner / invariant
Rust app-server transportApp-server message transportAppServerTransportNormalize listener forms,携带 ConnectionOrigin metadata,但不改变 message meaning。
Python SDKApp-server v2 over stdioCodexMessageRouter保持 single stdout reader、typed calls、per-request queues、turn streams 和 result projection。
TypeScript SDKcodex exec process streamCodexThread.runStreamedInternal暴露围绕 line-delimited JSON events 的 process-oriented thread API。
DaemonLocal app-server lifecycleDaemon::runPidBackend串行化 lifecycle changes、probe socket health、发布 pid state,并管理 restart/update loops。
Remote controlBackend-mediated app-server streamstart_remote_controlClientTracker保留 remote connection identity、chunking、ack cursor、reconnect 和 replay。

这张表先把 contract shape 和 client ergonomics 拆开,避免两个错误结论。第一,TypeScript SDK 不是“没有 Python SDK 完整所以错了”。它包装 codex exec,把 input 写给 child process,读取 line-delimited stdout,然后解析 ThreadEvent。第二,daemon 也不是 codex app-server 外面的一层 convenience flag;它是让 long-lived local server 可以被 short-lived tools 安全依赖的边界。

1.1 Schema 先给 clients 一套共享语言

SDK 做 ergonomic 之前,app-server protocol surface 已经有 generated types 和 transport normalization。Transport module 通过 AppServerTransport::from_listen_url 接受 stdio://unix://ws://IP:PORToff。同一个 module 又通过 TransportEvent 表示 incoming work,并用 ConnectionOrigin 区分 StdioInProcessWebSocketRemoteControl

这个 enum 是一个小但关键的线索。Transport boundary 之上,runtime 可以知道 connection 从哪里来,但不应该因此改变 protocol message 的含义。Origin 是 connection handling 和 disconnection path 的 metadata;protocol message 本身仍然是语义 contract。它不是偷偷改写 turn/startserver request 语义的许可。

1.2 Public client surface 不应该泄漏所有 protocol 细节

Python SDK 的 public class 会启动 app-server client,并在暴露 generated method facade 前校验 initialize metadata。源码锚点是 Codex.__init__,后面接着 thread_start 这类 generated method。这是用户面对的 surface:caller 应该看到 thread API,而不是被要求自己写 response router。

TypeScript SDK 的 tradeoff 不同。Codex.startThread()resumeThread() 返回 Thread,但实现运行的是 codex 可执行文件。在 exec.ts 里,threadId 变成 resume <id>,stdin 收到 prompt,stdout 被 readline 读成 stream,每一行再交给 thread 层。下面的摘录是缩略版;中间省略的是 image arguments、env construction、API key injection、stderr collection 和 exit handling。

if (args.threadId) {
  commandArgs.push("resume", args.threadId);
}

// ... image arguments and environment setup omitted ...

const child = spawn(this.executablePath, commandArgs, {
  env,
  signal: args.signal,
});

child.stdin.write(args.input);
child.stdin.end();

const rl = readline.createInterface({
  input: child.stdout,
  crlfDelay: Infinity,
});

for await (const line of rl) {
  yield line as string;
}

// ... spawn errors and non-zero exit handling omitted ...

这是有效的 SDK 边界,但它比“app-server client”窄。它是 command wrapper with structured events。谈 server-request replay、daemon-managed socket、remote-control cursor 时,不能把那些语义自动投射到这个 SDK 上,除非 pinned source 显示它接了这些 hook。

二、SDK routing:一条 ordered stream,多个本地 owner

Python SDK MessageRouter 图:单 process stdout reader 将消息路由到 response waiters、turn queues、pending turn replay、global queue 和 fail_all
Python SDK 只让一个 reader 读取 ordered stdout stream,把 response 和 notification 路由给本地 owner,并在 reader failure 时通过同一个 router 唤醒 blocked SDK operations。

Python SDK 最重要的内部 invariant 写在 MessageRouter 里:only the reader thread should consume stdout。这句话解释了整个 class。Stdio 是 ordered,但不等于天然有 owner。如果两个高层 SDK method 都直接读 process output stream,一个 response 就可能被错误 caller 拿走。

所以 router 创造了本地 ownership:

class MessageRouter:
    """Route reader-thread messages to the SDK operation waiting for them."""

    def __init__(self) -> None:
        self._lock = threading.Lock()
        self._response_waiters: dict[str, queue.Queue[ResponseQueueItem]] = {}
        self._turn_notifications: dict[str, queue.Queue[NotificationQueueItem]] = {}
        self._pending_turn_notifications: dict[str, deque[Notification]] = {}
        self._global_notifications: queue.Queue[NotificationQueueItem] = queue.Queue()

这四个 routing structures 不是实现细节堆砌,而是 app-server contract 在 SDK 本地的镜像:

  • _response_waiters 把 request id 映射到 one-shot JSON-RPC response queues。
  • _turn_notifications 把 turn id 映射到 caller 已经开始消费的 stream queues。
  • _pending_turn_notifications 把 turn id 映射到 deques,用来保留 turn/start 后、caller 注册 stream 前就到达的早期 events。
  • _global_notifications 接收不属于某个 turn 的 notifications。

最容易破坏 naive SDK 的就是早期 event 场景。源码在 register_turn 里处理它:为 turn 注册 queue 时,把 pending events 弹出来再放进新 queue。route_notification 做另一半:当 turn queue 还不存在时,先 buffer 非 completed 的 turn notifications。

def register_turn(self, turn_id: str) -> None:
    turn_queue: queue.Queue[NotificationQueueItem] = queue.Queue()
    with self._lock:
        if turn_id in self._turn_notifications:
            return
        pending = self._pending_turn_notifications.pop(turn_id, deque())
        self._turn_notifications[turn_id] = turn_queue
    for notification in pending:
        turn_queue.put(notification)

这就是 runtime recovery rule 的 client-side 版本:event 可以早于 consumer readiness 到达,但仍然属于同一个 turn。没有这个机制,快速 turn 会只在 timing pressure 下偶发丢事件。

2.1 Failure 必须唤醒每个 blocked owner

Router 同时拥有 failure propagation。fail_all 会 snapshot 已注册的 turn queues,清空 response waiters 和 pending-turn buffer,然后把同一个 exception 注入每个 response waiter、已注册 turn queue,以及 global-notification queue。源码注释直接写出 invariant:没有 SDK call 应该永远阻塞在一个不可能到来的 response 上。

所以 stream router 不只是 multiplexer。它是本地 failure boundary。reader thread 一旦退出,只有 router 知道哪些 user-facing operation 可能还在 sleep。

2.2 Result collection 是 projection,不是整条 stream

高层 run() result 是从 notifications 投影出来的。_collect_run_result 收集 target turn 的 completed items、token usage 和 turn/completed notification。如果一直没有 turn completion event,就 raise。

这意味着 public return value 是 stream 的 projection,而不是 stream 的替代品。只需要最终文本的 caller 可以用 run();需要 progressive items、approvals 或自定义 UI 的 caller,则应该把 notification stream 当成更丰富的 contract。

2.3 Python 和 TypeScript 的差异是有意的

问题Python SDKTypeScript SDK
Primary input/outputApp-server protocol over stdio。codex exec process stdin/stdout。
Stream ownerMessageRouter 拥有 single reader 和 per-operation queues。readline 逐行 yield process output。
Turn identityProtocol methods 和 generated models 暴露 thread/turn operations。thread.started 可以设置 _idresume <id> 被传给 executable。
Failure boundaryRouter 唤醒 waiters 和 streams。Child process errors、non-zero exit、JSON parse failures 变成 SDK errors。
Best fit需要 app-server semantics 的 clients。需要 structured execution events 的 scripts。

这个区别能保持文章诚实。需要 shared app-server thread ownership、server-request handling、remote-control semantics,就要使用真正暴露这些 contract 的 surface。只需要围绕命令做一个语言 wrapper,较窄的 event-stream surface 反而更合适。

三、Daemon lifecycle:可靠性不是 pid file

Daemon lifecycle 图:command、lock、probe、pid、backend、ready、update 与 stale pid cleanup loop
Daemon 用 operation lock 串行化 lifecycle commands,用 socket probe 判断真实健康,用独立 pid reservation lock 保护启动间隙,并在 readiness 发布前清理 stale pid records。

本地 daemon 听起来像小工具,直到两个 caller 同时 start、restart、update 或 stop 同一个 app-server。源码明确暴露了这个风险。Daemon 定义了 app-server.pidapp-server-updater.piddaemon.lock 等 state files,然后通过 Daemon::run 分发 lifecycle commands。

关键模式是:StartRestartStop 在修改 process state 前都会 acquire operation lock。Version 不需要,因为它是 probe-style read。

async fn run(&self, command: LifecycleCommand) -> Result<LifecycleOutput> {
    match command {
        LifecycleCommand::Start => {
            let _operation_lock = self.acquire_operation_lock().await?;
            self.start().await
        }
        LifecycleCommand::Restart => {
            let _operation_lock = self.acquire_operation_lock().await?;
            self.restart().await
        }
        LifecycleCommand::Stop => {
            let _operation_lock = self.acquire_operation_lock().await?;
            self.stop().await
        }
        LifecycleCommand::Version => self.version().await,
    }
}

这个 lock 不是隐藏在进程内存里的抽象 mutex。acquire_operation_lock 会打开 daemon lock file,在 timeout 前反复尝试文件锁,并在尝试之间 sleep。file-backed 设计很重要,因为不同 CLI invocation 和 update loop 可以通过 filesystem 协调。

3.1 先 probe,再信任 process record

Daemon 不把“pid file 存在”等同于“server 可用”。start 会先 load settings,然后在 control socket 上调用 client::probe。Probe 会连接 socket、升级 WebSocket、发送 initialize、等待匹配 response、发送 initialized、关闭连接,并从 response user agent 解析 app-server version。

这比 pid file 是更强的信号。Crash 之后可能留下 stale pid record;能对 initialize 作出 response 的健康 socket,才证明 app-server 正在接受协议。

Daemon 在 restart 里也遵循这个纪律:如果有 server 正在运行但不是 daemon 管理的 backend,restart 会返回错误,而不是杀掉未知进程。wait_until_ready 则持续 probe,直到 app-server ready 或 start timeout。

3.2 PID reservation 保护 startup gap

Pid backend 还有自己的 lock,因为 startup 有危险的中间状态:某个 process 已经决定启动 server,但 pid record 还没完整发布。PidBackend::start 会创建 pid directory、acquire reservation lock、用 create_new 创建 pid file、移除 stale records、spawn detached process、读取 process start time、写 temp record,并 rename 到最终位置。

读取侧也会区分 missing、empty、starting、running 和 stale。read_pid_file_state 在 pid file 缺失但 reservation lock active 时返回 Startingrefresh_after_stale_record 会重新拿 reservation lock,再移除 stale record。

这就是 daemon 的 invariant ledger:

Pressure会失败的简单做法Source mechanism保护的 invariant
两个 command 同时 start。两边都 spawn server。Daemon::run 的 operation lock。同一时间只有一个 lifecycle mutation。
PID 存在但 server 已死。信 pid file。Control socket initialize probe。Readiness 必须意味着 protocol acceptance。
进程在 reservation 后死亡。把 empty pid 当 running。Reservation lock 和 starting state。Startup gap 可观测。
Update loop 遇到 busy daemon。直接 restart。try_restart_if_running 在无法拿到 lock 时返回 BusyUpdate 不与 user lifecycle commands 竞争。
Server 属于另一个 owner。杀掉任何响应该 socket 的进程。Restart/stop 前检查 managed backend。Daemon 只管理自己拥有的 backend。

因此,daemon 不只是“server 从这里启动”。它让 local app-server reach 稳定到足以被 SDK、UI 和 update flows 依赖。

四、Remote control:网络失败变成 runtime state

Remote-control cursor replay 图:remote client、websocket、app-server、client tracker、message chunks、outbound buffer、ack cursor、reconnect 与 replay
Remote control 把网络不确定性显式化成状态:client identity、stream identity、message chunks、outbound buffer entries、ack cursors、reconnect 和 replay。

Remote control 是本章最尖锐的边界,因为它跨网络。本地 stdio client 在 child process 退出时通常可以 fail fast;remote client 可能在本地 runtime 继续发 server messages 时断线。如果 bridge 不记得 remote side 已经 ack 了什么,reconnect 后 client 就可能悄悄漏掉 notifications。

Remote-control module 先处理 enablement 和 status。RemoteControlStartConfig 携带 remote-control URL 和 installation id。RemoteControlHandle 可以 enable/disable bridge 并暴露 status updates。start_remote_control 只有在 remote control 初始启用时才会立即 normalize target;否则后续 connect path 再 normalize。它还会把 initial status 设为 ConnectingDisabled,创建 websocket runner,并返回 handle。

Recovery fields 在 protocol 里是可见的。ClientEnvelope 包含 client_id、optional stream_id、optional seq_id 和 optional cursor。源码注释说明 seq_id 是用于 acknowledgement 的 backend-generated per-stream cursor。ServerEvent 可以携带 ServerMessageServerMessageChunkAckPong

pub(crate) struct ClientEnvelope {
    pub(crate) event: ClientEvent,
    pub(crate) client_id: ClientId,
    pub(crate) stream_id: Option<StreamId>,
    /// For `Ack`, this is the backend-generated per-stream cursor over
    /// `ServerEnvelope.seq_id`.
    pub(crate) seq_id: Option<u64>,
    pub(crate) cursor: Option<String>,
}

URL normalizer 也刻意收窄。normalize_remote_control_url 接受 allowed ChatGPT hosts 的 HTTPS URL,以及 localhost 的 HTTP/HTTPS URL,然后派生 enroll 和 websocket endpoints。这是 transport contract,不是任意 tunnel。

4.1 ClientTracker 把 remote messages 变成 app-server connections

ClientTracker::handle_message 会把 incoming remote envelopes 映射成本地 app-server connection events。遇到 initialize-style message 时,它用 ConnectionOrigin::RemoteControl 打开一个新 connection。之后同一 (client_id, stream_id) 的 messages 会变成 TransportEvent::IncomingMessage

这一步是 bridge 的 semantic hinge。Remote control 不是“把 JSON 写到 websocket”。它创建一个正常 app-server connection origin,然后让 transport boundary 之上的 message processor 继续处理 app-server semantics。

4.2 OutboundBuffer 是 replay ledger

Outbound side 会按 (client_id, stream_id) 存储尚未 ack 的 server envelopes。BoundedOutboundBuffer 插入每个 server envelope,只移除 ack cursor 已覆盖的部分。

fn ack(
    &mut self,
    client_id: &ClientId,
    stream_id: &StreamId,
    acked_seq_id: u64,
    acked_segment_id: Option<usize>,
) {
    let key = (client_id.clone(), stream_id.clone());
    let Some(buffer) = self.buffer_by_stream.get_mut(&key) else {
        return;
    };
    let acked_cursor = (acked_seq_id, acked_segment_id.unwrap_or(usize::MAX));
    buffer.retain(|server_envelope| {
        let envelope_cursor = (
            server_envelope.seq_id,
            server_envelope.event.segment_id().unwrap_or_default(),
        );
        let is_acked = envelope_cursor <= acked_cursor;
        !is_acked
    });
}

完整源码还会更新 usage watch channel 并删除 empty buffers。文章层面最重要的是 comparison key:sequence id 加 optional segment id。这让 segment acknowledgement 可以按 wire-chunk granularity 前进,而不是把大 server message 假装成不可分割的 atom。

run_server_writer_inner 在 websocket writer 启动时 replay outbound-buffer 中已有的 envelopes,然后给新的 server events 分配 per-stream contiguous sequence ids,必要时 split large messages,再把 envelopes 插入 buffer 并发送 JSON payload。run_websocket_reader_inner 会读 client envelopes,保存 subscribe cursor,并在看到带 seq_idstream_id 的 ack 时调用 outbound_buffer.ack(...)

Shape-level sequence:

connect with subscribe cursor C
-> writer replays unacked server envelopes still in outbound buffer
-> new server event gets next per-stream sequence id
-> large message may split into chunks
-> remote client sends ack cursor
-> outbound buffer drops covered sequence/segment entries

解释这条链路不需要任何 provider-internal behavior。可见源码 contract 已经说明 cursor 和 replay 为什么存在:remote control 必须在 websocket reconnect 后,不丢失 remote side 尚未 acknowledge 的 app-server messages。

4.3 Chunk reassembly 是 safety boundary

Remote clients 也可以发送 chunked messages。REMOTE_CONTROL_SEGMENT_* constants 定义 target、max segment、max reassembled size 和 max segment count;ClientSegmentReassembler 拥有 in-progress assemblies。observe 会丢弃缺少 required seq_idstream_id 的 segmented envelope,拒绝非法 counts 和 sizes,并在 stream change 时 reset state。AssemblyUpdate enum 命名这些 outcome;observe branch 会在 forward reassembled ClientMessage 前,拒绝 old、mismatched、out-of-order、oversized、invalid-base64 和 invalid-JSON chunks。

这不只是 defensive parsing。它保护 app-server 不会在网络中断后收到 half-assembled 或 replay-confused protocol messages。

五、Compatibility 应该留在边界

SDK、daemon supervision 和 remote control 都会带来兼容性成本,但源码倾向于把这些成本留在边界:

BoundaryCompatibility pressureEdge response
Python SDKInitialize metadata 可能来自 serverInfo,也可能来自 user-agent shape。_validate_initialize 在暴露 SDK 前 normalize required metadata。
TypeScript SDKExisting session 通过 command-line surface resume。exec.tsresume <threadId> 传给 executable。
DaemonServer 可能已经运行,但不是 daemon-managed。startrestartstop 区分 healthy unmanaged server 与 daemon-owned backend。
Remote control旧 clients 可能在 initialize 时省略 stream_idClientTracker 包含有注释的 legacy stream-id fallback。
Remote reconnectRemote side 可能只收到 stream 的一部分。BoundedOutboundBuffer 按 client 和 stream 保留 unacked envelopes。

这条模式可以迁移:compatibility 应该靠近制造它的 surface。Stream-id fallback 属于 remote tracker,不属于 core turn loop。User-agent normalization 属于 SDK initialize handling,不应该散落到每个 downstream call。Daemon ownership checks 属于 process supervision,而不是 protocol message routing。

常见误读

误读修正
“所有 SDK 都是 app-server clients。”Python SDK 是 app-server protocol client;TypeScript SDK 包装 codex exec 并解析 JSON event lines。
“Daemon 只是 pid file。”Daemon 使用 operation locks、socket probes、pid reservation locks、stale-record cleanup 和 readiness polling。
“Remote control 只是 WebSocket。”Remote control 增加 enrollment、client/stream identity、chunking、ack cursor、outbound buffering、reconnect 和 replay。
“Transport 差异会改变 turn semantics。”Transport origin 可见,但 app-server protocol messages 在 transport boundary 之上应保持同一含义。
“SDK final result 就是完整 stream。”run() projection 收集 completed items、final response、usage 和 failure status;notification stream 仍然更丰富。

应用到实践

  1. 先命名 client boundary,再评估它。 Protocol client、process wrapper、daemon、remote bridge 的义务不同。
  2. 每条 ordered stream 只设一个 reader。 内部路由 responses 和 notifications,避免并发 user code 偷走 protocol bytes。
  3. Probe health,而不是只看存在性。 Pid file 是证据;control socket 的 initialize response 是更强证据。
  4. 把 reconnect 显式化。 如果 transport 会在 runtime 继续运行时断开,那么 cursor、buffer 和 replay 就是 correctness 的一部分。
  5. Compatibility 留在 owner 附近。 SDK metadata 在 SDK normalize,process ownership 在 daemon 判断,stream-id fallback 在 remote tracking 处理。

收束

SDK、daemon 和 remote control 不是“真正 runtime”旁边的附属代码。它们是 app-server contract 进入程序、脚本、本地 supervisor 和远程 client 的地方。源码层面的主题是一致的:保留同一份 semantic contract,但让每个边界拥有自己独特负责的 mechanics。第 16 章转向这份 contract 最可见的本地消费者:terminal UI。

Source Map

概念源码锚点
Transport modes 与 connection origintransport/mod.rs
Python SDK public API 与 initialize normalizationapi.py
Python SDK message routing_message_router.py
Python SDK run-result projection_run.py
TypeScript SDK public APIcodex.ts
TypeScript event stream 与 run() collectionthread.ts
TypeScript process wrapperexec.ts
Daemon lifecycle commands、probe、bootstrap 与 operation lockapp-server-daemon/src/lib.rs
Daemon control-socket probeclient.rs
PID backend reservation、stale cleanup 与 process startbackend/pid.rs
Remote-control start handleremote_control/mod.rs
Remote-control envelope、chunks、ack cursor 与 URL normalizationprotocol.rs
Remote-control client tracking 与 connection originclient_tracker.rs
Remote-control websocket buffering、reconnect writer 与 ack handlingwebsocket.rs
Remote-control segment reassembly 与 drop rulessegment.rs