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::AgentTurn(action.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 | 必填字段 | 可选字段 | 备注 |
|---|---|---|---|
channel | channel_type, recipient | thread_id, account_id | channel_type 对应 [channels] 里的 adapter key("telegram" / "slack" / "discord" …)。多账号场景下 account_id 解析为 <channel>:<account_id>。 |
webhook | url | auth_header | URL 必须以 http:// 或 https:// 开头。URL 长度上限 2048。SSRF 黑名单中的 host 在校验时拒绝(见下文)。 |
local_file | path | append(默认 false) | 路径必须相对 workspace — 绝对路径和 .. 穿越被拒。append = true 以追加打开;false(默认)每次触发都覆盖。 |
email | to | subject_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.internal | GCP 元数据服务。 |
metadata.aws.amazon.com | AWS 元数据服务主机名。 |
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"
任务触发时分派器:
-
校验
argv[0]是否在<home_dir>/scripts/白名单内(见 路径白名单)。 -
直接 spawn 二进制(不走 shell —
argv是分割形式,没有$VAR展开,没有 piping)。 -
在守护进程环境之上叠加
pre_script.env,并应用cwd(如果设了)。 -
在 60 秒 tokio timeout 下捕获 stdout。超时的子进程通过
kill_on_drop(true)回收。 -
trim 后 stdout 非空就追加到 prompt:
{scheduled message} --- pre_script output --- {stdout} -
如果校验失败、二进制启动失败、脚本非零退出、或 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_AUDIT | glibc 动态链接器注入。 |
DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH, DYLD_FALLBACK_LIBRARY_PATH | Darwin 动态链接器注入。 |
PATH | 脚本自己的 subprocess.Popen("subcmd") 调用会经攻击者控制的 PATH 解析。 |
IFS | POSIX shell 字段分隔符。 |
比较是 ASCII 大小写无关的,所以 path 和 Path 跟 PATH 一样被拒。敏感值放进加密 vault(LIBREFANG_VAULT_KEY / ~/.librefang/vault.enc),不要明文写进 pre_script.env。
Wake Gate vs Pre-Script
pre_check_script 跟 pre_script 看着像,但回答不同问题:
pre_check_script(wake gate) | pre_script(context 注入) | |
|---|---|---|
| 决定 | "agent 该不该跑?" | "这次跑给它什么额外上下文?" |
| 输出协议 | stdout 最后一行非空必须是 JSON。{"wakeAgent": false} 跳过 LLM 调用。其他都唤醒。 | 非空时 stdout 原样追加到 prompt。 |
| Timeout | 30 秒。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 interval | 60 秒 – 86 400 秒(24 小时) |
一次性 at 最远 | 未来 1 年内 |
SystemEvent.text 长度 | 4096 字符 |
AgentTurn.message 长度 | 16 384 字符 |
AgentTurn.timeout_secs | 10 秒 – 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 timeout | 60 秒墙钟 |
| Wake-gate timeout | 30 秒墙钟 |
校验失败的任务会从 /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 历史无限增长的方式。 - Workflows —
session_mode解析顺序和 cron 特定注意。每次 cron 触发需要完全 session 隔离时,per-triggersession_mode = "new"是合适的旋钮。