向 Hermes Agent 提交 Bug 修复:从诊断到 CEO 亲自加固的完整记录

0 阅读10分钟

不是"我提了个 PR 被 merge 了"——是在一个拒绝外部 merge 的项目里,你的代码如何以作者身份出现在主分支上,以及我的方案被 CEO 推翻后学到了什么。


背景:salvage 制度下的外部贡献

Hermes Agent 是 Nous Research 的旗舰开源项目。和其他开源项目不同,你没法直接 merge 代码进主分支。项目从 2026 年 5 月起进入 salvage-only 模式:所有外部 PR 都由维护者 cherry-pick 重写后再合入,commit 作者默认是维护者自己。

这意味着两件事:

  1. 你能贡献代码,但它不会以你的名义出现在 git blame 上——除非维护者主动保留你的 author 信息。
  2. 你的方案会被 review,但不一定会被采用——维护者可能在你的思路上做完全不同的实现。

近 30 天统计:cron 调度子系统有 68 位外部开发者提交过代码,但只有 8 人获得了多个 commit 的 author 署名。本文记录的就是我在这套规则下两次让代码以作者身份合入主分支的过程,以及第二次提交时 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 路径

# cron/jobs.py 第 1186 行附近(修复前)
if updated.get("enabled", True) and \
   updated.get("state") != "paused" and \
   not updated.get("next_run_at"):
    updated["next_run_at"] = compute_next_run(updated["schedule"])

这段代码的本意是:如果 job 的 next_run_at 丢了(比如数据文件损坏),自动重新计算。但 compute_next_run() 对已经过去的一次性任务会返回 None——然后这个 None 被静默写入 next_run_at,调度器永远不会调度它。

后门 2:resume_job 路径

# cron/jobs.py 第 1217 行附近(修复前)
next_run_at = compute_next_run(job["schedule"])
return update_job(job["id"], {"next_run_at": next_run_at, ...})

用户暂停了一个一次性任务,等想起来恢复时,run_at 已经过去了。resume_job 同样调用 compute_next_run(),得到 None,然后通过 update_job 写入——又是同一个静默失败。

修复

修复逻辑很直接——在两个后门处加入显式的 past-time 检查:

# 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,报告了一个诡异的问题:

创建了一个 one-shot cron job,结果收到了两条完全相同的 Telegram 消息,中间隔了 28 秒。

日志清晰地复现了问题:

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 save

根因分析

问题出在 _get_due_jobs_locked() 函数中。当你运行 hermes gateway runhermes serve(桌面版)时,两个进程各自有一个 60 秒间隔的 cron 调度器,它们操作同一份 cron 数据文件。

关键代码路径:

# cron/scheduler.py 第 3430 行 — 调度器 tick 循环
due_jobs = get_due_jobs()        # 获取到期任务列表
for job in due_jobs:
    advance_next_run(job["id"])   # 只对 recurring job 推进 next_run_at

问题在这里:advance_next_run() 只对重复执行的 job 有效。对于 one-shot job,调度器在拿到任务后不会修改 next_run_at,这意味着:

  1. 进程 A 的调度器在 21:00:31 tick,拿到 job,开始执行(这个 job 需要 2.5 分钟完成)
  2. 进程 B 的调度器在 21:00:59 tick,检查同一个 job——因为 next_run_at 没变,它仍然被认为是"到期"的
  3. 于是同一个 job 被执行了两次

进程内的 _running_job_ids 集合(cron/scheduler.py 第 298 行)能防止同一进程重复调度,但它是一个内存结构——进程 B 看不到进程 A 的 _running_job_ids.tick.lock 文件锁只序列化 tick 的执行,但两个 tick 相隔 28 秒,锁早就释放了。

我的修复方案:+60s advance

我的思路是:在 _get_due_jobs_locked() 中,对 one-shot job 在返回前将其 next_run_at 推进 60 秒(超过下一个 tick 窗口),并在文件锁持有期间立即持久化:

