← Silent Star wake path traced runtime notes · 2026.08.25

Agent runtime deep dive

Monitor 发出事件以后,谁来开下一回合

上一篇查了后台任务怎样等待。这次继续检查完成事件的去向:它可能只把界面上的 running 改成 completed,也可能进入会话输入,触发新的模型请求。要判断 Agent 是否会自己接着做事,需要把这两段路径连起来看。

Grok Build 1.0.5 · Claude Code 2.1.241 · Codex CLI 0.149.1 · 记录于 2026 年 8 月 25 日

Grok 把完成事件送成一条合成 Prompt。Claude Code 把 Monitor 通知放进 next 队列,空闲时会生成新的模型回合。Codex 也监听进程退出,但这条事件先更新界面,模型仍靠 write_stdin 取结果。

三套实现都有事件,但界面收到事件和模型收到新输入并不相同。下面分别检查进程退出、界面更新和会话续接,再用本机记录核对时序。

01 · Three layers

进程退出、界面更新和模型醒来是三件事

我重新读这几套实现时,先把“唤醒”这个词拆开了。后台进程可以自己感知结束,TUI 可以立即更新命令卡片,主会话也可以再发一次模型请求。它们常常挨着发生,代码里却由不同对象负责。

Process

进程层

watcher 等退出信号,收尾 stdout,保存 exit code。这一步不需要模型。

Surface

界面层

终端或 App Server 收到事件,把 running 改成 completed。用户已经能看见结果。

Model

会话层

运行时构造一条模型可见输入,再调一次 API。Agent 才能继续验产物、改文件或汇报。

这层区分改掉了我最初一个过于粗的说法。Codex 并不缺进程退出事件。它有完整的异步 watcher,也会推送输出增量和完成状态。它缺少的是从普通后台 terminal 的完成事件直达新模型回合的默认路径。

比较时要看完成事件最终进入哪个组件。

进入 TUI 可以及时更新状态;进入模型输入队列,才可能让 Agent 在没有新的人类消息时继续工作。后一条路径还需要取消、权限和重复通知处理。

本次检查的三个安装包

Grok 指向 ~/.grok/downloads/grok-1.0.5-macos-aarch64,文件大小 134,349,648 字节,SHA-256 为 3dfa7f04fbb5427a8fbead286591543aaecb478b3a0ab222c4329eca1a3b2f86。签名方是 X.AI Corporation。

Claude Code 位于 ~/.local/share/claude/versions/2.1.241,文件大小 325,055,632 字节,SHA-256 为 1495eb7c42d3b4451f5f1cd38b6d498d22a4a38c802bc2be5c1cf1795e64820d。签名方是 Anthropic PBC。

Codex 原生程序来自 @openai/codex 0.149.1 的 darwin-arm64 包,文件大小 220,552,944 字节,SHA-256 为 f0d8762236594359b60cfbe17f4c7e945a3ce8d1c91e74778838c968d250fb6c。签名方是 OpenAI OpCo, LLC。源码检查固定在 rust-v0.149.1 对应的提交 ff29a44391deccde0aba0f8390337d7f3c319ea4。

02 · Grok Build

Grok 把 TaskCompleted 改造成一条合成 Prompt

Grok 中,后台 Bash 或 Monitor 自然退出后,shell adapter 产生 ToolNotification::TaskCompleted。notification_bridge.rs 随后检查 block wait 是否接管、任务是否被模型主动停止、goal loop 是否运行,以及 AutoWake 是否开启。

通过这些检查以后,bridge 生成 task-completed-{task_id},再向 session actor 发送 SessionCommand::Prompt。Prompt 的模式是 Agent,内容提醒模型 Monitor 已经结束,并告诉它从哪个任务读取完整输出。

01TaskCompleted后台任务自然退出,快照带着任务类型和退出码进入 bridge
02SessionCommand::Promptbridge 构造一条合成 Prompt,并附上 admission 通道
03admit_task_completion_wakesession actor 再检查取消后的抑制状态和通知状态
04queue_input通过 admission 的 Prompt 进入 pending inputs
05handle_prompt运行时开启合成回合,模型继续处理产物

admission 这一层很要紧。用户刚取消过任务时,新的完成事件可能与取消操作撞在一起。session actor 用 task_wake_suppressed 和 notifications_suppressed 把这类事件挡住。接收失败时,完成消息会退回延后通知,不会悄悄丢掉。

Monitor 中途输出和自然退出走两条路

Monitor 打出一行有意义的 stdout 时,bridge 收到 MonitorEvent,以 NotificationPriority::Next 放进通知队列。Monitor 自然退出时,TaskCompleted 走上面的合成 Prompt 通道。完成事件已经预留任务以后,晚到的 MonitorEvent 会被丢掉,避免同一个终态开两个回合。

公开测试专门覆盖了这件事。退出码为零的 Monitor 会生成含有 [monitor ended: exited (code 0)] 的 Prompt,随后发送 DropMonitorNotifications。模型主动停止 Monitor 时不会再 AutoWake,UI 或 Stop 操作终止 Monitor 时则会提醒模型不要重新启动它。

