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

发布时间:2026/7/7 1:19:34
向 Hermes Agent 提交 Bug 修复:从诊断到 CEO 亲自加固的完整记录 不是我提了个 PR 被 merge 了——是在一个拒绝外部 merge 的项目里你的代码如何以作者身份出现在主分支上以及你的方案被 CEO 推翻后学到了什么。背景salvage 制度下的外部贡献Hermes Agent 是 Nous Research 的旗舰开源项目。和其他开源项目不同你没法直接 merge 代码进主分支。项目从 2026 年 5 月起进入salvage-only模式所有外部 PR 都由维护者 cherry-pick 重写后再合入commit 作者默认是维护者自己。这意味着两件事你能贡献代码但它不会以你的名义出现在 git blame 上——除非维护者主动保留你的 author 信息。你的方案会被 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堵上了。但还有两个后门会绕过这个检查后门 1update_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调度器永远不会调度它。后门 2resume_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( fRequested 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( fCannot 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 秒内仍然允许创建防止时钟偏差导致的误拒绝。这个修复以 commit8def4ccb4合入主分支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) ← 同一个 job28 秒后又启动了 ... 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 run和hermes 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这意味着进程 A 的调度器在 21:00:31 tick拿到 job开始执行这个 job 需要 2.5 分钟完成进程 B 的调度器在 21:00:59 tick检查同一个 job——因为next_run_at没变它仍然被认为是到期的于是同一个 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(seconds60)).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-shots due-state isnt 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 reporters ~2.5-min prompt) still re-fired on the next tick.翻译过来你的诊断完全正确但 60s 方案只是把问题推迟了一个 tick。如果一个任务运行超过 60 秒它仍然会在下一个 tick 被重复触发。CEO 的方案durable run-claimCEO 的方案不推进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 180030 分钟。如果持有声明的调度器进程崩溃了30 分钟后声明自动过期job 会被重新调度不会永久卡住。为什么 run_claim 比 60s 更好场景我的方案60s advanceCEO 方案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-pickauthor 保留 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注意06cc983b8的committer 是 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我的原始方案和3b5c64543CEO 的加固在 git 历史中相邻存在完整记录了外部贡献者诊断 → 维护者加固的协作过程。这比一个直接的 merge commit 更有故事性。附如何验证这些 commitgit clone https://github.com/NousResearch/hermes-agent.git cd hermes-agent git log --authorisheng --oneline # 8def4ccb4 fix(cron): reject past one-shot timestamps... # 06cc983b8 fix(cron): prevent double-execution of one-shot jobs...相关链接Issue #59229cron: one-shot job executed twice when gateway and desktop schedulers run concurrently (job not claimed before dispatch) · Issue #59229 · NousResearch/hermes-agent · GitHubPR #59446我的原始提交fix(cron): prevent double-execution of one-shot jobs across concurrent schedulers by isheng-eqi · Pull Request #59446 · NousResearch/hermes-agent · GitHubPR #59524CEO salvagefix(cron): durable run-claim prevents one-shot double-execution across concurrent schedulers by teknium1 · Pull Request #59524 · NousResearch/hermes-agent · GitHubCommits by isheng-eqiCommits · NousResearch/hermes-agent · GitHub