额度恢复之后:用 app-server 协议把 Codex 的受限线程接回来
不重启会话、不猜 --last:看门狗怎样读 Goal 状态、判断配额恢复、处理 active writer 和卡住的 turn,以及在不确定时停手。
这篇对应实际项目:
https://github.com/UcnacDx2/codex-quota-resumer
长任务跑到一半撞上额度上限,是最难受的一种中断:上下文还在,Goal 还在,但线程被标成 usageLimited,不会自己恢复。手动重开一个会话,等于把前面所有铺垫丢掉。
我做了一个只走 Codex app-server 协议的小看门狗:扫出处于配额限制的线程,等账号额度真的恢复,再用原线程继续。
先划三条不做的事
这类工具很容易滑向“模拟用户操作”,所以我先把边界写死:
- 不调用
--last。它恢复的是“最近一个会话”,不是“被限制的那个 Goal”。 - 不兑换 reset credit,不改审批或沙箱策略。
- 不依赖插件 hook,也不去点 UI。
它只做一件事:通过官方 app-server 协议读取状态、恢复线程、启动 turn。
app-server 是什么形态
它不是 HTTP,而是 codex app-server 进程上的 JSONL(每行一个 JSON-RPC 消息):
watchdog ──stdin──▶ codex app-server
◀─stdout─ responses + notifications
所以客户端至少要做三件事:
- 给每个请求编号,按
id把响应分发给对应的等待者; - 单独消费没有
id的 notification(turn/completed、thread/goal/updated等); - app-server 退出时,把所有挂起的请求立刻失败掉,而不是等超时。
cqr.py 里的 AppServer 就是这么拆的:两个读线程分别读 stdout/stderr,call() 用 queue.Queue 等响应,notification 走另一条队列。
self._seq += 1
request_id = self._seq
self._pending[request_id] = response_queue
...
target = self._pending.get(message.get("id"))
(target.put(message) if target is not None else self.notifications.put(message))
install.ps1 在 Windows 上装的是同逻辑的 Node 实现 cqr.mjs,Python 那份更适合当参考实现读。
状态机只有四步
持久化文件 state.json 里,每个待恢复线程是一条记录,stage 只有四种:
queued ── 扫描到,等额度
starting ── 正在 resume + 起 turn
running ── turn 已启动,等 turn/completed
completed ── 从 blocked 里移除
失败不会删除记录,只会退回 queued 并写下 lastError。这样重启看门狗之后,之前没处理完的线程还在。
判断一:什么叫“额度恢复了”
不能看“上次失败了,这次应该好了”。我的判断是三条同时成立:
recovered = (
bool(windows)
and not rate_limits.get("rateLimitReachedType")
and len(numeric) == len(windows)
and all(value < 100 for value in numeric)
)
其中 windows 来自 account/rateLimits/read 的 primary / secondary,兼容几种返回形态。少一个窗口、字段类型不对、或者还有 rateLimitReachedType,都算没恢复。
宁可多等一个周期,也不要在半恢复状态下把线程再撞一次。
判断二:usageLimited → active 不是取消
这是整个项目里最容易写错的一处。
Codex 在配额重置后,会把 Goal 状态从 usageLimited 改回 active,但那不等于用户取消了任务,也不等于已经恢复执行。它只是状态回到可用。
所以恢复逻辑里单独处理了这种情况:
auto_reactivated = status == "active"
if not is_usage_status(goal) and not auto_reactivated \
and stage not in {"starting", "running"} \
and not allowed_active_recovery:
cancel()
反过来说,如果线程已经被手动取消、Goal 变成终态,就把它从队列里删掉,不再尝试。
判断三:Desktop 可能已经接手了
如果 Codex Desktop 自己恢复了线程,看门狗再调 thread/resume 会拿到 “active writer” 之类的错误。
这时候不能重试到死,也不该抢回控制权。我的做法是去看 rollout 文件:
~/.codex/sessions/**/*<threadId>*.jsonl
~/.codex/archived_sessions/**/*<threadId>*.jsonl
如果最近 10 分钟内这个文件有新写入,说明已经有人在跑,直接判定为“外部已恢复”,把记录清掉:
if activity and activity[1] > observed_at and now_ms - activity[1] < 10 * 60_000:
add_history(data, "external_recovery_observed", thread_id)
data["blocked"].pop(thread_id, None)
return False
这不是猜,是拿文件时间戳当证据。
判断四:上一轮 turn 是不是卡住了
有些线程在中断时留下一个 inProgress 的 turn。直接 turn/start 会和它打架,所以恢复前先 thread/read:
- 没有活动 turn,且线程不是 active:可以恢复;
- 活动 turn 的
startedAt晚于我们第一次发现它的时间:说明线程还在动,跳过; - 活动 turn 早于发现时间:判断为陈旧,
turn/interrupt之后再启动。
时间戳要兼容秒、毫秒和 ISO 字符串三种形态,这段代码比看起来长。
恢复本身只调四个方法
thread/goal/get 确认 Goal 还在、状态是什么
thread/read 看 turn 列表
thread/resume 绑定到原线程
turn/start 用固定 prompt 继续
如果 Goal 还是 usageLimited,会先 thread/goal/set 成 active,并记录 watchdogActivated,方便之后区分“我改过”和“系统自己变的”。
启动 turn 时带上原线程的 cwd,避免恢复出来的任务在错误目录里继续。
提示词是固定的:
额度已经恢复。继续此前因额度限制中断的同一任务,从最后一个安全点开始;先核对现状,不要重复已经完成的操作。
结果由 notification 决定,不由 HTTP 决定
turn 启动成功只代表“开始了”。真正的结局在 turn/completed:
- error 里是 usage limit:退回
queued,等下一轮; status == completed且没有 error:删除记录;- 其他:记
lastError,同样退回queued。
这样一次失败不会把线程丢掉,也不会无限重试。
单实例和失败关闭
Windows 和 Unix 的锁写法不同,但目的相同:只允许一个看门狗。
if os.name == "nt":
msvcrt.locking(self.stream.fileno(), msvcrt.LK_NBLCK, 1)
else:
fcntl.flock(self.stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
拿不到锁就退出,不去写 pid 文件,也不碰状态文件。
计划任务本身也配了 MultipleInstances IgnoreNew、崩溃后每分钟重启、电池上照常运行。登录时启动,是因为它需要当前用户的 Codex 登录态。
这个项目真正解决的是什么
不是“自动点继续”,而是把一次不确定的中断变成一条有证据的状态迁移:
扫到 usageLimited
→ 记到磁盘
→ 等所有额度窗口都低于 100%
→ 确认没有别的 writer
→ 清掉陈旧 turn
→ resume + turn/start
→ 用 turn/completed 收尾
每一步都能在 watchdog.log.jsonl 里查到,失败也停在安全的一侧。协议一旦变动,它会报错退出,而不是替你做一个看起来很聪明的决定。