开源自托管 Agent 项目 Hermes Agent 的定时任务怎么跑

2026-07-30

本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。

给 Agent 加定时任务,真正难的从来不是 cron 表达式,而是”到点那一刻”的语义:这一次算不算已经跑过、进程被杀在半路要不要重放、跑完的结果谁负责收。 NousResearch 开源的 hermes-agent(MIT 许可,一个常驻在自己机器上的自托管 Agent 项目;它和 Nous Research 的 Hermes 开源模型系列、以及若干同名商标与同名库不是一回事)把这套语义全部写进了仓库的 cron/ 目录,而且写法相当直白——存储就是一个 JSON 文件,触发就是一个 60 秒的轮询循环,剩下的复杂度全花在”别多跑一次”和”别悄悄花钱”上。

站内已经有三篇讲通用方法论的文章:Agent 日常运维怎么做 讲的是你手上已经有一个常驻 Agent 之后的运维动作,Agent 无限循环怎么停机 讲的是失控循环的通用刹车思路,Agent 可观察日志怎么设计 讲的是日志分层的一般原则。本篇不重复这些结论,只做一件事:把这个具体项目的定时任务模块摊开,看它把那些原则落成了哪几个文件、哪几个字段、哪几处拒绝。

一、任务长什么样、存在哪、schedule 怎么定义

存储层朴素到可以直接用编辑器打开。cron/jobs.py 的模块注释就写明了两个位置:任务清单在 ~/.hermes/cron/jobs.json,每次运行的产出落在 ~/.hermes/cron/output/{job_id}/{timestamp}.md。任务 id 是 12 位十六进制串,同时被当成输出目录的路径片段,所以 id 被列入不可更新的字段集合,_job_output_dir 还会额外拒绝含 ..、含分隔符、绝对路径形式的 id——防的是路径逃逸写到输出沙箱外面去。

这套存储按 profile 隔离。get_hermes_home() 解析的是当前活跃 profile 的家目录,所以在 profile coder 下建的任务存在 ~/.hermes/profiles/coder/cron/jobs.json,也用那个 profile 自己的 .envconfig.yaml 和技能执行。源码注释特意警告别把它改回共享根目录,否则各 profile 的凭据和技能会互相泄露;跨 profile 的调用方该用 use_cron_store() 临时改指向,而不是改进程级常量。

schedule 的定义由 parse_schedule 负责,只认三种 kind,输入形态却比较宽松:

"30m"              → once in 30 minutes
"2h"               → once in 2 hours
"every 30m"        → recurring every 30 minutes
"every 2h"         → recurring every 2 hours
"0 9 * * *"        → cron expression
"2026-02-03T14:00" → once at timestamp

也就是说裸时长(30m / 2h / 1d)是一次性任务,加了 every 前缀才是周期任务;5 到 6 个空格分隔字段才走 cron 表达式分支,而这一分支依赖 croniter,模块里做了懒加载(注释解释是 croniter 导入时要编译约 15 毫秒的正则,不用就不付这个钱)。ISO 时间戳分支有个容易忽略的细节:不带时区的时间会被按 Hermes 配置的时区补齐,而不是服务器本地时区——因为到期判断用的是配置时区的当前时间,两边不一致会让一次性任务永远不到期。

ONESHOT_GRACE_SECONDS 是 120。一次性任务如果指定的时间已经过去超过这个宽限,创建、更新、恢复三个入口都会直接抛 ValueError 拒绝,而不是留下一个 next_run_at 为 null、状态却是 scheduled 的幽灵任务。周期任务另有一套宽限计算 _compute_grace_seconds:取周期的一半,夹在 120 秒到 2 小时之间——日更任务迟到两小时内还能补跑,5 分钟一次的任务则很快选择快进。

不想让人手写 cron 的那一层在 cron/blueprint_catalog.py。它定义了 AutomationBlueprintBlueprintSlot 两个冻结 dataclass,槽位类型只有 timeenumtextweekdays 四种,星期集合走一张三项预设表:

WEEKDAY_PRESETS: Dict[str, str] = {
    "everyday": "*",
    "weekdays": "1-5",
    "weekends": "0,6",
}