installed binary
auto-wake: requesting synthetic prompt admission
auto-wake: session actor received synthetic prompt
skipping model inject for monitor event: task already auto-woke

本机安装的 Grok 1.0.5 二进制里能找到上面三段日志文本,对应字节偏移分别落在 107441761、107737945 和 107442351 附近。公开仓库目前没有可解析到这个安装包的 1.0.5 标签,二进制内的构建标识 5115b46bc909 也无法从公开远端直接取回。因此我用安装包字符串确认能力存在,用公开代码解释结构,没有把两者写成同一个提交。

03 · Claude Code

Claude Code 的 next 队列确实会在空闲时开新回合

Claude Code 2.1.241 的 Monitor 实现在发布二进制的 Bun JavaScript 里。命令 stdout 先进入 PBi,由 HSl 切行、合并和限流。xwe 随后构造一条 task-notification,优先级设为 next,交给 enqueuePendingNotification。

bundled js
mode: "task-notification",
priority: "next"

主查询循环在回合开始和工具边界读取 getCommandsByMaxPriority("next")。当前模型请求正在跑时,next 通知不会像 now 那样立即抢断。回合空闲时,队列可以直接生成下一次请求。

静态代码已经说明队列怎样工作,本机 GPU 项目的会话记录又给了两种真实时序。我只抽取时间戳、任务标识、队列动作和消息来源,没有把任务输出或聊天正文带进文章。

Monitor运行结果队列动作模型侧表现
bhceuqzer25 分钟超时入队后 7 毫秒出队,8 毫秒后写成 user 消息约 51 秒后出现首条 assistant 记录
brct5dn0c11 分 31 秒成功两条通知入队,4 毫秒后出队10 毫秒后出现合成 user 消息,约 45 秒后模型输出
btbog7o0q8 分 11 秒成功模型工作期间入队,24.8 秒后在工具边界移除没有抢断当时正在进行的 Edit
bu6hbevli40 分钟超时事件先入队,稍后随当前工作收尾保持 next 的非抢断语义

前两条最能回答最初的问题。队列入队以后,Claude Code 没有等用户再敲一句话。它把通知写成新的 user 消息,随后发起模型请求。几十秒延迟主要发生在模型响应阶段,队列本身只用了几毫秒。

第三条说明了另一面。Monitor 在 03:53:30.677 产生完成事件,当时主会话还在分析图片和改文档。通知留在队列里。03:53:55.082,模型发出 Edit。工具结果回来以后,两条 Monitor 通知才从队列移除。事件驱动没有破坏当前回合的工具顺序。

priority: "next" 表示排到下一个可用边界。

空闲时,它能开启新回合。模型仍在工作时,它先排队。用“等 Monitor 结束才继续”概括 Claude Code,大体方向对了,还要补上这个非抢断条件。

通知在会话文件里是一条合成 user 消息

本机记录里的这几条消息都带着 origin.kind = "task-notification" 和 promptSource = "system",消息角色仍是 user,permissionMode 为 bypassPermissions。这能证明运行时在没有新的人类输入时调用了模型,不能单独证明通知回合可以执行任何动作。

Anthropic 的公开 issue 里已经有人提交过相同形态的记录。一个 issue 展示了后台通知在空闲时触发自主 API 请求,另一个 issue 记录了模型把通知误当成人类回答的情况。这些来源属于用户报告,不能当作 Claude Code 实现文档,却提醒了一个真实的设计问题。自动唤醒省掉人工等待以后,通知来源、权限和待回答问题都要跟着收紧。

04 · Codex

Codex 能把完成状态推到界面,模型仍要自己来取

Codex 0.149.1 的 UnifiedExec 启动后台进程时,会同时启动两个异步任务。start_streaming_output 持续读取 PTY,发送 ExecCommandOutputDelta。spawn_exit_watcher 等取消令牌,也就是进程退出信号,随后等输出排空,聚合 transcript,最后发送一条 ExecCommandEnd。

01spawn_exit_watcher等待进程退出,排空剩余输出
02ExecCommandEnd带着退出码、运行时长和聚合输出发送事件
03ItemCompletedApp Server 把事件映射为命令卡片完成
04write_stdin模型另行发起空查询,拿到工具结果

event_mapping.rs 把 ExecCommandEnd 映射为 ServerNotification::ItemCompleted。这条路径解释了 Codex 界面为什么能在后台命令结束时更新。它绑定的是原始 turn 和 command item,没有向 thread 插入新的用户输入,也没有提交新的 turn。

模型侧的工具合同在另一处。exec_command 超过初始等待时间以后返回 session ID。write_stdin 把空 chars 明确定义为 background poll。空查询至少等 5 秒,默认最多等 300 秒。进程在等待窗口内结束,这次工具调用会拿到 exit code 和剩余输出。

rust
if request.input.is_empty() {
    time_ms.clamp(
        MIN_EMPTY_YIELD_TIME_MS,
        self.max_write_stdin_yield_time_ms,
    )
}

因此,用户能在界面上看到命令结束,并不表示模型已经重新运行。当前回合还在等待时,模型可以继续调用 write_stdin;回合已经结束时,用户后来再输入,也可以让模型回头读取结果。

在 Codex 0.149.1 的这条路径上,界面接收推送,模型通过工具查询续接。

05 · Session records

三条 Codex 会话留下了一百次空查询

源码能说明能力,历史会话能说明 Agent 真会怎么用。我筛了本机三条以 GPU 项目为 cwd 的 Codex 会话,只统计实际调用 tools.write_stdin 且 chars 为空的记录。总数正好是一百次。

100空 write_stdin 查询
63请求等待 60 秒
15请求等待 55 秒
20请求等待 30 秒
2分别等待 5 秒和 10 秒

这组记录没有统一控制命令、并发量和任务时长,其中也包含本文取证过程。它只能证明一个操作习惯。面对长任务,Codex 会反复发起空查询,长挂起把频率压低了,查询本身仍属于模型工具回合。

其中几条 terminal session 很能说明节奏。一个 session 在十分钟左右被空查 11 次,另一个也查了 11 次。每次工具等待约 55 或 60 秒,返回 still running 后,模型再安排下一次。任务没有更快,Agent 只是用更长的阻塞窗口少醒几次。

Claude Code 的四次 Monitor 没有对应的状态查询链。普通 shell 在任务外面安静地等条件,完成后才输出。主会话记录里留下的是 queue-operation 和合成通知。两边的本地进程都可能轮询,进入模型上下文的次数差得很明显。

这组统计怎样避免把字符串搜索算成工具调用

新版 Codex 会把工具编排保存在 custom_tool_call 的 JavaScript 输入里。我只解析实际出现的 tools.write_stdin({...}) 调用对象,再读取 chars 和 yield_time_ms。文章、源码搜索和提示词里出现的 write_stdin 文本没有计入。

九条调用使用未加引号的 JavaScript 对象键,无法直接交给 JSON 解析器。我另外按固定字段提取并人工核对。其中八条是 60 秒空查询,剩下一条发送中断字符。最终的一百次只保留空输入。

06 · Tradeoff

自动唤醒省模型回合,也增加了运行时的责任

让本地 watcher 等待明确条件,可以减少没有新信息的模型调用。收益取决于原来的查询频率和任务时长,需要用请求记录衡量,不能只数后台进程有没有轮询。

事件到达后仍有几种竞争需要处理:模型可能还在执行工具,用户可能刚取消任务,同一终态也可能先后报告两次。日志内容还不能被当作新的用户授权。Grok 的 admission、suppression、reservation,以及 Claude Code 的优先级、队列合并和来源标记,都与这些问题有关。

Codex 的普通 terminal 完成事件在当前 turn 结束后不自行调用模型。这个边界减少了无人输入时启动后续动作的机会,但等待中的模型仍可能花回合查询。是否需要自动续接,应按任务权限和交互要求决定。

设计问题Grok BuildClaude CodeCodex 0.149.1
终态怎样到模型合成 Prompt 进入 session actortask-notification 进入 next 队列模型调用 write_stdin 读取
当前回合忙碌时进入输入队列,并受 admission 控制等下一个工具或回合边界当前工具回合按原计划继续
重复终态reservation 与 DropMonitorNotifications 去重消息队列注册 in-flight 并移除已吸收项进程条目退出后由 refresh state 移除
用户取消以后suppression gate 可以拒绝合成 wake依赖队列状态和任务生命周期处理模型没有默认的终态 AutoWake
主要成本运行时竞争控制更复杂合成 user 回合的来源和权限要可靠长任务会产生重复模型查询

因此“有没有轮询”不够用来评估一款 Agent。更有用的问题是,轮询发生在普通进程还是模型回合,状态变化由谁接收,接收以后能不能安全地开启下一回合。

07 · Reading the result

各自怎样把事件接到下一回合

Grok 的公开代码能从 TaskCompleted 追到 session actor,并有取消、重复事件和回退测试。Claude Code 的本机会话记录显示,通知在空闲时能成为新的模型输入;对应实现需要从发布二进制核对。两者的来源形态不同,不能只因都有自动通知就省略版本对应关系。

Codex 的退出 watcher 已经会发送完成状态;本文检查的默认路径没有把这条状态直接转成新 turn。要补上这段连接,还需要定义输入来源、权限和 thread admission,不能简单地在进程退出时再调一次模型。默认角色表中暂时移除的 awaiter 仍采用重复工具查询,也与事件直接续接不同。

到这里,最初的问题可以拆开回答:后台任务结束,界面可以先更新;主 Agent 是否继续,则取决于运行时有没有提交新的模型输入,以及这次提交是否通过取消与权限检查。

08 · Sources

源码、发布物与公开记录

文章目录8
Silent Star约 11 分钟