# cron/jobs.py _get_due_jobs_locked() — 我的方案
if kind == "once":
    claimed_next = (now + timedelta(seconds=60)).isoformat()
    job["next_run_at"] = claimed_next
    for rj in raw_jobs:
        if rj["id"] == job["id"]:
            rj["next_run_at"] = claimed_next
            needs_save = True
            break

这个方案能防止 28 秒后的重复触发——进程 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。他在我的 PR 下留下了这样一段评论:

Your diagnosis was spot-on: concurrent gateway + desktop 60s tickers on one HERMES_HOME, and a one-shot's due-state isn't durably claimed before dispatch. I reworked the fix on top of your commit before merging: the +60s next_run_at advance only delayed a duplicate by one tick, so a job that outlives the 60s interval (the reporter's ~2.5-min prompt) still re-fired on the next tick.

翻译过来:你的诊断完全正确,但 +60s 方案只是把问题推迟了一个 tick。如果一个任务运行超过 60 秒,它仍然会在下一个 tick 被重复触发。

CEO 的方案:durable run-claim

CEO 的方案不推进 next_run_at,而是引入了一个持久的运行声明(run_claim) ,镜像了代码库中已经存在的 fire_claim 模式:

声明阶段——在 _get_due_jobs_locked() 中:

if kind == "once":
    claim = {"at": now.isoformat(), "by": _machine_id()}
    job["run_claim"] = claim
    for rj in raw_jobs:
        if rj["id"] == job["id"]:
            rj["run_claim"] = claim
            needs_save = True
            break

检查阶段——在同样的函数顶部,扫描 due jobs 时:

existing_claim = job.get("run_claim")
if existing_claim and job.get("schedule", {}).get("kind") == "once":
    claimed_at = _ensure_aware(
        datetime.fromisoformat(existing_claim["at"])
    )
    if (now - claimed_at).total_seconds() < ONESHOT_RUN_CLAIM_TTL_SECONDS:
        continue  # 有其他进程正在执行,跳过

清理阶段——在 mark_job_run() 中:

if job.get("run_claim") is not None:
    job["run_claim"] = None  # 任务结束,释放声明

还有一个安全阀: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
           fix(cron): prevent double-execution of one-shot jobs...
            我的原始方案,CEO 亲自 cherry-pick,author 保留

8f849ea36  Author: teknium1    Committer: Teknium
           chore: credit isheng-eqi for #59446 in AUTHOR_MAP
            将我的 GitHub 账号注册到项目的作者映射表

3b5c64543  Author: Teknium     Committer: Teknium
           fix(cron): durable run-claim for one-shots...
            CEO 的改进:把我的 +60s 替换为 run_claim

注意 06cc983b8committer 是 Teknium——这意味着是 CEO 本人执行的 cherry-pick 操作。在 salvage 流程中,committer 代表谁做了代码审查和合并操作,author 代表谁写了原始代码。


复盘:我学到了什么

1. 诊断能力比修复方案更值钱

CEO 没有用我的代码,但他保留了 commit 署名,并公开说"your diagnosis was spot-on"。这说明在 salvage 制度下,准确定位根因比写出完美修复更重要——维护者可以在你的诊断上做更好的实现,但诊断本身是你不可替代的贡献。

2. 时间窗口不是状态管理

我的 +60s 方案本质上是用"推迟问题"代替"解决问题"。60 秒是一个魔法数字——它碰巧覆盖了 issue 报告中的 28 秒重复,但对于 2.5 分钟的长任务完全无效。run_claim 方案把问题建模为分布式锁而非时间窗口,这才是正确的抽象。

3. 在代码库中寻找先例

CEO 的方案镜像了已有的 fire_claim 模式——这是代码库中已经存在的设计语言。如果我在提交前更仔细地读完 cron/jobs.py 中的 fire_claim 实现,也许能自己想到这个方向。在大型项目中,最好的方案往往已经在代码库里了,只是还没被应用到当前问题上

4. 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...

相关链接: