Agent 怎么被自动驾驶:Autopilot 让它到点自己开工
本文是 Multica 拆解系列的第 6 篇(也是本季最后一篇)。前五篇讲的都是"你主动喊一声,Agent 才干活"。这一篇讲同事能接 standing order:到点了,自己开工。
这一篇要解决的问题
前面讲过的触发方式——分配 issue、@mention、聊天——都有一个共同点:需要你主动触发。但很多工作是周期性的:每周一早上做 standup 总结、每天凌晨跑清理、每个 PR 到达时跑代码审查。你不可能守在电脑前每次手动派。
能不能给 Agent 下一道"长期指令",让它按时间或事件自己跑?这就是 Autopilot 要解决的事。

我通读了 server/internal/service/autopilot.go 和 cron.go,Autopilot 的核心区别是它是时间/事件驱动,而不是人驱动。
心智模型:Autopilot = 触发器 + 执行模式 + 智能体
一个 autopilot 由三部分定义:触发器(什么时候跑)、执行模式(怎么跑)、执行智能体(派给谁)。

触发器至少一条。schedule 是标准 5 字段 cron(分 时 日 月 周),最小粒度 1 分钟,时区用 IANA 格式(比如 Asia/Shanghai)。webhook 让外部系统(比如 GitHub 的 PR 事件)触发。执行模式有两种,选哪个是这一篇的关键决策。
深度点一(免费):create_issue vs run_only——先有 issue 还是直接跑
Multica 文档明确说建议从 create_issue 模式开始,我读了代码才理解为什么。两种模式的差别在 dispatch 分支:
// server/internal/service/autopilot.go
switch autopilot.ExecutionMode {
case "create_issue":
// 先在工作区建一个 issue(标题插值 {{date}}),
// 再走标准分配流程把 issue 派给智能体
s.dispatchCreateIssue(ctx, autopilot, &run, triggerTimezone)
case "run_only":
// 不建 issue,直接入队一个 task
s.dispatchRunOnly(ctx, autopilot, &run)
}
create_issue 模式:每次触发先在工作区建一条 issue(标题里的 {{date}} 会插值成 UTC 日期 YYYY-MM-DD),再按正常分配流程派给智能体。结果落在 issue 看板上,历史、评论、状态都跟手动分配的 issue 完全一致。
run_only 模式:不建 issue,直接往任务队列塞一个 task。看板上看不到这次运行,只能在 Autopilot 的运行历史里查。
两种执行模式的对比:

为什么推荐 create_issue?因为可观测性。一个周期任务,你最关心的是"这次跑没跑、跑成什么样了"。create_issue 让每次运行都变成看板上一条可见的 issue,你能看到它的生命周期、能评论、能追踪。run_only 像是后台幽灵——跑了但你不容易发现。
反直觉:run_only 看起来更"轻量"(不污染看板),但在生产里这恰恰是它的缺点。一个周期任务如果静默失败(run_only 直接入队,失败也只在 autopilot 历史里),你可能几周都不知道它坏了。create_issue 用 issue 的可见性换可靠性,这在工程上几乎总是值得的。Multica 把 create_issue 设为默认推荐,是吃过亏后的结论。
深度点二:占位符插值的严格校验
create_issue 模式里,issue 标题目前只支持一个占位符 {{date}},插值成 UTC 日期。但我在代码里注意到一个细节:其它任何 {{...}} 形式的占位符会被直接拒绝,而不是当成原文塞进标题。
这是个很 defensive 的设计。如果允许任意占位符,用户拼错了(比如 {{daten}}),系统可能悄无声息地把原文当标题,issue 就叫"{{daten}} standup",几天后才发现。拒绝 + 报错,把拼写错误在创建那一刻暴露出来。
// {{date}} → YYYY-MM-DD
// 其它 {{...}} → 创建时拒绝,避免拼错后静默用原文当标题
这个设计哲学贯穿 Multica:宁可显式失败,也不要静默降级。跟第 3 篇的失败分类器、第 4 篇的 skill 注入路径是一脉相承的——凡是有歧义的地方,系统选择"明确告诉你不对",而不是"猜一个继续跑"。
深度点三:scheduler 的 30 秒 tick——准时不靠精度靠频率
Autopilot 到点就跑,这个"到点"是怎么被检测的?我看了 scheduler 的实现。
// server/internal/scheduler/manager.go
type Options struct {
// TickInterval is how often the manager wakes up to evaluate due plans.
// Should be smaller than the smallest job interval.
TickInterval time.Duration
}
if opts.TickInterval <= 0 {
opts.TickInterval = 30 * time.Second // 默认 30 秒一次
}
scheduler 不是精确的"到 09:00:00 这一毫秒触发",而是每 30 秒醒一次,检查哪些 plan 到期了。cron 表达式用 robfig/cron/v3 解析,ComputeNextRun 算出下一次触发时间,tick 时发现到期就派发。
这个设计有个隐含权衡:30 秒的 tick 意味着最坏情况下一个 cron 任务会晚最多 30 秒触发。对"每周一 standup""每天凌晨清理"这种任务,30 秒的抖动完全无所谓。但如果你的 cron 精度要求到秒,这套机制不够。
// server/internal/service/cron.go
var cronParser = cron.NewParser(
cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow,
)
func ComputeNextRun(cronExpr, timezone string) (time.Time, error) {
sched, err := cronParser.Parse(cronExpr)
// ...
}
注意 parser 只到分钟粒度(没有秒字段)——这跟 30 秒 tick 是配套的。精度上限被刻意设成"分钟",因为周期任务的真实需求从来没有秒级。这是一个"不做不需要的能力"的例子:如果支持秒级 cron,就要把 tick 压到 1 秒,scheduler 的负载、数据库扫描的压力都会涨,而 99% 的用户根本用不到。
这个全栈闭环是:前端配置 autopilot(cron + 时区 + 模式 + 智能体)→ 存数据库 → scheduler 每 30 秒 tick,ComputeNextRun 命中到期 → dispatchCreateIssue/dispatchRunOnly 建 issue 或入队 task → WS 广播 task:queued → 前端实时看到 Agent 启动。你不在场,Agent 也会在每周一早上 9 点自己开工。
收尾
六篇拆下来,Multica 把"Agent 当同事"这句话拆成了六个可工程化的属性:
- 工位(runtime)——Agent 在你机器上不在服务器上,心跳判定在不在
- 大脑(适配器)——13 款工具统一成一个 Backend 接口
- 生命周期(task 状态机)——三级超时区分"慢"和"死",失败自动归因
- 技能(skills)——一个好 prompt 沉淀成全队资产,跟着组织走
- 组队(squad)——@一个团队而不是某个人,路由稳定
- 自治(autopilot)——到点自己开工,接 standing order
这是"2 人 + 10 Agent ≈ 20 人产出"背后真实的技术骨架——不是一句口号,是一套把"Agent 是同事"从产品理念落到可运行系统的完整工程。每一篇我都通读了对应模块的真实代码,图是我照着代码画的,事件名是从 events.go 抄的,失败分类是从 failure.go 数的。
这个系列会继续——下一个要拆的方向,我会回头读你的反馈再定。
想看 AI 拆完下一篇?留个邮箱,自动推送。