蓝图自己带一个 schedule_template(里面是 {minute} {hour} * * {dow} 这样的占位)和一个 prompt_templatefill_blueprint 校验完槽位值就吐出一份 create_job 的 kwargs,不另起第二套任务引擎。同一份定义可以渲染成表单(blueprint_form_schema)、渲染成 /blueprint <key> slot=val 命令行、渲染成 hermes://blueprint/<key>?... 深链。内置目录有 14 个条目,从 morning-briefnews-digestimportant-mailhydration-move。顺手值得一提的是 hydration-move 上方那条注释:cron 分钟字段的步长 */90 会按小时回绕,*/90*/120 都退化成每小时一次,所以这个蓝图改用小时字段的步长写法。这类踩过的坑写在源码旁边,比写在文档里有用。

组成部分它负责什么对应仓库位置你什么时候会碰到它
任务存储与 schedule 解析jobs.json 读写、三种 schedule 解析、下次运行时间计算、各类 claim 与心跳文件cron/jobs.py改任务字段、排查”为什么没到点触发”
触发与执行主体取到期任务、并发/串行分派、跑 agent 或脚本、投递结果、回写状态cron/scheduler.py任务超时、投递失败、被守卫拒绝
触发器抽象与内置 ticker60 秒轮询循环,或替换成外部触发实现cron/scheduler_provider.py想让进程空闲时不常驻
执行台账每次尝试的 claimed/running/completed/failed/unknown 状态cron/executions.py复盘”这次到底跑没跑”
自动化蓝图带类型槽位的参数化模板,一处定义多端渲染cron/blueprint_catalog.py不想让使用者手写 cron 表达式
生命周期护栏创建时就拒掉会把网关自己重启掉的任务cron/lifecycle_guard.py建任务时收到拒绝
托管触发协议外部定时器回调 agent 的鉴权与幂等约定docs/chronos-managed-cron-contract.md部署在按需唤醒的托管环境

二、到点之后:一次 tick 里到底发生了什么

TICKER_INTERVAL_SECONDS = 60,注释说明这个常量是内置 ticker 和 hermes cron status 的过期阈值共用的唯一来源,避免两边各写一个数字然后慢慢对不上。

tick() 一进来先抢 ~/.hermes/cron/ 下的 .tick.lock,非阻塞,抢不到就直接返回 0。然后 get_due_jobs() 在 jobs 锁内扫一遍清单,扫描函数里有大量”修坏数据”的代码:缺 id 的记录会补上、schedule 不是 dict 的重置成空 dict、next_run_at 不是合法 ISO 串的剥掉重算。理由写在注释里——这些字段在后面的循环里被立即索引,一条坏记录抛出的异常会让整轮扫描在 save_jobs() 之前中断,健康任务算好的快进结果全部丢弃,整个 profile 的调度就卡在每分钟重算又重丢的循环里。

周期任务拖得太久(网关停机,或上一次执行本身超过了间隔)时,累积的错过次数不会补偿式连发:next_run_at 直接快进到下一个未来时刻,但当次仍然触发一次。这个取舍同时避开了两个极端——重启后突然连发几十次,和执行时间长于”间隔加宽限”的任务被永久跳过。

拿到到期列表后,tick 做的第一件事是给所有周期任务调 advance_next_run(job_id),在文件锁内、任何执行开始之前。这行代码就是周期任务的语义:从至少一次改成至多一次。注释写得很坦白——少跑一次远好过在崩溃循环里跑几十次。一次性任务走另一条路:run_one_job 在真正执行前调 claim_dispatch,在锁内把 repeat.completed 加一并立刻落盘,于是有限次一次性任务从至少一次变成”至多 times 次”。

分派分两个池。带 workdir 的任务会改进程级的 TERMINAL_CWD 环境变量,所以排进一个 max_workers=1 的持久单线程池,跨 tick 也保持顺序;其余任务进并发池,上限由 HERMES_CRON_MAX_PARALLELcron.max_parallel_jobs 决定,默认无上限。分池只保证 workdir 任务之间不重叠,真正阻止一个无 workdir 任务读到别人的目录覆盖的,是写者优先的读写锁 _terminal_cwd_lock——workdir 任务是写者,其余是读者。

