Cron 调度器

定时 cron 任务是 LibreFang 按时钟跑 agent 回合的方式。每个任务挂在某个 agent 上,按固定 interval / 一次性未来时间戳 / 5 字段 cron 表达式触发。本页文档化让调度器从"发完就不管,响应只发一个 channel"变成可控管道的三个特性:

  • 多目标分发 — 一次触发扇出到多个 channel / webhook / 文件 / email 收件人,每个目标失败隔离。
  • Pre-script — LLM 回合前跑一段确定性命令,把 stdout 注入 prompt 当额外上下文。
  • Silent marker — 让 agent 在运行时决定本次触发不产生任何分发。

包含主题


CronJob Schema

cron 任务以 JSON 持久化在 agent 数据目录里,通过 /api/agents/{id}/cron 端点暴露。在 API/测试边界可以用 TOML 写出同样形状:

# 最小任务
id = "00000000-0000-0000-0000-000000000000"
agent_id = "research-agent"
name = "morning-briefing"
enabled = true
created_at = "2026-04-25T00:00:00Z"

[schedule]
kind = "cron"
expr = "0 9 * * 1-5"
tz = "America/New_York"

[action]
kind = "agent_turn"
message = "用三条要点总结昨夜市场动态。"
timeout_secs = 120

[delivery]
kind = "channel"
channel = "telegram"
to = "123456789"

跟新特性相关的字段都在 CronAction::AgentTurnaction.kind = "agent_turn")和顶层 delivery_targets 列表上:

字段位置用途
pre_check_script[action]已有的 wake gate。stdout 最后一行非空若是 {"wakeAgent": false} 则跳过 LLM 调用。
pre_script[action]跑一段脚本把 stdout 作为额外上下文注入 LLM prompt。
silent_marker[action]标记字符串。agent 把它放在响应最后一行可抑制本次分发。默认 "[SILENT]"
delivery_targets顶层delivery 之外额外的扇出目标。默认空。

多目标分发

老的单 delivery 字段仍然能用,依然是主要目标。delivery_targets 是 additive 的列表 — 每条多触发一次额外的发送。多个目标并发跑,单个失败不会中止其他。

[action]
kind = "agent_turn"
message = "跑一下日运维摘要。"
timeout_secs = 180

# 主目标 — 跟原来一样。
[delivery]
kind = "channel"
channel = "slack"
to = "C0OPS"

# 额外扇出目标。
[[delivery_targets]]
type = "channel"
channel_type = "telegram"
recipient = "987654321"

[[delivery_targets]]
type = "webhook"
url = "https://ops.example.com/hooks/daily-digest"
auth_header = "Bearer prod-token-redacted"

[[delivery_targets]]
type = "local_file"
path = "reports/daily-digest.md"
append = true

[[delivery_targets]]
type = "email"
to = "ops-team@example.com"
subject_template = "Daily digest from {job}"

Target 变体

每条用带 tag 的 union 形状,type 选择变体。

