App-server 如何把 Core 变成一套可消费协议
沿固定版本的 typed JSON-RPC、请求串行化、thread 与 turn processor、每 Thread listener 和 bespoke event projection,追踪 Core 的 Op/Event 怎样变成客户端可以稳定消费的响应、通知与回调请求。
第 29 章把 Goal 放在 Turn 之上,但 Core 内部对象仍然不是客户端协议。CodexThread 接收 Op、产生 Event;桌面端、TUI 或其他嵌入方需要的是有 method、request id、threadId、响应类型和通知顺序的边界。中间这层就是 app-server。
它做的工作比“给 Core 套一层 JSON-RPC”多:连接要先初始化,请求要按资源冲突关系排序,新建 Thread 后要绑定 listener,Core event 要投影成稳定 notification,需要客户端回答的动作还要保留 callback。与此同时,它又没有接管 Agent loop、rollout 或 SQLite。理解这条边界,关键不是背 method 列表,而是顺着一条请求和一条事件反向走完所有 owner。
flowchart LR
accTitle: App-server 的双向协议链
accDescr: 客户端请求经过初始化门禁和按资源串行化后进入 request processor,再转换为 CodexThread 的 Core 操作;普通 Core 事件由每 Thread listener 消费并投影。Goal extension event 走独立 extension bridge 和 global broadcast,不经过普通 projector。需要客户端回答的事件还会建立反向 server request callback。
CLIENT["client"] --> RPC["typed JSON-RPC"]
RPC --> INIT["connection initialize gate"]
INIT --> QUEUE["serialization scope queue"]
QUEUE --> PROCESSOR["thread / turn processor"]
PROCESSOR --> THREAD["CodexThread"]
THREAD --> LOOP["Core Agent loop"]
LOOP --> EVENT["Event / EventMsg"]
EVENT --> LISTENER["per-thread listener"]
LISTENER --> STATE["thread-local projection state"]
STATE --> PROJECTOR["bespoke event handling"]
PROJECTOR -->|"thread-scoped notification"| NOTICE["typed ServerNotification"]
NOTICE --> SUBSCRIBERS["subscriber snapshot"]
GOAL["Goal ExtensionEventSink"] --> EXTENSION["extension listener bridge"]
EXTENSION -->|"ThreadGoalUpdated"| GLOBAL["global broadcast"]
GLOBAL --> CONNECTIONS["all connections"]
PROJECTOR --> REQUEST["ServerRequest + callback"]
REQUEST --> CLIENT
协议先固定连接状态,再谈业务 method
JSON 和 typed embedder 共用同一个语义入口
协议 crate 用宏生成 ClientRequest:每个 variant 同时声明 wire method、params、response 与 serialization scope。ServerNotification 也由统一定义生成 serde/TypeScript/schema 投影。这里的稳定边界是 typed enum,不是 match 里临时拼出的 JSON 字符串。
外部 transport 进入 process_request 后先把 JSON 反序列化成 ClientRequest;进程内 embedder 可以直接调用 process_client_request,绕过 JSON 反序列化。两条路径最终都进入 handle_client_request,因此“typed 调用”不等于另一套业务语义。
本节源码依据(3 处)
initialize 是一次性的 connection capability contract
ConnectionSessionState 在连接建立时只有一个空的 OnceLock。初始化后才固定 experimental API 开关、客户端名称和版本、notification opt-out、attestation 需求,以及是否支持 OpenAI form elicitation。普通 request 在这个状态出现前直接返回 Not initialized;带 experimental 标记的 method 还要再过一次 capability gate。
因此 capability 不是每个 request 随手传入的参数。它属于 connection,并参与后续 dispatch、通知选择和 client metadata。重复初始化也不能覆盖已生效的 session state。
本节源码依据(2 处)
Request serialization 是资源顺序,不是全局锁
ClientRequestSerializationScope 描述“哪些 request 不能互相穿越”。它可以是 global key、thread id、thread path、process handle、command process、fuzzy-search session、watch id 或 MCP OAuth server。None 的 request 可以直接 spawn;有 scope 的 request 先被转换为 queue key。
这里有两个容易误读的细节:
- 同一个 Thread 的修改请求进入同一个 exclusive FIFO;不同 Thread 可以并发;
GlobalSharedRead与对应 global key 共用队列,但队头连续的 shared read 会一起执行,遇到 exclusive request 才形成屏障。
部分 process key 还带 connection_id,说明它们只要求同一连接内的资源顺序;thread key 则不带连接 id,同一 app-server 内多个连接对同一 Thread 的操作仍受同一队列约束。队列负责完成顺序,不负责事务回滚,也不证明 Core 内部只有一个 task。
本节源码依据(3 处)
thread/start 先造 Core Thread,再建立协议可见性
processor 负责把外部配置收成启动参数
MessageProcessor 对 ClientRequest::ThreadStart 只做 dispatch,具体工作落在 ThreadProcessor。它先拒绝同时传入 sandbox 与 permissions,解析 environment selections 和 workspace roots,把 model、cwd、approval、sandbox、instructions、personality 等字段收进 typesafe overrides,然后把真正启动过程交给受管理的 background task。
background task 很重要,但不要把它读成“客户端先收到一个调度成功响应”。thread_start_inner 只把启动任务注册进 tracker 并返回 Ok(());真正的 ThreadStartResponse 要等 thread_start_task 创建 Core Thread、拿到快照后,再用原 request_id 发送。启动失败也沿同一个 request_id 发 error。这样做只是把长启动过程移出 dispatch future,Thread 仍然在协议生命周期内完成或失败。
本节源码依据(3 处)
ThreadManager 返回的才是 Core identity
thread_start_task 最终调用 ThreadManager::start_thread_with_options。Core 返回 NewThread { thread_id, thread, session_configured } 后,app-server 才能读取 config snapshot、构造协议层 Thread 摘要并确定真实 threadId。app-server 没有提前生成一个“前端 thread id”再让 Core 配合。
接下来的顺序是:
- 为新
CodexThread附着 connection 对应的 listener; - 把 Thread 摘要放进 watch manager,并解析初始状态;
- 发送
ThreadStartResponse; - 再广播
thread/startednotification。
响应和通知都携带 Thread,但用途不同。response 完成这次 RPC;thread/started 让其他观察者看见新 Thread。仅收到 response 不等于之后的事件一定会送到任意连接,事件 fan-out 仍取决于 subscription。
本节源码依据(2 处)
turn/start 把协议输入收回一个 Core Op
TurnProcessor 先按 thread_id 找到已加载的 CodexThread,检查该 Thread 是否允许 direct input 和输入数量上限,再处理本轮 environment、cwd、approval、model、reasoning effort 等 override。协议里的 v2::UserInput 在这里映射为 CoreInputItem。
最终提交给 Core 的对象只有一个 Op::UserInput,里面包含 input items、output schema、client metadata、additional context 和 thread settings。Core 返回的 submission id 被当作 turn_id,app-server 立刻响应一个 InProgress、items 尚未加载的 Turn。真实 item、delta 和完成状态随后通过事件链到达;response 不是完整 Turn 的快照。
这条路径也解释了 owner 边界:app-server 可以拒绝协议形状、选择 Thread、记录 request 到 turn 的映射;Agent loop 如何消费 Op::UserInput,仍然属于 Core。
本节源码依据(2 处)
每个 Thread 有一个 listener,projection 先于发送
listener 先维护局部状态,再翻译事件
listener 循环在三类输入之间选择:取消信号、listener command、conversation.next_event()。收到 Core event 后,它先调用 ThreadState::track_current_turn_event;raw response item 还要检查 connection 是否 opt in。之后才读取当前 Thread 的订阅 connection 集合,构造 ThreadScopedOutgoingMessageSender,并进入 apply_bespoke_event_handling。
这个顺序不能反过来。Turn 当前状态、重复 command start 抑制、pending interrupt 与 callback 清理都依赖 app-server 的局部 projection state。若先发送再更新,后续通知可能从旧状态构造。
本节源码依据(2 处)
bespoke projection 是兼容层,不是机械 rename
apply_bespoke_event_handling 对 EventMsg 做语义投影。TurnStarted 会构造协议 Turn;TurnComplete 和 TurnAborted 会先取消该 Thread 的 pending server requests,再解析最终状态;Warning、GuardianWarning、MCP startup 等事件会补上 thread_id 并映射到各自 notification。
item lifecycle 更能说明它不是机械序列化:command start 可能因为 approval/Guardian 路径已经发过而被去重;DynamicToolCall item start 除了发 item/started,还会建立一个 server request,等待客户端返回工具结果。相反,旧的 collab begin/end、exec begin/end 和 MCP begin/end 被明确忽略,因为 v2 以 canonical TurnItem lifecycle 为准。Core 继续发出某个 event,不代表它自动成为 v2 wire contract。
本节源码依据(4 处)
thread-scoped 与 broadcast 是两种投递语义
ThreadScopedOutgoingMessageSender 保存的是 listener 当次读取到的 subscriber snapshot。它的普通 notification 只投给这些 connection;列表为空时直接停止。底层 OutgoingMessageSender 的接口则规定:target list 为空意味着 broadcast。scoped wrapper 特意在调用底层前拦住空列表,避免“没有 subscriber”意外升级成“发给所有人”。
这里有一个代码写明的例外:EventMsg::ThreadGoalUpdated 进入 bespoke handler 后,不调用 scoped 的 send_server_notification,而是调用 send_global_server_notification。后者绕过 subscriber snapshot,委托底层 sender 做 global broadcast。payload 仍携带 thread_id,但传输目标是所有连接。
因此看见 notification payload 里有 threadId,还不能证明路由正确。真正的路由由 sender envelope 和 connection subscription 决定;payload identity 让接收端验证与二次路由,但不能代替传输目标。
本节源码依据(3 处)
Server request 把协议方向临时反过来
审批、用户输入、elicitation 和 dynamic tool call 不能只发 notification,因为 Core 需要一个结果。app-server 为此生成 server-side request id,把 oneshot::Sender、可重放的 ServerRequest 和可选 thread_id 放进 PendingCallbackEntry,再向目标 connection 发送 request。调用方持有 receiver,客户端 response 到达后由 request id 解开等待。
callback 还有明确生命周期:
- 新 connection resume 到仍在运行的 Thread 时,只重放该 Thread 尚未解决的 requests;
- Turn complete、Turn aborted 或 Thread teardown 会取消该 Thread 的 callbacks;
- 发送失败会立即从 callback map 移除 entry,避免永远等待一个从未到达客户端的请求。
这是一段临时协议状态,不是 durable task queue。进程退出后,仅凭 rollout 不能自动恢复原有 oneshot channel。
本节源码依据(3 处)
Running-thread resume 同时恢复可见快照与临时交互
这里专指第 27 章分流矩阵里的 running rejoin,不是从 rollout 重建的 cold resume。对仍在内存中的 Thread,resume handler 会读取 active turn 与 history,按 include_turns 构造 response,并在确认 Thread 没有进入 unload 后把新 connection 加入 subscription。发送 response 之后,它可以补发 token usage、Goal snapshot,随后重放 pending server requests;只有这些步骤完成,才允许 idle lifecycle 继续触发 extension。
这个顺序说明 app-server 确实拥有“客户端重新加入时先看到什么”。但它消费的 durable history 仍来自 Core/thread store;它自己保存的是连接订阅、局部投影状态和 callback map。把 resume response 组装完整,不等于 app-server 成了会话持久化 owner。
| 对象 | 主要 owner | app-server 做什么 | app-server 不保证什么 |
|---|---|---|---|
| Agent loop | Core CodexThread | 提交 Op,读取 Event | sampling、工具调度与 loop 正确性 |
| durable history | rollout / thread store | resume 时读取并投影为 protocol Thread | 自己持久保存每个 history item |
| connection | ConnectionSessionState | 固定 capability、opt-out 与 client metadata | 跨进程保留连接状态 |
| request ordering | RequestSerializationQueues | 按 scope 排队并维护 barrier | 跨资源事务或 Core 内部串行 |
| UI event contract | app-server projector | 把 Core event 变成 typed notification | 客户端最终怎样渲染 |
| pending interaction | OutgoingMessageSender callback map | 发 server request、等待、重放或取消 | crash 后恢复原 oneshot callback |
本节源码依据(2 处)
实验:只证明一条 thread/start 闭环
在固定源码 rust-v0.144.6、commit 5d1fbf26c43abc65a203928b2e31561cb039e06d 上运行:
: "${ARCHIVE_CODEX_RS:?先执行第五部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
just test --locked -p codex-app-server thread_start_creates_thread_and_emits_started
本次结果是 1 test run: 1 passed; 946 skipped。这个 fixture 通过真实 app-server 测试 harness 发 thread/start,观察创建出的 Thread、RPC response 与 thread/started notification,能够证明本章“processor -> Core Thread -> response -> started notification”这条命名路径在固定版本成立。
它不能证明 stdio、WebSocket 和进程内 transport 在所有断线条件下等价,也没有覆盖全部 ServerNotification、subscription 竞争或 callback replay。若过滤结果显示 running 0 tests,不能把退出码 0 记作通过。
本节源码依据(1 处)
把协议交给 TUI 时,只交一条 thread-scoped contract
本章最后可以固定一条源码合同:GuardianWarning { thread_id: T, message } 会被投影为 typed notification,并按当次 subscriber snapshot 投递。这里没有一条真实事件继续穿过 TUI;客户端仍要回答三个问题:它会把通知路由到哪个 Thread、当前不在前台时保存在哪里、切回来后怎样重放而不制造重复副作用。
这些问题属于第 31 章:TUI 事件投影。进入下一章时保留这条证据边界:app-server 源码存在正确 projection,只证明这条协议路径可用;本次 thread/start 实验没有触发 GuardianWarning。界面是否稳定,还要分别检查 TUI 的路由、缓冲、replay marker 和渲染 handler。