在飞的任务用进程内集合 _running_job_ids 去重,上一轮没跑完的这一轮直接跳过;集合成员从分派那刻起覆盖整个执行过程(含工具调用),网关关停的排空逻辑也读它。关停路径强杀工具子进程后会调 mark_running_jobs_interruptedrun_one_job 在决定投递什么之前先偷看这个标记——因为工具输出被截断后,Agent 线程可能还活着并产出一段看起来挺像样的最终回复,这段回复绝不能当正常结果发出去。

活性信号是几个小文件:ticker_heartbeat 每轮都写,ticker_last_success 只在这轮没抛异常时写,ticker_last_error 记最近一次失败,catch_up_occurrences 记快进发生过几次。分两个心跳的理由很实际——只有一个心跳时,“活着但每轮都失败”的 ticker 会一直保持心跳新鲜并谎报健康。这些文件都走临时文件加 fsync 加原子替换,因为读它的 hermes cron status 是另一个进程。

更耐用的一层是 cron/executions.py 的 SQLite 台账。每次尝试在分派前就落一条 claimed,开跑转 running,结束写 completedfailed,终态不可改写。recover_interrupted_executions 只在能证明拥有者进程确实没了(pid 不存在,或 pid 存在但启动时间不匹配)时才把记录改成 unknown,并且明确不安排重试——它是审计账本,不是重试队列。这个区分很关键:无人值守场景下”不知道跑没跑”是一个必须能表达的状态,硬归到成功或失败都会骗人。

三、跑完往哪送:投递、静默与串联

deliver 字段是个字符串,取值可以是 local(只存盘不发)、origin(建任务时那个会话)、某个平台名、平台:会话id 这种显式目标、以及逗号分隔的组合,另外还支持一个 all 令牌,在触发时展开成”当前所有配了 home 频道的平台”。这个展开发生在触发时而不是创建时,所以一个在接通某平台之前就建好的任务,接通之后会自动开始往那儿发。

平台的 home 目标读环境变量:Telegram 读 TELEGRAM_HOME_CHANNEL、Slack 读 SLACK_HOME_CHANNEL、邮件读 EMAIL_HOME_ADDRESS;插件平台通过注册表上的 cron_deliver_env_var 接进来,不用改这个模块。Telegram 另有一个 TELEGRAM_CRON_THREAD_ID 覆盖项,因为开了话题模式后落在根 DM 的消息进的是用户无法回复的系统大厅。

不想说话的时候怎么办?run_job 给每个任务的提示词前面都会拼一段固定的执行说明,这段文本本身就是契约:

[IMPORTANT: You are running as a scheduled cron job. DELIVERY: Your final
response will be automatically delivered to the user — do NOT use
send_message or try to deliver the output yourself. ... SILENT: If there is
genuinely nothing new to report, respond with exactly "[SILENT]" (nothing
else) to suppress delivery. ...]

判定这个标记的函数比”字符串包含”讲究得多:整条回复是标记、标记单独占首行或末行都算静默,但夹在句子中间提到 [SILENT] 的正常报告必须照常发出去,同时也认模型经常漏掉方括号的几种变体。静默只影响投递,产出照样存盘留档。

一整类任务根本不需要模型。设 no_agent=True 时脚本本身就是任务,run_job 在导入任何 agent 机器之前就短路返回:stdout 直接当最终消息投递,空 stdout 视为静默,非零退出或超时则当告警发出去——对报警任务来说,静默失败是最坏结果。脚本必须位于 ~/.hermes/scripts/ 之内,相对与绝对路径都会解析后校验是否越界;.sh / .bash 交给 bash,其余交给当前 Python 解释器,并且刻意不遵循文件自己的 shebang,理由是让可执行面小且可审计。stdout 与 stderr 在任何返回路径之前都过一遍 redact_sensitive_text,脱敏本身失败时整段替换成占位符。超时由 HERMES_CRON_SCRIPT_TIMEOUTcron.script_timeout_seconds 控制。

