为什么我总是收不到 claude code usage reset 的通知消息?
前段时间我写过 claude-code-notify,一个我自己写的小工具,起因是我老在没盯着的任务上白白耗掉时间。它会在一轮 Claude Code 任务跑完、需要我输入、或者报错挂掉的时候,通过 Telegram 提醒我——这样我就能起一个长任务,切去别的窗口,然后彻底不去想它。这条推送不管哪个会话在前台都能到我手上;能在我没盯着终端的时候找到我,才是它全部的意义。
有一种情况漏掉了:用量限制。
撞上账号的用量限制,所有正在跑的会话会同时停下。工具其实是触发了的——错误钩子逮到了这个死掉的回合,发出了它那条笼统的 “stopped with error” 提醒。但这条提醒只告诉你有东西坏了,没告诉你这是限额,也没告诉你什么时候能解除。然后几个小时后限额悄悄重置,这回什么都不触发。所以套路永远是同一个:撞限额,收到一条含糊的报错,把手机放下,忘掉。等我晃回来,限额已经开着不知道多久了——而这段时间,恰恰就是这个工具本该消灭掉的那种死时间。
于是我打算把这个缺口补上,加一条通知:一条限额专属的消息——类似"用量已达上限,5:20am 重置"——再加一条它真正重置时的提醒。
本来应该是一下午的活。结果呢,就为了加这么一条通知,我掉进了一连串悄无声息的失败里:一个通知工具,偏偏在它最不能出错的那个点上坏掉了,还从来不告诉我它坏了。
永远不触发的那一版
我是按最顺理成章的思路接的。在错误钩子里,读会话记录(transcript),找到最后一条 assistant 消息,判断它是不是一条速率限制的错误信封(envelope)——Claude Code 会给这类消息打上 isApiErrorMessage: true 和 error: "rate_limit" 标记——如果是,就从文本里把重置时间抠出来,发那条限额专属提醒。我拿一份存好的会话记录测了测,能用,发布。
接下来那一周,我真撞上了两次用量限制。两次都是:那条笼统的 “stopped with error” 提醒,然后就没了。没有限额专属消息,没有重置提醒。新加的那段检测跑了,两次都判定这不是用量限制——而那个回合,除了撞限额根本没别的死因。
那条笼统的提醒本身就是破绽。它是一次悄悄失败的检测发给你的安慰奖:回合确实报错了,所以旧的错误钩子照样会触发,于是每一次真正的漏判,看起来都还是有一条通知到了。我能发现不对劲,纯粹是因为我知道那儿本该有一条限额专属消息,而它没出现。
于是我在读会话记录的前后加了调试日志,然后等着再撞一次限额。等真撞上了,日志显示这次读取什么都没返回——我的钩子去看的那一刻,会话记录里没有速率限制信封。然后我把日志里的读取时间戳,跟文件自己的修改时间比了一下。就那一次,我的代码在 00:13:14.735881 读了会话记录;文件的 mtime 是 00:13:14.755575。我比 Claude Code 写完它早了 19.7 毫秒读到。一次测量,一个个例——我不知道整体分布是什么样——但方向毫不含糊:读,抢在了写前面。
机制就在这儿,而这也是整篇文章的重点。钩子载荷(payload)里的 transcript_path 只保证这个文件存在,不保证当前这个回合已经写进去了。Claude Code 是异步写会话记录的,StopFailure 钩子完全可能在速率限制信封被刷到磁盘之前就触发——同一块本地磁盘,同一台机器,中间没有任何网络。我的检测读的是一个 Claude Code 还没写完的文件,没看到速率限制信封,就把这判成"不是限额",然后接着往下走。以磁盘上当时的内容来看,这个判断没错;以事实来看,这个判断毫无用处。
补丁,和本该一开始就用的那个解法
第一个修法是最直白的那个:如果第一次读是空的,就等一下再读一次。重试一次,间隔 200 毫秒——_STOP_FAILURE_RETRY_DELAYS = (0.2,),大约是我测到那个差距的十倍——给了写操作足够的余量,等第二次去看的时候基本就落盘了。它管用;接下来几次限额都发出了正确的重置提醒。
但拿一个 200 毫秒的 sleep 去糊住一个我还没完全搞懂的竞态,那是补丁,不是答案。在往上继续搭任何东西之前,我去把 Claude Code 官方的 hooks 文档读了一遍——这事我本该在写下第一行会话记录解析代码之前就做,因为答案在那份文档里明明白白写了两遍。
这个竞态是文档写明的行为,不是我瞎撞出来的。文档原话是,会话记录文件"是异步写入的,可能滞后于内存里的对话,所以钩子触发时,它可能还没包含当前回合最新的那几条消息",而且它直接给了替代做法:用 last_assistant_message,“而不是去读会话记录”。甚至连这个症状对应的 issue 都有一个公开的——anthropics/claude-code#15813——只不过一个没人管的机器人以"长期无活动"为由把它关了,而不是真修了什么。所以这就是它现在的样子:文档写了,但没人认领。
而更大的疏忽,是我想当然直接略过的那部分。StopFailure 是把错误放在载荷里直接递给你的。它就在钩子的 stdin JSON 里,紧挨着 transcript_path:一个结构化的 error 字段,取值来自一个固定枚举——rate_limit 就是其中一个值——外加 last_assistant_message,在报错回合里,这个字段装的就是 API 错误字符串本身。不用读文件,不用等刷盘,没有竞态。我却把整条读会话记录的路径,建立在"钩子不会给我任何结构化信息、我得自己把错误重建出来"这个假设上。而它其实自始至终,都在把这些东西直接递到我手上。
所以我把优先级翻了过来。现在载荷是第一数据源:如果 error 是 rate_limit、last_assistant_message 也在,就直接分类、发送,完全不碰磁盘。读会话记录那条路——连带重试——只留作载荷覆盖不到的边缘情况的兜底:那条有竞态的路径,该跑的时候才跑,不该跑的时候一次都不跑。
然后它在不该响的时候响了
分类器里还埋着第二个 bug,而这个坏的方向正好相反:某天它发了我一条用量限制通知,纯属谎报。
那个回合根本没撞上我账号的用量限制。它是想用 Fable 5——一个不走订阅额度、而是从自己那份 usage-credits 余额里扣费的模型——但余额不够。Claude Code 的消息说得清清楚楚:“Fable 5 requires usage credits. Run /usage-credits to continue or switch models with /model.” 我的工具还是照样弹了一条"用量已达上限"。
它能骗过分类器,是因为 Claude Code 给这个错误打的字段,跟真正的限额一模一样。一样的 isApiErrorMessage: true,一样的 error: "rate_limit",一样的 429 状态码。如果你的规则是"error 是 rate_limit 就算限额"——而这恰好就是我的规则,载荷和会话记录两条路上都是——那么一个针对单个模型的额度门槛,跟账号级别的限额就根本没法区分。这一个字段,一个人干着两份活——对应两种在我看来意思完全不同的情况。
真正把两者分开的东西,藏在更深一层。在磁盘上,我查过的那些真限额压根没有 errorDetails 这个字段;而 credits 错误带着一个,里面还有一个结构化的错误码:error.details.error_code == "credits_required"。这个字段,也只有这个字段,才是真正的判据。
所以修法就是两条路上各加一个判断:只有当 error 是 rate_limit、并且错误体不是 credits_required 错误时,才算用量限制。它认的是那个结构化的错误码,而绝不是消息文本里 “usage credits” 这几个字——靠文本匹配,你造出来的分类器措辞一改就崩,而不这么干,是这个项目里一条雷打不动的规矩。谁的结构化字段最具体,就听谁的。
两次失败的共同点
把这两个 bug 摆在一起看。一个从不触发;一个在不该触发时触发。症状相反——错误却是同一个。它们都是同一件事的后果:另一个进程递给你一个含糊的、文档只写了一半的信号,而你信了自己对它的第一眼判断。漏判那次,信了一个空文件;误报那次,信了一个被塞了太多含义的字段。接上钩子从来不是难的部分;难的全在另一头——怎么把那个信号分类对。
还有一点扎心的:这两个 bug,当时那套测试一个都抓不到。我的测试夹具(fixture)覆盖的全是一切顺利的那条路径——一份完整写好的会话记录,里面一个干净的速率限制信封。夹具本身就是个已经写完的文件,所以它没法复现一场跟"还在写的文件"之间的竞态。而 credits 错误的夹具我压根没有,因为在 Claude Code 真发给我一条之前,我根本不知道有这个错误。测试从头到尾都是绿的。它们确认的是"我的代码跟我的假设一致",而这跟"确认代码是对的"是两码事。
两个 bug 浮出水面的方式是一样的:一个我根本没法预测的真实事件,加上事发时早已埋好、足够看清到底发生了什么的调试日志。重试、切到载荷、加 credits 判断——一旦我能看见信号,这些都是简单的部分。真正让它们成为可能的那份自律,是在我还没搞懂这东西之前,就先给它装好探针,好让下一个奇怪的事件留下一条痕迹,而不是让我耸耸肩、不了了之。这一点,比任何单个修法都更算得上是门手艺。
完整打磨过的版本——重试、载荷优先、credits 判断——都在 GitHub 上的 claude-code-notify 里,MIT 协议:github.com/Jeromefromcn/claude-code-notify。我还想收集的,是别人版本的同一个故事:一条因为错误的原因触发的通知,或者因为正确的原因反而沉默的通知。有意思的 bug 通常就藏在那里——藏在那个你没读第二遍就信了的信号里。
更多推荐

所有评论(0)