type必填字段可选字段备注
channelchannel_type, recipientthread_id, account_idchannel_type 对应 [channels] 里的 adapter key("telegram" / "slack" / "discord" …)。多账号场景下 account_id 解析为 <channel>:<account_id>
webhookurlauth_headerURL 必须以 http://https:// 开头。URL 长度上限 2048。SSRF 黑名单中的 host 在校验时拒绝(见下文)。
local_filepathappend(默认 false路径必须相对 workspace — 绝对路径和 .. 穿越被拒。append = true 以追加打开;false(默认)每次触发都覆盖。
emailtosubject_template"email" channel adapter — 该 adapter 必须在 [channels] 里注册。subject_template 里的 {job} 占位符在发送时替换成 job 名称。

失败隔离

任务触发、agent 产出响应后,分派器会迭代老的 delivery 和每个 delivery_targets 条目。每次发送跑在自己的 task 里。上面例子里如果 webhook 返回 503,Slack 和 Telegram 消息照样发出去,文件照样写入,email 照样入队。每个目标的错误带 job 名和 target 索引记日志,但不会向下个目标传播也不会标记 job 本身失败 — LLM 回合已经成功完成。

这种隔离也是为什么 [delivery] 没被并入 delivery_targets 的原因:老的单目标任务保持原有语义(分派器走 cron_deliver_response 路径),新的扇出目标叠加在上层,无需迁移。


Webhook 上的 SSRF 防护

Webhook URL 在任务创建/更新时校验,触发前不会被拉取。校验器拒绝指向 loopback 接口、link-local 地址、以及著名云元数据服务的 host。

被拦截的 host 模式(大小写无关地匹配 URL 的 host 部分):

模式为什么拦截
localhost按名 loopback。
127.*IPv4 loopback 段。
[::1]IPv6 loopback。
169.254.*IPv4 link-local — 涵盖 AWS / Azure / DigitalOcean 元数据 IP 169.254.169.254
fe80:*[fe80:*IPv6 link-local。
metadata某些元数据端点用的裸主机名。
metadata.google.internalGCP 元数据服务。
metadata.aws.amazon.comAWS 元数据服务主机名。

userinfo(user:pass@host)和端口在检查前剥掉,所以 http://attacker@127.0.0.1:8080/x 会按 127.0.0.1 host 拒绝,跟 http://127.0.0.1/x 一样。

如果你确实需要扇到 loopback 段的 host —— 比如同机的 sidecar — 走 channel adapter 或者 local_file 目标,不要用 webhook。webhook 路径就是有意限制为非内部目标。


Pre-Script

pre-script 在 LLM 回合触发前跑,stdout 成为 prompt 的一部分。用它把确定性的数据获取(HTTP 抓取、跟前一状态做 diff、计算)跟 LLM 推理步骤分开 — LLM 拿到新鲜数据无需消耗 token 在 tool-call 开销上,幻觉风险也下降,因为数据是字面注入而不是重构出来的。

[action]
kind = "agent_turn"
message = "用三条要点总结昨天起新出现的 GitHub issue。如果一个都没有,回复 [SILENT]。"
timeout_secs = 120
silent_marker = "[SILENT]"

[action.pre_script]
argv = ["fetch-new-issues.sh", "myorg/myrepo"]
cwd = "/home/alice/.librefang/scripts"

[action.pre_script.env]
GITHUB_REPO_FILTER = "is:open"

任务触发时分派器:

  1. 校验 argv[0] 是否在 <home_dir>/scripts/ 白名单内(见 路径白名单)。

  2. 直接 spawn 二进制(不走 shell — argv 是分割形式,没有 $VAR 展开,没有 piping)。

  3. 在守护进程环境之上叠加 pre_script.env,并应用 cwd(如果设了)。

  4. 在 60 秒 tokio timeout 下捕获 stdout。超时的子进程通过 kill_on_drop(true) 回收。

  5. trim 后 stdout 非空就追加到 prompt:

    {scheduled message}
    
    --- pre_script output ---
    {stdout}
    
  6. 如果校验失败、二进制启动失败、脚本非零退出、或 timeout — agent 仍然跑,只是没有注入上下文。pre-script 失败是加性的,永远不阻塞计划触发。如果你想让失败跳过运行,wake gate(pre_check_script)才是合适的工具。

路径白名单

pre_script.argv[0] 必须 canonicalize 后落在 <home_dir>/scripts/ 之下,<home_dir> 是守护进程 home(通常是 ~/.librefang)。

  • 相对路径 先拼到 <home_dir>/scripts/ 上再 canonicalize。argv = ["fetch.sh"] 解析为 ~/.librefang/scripts/fetch.sh
  • 绝对路径 直接 canonicalize;结果仍必须在 <home_dir>/scripts/ 下。symlink 会被跟随,所以白名单内指向外部的 symlink 会被拒。
  • .. 穿越 由 canonicalize 折叠,结果路径在路径组件层级做前缀检查 — 所以 <home_dir>/scripts-other 没法假装满足 <home_dir>/scripts,即使字符串前缀相同。
  • 缺失文件 校验失败 NotFound — 脚本必须在校验时存在于磁盘。

危险环境变量

pre_script.env 叠在守护进程环境之上,但有 denylist 拒绝那些会破坏路径白名单(劫持被 spawn 的二进制的库或路径解析)的 key:

拒绝的 key原因
LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDITglibc 动态链接器注入。
DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH, DYLD_FALLBACK_LIBRARY_PATHDarwin 动态链接器注入。
PATH脚本自己的 subprocess.Popen("subcmd") 调用会经攻击者控制的 PATH 解析。
IFSPOSIX shell 字段分隔符。

比较是 ASCII 大小写无关的,所以 pathPathPATH 一样被拒。敏感值放进加密 vault(LIBREFANG_VAULT_KEY / ~/.librefang/vault.enc),不要明文写进 pre_script.env


Wake Gate vs Pre-Script

pre_check_scriptpre_script 看着像,但回答不同问题:

pre_check_script(wake gate)pre_script(context 注入)
决定"agent 该不该跑?""这次跑给它什么额外上下文?"
输出协议stdout 最后一行非空必须是 JSON。{"wakeAgent": false} 跳过 LLM 调用。其他都唤醒。非空时 stdout 原样追加到 prompt。
Timeout30 秒。timeout 唤醒 agent(failsafe)。60 秒。timeout 让 agent 不带上下文触发(failsafe)。
路径白名单<home_dir>/scripts/(canonicalize + component prefix)。路径违规唤醒 agent。<home_dir>/scripts/(canonicalize + component prefix)。校验失败让 agent 不带上下文触发。
用例没新东西就跳过 LLM 调用。省 token。把确定性数据喂进 prompt。降低数字/diff 上的幻觉。

两者可以同时挂在一个 [action] 上。常见模式是"用 wake gate 守卫抓取,再注入抓到的数据":

[action]
kind = "agent_turn"
message = "处理新 issue。"
pre_check_script = "/home/alice/.librefang/scripts/has-new-issues.sh"

[action.pre_script]
argv = ["fetch-new-issues.sh"]

issue 列表为空时 has-new-issues.sh 返回 {"wakeAgent": false} — agent 永远不被唤醒,没有 LLM token 消耗。有新 issue 时 wake gate 静默返回,然后 fetch-new-issues.sh 跑、它的 stdout 注入 prompt。


Silent Marker

silent_marker 是 agent 一侧的运行时逃生口:即使 LLM 回合已经跑过并产出响应,agent 也能通过让响应以这个 marker 结尾来抑制所有分发。

[action]
kind = "agent_turn"
message = "如果日志里有关键告警就总结。否则只回复 [SILENT]。"
silent_marker = "[SILENT]"

[delivery]
kind = "channel"
channel = "telegram"
to = "123456789"

[[delivery_targets]]
type = "email"
to = "oncall@example.com"

行为:

  • 不设 silent_marker 时默认 "[SILENT]"
  • 匹配是严格的最后一条非空 trim 行相等。响应 "无事可报。\n[SILENT]\n\n " 命中(尾部空行被忽略)。响应里段中提到 [SILENT]命中 — marker 必须独占最后一行。
  • 空响应不命中 marker。空响应走已有的"silent"处理(agent loop 里的 result.silent 标志)。
  • marker 命中时,老的 delivery 和每个 delivery_targets 条目本次触发都被抑制。任务本身仍记成功 — agent 是有意选择本次保持安静,这不是错误。

这是"嘈杂 no-op"任务的合适工具:每 5 分钟 cron 一次 agent,但只在真有事时给团队发消息。配合 pre_script 取数据,安静时段的成本下限就是一次 shell 脚本 + 一次决定输出 [SILENT] 的 LLM 回合 — 没有扇出成本。


上限与校验

job-create / job-update 时强制的硬性上限和校验规则:

上限数值
每个 agent 的任务数50
任务名长度128 字符(字母、数字、空格、连字符、下划线)
every_secs interval60 秒 – 86 400 秒(24 小时)
一次性 at 最远未来 1 年内
SystemEvent.text 长度4096 字符
AgentTurn.message 长度16 384 字符
AgentTurn.timeout_secs10 秒 – 600 秒
Webhook URL 长度2048 字符
Webhook host不在 SSRF 黑名单
local_file.path非空、相对 workspace、无 ..
pre_script.argv[0]解析在 <home_dir>/scripts/ 之下
pre_script.env不属于动态链接器 / PATH / IFS denylist
Pre-script timeout60 秒墙钟
Wake-gate timeout30 秒墙钟

校验失败的任务会从 /api/workflows/cron 返回带描述的错误 — 规则是"第一个失败优先",修掉报告的问题再重试。canonical schema 见 crates/librefang-types/src/scheduler.rs,分派器见 crates/librefang-kernel/src/cron_delivery.rs


相关

  • Session 自动 Reset — 按计划清理消息历史。cron 任务默认共享一个 (agent, "cron") session;session_reset 是防止那个 session 历史无限增长的方式。
  • Workflowssession_mode 解析顺序和 cron 特定注意。每次 cron 触发需要完全 session 隔离时,per-trigger session_mode = "new" 是合适的旋钮。