从消息通知到继续会话:Push9 的 Codex Runner 实现
跑一个耗时的代码任务时,真正影响使用体验的,往往是完成之后的那一步:人已经离开电脑,结果还停在终端里;手机收到通知,又得回到原设备才能补充要求。
我在 Push9 中做的 Codex Runner,围绕这两个间隙设计:网页下发任务,本机运行 Codex CLI,完成后回传摘要并发送消息;打开消息详情,可以继续同一项目里的原会话。本文只讨论这个功能的实现,不展开介绍其他消息渠道。
以下依据项目中 Runner 0.6.0 的 Go 客户端、Java 后端及接入文档整理。界面图片来自项目已有的隔离演示环境,结果为演示数据;本文没有另外执行真实模型任务,也不将演示截图当作效果测试。
1. 把任务执行与结果通知分开

图 1:按当前实现整理的架构示意。任务通过 Runner 协议领取,Topic 只负责可选的结果通知。
这个闭环有四个角色:网页负责选择设备、项目和提交自然语言指令;后端保存任务、校验归属并管理领取状态;Runner 在本机调用执行器;消息系统投递执行后的摘要。
这里有一个容易混淆的边界:发一条普通推送,不等于创建一个 Codex 任务。 普通推送 API Token 不具有 Runner 执行权限,任务也不依赖 Topic。未配置通知 Topic,仍然可以在网页下发任务并查看结果。
接入消息的目的,是把“结果到了”和“还要做什么”串起来,而不是让每一条通知都变成远程命令。
2. Runner 主动出站,项目路径留在本机
Runner 是独立的 Go 可执行程序,主动访问服务端领取任务,因此这条任务链不要求开发电脑开放公网入站端口。执行 Codex 任务时,仍需在本机另外安装 Codex CLI 并完成登录;Runner 不内置模型。
首次使用分成三步:
- 在网页创建 Runner 设备,获取一次性绑定码。当前有效期为 10 分钟,只能使用一次。
- 在本机运行客户端,输入绑定码,登记允许操作的项目目录。
- 保持 Runner 运行,回网页选择设备和项目,提交任务。

图 2:项目已有的客户端向导展示,路径与绑定码是示例值,图中的 CLI 版本只代表该演示素材,不代表当前推荐版本。
命令行也可以显式完成配置。以 Windows 为例:
.\push9-runner.exe setup --server https://push9.top `
--project "D:\work\example-project" --sandbox read-only
这条命令用于配置,不应理解为已经完成绑定或执行任务;继续按客户端提示输入网页生成的绑定码。第一次验证建议使用只读项目,再发送“介绍目录结构,不修改文件”这样的任务。
项目的实际路径与沙箱策略保存在本地。服务端以项目标识调度,不能在任务正文里指定一个新的任意工作目录。配置目录包含设备凭据,不应放进代码仓库或截图共享。
3. 下发任务先处理幂等,再考虑执行

