Hermes Agent 是 Nous Research 旗下的旗舰开源项目,但它的贡献规则有点特殊——你没法直接往主分支合并代码。从 2026 年 5 月起,项目进入了 salvage-only 模式:所有外部提交的 PR,都由维护者 cherry-pick 重写后再合入,commit 的作者默认是维护者自己。

这意味着两件事:第一,你可以贡献代码,但除非维护者主动保留你的作者信息,否则在 git blame 里看不到你的名字。第二,你的方案会被 review,但不一定被采用——维护者很可能在你的思路上做一套完全不同的实现。
近 30 天的数据很说明问题:cron 调度子系统有 68 位外部开发者提交过代码,但只有 8 人获得了多个 commit 的作者署名。这篇文章记录的就是在这套规则下,两次让代码以作者身份合入主分支的经历——尤其是第二次提交时,CEO 亲自 review,并在我的方案上叠加了一套生产级的加固。
Bug #1:一次性任务为何“永远不触发”
现象
用户创建一个 schedule.kind = "once" 的 cron job,并指定 run_at 为未来某个时间点。但如果 run_at 不小心设成了过去的时间,会发生什么?直觉上应该报错,但实际上这个 job 会静默创建成功,然后永远不触发——它变成了一个“幽灵任务”,躺在 cron 数据文件里,调度器每次 tick 都跳过它,用户完全不知道发生了什么。
全链路追踪
问题的根源分布在三个入口。cron/jobs.py 中的 create_job() 函数是第一个入口,不过在我修复之前,它已经被另一个人(#59410)堵上了。但还有两个“后门”会绕过这个检查。
后门 1:update_job 的 fallback 路径
代码的本意是:如果 job 的 next_run_at 丢了(比如数据文件损坏),自动重新计算。但 compute_next_run() 对已经过去的一次性任务会返回 None——然后这个 None 被静默写入 next_run_at,调度器从此再也不会调度它。
后门 2:resume_job 路径
用户暂停了一个一次性任务,等想起来恢复时,run_at 已经过去了。resume_job 同样调用 compute_next_run(),得到 None,然后通过 update_job 写入——又是同一个静默失败。
修复
修复逻辑很直接,在两个后门处加入显式的过去时间检查:
# update_job fallback(修复后)
next_run = compute_next_run(updated["schedule"])
if next_run is None and updated["schedule"].get("kind") == "once":
run_at = updated["schedule"].get("run_at", "unknown")
raise ValueError(f"Requested one-shot time {run_at} is in the past "
f"(grace window: {ONESHOT_GRACE_SECONDS}s) and cannot be scheduled.")
updated["next_run_at"] = next_run
# resume_job(修复后)
next_run_at = compute_next_run(job["schedule"])
if next_run_at is None and job["schedule"].get("kind") == "once":
run_at = job["schedule"].get("run_at", "unknown")
raise ValueError(f"Cannot resume: one-shot time {run_at} is in the past "
f"(grace window: {ONESHOT_GRACE_SECONDS}s) and will never fire.")
ONESHOT_GRACE_SECONDS = 120 是一个宽容窗口——job 的 run_at 在当前时间之前 120 秒内仍然允许创建,防止时钟偏差导致的误拒绝。这个修复最终以 commit 8def4ccb4 合入主分支,author 保留为 isheng。
Bug #2:同一个 job 为什么被执行了两次
问题来自真实用户
7 月 5 日,用户 gservat 提交了 issue #59229,报告了一个相当诡异的问题。日志清晰地复现了整个过程:
21:00:31 Running job 'Reminder' (ID: d9b7231ebaa4)
21:00:59 Running job 'Reminder' (ID: d9b7231ebaa4) ← 同一个 job,28 秒后又启动了
...
21:03:06 delivered to telegram:718495351
21:03:29 delivered to telegram:718495351 ← 两条重复消息
21:03:29 WARNING: job_id d9b7231ebaa4 not found, skipping sa ve
根因分析
问题出在 _get_due_jobs_locked() 函数中。当你运行 hermes gateway run 和 hermes serve(桌面版)时,两个进程各自有一个 60 秒间隔的 cron 调度器,它们操作同一份 cron 数据文件。
关键代码路径是这样的:调度器在 tick 循环中获取到期任务列表,然后逐个推进 next_run_at。但问题在于:advance_next_run() 只对重复执行的 job 有效。对于 one-shot job,调度器在拿到任务后不会修改 next_run_at。这意味着:
- 进程 A 的调度器在 21:00:31 tick,拿到 job,开始执行(这个 job 需要 2.5 分钟完成)
- 进程 B 的调度器在 21:00:59 tick,检查同一个 job——因为
next_run_at没变,它仍然被认为是“到期”的 - 于是同一个 job 被执行了两次
进程内的 _running_job_ids 集合能防止同一进程重复调度,但它是一个内存结构——进程 B 看不到进程 A 的 _running_job_ids。.tick.lock 文件锁只序列化 tick 的执行,但两个 tick 相隔 28 秒,锁早就释放了。
最初方案:+60s advance
思路是在 _get_due_jobs_locked() 中,对 one-shot job 在返回前将其 next_run_at 推进 60 秒(超过下一个 tick 窗口),并在文件锁持有期间立即持久化。这样进程 B 在下一次 tick 时,next_run_at 已经被推进了,不再是“到期”状态。mark_job_run() 在任务完成时会重新设置 next_run_at,所以即便调度器在 advance 之后崩溃,job 也只是延迟一个 tick 窗口。这个方案提交为 PR #59446。
CEO 的回应:你的方案不够
PR #59446 提交后,CEO @teknium1 没有直接 merge,而是在 #59524 中开了一个新的 salvage PR。他的评论翻译过来是:你的诊断完全正确,但 +60s 方案只是把问题推迟了一个 tick。如果一个任务运行超过 60 秒,它仍然会在下一个 tick 被重复触发。
CEO 的方案:durable run-claim
CEO 的方案不推进 next_run_at,而是引入了一个持久的运行声明(run_claim),镜像了代码库中已经存在的 fire_claim 模式:
- 声明阶段——在
_get_due_jobs_locked()中写入 run_claim,标记“这个 job 正在被哪个机器执行” - 检查阶段——在同样的函数顶部,扫描 due jobs 时,如果发现已有未过期的 run_claim,就跳过该 job
- 清理阶段——在
mark_job_run()中释放 run_claim
还有一个安全阀:ONESHOT_RUN_CLAIM_TTL_SECONDS = 1800(30 分钟)。如果持有声明的调度器进程崩溃了,30 分钟后声明自动过期,job 会被重新调度,不会永久卡住。
为什么 run_claim 比 +60s 更好
| 场景 | 我的方案(+60s advance) | CEO 方案(run_claim) |
|---|---|---|
| 进程 B 在 28s 后 tick | ✅ 阻止 | ✅ 阻止 |
| 进程 B 在 61s 后 tick(长任务) | ❌ 重新触发 | ✅ 阻止 |
| 整个 2.5 分钟运行期间 | 重复只被延迟到下一个 tick | 全程保护 |
| 调度器崩溃 | 延迟一个 tick 恢复 | TTL 过期后恢复 |
核心差异在于:我的方案是时间窗口(“未来 60 秒内不要碰”),CEO 的方案是状态锁(“有我正在运行的声明就不要碰,直到我完成或崩溃”)。前者有竞态窗口,后者没有。
最终合入的 commit 链
这三个 commit 按顺序出现在 upstream/main 上:
- 06cc983b8 — Author: isheng, Committer: Teknium。这是我原来的方案,CEO 亲自 cherry-pick,author 保留。
- 8f849ea36 — Author: teknium1, Committer: Teknium。将我的 GitHub 账号注册到项目的作者映射表。
- 3b5c64543 — Author: Teknium, Committer: Teknium。CEO 的改进:把我的 +60s 替换为 run_claim。
注意 06cc983b8 的 committer 是 Teknium,这意味着是 CEO 本人执行的 cherry-pick 操作。在 salvage 流程中,committer 代表谁做了代码审查和合并,author 代表谁写了原始代码。
复盘:几个值得记住的经验
第一,诊断能力比修复方案更值钱。 CEO 没有用我的代码,但他保留了 commit 署名,并公开说“your diagnosis was spot-on”。在 salvage 制度下,准确定位根因比写出完美修复更重要——维护者可以在你的诊断基础上做更好的实现,但诊断本身是你不可替代的贡献。
第二,时间窗口不是状态管理。 +60s 方案本质上是用“推迟问题”代替“解决问题”。60 秒是一个魔法数字,碰巧覆盖了 issue 报告中的 28 秒重复,但对于 2.5 分钟的长任务完全无效。run_claim 方案把问题建模为分布式锁而非时间窗口,这才是正确的抽象。
第三,在代码库中寻找先例。 CEO 的方案镜像了已有的 fire_claim 模式——这是代码库中已经存在的设计语言。如果提交前更仔细地读完 cron/jobs.py 中的 fire_claim 实现,也许自己就能想到这个方向。在大型项目中,最好的方案往往已经在代码库里了,只是还没被应用到当前问题上。
第四,salvage 不是拒绝,是协作。 在 salvage-only 时代,维护者重写你的代码不是否定你的能力——这是项目的质量门槛。06cc983b8(原始方案)和 3b5c64543(CEO 的加固)在 git 历史中相邻存在,完整记录了“外部贡献者诊断 → 维护者加固”的协作过程。这比一个直接的 merge commit 更有故事性。
附:如何验证这些 commit
git clone https://github.com/NousResearch/hermes-agent.git
cd hermes-agent
git log --author="isheng" --oneline
# 8def4ccb4 fix(cron): reject past one-shot timestamps...
# 06cc983b8 fix(cron): prevent double-execution of one-shot jobs...