脚本还能只当”要不要唤醒模型”的闸门:_parse_wake_gate 读 stdout 最后一行非空内容,如果它是 {"wakeAgent": false} 这样的 JSON,整个模型调用直接跳过。非 JSON、缺字段、解析失败一律按”唤醒”处理——闸门自己坏掉时应该放行而不是静默停摆。

任务之间串联靠 context_from:填一个或多个上游任务 id,每次运行前把它们最新一份产出注入提示词,上游取数、下游分析。注入前校验 id 必须是纯十六进制(防路径逃逸),单份内容截断在 8000 字符以内,上游还没产出时静默跳过而不是往提示词里塞报错。

投递失败和执行失败在任务记录里是两个字段:last_errorlast_delivery_error——Agent 成功产出了内容但平台挂了,是完全不同的故障。失败往聊天里发时也不倒异常栈,_summarize_cron_failure_for_delivery 会把限流、超时、鉴权几类高噪声的服务商错误压成一行,细节留在输出目录和日志里。Agent 跑完但最终回复空白,会被当成软失败,last_status 不写成 ok。

默认情况下 cron 的投递只活在任务自己的会话里,不进目标聊天的历史。想改有两个开关:任务级的 attach_to_session 和全局的 cron.mirror_delivery,默认都关。打开后简报会以带前缀的用户角色消息追加进目标会话,选用户角色而不是助手角色是为了不破坏消息交替。

四、无人值守最容易失控在哪

这一节是我读这个模块最大的收获:它把大部分守卫做成了 fail-closed——条件不满足就不跑,而不是尽力试一试。

花钱这条最狠。 没有显式 pin 模型和服务商的任务,会跟着全局默认走,而全局默认是会变的。所以 create_job 在创建时把当时解析出的结果快照进 provider_snapshotmodel_snapshot;到了触发时,如果某个轴仍未 pin、有快照、且当前解析出的值不一样,这一次运行直接跳过,不发起任何推理调用,并投递一条明确告诉你”去把这个轴显式 pin 上”的告警。源码注释里提到促成这条守卫的是一次真实的意外账单。没有快照的老任务、pin 过的轴、以及从显式 cron 专用默认(cron.model)解析出来的轴都不算漂移——那是用户自己的决定。各家服务商的计费与限流规则不同且会调整,以官方最新说明为准,但这个”不确定就别花钱”的机制取向是可以直接借走的。

工具面第二狠。 cron 上下文永久禁掉三个工具集:cronjob(不让被调度出来的 Agent 再去建定时任务)、messagingclarify(这两个需要活人在线)。config.yaml 里的 agent.disabled_toolsets 叠加在上面,所以任务级的 enabled_toolsets 白名单没法绕过全局策略。白名单与 MCP 的关系也做了处理:单给一份原生工具集白名单会静默把所有 MCP 服务器排除掉,于是有一层合并逻辑——列了 no_mcp 哨兵就一个不加,已点名某些 MCP 服务器就当白名单,否则把全局启用的并进来。

提示词注入。 cron 非交互执行、工具调用自动批准,注入的后果比交互场景严重。创建时扫的是用户填的提示词,但技能内容是运行时才从磁盘加载的——这条缝由 _scan_assembled_cron_prompt 补上,扫最终拼装完的整段提示词,命中抛 CronPromptInjectionBlocked。扫描分两档:不含技能与注入数据时用严格模式;含技能 markdown 或含脚本/上游产出注入的数据时换宽松模式,只拦明确的注入指令,命令形状的模式放过,隐形 unicode 剥掉并记日志而不是拦——避免一个零宽空格把任务永久打死。这个仓库技能量不小(skills/ 14 个分类目录共 70 份 SKILL.md,optional-skills/ 21 个分类目录共 111 份),加载技能的定时任务是常态而非边角场景。