图 3:隔离演示环境中的任务表单。每台设备同时执行一个任务。
管理侧创建任务使用登录凭据,而不是普通推送 Token。当前请求结构如下,所有 ID 均应来自当前账号的设备与项目列表:
POST /api/runner-tasks
Authorization: Bearer <console-jwt>
Content-Type: application/json
{
"runnerId": "<runner-id>",
"projectId": "<project-id>",
"prompt": "只读取项目并介绍目录结构,不修改文件。",
"requestKey": "a7f18392c6e04b5fa0d297c84e63b159"
}
requestKey 是同一次提交的幂等标识,需要生成 32–128 位、由字母数字下划线或短横线组成的安全随机值。示例仅展示格式,不能在不同任务里复用固定值。
如果网络断开,客户端不知道任务是否创建成功,应保持同一个键和同一份内容重试。服务端返回原任务;真正创建下一条任务时才换新键。仅在前端把按钮变灰,不能解决刷新页面、并发请求或响应丢失带来的重复创建。
Runner 的领取又有另一层幂等:它先把随机 claimId 写进本地执行日志,再请求 /api/runner/v1/claim。领取响应丢失时,继续使用原 claimId,而不是再领一个任务。服务端通过设备锁、任务状态和租约校验,限制同设备并发执行。
4. Codex 是本机子进程,输入通过 stdin 传入
客户端 executor.go 中的基础参数构造可概括为:
args := []string{
"-a", "on-request", "-s", project.Sandbox, "exec"}
if task.SessionID != "" {
args = append(args, "resume", task.SessionID)
}
args = append(args, "--json", "--skip-git-repo-check", "-")
这是该项目当前版本的实现片段,不是适用于所有版本的安装说明。执行器使用原生进程调用,工作目录取自已登记项目,任务文本通过标准输入传入,不把用户指令拼接成 Shell 命令。项目只允许 read-only 和 workspace-write 两种沙箱配置。
--skip-git-repo-check 用于跳过 Git 仓库前置检查,不是关闭沙箱;本机认证、项目规则与审批约束仍然存在。遇到需要确认而当前执行方式无法完成的情况,任务可报告 WAITING_CONFIRMATION,用户需要回到本机检查。
比起抓取整段终端文本,Runner 更关心结构化事件:
| Codex JSONL 事件 | Runner 的处理 |
|---|---|
thread.started |
校验并记录会话 UUID |
item.completed,且类型为 agent_message |
更新最后一条 Agent 回复摘要 |
turn.completed |
记录完成标记 |
turn.failed 或 error |
记录失败,必要时提示本机确认 |
判断结果会结合会话、完成事件和进程状态,而不是见到一行输出就认定成功。回传的摘要最多 4000 字符,当前实现不上传完整 Shell 输出或推理日志。
5. 断线恢复最重要的是“不再执行一次”
写代码、运行脚本都可能已经产生副作用。一次网络重试可以接受,盲目重跑任务则可能重复修改文件。
Runner 在本地保存三个主要阶段:
claim 先记录领取标识,再向服务器领取
executing 记录已开始执行,随后运行 Codex
report 先保存结果,再尝试回传
如果任务执行完成、但结果上传失败,重启后继续回传已保存的结果。如果重启时日志停在 executing,客户端不会假定任务未执行,而是记录中断并上报 INTERRUPTED,要求检查本机进程与文件变化。
这不是对所有外部副作用的“严格恰好一次”保证,而是把网络重试与任务重跑区分开。取消或终止进程也不会撤销已发生的文件修改或外部操作。
6. 通知失败,不能把已完成任务变成失败
服务端保存任务结果后,如果配置了通知 Topic,会把通知状态设为 PENDING。后台 RunnerWorker 使用独立的 outbox 逻辑发送消息,并使用如下键关联同一任务的结果通知:
runner-result:<task-id>
当前消息正文取最后摘要,最多截断至 3600 字符。通知提交失败会按实现中的尝试次数与退避策略重试;消息系统接受通知后记录 ACCEPTED。这只表示通知已进入消息系统,不等于用户手机已收到或已读,还要看渠道投递结果。
因此,应分别观察两种状态:
- 任务
COMPLETED,通知FAILED:任务结果已保存,检查消息投递链路,不要重跑 Codex。 - 任务
FAILED,通知已受理:失败摘要仍可以发送,方便用户及时发现问题。
通知 Topic 可能有多个成员。摘要虽然不是完整日志,仍可能包含业务信息,配置前应确认接收范围。
7. 从通知继续原会话,不能只靠一个链接

图 4:隔离演示环境的结果详情,截图中的“已完成”及摘要均为演示数据,不代表本文新执行了任务。
网页里的“继续会话”会创建后续任务,客户端使用明确的 sessionId 执行 exec resume,不会恢复“最近一个会话”。本地还必须保留原会话文件,并使用对应的系统用户、项目和 Codex 环境。
消息详情也提供下一条指令入口:
POST /api/runner-tasks/from-message/{messageId}
Authorization: Bearer <console-jwt>
X-Message-Key: <message-view-key>
Content-Type: application/json
{
"prompt":"继续检查测试入口,先说明计划,不修改文件。"}
消息查看密钥和账号登录需要同时满足;持有分享链接,或只是 Topic 成员,都不能直接控制设备。设备、项目和会话由原任务确定,不接受请求方随意替换。
当前实现中,每条通知只能提交一次下一条指令。同内容重试返回同一个后续任务,换内容再次提交会产生冲突;后续任务完成后,新通知才提供新的继续入口。身份验证或打开通知本身不会自动提交指令,仍需用户点击发送。
本地已结束的 Codex 会话也可以通过客户端 attach 选择后登记。登记只上传会话标识与标题摘要,不把整段聊天同步到云端,也不能接管桌面里正在运行的会话。
8. 实际排查时,先分清哪一段没有完成
| 现象 | 优先检查 |
|---|---|
| 网页一直排队 | Runner 是否运行、设备是否在线、项目是否登记并启用 |
| 提示需本机确认 | Codex 是否安装登录、执行器路径及审批要求 |
| 任务已完成但没有推送 | 是否选择通知 Topic、通知状态及渠道投递回执 |
| 不能继续会话 | 原 session 文件、系统用户、项目与 CODEX_HOME 是否保持一致 |
| Runner 重启后显示中断 | 检查进程和文件变更,不直接重复发送原任务 |
这套实现目前也有明确边界:Runner 需要持续运行;后台启动不等于开机自启;没有网页远程审批或实时终端;云端会话记录不能替代本机历史。它解决的是“任务下发—本机执行—结果通知—原会话继续”的协作流程,而非把开发电脑完整搬到网页里。
实现参考:Push9 Runner 接入文档 push9.top/docs#runner。源码核对位置为项目内的 runner.go、executor.go、RunnerService.java 与 RunnerWorker.java;本文基于现有实现与文档整理,未提供额外性能测试结论。