Cron 定时
职责
让 agent 能「到点自动跑」:定时报告、备份、简报等无需人在场。cron/ 子系统提供调度器、作业存储、执行记录、生命周期守卫、蓝图/建议目录,以及独立的 toolsets 隔离(定时任务可禁用部分工具,防止无人值守时干危险事)。网关用 60s 心跳驱动调度器。
设计动机
无人值守是这套子系统的核心问题。agent 在凌晨三点自己跑起来,谁盯着?Hermes 的回答是「调度链路上层层降权」:toolsets 默认裁掉交互类和能再开 cron 的 cronjob;lifecycle_guard 把脚本里 systemctl restart hermes-gateway 这类命令拦在调度前;executions 表给每次执行留可查的痕迹,崩溃后标 unknown 而不是冒充成功。让「agent 自己跑」能审计、能回滚。
关键文件
def run_job:2659— 执行单个作业的核心_run_job_script / _run_job_script_with_claim_heartbeat:2113-2247— 脚本型作业执行 + 租约心跳运行态与中断:352-445—get_running_job_ids/mark_running_jobs_interrupted/_consume_interrupted_flagtoolsets 解析:143-210—_resolve_cron_disabled_toolsets/_resolve_cron_enabled_toolsets(定时任务的工具隔离)线程池:501-547—_get_parallel_pool/_get_sequential_pool_CronStorePaths / _current_cron_store:106-157— 作业存储路径锁与输出目录:251-360—_jobs_lock_file/_job_output_dir_normalize_job_record:426-461— 作业记录规范化执行状态机:99-156—create_execution/mark_execution_running/finish_executionrecover_interrupted_executions:156-213— 崩溃恢复 +list_executions/latest_executioncheck_gateway_lifecycle:69-112— 防止定时脚本误操作网关生命周期blueprint_catalog— 作业蓝图目录suggestion_catalog/suggestions— cron 建议生成_start_cron_ticker:22335— 网关内 60s 心跳驱动调度
数据流
- 网关启动
_start_cron_ticker:22335,每 60s 触发调度器(实际已迁到cron/scheduler_provider.py::InProcessCronScheduler,旧符号作 shim)。 - 调度器读作业存储(
cron/jobs.py:106),按 cron 表达式选到期作业。 - 每个到期作业:
check_gateway_lifecycle:112扫描脚本,阻止误改网关生命周期- 解析 toolsets(
cron/scheduler.py:210),通常禁用危险工具(见子代理的 auto-deny 同源思路) create_execution(cron/executions.py:99)记一笔执行run_job(cron/scheduler.py:2659)实际跑:脚本型走_run_job_script:2113,会话型喂给run_conversation:588
- 崩溃 → 下次
recover_interrupted_executions(cron/executions.py:156)恢复标记。 - 结果经网关投递。
调度器对 toolset 的裁剪硬编码三项,叠加用户 config.yaml 的 agent.disabled_toolsets:
def _resolve_cron_disabled_toolsets(cfg: dict) -> list[str]:
"""Toolsets a cron-spawned agent must never receive."""
disabled = ["cronjob", "messaging", "clarify"]
agent_cfg = (cfg or {}).get("agent") or {}
user_disabled = agent_cfg.get("disabled_toolsets") or []
for name in user_disabled:
name = str(name).strip()
if name and name not in disabled:
disabled.append(name)
return disabledcronjob 被禁是不让 cron 自己再造 cron(误触能级联开一堆作业),messaging/clarify 是交互类,无人值守只会挂着等不到人。脚本型作业走 _run_job_script,路径被关在 HERMES_HOME/scripts/ 里防 traversal。
执行状态机在 executions.py,状态只有 claimed → running → completed/failed/unknown,终态只能写一次(finish_execution 的 UPDATE 加 WHERE status IN ('claimed','running') 保护,重复写直接 rowcount=0 返回 None)。崩溃恢复只把「进程已不存在」的执行标成 unknown,不假装成功也不自动重跑——子进程可能已经写了副作用,也可能没,留给操作者去查,而不是猜 failed 让人以为重跑就完事。lifecycle_guard 把 systemctl restart hermes-gateway、pkill hermes gateway 编成正则,创建时和执行前各扫一次,把 prompt 和 script 拼起来扫,堵住「prompt 里说一句、脚本里干一刀」的拆分绕过。
边界与失败
- 秒级漂移与误伤:60s 心跳意味着触发点最多差一分钟。
mark_running_jobs_interrupted不区分哪个 PID 被杀,凡在杀的时刻在跑的作业统统打断,可能误伤快收尾的作业。 unknown状态的副作用:崩溃恢复标unknown后不重跑,但脚本可能已发过消息/写过文件。下游不能假设「未完成」。- 租约心跳卡死:
_run_job_script_with_claim_heartbeat用独立线程续租,心跳线程本身卡住(如 GIL 长持有)时,另一个调度进程可能把同一个 one-shot 作业再派一次,产生重复副作用。 - prompt 注入盲区:运行时才扫 assembled prompt(含 skill 内容),恶意 skill 要等调度那一刻才被拦下,创建作业时看不到。
小结
Cron 子系统的重点是「无人值守的安全性」:toolsets 隔离、lifecycle_guard 防误操作、execution 状态机做崩溃恢复。调度本身由网关心跳驱动,不需要外部 cron 守护进程。