自己把自己重启掉。 cron/lifecycle_guard.py 处理的是一个具体到有点滑稽的故障:Agent 建了个定时任务去执行网关重启命令,任务一触发网关就死,进程守护把它拉起来,自动恢复接上那个会话又跑一遍同样的逻辑,于是十秒级的重启循环直到人工介入。守卫在 create_job 里执行,CLI 和模型可调用的工具两条路都覆盖;匹配的是命令形状(hermes gateway restart|stop、针对网关标签的 launchctl/systemctl、针对网关进程的 pkill),而不是英文散文里的”gateway”和”restart”——提示词是喂给模型不是喂给 shell 的,宽泛匹配只会制造误报。

跑不动却没人知道。 超时按”无活动”算,不按墙上时间:默认 600 秒无活动,HERMES_CRON_TIMEOUT 可覆盖,0 表示不限。监视线程每 5 秒查一次 Agent 的活动摘要,超限就中断并抛 TimeoutError,日志带上最后一次活动描述、当前工具、迭代次数。这个口径选得对——持续调工具、持续收流式 token 的任务跑几小时是合法的,卡死在一次挂起的 API 调用上才是要杀的。另有一处容易忽略的边界:会话数据库初始化本身也有独立超时(HERMES_CRON_SESSION_DB_TIMEOUT / cron.session_db_timeout_seconds,默认 10 秒),因为它发生在”在飞任务集合”的释放逻辑存在之前,卡在这儿会让任务永久占着 running 名额,之后每次触发都被”已在运行”跳过。

五、边界与代价:它明确不管的事

先说存储这条。任务清单是一个 JSON 文件加一个建议性文件锁 .jobs.lock(fcntl 或 msvcrt,都不可用时退化为仅进程内锁),锁的等待上限 30 秒,超时后打错误日志并降级继续——注释里的理由是”短暂撕裂的跨进程写入,也严格优于一个永久死掉的调度器”。这是个清醒的取舍,但它意味着这套东西的定位是单机(或共享同一个家目录的少量副本),不是一个分布式任务队列。想要多机分发、任务依赖图、优先级抢占、按秒级精度对齐,都不在这里。

再说语义这条。周期任务是至多一次,一次性任务是至多 N 次,这两条都以”宁可漏跑”为代价换”绝不重放”。如果你的任务是幂等的、漏一次的成本远高于重复一次的成本,这个默认方向是反的,你得自己在任务内部补重试。同理,快进策略下的错过次数是被折叠掉的——需要”每一次都要补齐”的场景(比如按小时结算的账目)不适合直接靠它。

产出留存也是有上限的。每次写完输出就按 cron.output_retention 剪掉最旧的几份 markdown,所以输出目录不是长期档案库;executions.db 的终态记录也只留最近 MAX_TERMINAL_EXECUTIONS(1000)条。要长期留痕得自己往外导。

然后是必须直说的代价:这个项目常驻在你的机器上、开终端执行命令、连你的聊天软件账号、往磁盘写文件、访问外部服务,定时任务把这些能力从”你在场时按需触发”变成”没人看着也会自己发生”。脚本目录是被信任的(不校验 shebang、直接交给 bash 或 Python 执行),环境变量里的 home 频道决定消息发给谁,workdir 让任务把某个目录的 AGENTS.md / CLAUDE.md / .cursorrules 注进系统提示词并把工具的工作目录指过去。这些不是缺陷,是设计的必要条件,但意味着一件事:任何能写到 ~/.hermes/ 的人,等于拿到了在你机器上定时执行代码并往你的聊天账号发消息的能力。凭据外泄这条留了运行时兜底——_guard_job_credential_exfil 在触发前重新校验存储里的 provider 与 base_url 组合,校验器自身出错时,带了 base_url 覆盖的任务一律拒跑。

