新增一个事件:从 core 到 app-server 的完整影响面
以 GuardianWarning 为真实案例,追踪 core emitter、Session 事件队列、rollout policy、app-server 投影、wire schema、thread 路由与 TUI 消费。
上一章的工具输出有一个明确去向:带着原 call_id 回到下一次模型请求。客户端是否单独显示它,是另一份合同。
GuardianWarning 正好从反方向把问题暴露出来。这个 variant 不只服务熔断:普通 Guardian allow/deny 结果和 timeout 路径也会发出它。本章选择连续拒绝触发 circuit breaker 的发射点,是因为这条路径同时包含一条用户可见 warning 和明确的 turn interruption,适合把生产者到消费者的边界追完整;它不代表 GuardianWarning 的全部业务语义。
core 能发出这条消息,只说明事件进入了内部队列。app-server 客户端要收到它,还需要另一种 payload、稳定的 wire method、thread 路由和消费逻辑。
这个事件最初出现在 ed4def8286b6 的 guardian short-circuit 改动中。那次提交改了 28 个文件,业务熔断、core protocol、app-server schema、TUI 和测试一起变化。这份跨度刚好可以检查一次事件扩展会漏掉什么。
可复现实验:四条测试,各自只覆盖一段
我在固定 tag 的 detached checkout 中依次运行:
just test -p codex-core \
guardian_auto_review_interrupts_after_three_consecutive_denials
just test -p codex-app-server \
verify_guardian_warning_notification_serialization
just test -p codex-tui \
guardian_warning_notifications_route_to_threads
just test -p codex-tui \
live_app_server_guardian_warning_notification_renders_message
四条命令各选中一条测试,结果合计 4 passed, 0 failed。
core 测试连续记录三次 denial,等待 turn 被 Interrupted。app-server 测试直接构造 notification,锁住 JSON 中的 method、threadId 和 message。两条 TUI 测试分别检查 thread target 与最终 warning cell。
这组测试刻意不能合并成一句“端到端已覆盖”。第一条没有启动 app-server listener,后三条也没有触发 core guardian。固定 tag 中缺少一条从真实 emitter 一直走到 wire notification 的集成测试;本章的改造练习就补这段空白。
先把两种形状分开
core 的 payload 是 EventMsg variant。因为枚举使用 #[serde(tag = "type", rename_all = "snake_case")],它的 JSON 形状是:
{
"type": "guardian_warning",
"message": "Automatic approval review rejected too many approval requests..."
}
真正进入 core event queue 时,外面还有一层 Event:
Event {
id: turn_context.sub_id,
msg: EventMsg::GuardianWarning(...)
}
app-server 发给客户端的不是这份 JSON。对外形状是:
{
"method": "guardianWarning",
"params": {
"threadId": "019f...",
"message": "Automatic approval review rejected too many approval requests..."
}
}
这里至少发生了三次变换:snake_case variant 变成 camelCase method;core Event.id 没有成为 wire 字段;app-server 用当前 conversation_id 补上了必填 threadId。所以不能把 app-server notification 说成 EventMsg 直接 serde 后的结果。
Emitter 先决定何时值得打断
以 circuit breaker 路径为例,guardian 每次 denial 都先更新计数。只有返回 InterruptTurn,并且目标 turn 仍有 context,才发送 GuardianWarning。发完 warning 后,代码另起 task 调 abort_turn_if_active(..., Interrupted)。
这个顺序保留了一个重要观察窗口:消费者有机会先看到中断原因,再收到 turn aborted。它也不是强事务。warning channel 关闭、客户端断开或 abort task 调度变化时,不能承诺所有消费者总以完全相同顺序显示两条消息。
core 测试只等待 TurnAborted,沿途把其他 event 收集进 observed 方便超时报错。它证明三次连续拒绝会打断 turn,却没有显式断言 warning 的 message、次数或先后顺序。
Session::send_event 负责统一包裹,不负责对外协议
emitter 把 WarningEvent 交给 Session::send_event。这个边界会:
- 使用当前 turn 的
sub_id构造 coreEvent.id; - 让 rollout thread trace 观察事件;
- 把
EventMsg交给 rollout persistence path; - 把完整
Event发送到 session event queue; - 再处理 legacy、父子 thread 和 realtime 等通用分支。
“交给 persistence path”不等于一定写进 JSONL。send_event_raw_with_persistence 先包装成 RolloutItem::EventMsg,local thread store 随后应用 rollout policy。GuardianWarning 被明确归入 transient、non-durable,最终不会成为可恢复历史。
这一处决定了 resume 边界:同一进程里的客户端可以实时看到 warning;进程退出后,cold resume 不会从 rollout 重放它。TUI 的 thread-switch replay filter 处理的是当前进程里已经缓冲的 notice,也不是《Resume 与 Fork》里的 rollout reconstruction。
App-server 做的是作用域投影
app-server 为每条 live conversation 运行 listener。它从 conversation.next_event() 读取 core Event,更新 thread-local tracking,再创建绑定 conversation_id 和订阅连接集合的 sender,最后调用 apply_bespoke_event_handling。
GuardianWarning 走 bespoke 分支。mapping 取出 core message,用 listener 已知的 conversation_id 构造 GuardianWarningNotification { thread_id, message },再发送 ServerNotification::GuardianWarning。
flowchart TD
accTitle: GuardianWarning 从 core emitter 到 TUI warning cell
accDescr: guardian circuit breaker 生成 core EventMsg,Session 补 turn correlation id 并经过 transient rollout policy;app-server listener 用 conversation id 投影成 thread-scoped notification,协议层序列化 method/params,TUI 再按 thread 路由并渲染 warning。
A["guardian circuit breaker"] --> B["EventMsg::GuardianWarning\n{ message }"]
B --> C["Session::send_event\nEvent { id, msg }"]
C --> D["rollout policy\ntransient,不持久化"]
C --> E["session event queue"]
E --> F["app-server thread listener"]
F --> G["bespoke projection\n{ threadId, message }"]
G --> H["guardianWarning\nmethod + params"]
H --> I["TUI thread target"]
I --> J["on_warning -> history cell"]
这个 mapping 也说明 core Event.id 与 app-server threadId 不是同一个维度。前者关联 turn/submission,后者选择客户端上的 thread。当前 notification 没有 turnId;消费者只能把它路由到 thread,不能据此精确挂到某个 durable turn item。
Wire 名称、schema 与序列化测试是一组合同
app-server-protocol 用宏生成 ServerNotification。枚举整体使用 method 作为 tag、params 作为 content,GuardianWarning 又显式注册为 "guardianWarning"。payload 自己用 camelCase,因此 Rust 的 thread_id 最终成为 threadId。
这次 variant 没有 #[experimental(...)],也没有采用现在常见的 thread/... 命名域。它是固定 tag 中已经存在的 wire 事实,不是新增事件应该机械复制的命名建议。新事件要先决定稳定性、作用域和命名,再生成 TypeScript/JSON schema;不能手改 generated 文件假装协议已经更新。
TUI 消费前还要先找到目标 thread
TUI 收到 ServerNotification 后,不会直接交给当前可见的 chat widget。server_notification_thread_target 先从不同 payload 提取 thread id。GuardianWarning.thread_id 是必填值,因此它只能成为 thread-scoped 或 invalid-thread-id,不会像普通 Warning { thread_id: None } 那样落到 global。
当用户切换 thread,TUI 会处理该 thread 的内存缓冲事件。event_is_notice 把 GuardianWarning 和其他 warning 一样视为 notice,避免它被当作普通 turn lifecycle 事件过滤掉。再次强调:这是 live TUI buffer 的 replay 分类,不会让 transient core event 进入 rollout。
最后 ChatWidget::handle_server_notification 把 message 交给 on_warning,插入 warning history cell。这个消费者没有使用 guardian 专属视觉类型;专属之处在 wire method 与路由,最终渲染复用普通 warning。
如果它是用户工件,就不能照抄这条链
GuardianWarning 适合 notification-only,是因为它说明一次实时 guardian 决策,且 turn 随后会有自己的 aborted 状态。丢掉 warning 后,durable thread 的主要事实仍可由 turn 状态解释。
如果新增的是用户以后需要查阅、恢复、fork 或审计的产物,例如一份计划、文件修改、命令执行结果,就不应只加 transient EventMsg 和 notification。core 侧通常先定义带稳定 id 的 TurnItem,再用 ItemStarted / ItemCompleted 表达生命周期;app-server 才把 core TurnItem 转成 wire ThreadItem。在 paginated rollout 中,durable checkpoint 是 ItemCompleted,ItemStarted 本身仍是 transient。不能把两层 item 类型或 started/completed 的持久性合在一句话里。
判断标准不是“界面要不要弹一下”,而是进程重启后这件事是否仍属于 thread 的事实。属于,就要设计 durable item;只服务当前连接的观察,就可以考虑 notification。
源码依据
本章跟踪的是固定 tag 中的 guardian circuit-breaker 路径与 app-server/TUI 消费面。祖先提交同时包含较大的 guardian 业务改动,不能据此推断任何新事件都需要修改相同数量的文件。
四条实验测试分别证明 core interrupt、wire serialization、thread routing 和 TUI rendering。它们没有证明 emitter 到 wire 的完整链,也没有证明 codex exec、MCP server 或第三方客户端会显示同一 warning。含 wildcard 的消费者可能静默忽略新 variant;做完整影响面搜索仍然必要。
失败边界
- 只加
EventMsgvariant:core emitter 能编译,app-server bespoke match 可能忽略或无法投影。 - core 与 wire 共用一个名字:snake_case type、camelCase method 和命名域变化会让客户端合同漂移。
- 忘记 thread scope:多 thread 客户端可能把 warning 显示到当前可见但错误的会话。
- 只改 Rust protocol:生成的 TypeScript/JSON schema fixture 会过期,外部客户端仍按旧 union 编译。
- 把 transient warning 当 durable:running UI 看得到,cold resume 与 fork 后消失。
- 把 TUI replay filter 当 rollout replay:它只处理当前进程缓冲,不提供重启恢复。
- 只测试 JSON:能证明序列化,不能证明 core emitter、listener 和订阅连接真的到达该分支。
EventMsg新增 variant:外部 exhaustive Rust match 可能编译失败,内部 wildcard match 又可能静默丢弃。
动手改一个地方
补一条 GuardianWarning 的 core-emitter-to-wire integration test,不再发明新的教学事件。
现有 record_guardian_denial_for_test 是 core 内部的 #[cfg(test)] pub(crate),app-server 集成测试不能调用;现有 TestAppServer 又启动独立子进程,测试进程里的 helper 也碰不到子进程的 ThreadManager。
先增加一个专用于这类跨层测试的 in-process app-server harness。它在同一进程中持有真实 ThreadManager、thread listener 与 outgoing channel,再通过 codex_core::test_support 提交一个受控 SessionTask,在 active turn 中连续记录三次 denial。测试最终对 outgoing message 做与 JSON-RPC 相同的序列化断言。不要给生产 JSON-RPC 增加测试 method,也不要把注入开关放进用户配置。
有了这个注入点,测试再启动 app-server 的真实 thread listener,从客户端连接等待 notification。断言至少包括:
- 只收到一条
method = "guardianWarning"; params.threadId等于启动的 thread,且不等于 coreEvent.id;- message 包含连续拒绝计数与中断原因;
- 随后同一 thread 收到 interrupted completion/aborted 状态;
- shutdown 后直接检查 JSONL,确认没有持久化
GuardianWarning; - 重启 app-server 并发送
thread/resume,收集从请求发出到匹配 response 为止的全部消息,确认没有guardianWarning,且 response 中的 thread items 没有对应工件; - 再提交一个 mock follow-up turn 并等到确定性的
turn/completed,期间仍不得出现旧 warning。
这条测试会同时穿过 emitter、Session::send_event、persistence policy、event queue、listener、bespoke projection 和 wire serialization。TUI 渲染仍保留为独立消费者测试,避免把 UI 依赖塞进 app-server 集成层。
这一章建立了什么
新增事件需要同时回答四个问题:core 何时发、app-server 怎样投影、客户端把它路由到哪里、重启后还应不应该存在。GuardianWarning 的答案是 thread-scoped、实时可见、transient、复用 warning 渲染。
最后一章转向持续生效的安全合同。审批策略会被配置解析、协议投影、运行时分支和恢复路径共同依赖:改审批策略之前,先把影响面追完。