最后是常驻本身的代价:内置触发器就是一个 60 秒轮询的线程,进程必须一直活着。仓库给了另一条路——把 cron.provider 设成 chronos,改成”每个任务只在外部预约一个真实到点时刻的一次性触发,到点回调唤醒 agent”,docs/chronos-managed-cron-contract.md 是这条路的线上协议。三跳信任模型写得很清楚:agent 拿自己已有的门户访问令牌去 provision / cancel,外部定时器带签名打到中间服务的 relay,中间服务再签一个短时效、purpose=cron_fire 的 JWT 打到 agent 的 /api/cron/fire;agent 只验它本来就会验的那种令牌,一个新密钥都不引入,重复回调靠存储层的 compare-and-set(claim_job_for_fire)去重。代价也写明了:不做周期性唤醒(那会抵消空闲不常驻的意义),漏掉的预约只能等下一次对账自愈;而只要回调地址或门户地址是空的,可用性检查返回假,直接退回内置轮询——这个”触发器永不缺失”的兜底方向,比协议本身更值得抄。

六、上手与避坑清单

  • 别拿 30m 当周期任务。 会踩是因为这两种写法只差一个前缀,语义却一个是一次性一个是周期性。避法是建完立刻看任务记录里的 schedule_display——它是解析结果的回显,once in 30mevery 30m 一眼分得出。
  • cron 表达式跑不了先查依赖,别怀疑表达式。 会踩是因为 5 字段表达式那条分支依赖 croniter 且是懒加载的,运行环境缺这个包时报出来的是”下次运行时间算不出来”,看起来像调度 bug。避法是记住这个因果,另外周期任务算不出下次运行时不会被静默停用,而是状态置 error 并保留启用——看到 error 状态先去查运行环境的 Python 依赖。
  • 给一次性任务定过去的时间会被直接拒绝。 会踩是因为很多人习惯”填个刚过去的时间试一下会不会立刻跑”。宽限只有 120 秒,超过就抛错。避法是要立刻跑就用触发接口(把 next_run_at 置为当前时间,下一轮 tick 捡起来),别靠填过去的时间。
  • 手改 jobs.json 之前先想清楚。 会踩是因为它就是个 JSON 文件、看起来很好改,但历史上手改造出的坏形状(缺 id、schedule 不是 dict、next_run_at 不是合法 ISO 串)都曾让整轮扫描崩掉从而冻结整个 profile 的调度。现在这些都有修复逻辑,你写出的下一种不一定有。避法是走 CLI 或工具接口改,真要手改先停网关。
  • 不 pin 模型的任务,改全局默认之后会集体停摆。 会踩是因为这正是漂移守卫的意图——它宁可不跑也不替你花钱。避法是把长期跑的任务显式 pin 上服务商与模型,或用专门的 cron 默认配置项路由整个任务集,这两种都不算漂移。
  • 报警类任务优先走不带模型那条路。 会踩是因为习惯性什么都交给 Agent,一个只需要”磁盘满了就吼一声”的任务要付完整推理成本,还多一层不确定性。避法是设 no_agent=True 配脚本,或用脚本先做闸门、真有情况才唤醒模型。
  • workdir 的任务会排队,别指望并发。 会踩是因为它们要改进程级环境变量,被刻意排进单线程池。避法是把互不相干的任务做成不依赖 workdir,或接受串行并把单个任务时长压下来。
  • 别用日志判断”这次到底跑没跑”。 会踩是因为进程被杀在半路时,日志里可能什么终态都没有。避法是查执行台账那条记录的状态——unknown 是一个明确答案,且只在能证明拥有者进程确实死了之后才写。

想动手核对,读的顺序建议是:cron/jobs.py 看清存储形状与 schedule 语义,cron/scheduler.pytick 往上读 run_one_jobrun_jobcron/executions.py 十分钟能读完但会改变你对”任务状态”的想法,最后翻 docs/chronos-managed-cron-contract.md 看它怎么把常驻变成可选。这个仓库 tests/ 下有 2499 个 test_ 开头的测试文件,源码注释里那些”某某场景会崩”的记述大多有对应用例兜着——可以放心把它们当真实踩过的坑来学。

自检三问:你的定时任务漏跑一次和重跑一次哪个更贵,答不出来就先别上无人值守;结果发到哪个会话、由谁看到、看不到时你多久会发现;以及最朴素的一条——如果这台机器上的定时任务今晚全部自己跑了一遍,你能接受吗。

本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 用聊天软件指挥开源自托管 Agent 项目 Hermes Agent开源自托管项目 Hermes Agent

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。