定时任务
命令行版为什么没有定时任务,以及桌面端的 Cron 怎样工作:cron 命令、表达式与时区、调度器、Run 变成回合的过程、停机后的处理与三代存储。
先说结论:在 mcode 命令行版里,定时任务是关着的。调度器不创建,Cron 服务不装配,模型拿不到能建定时任务的 mavis 工具,提示词里也就没有相关说明。仓库里的 Cron 代码为桌面端(Electron 宿主)服务,公开源码里连桌面端调用 Cron v2 的那个客户端都没有接上。
这些代码仍值得一读:它说明了一个“未来的时间点”怎样被持久化,又怎样在到点时变成一次普通回合。本篇先把命令行里的开关逐层列清,再按代码讲桌面端的实现:mavis 工具的 cron 命令、表达式与时区、Definition 与调度任务的分工、Run 的执行,以及进程不在时会发生什么。
命令行里的六道开关
TUI 以 runtimeOwnerKind: 'tui'、capabilityProfile: 'cli'、cliEmbedded: true 创建嵌入式运行时(packages/tui/src/runtime/embedded-host.ts:84),下面每一层都因此把 Cron 关掉:
| 层 | 条件 | 出处 |
|---|---|---|
| 调度器 | 只有 runtimeOwnerKind 为 electron 才创建 Scheduler | packages/local-runtime-v2/src/runtime.ts:842 |
| Cron 服务 | enableCron 取 ownsElectronRuntimeCapabilities,只认未指定或 electron | packages/local-runtime-v2/src/services.ts:492 |
| 工具能力 | 命令行宿主加上 disableCron,CLI 能力档再加 disableMavis | packages/local-runtime/src/api/hosted-agent-capability-factory.ts:27 |
| 提醒 | 宿主判定没有 Cron 能力,依赖 Cron 的系统提醒不再注入 | packages/local-runtime/src/api/host.ts:1384 |
| 第一代消费者 | cronConsumerEnabled 缺省时,对 cli 宿主与嵌入式宿主为假 | packages/local-runtime/src/api/host-cron-runtime.ts:88 |
| 工具适配器 | mavisCronAdapterProvider 在公开仓库里没有提供方 | host.ts:1375 |
hosted-agent-capability-factory.ts 的注释把前几层的关系说在一处(hosted-agent-capability-factory.ts:9):
Embedded command-line owners run a v2 Runtime without a Scheduler, so Cron, memory and CU are unavailable there.
background-runtime.ts 也写明调度器是 Electron 独有的能力,嵌入式命令行只保留事件总线(packages/local-runtime-v2/src/background-runtime.ts:15)。万一某个宿主拿到了 mavis 工具却没有 Cron 适配器,所有 cron 子命令会在参数校验之前统一拒绝(packages/agent-tools/src/desktop/local-mavis.ts:596),错误信息直接叫模型别再折腾(local-mavis.ts:1400):
function cronUnsupportedHostError(): LocalMavisRuntimeError {
return new LocalMavisRuntimeError(
'Scheduled tasks are not supported on this runtime host. Cron is a desktop-app capability; ' +
'do not retry, probe the runtime, or look for a service to start. ' +
'Track the pending work in this conversation and ask the user to check back instead.',
{
code: 'CRON_UNSUPPORTED_HOST',
status: 501,
},
);
}命令行里要定时跑任务,得在外面借助操作系统的计划任务去调用 mcode exec(见 Headless 与 ACP)。
怎么用:mavis 工具的 cron 命令
在开了 Cron 的宿主上,模型通过原生 mavis 工具管理定时任务,command 写成“组 动作”的形式(packages/agent-tools/src/desktop/builtin-defs.ts:1004):
| 命令 | 作用 |
|---|---|
cron list、cron get | 列出、读取任务 |
cron resolve-model | 只读地把用户说的模型名对到模型目录 |
cron create | 建周期任务,一直有效,直到被禁用或删除 |
cron once | 安排未来的一次回合 |
cron self | 周期性自我提醒,用来反复检查没有完成信号的外部状态 |
cron update、cron delete | 修改、删除 |
cron trigger | 立即手动跑一次,返回 run_id |
cron sessions | 查历史:v2 的 Run,或旧版的会话 |
参数放在 args 里:cron_name、schedule、timezone、prompt、model、enabled;目标会话放在 session 对象里,含 mode 与 session_id;cron once 用 after 或 at,cron self 用 every 与 quiet_on_skip(builtin-defs.ts:781 起)。一次 cron create 大致是这样(示意):
{
"command": "cron create",
"args": {
"cron_name": "每日构建检查",
"schedule": "0 9 * * 1-5",
"timezone": "Asia/Shanghai",
"prompt": "检查主干构建是否通过,失败时列出失败的任务",
"agent_name": "me",
"session": { "mode": "new" }
}
}使用规则写在 mavis 工具的说明里,第一句就划了边界(builtin-defs.ts:970):
Use Cron only to schedule a future Agent turn.
同一段还要求:当前工具已经承诺完成后会恢复对话的,不要再建 Cron;任务内容或时间不明确时先用 ask_user 问清,不许自己编;cron self 只用于反复检查没有完成信号的外部状态。参数说明补充了两条默认:新建时默认新开会话、agent_name 取 me(builtin-defs.ts:863);用户没选模型就省略 model,由运行时记下当前回合的模型(builtin-defs.ts:824)。仓库里另有一份 packages/local-runtime-v2/assets/agents/mavis/features/cron.md.hbs 写着同样的规则,但 TUI 与桌面都走 v2 提示词,这条路径下 feature 文件一律不渲染(packages/local-runtime-v2/src/service/agent/builtin/catalog.ts:431)。
桌面端的 mavis 工具不直接碰数据库。createLocalMavisCronAdapter 只做输入换算:解析 after 与 at,把 every 换成表达式,给 cron once、cron self 起 once- 或 watch- 加随机串的默认名(packages/agent-tools/src/desktop/local-mavis-cron-adapter.ts:151),然后转发给桌面服务生成的 Cron v2 客户端(local-mavis-cron-adapter.ts:186)。这个客户端属于桌面服务,不在公开仓库里。
表达式与时区
cron create 的 schedule 是 cron 表达式,timezone 是 IANA 时区名,两者原样存进调度任务的计划里,到运行时交给 croner 解释(packages/local-runtime-v2/src/infra/scheduler/index.ts:358,依赖声明为 ^9,见 packages/local-runtime-v2/package.json:106)。调度器自己只校验表达式与时区非空、maxRuns 为正整数(scheduler/index.ts:527);表达式要等 croner 构造定时器时才解析,非法表达式在创建时就会失败,算不出下一次触发时间的计划也按无效处理。不传时区时交给 croner 的默认行为,按运行进程的本地时间解释。调度任务的 maxRuns 字段有实现,但代码里没有找到设置它的调用方。
cron once 由适配器换算成绝对时间(packages/agent-tools/src/shared/cron-schedule-input.ts:28):after 与 at 必须二选一,结果必须晚于现在。after 接受 ms、s、m、h、d 的组合,如 1h30m;at 接受 Unix 毫秒或日期时间字符串。给了 timezone 而 at 又不带偏移时,按该时区的墙上时间换算,夏令时跳过的那段时间会被拒绝(cron-schedule-input.ts:98)。
cron self 的 every 可以是 5 段或 6 段表达式(原样透传),也可以是 5m、1h30m、2d12h、1w 这样的间隔。间隔换成 5 段表达式时只接受能精确表达的值,最短 1 分钟(cron-schedule-input.ts:241):
if (minutes < 60) return `*/${minutes} * * * *`;
if (minutes % 60 === 0) {
const hours = minutes / 60;
if (hours < 24) return `0 */${hours} * * *`;
if (hours % 24 === 0) {
const days = hours / 24;
if (days <= 31) return `0 0 */${days} * *`;
}
}90m、150m 这类值会报错,注释解释了原因:*/90 在 croner 里是非法的步长,并不表示“每 90 分钟”。cron self 的目标是一个固定会话,默认就是当前会话;quiet_on_skip 默认为真,会在 prompt 末尾追加一段 [gate-discipline] 约定,让条件未满足的那几次只输出一行 <mavis-progress> 状态就退出,不发即时消息,也不写普通回复(cron-schedule-input.ts:255)。
三张表:Definition、调度任务与 Run
v2 的 Cron 拆成三份持久数据,都在 v2 的数据库里(见存储):
| 表 | 内容 | 出处 |
|---|---|---|
local_runtime_v2_cron_definitions | 名字、Agent、prompt、目标会话、项目、模型、修订号、删除墓碑;同一 Agent 下未删除的任务名唯一 | packages/local-runtime-v2/src/infra/db/schema/cron.ts:4 |
local_runtime_v2_scheduler_jobs | 通用调度任务:计划、状态(active、completed、expired、cancelled)、已触发次数、下次触发时间 | packages/local-runtime-v2/src/infra/db/schema/scheduler.ts:18 |
local_runtime_v2_cron_runs | 每次执行一行:来源(手动或定时)、会话、状态(pending、delivered、failed)、错误码 | schema/cron.ts:51 |
Definition 只记“做什么”,“什么时候做、是否启用”归调度任务:服务对外的视图里,enabled 就是调度任务状态是否为 active(packages/local-runtime-v2/src/service/cron/orchestration/cron.service.ts:236)。创建时在同一个 immediate 事务里先登记调度任务、再插入 Definition,失败就把刚建的调度任务删掉(packages/local-runtime-v2/src/service/cron/orchestration/lifecycle.ts:26)。project 只能在第一次运行之前修改(lifecycle.ts:61)。产品里的删除只立墓碑并取消调度任务,Run 记录和执行过的会话都保留(cron.service.ts:142);反过来,一个会话若是某个未删除任务的固定目标,直接删会话会被拒绝,提示先删定时任务(packages/local-runtime-v2/src/services.ts:1170)。
模型字段有两种写法,由 decodeCronModel 统一解析(packages/shared/src/cron-model.ts:30):旧式的模型键字符串,或带 version: 1 的 JSON,里面是 providerID、modelID,以及可选的 variant、thinking(其中的 effort)、contextLimit。写入前,用户给的模型名会对照当前模型目录解析,对不上或有多个候选都拒绝,并要求让用户选(cron.service.ts:290)。
Scheduler:croner 定时器加一张表
Scheduler 是通用的持久化定时器,Cron 只是它的一个处理器,键为 cron.run(packages/local-runtime-v2/src/service/cron/contracts.ts:6)。每个活动任务在内存里对应一个 croner 定时器(scheduler/index.ts:354):
const pattern =
record.schedule.kind === 'once'
? new Date(record.schedule.runAtMs)
: record.schedule.expression;
const timezone = record.schedule.kind === 'cron' ? record.schedule.timezone : undefined;
const timer = new Cron(
pattern,
{
timezone,
paused: true,
unref: true,
protect: () => this.reportSkipped('overlap'),
catch: () => this.reportSkipped('trigger_error'),
},
async (cron) => {
await this.trigger(record.schedulerId, cron);
},
);unref 让定时器不拖住进程退出;protect 在上一次回调还没返回时挡掉本次触发,只记一个 overlap 指标。触发时先把已触发次数加一、算出下一次时间(一次性计划或达到 maxRuns 就转为 completed),写回数据库,再调用处理器(scheduler/index.ts:389)。Cron 的处理器只做一件事:用调度任务 id、计划代数和已触发次数拼成触发 id,在事务里插入一条 pending 的 Run(packages/local-runtime-v2/src/service/cron/composition.ts:96)。Run 表对触发 id 建了唯一索引(schema/cron.ts:71),同一次触发不会生成两条 Run。
到点:一条 Run 变成一次回合
插入 Run 之后,调度回调就返回了;真正的执行由 CronExecutor 的队列异步完成(packages/local-runtime-v2/src/service/cron/orchestration/executor.ts:171)。手动触发走同一个执行循环,只是 Run 的来源记为 manual。
executePending 的步骤(executor.ts:448):
- 抢占:
claimExecution用比较并交换写入execution_claimed_at_ms,抢不到说明别处在跑,本次跳过。 - 读 Definition:已被删除就记失败,错误码
CRON_DEFINITION_DELETED。 - 解析目标会话(
packages/local-runtime-v2/src/service/cron/adapters/session-creation.adapter.ts:98):mode为sessionId且给了会话 id,就校验会话存在、属于同一个 Agent、没有归档;否则新建一个会话,工作目录依次取任务的project、Agent 的默认工作目录、运行时的默认目录。新会话的sessionKind为cron,purpose为cron:加 Agent 名与任务名,标题就是任务名(packages/local-runtime-v2/src/service/cron/runtime-session-ports.ts:53)。mode为sessionId却没给 id 时,第一次运行新建的会话会被绑定为以后的固定目标。 - 投递:把 prompt 以转向的方式送进会话(
packages/local-runtime-v2/src/service/cron/runtime-delivery.ts:31):
const result = await turns.steer({
sessionId: request.sessionId,
input: { text: request.text },
producerId: `cron:${request.cronId}`,
idempotencyKey: request.runId,
provenance: {
source: 'cron',
routingFingerprint: `cron:${request.cronId}:${request.runId}`,
sourceContext: { cronId: request.cronId, runId: request.runId },
},
});转向的语义见回合系统:会话空闲就开一个新回合,正在运行就并入当前回合;幂等键是 Run 的 id,重复投递不会送两次。被拒绝时,错误码写成 CRON_DELIVERY_ 加原因的大写形式。
- 收尾:受理后把 Run 记为
delivered。这里的“已投递”只表示 prompt 被会话受理,Run 不跟踪那个回合最后成功与否。
进程不在时
调度器活在桌面应用的进程里,进程没在跑,什么也不会触发,错过的时间点也不补。启动时 restoreJob 逐个恢复活动任务(scheduler/index.ts:305):
- 一次性计划的时间已过,直接标成
expired,不执行,只记一个expired_once指标。 - 周期计划从“现在”起算下一次触发,停机期间错过的那些次不会补跑。
- 已达
maxRuns的转为completed。
Run 层面也有收尾,见 recoverPendingRuns 的注释(executor.ts:254):
/**
* Recover runs left by the previous process at startup:
* - Requeue unclaimed pending runs.
* - Mark claimed pending runs failed(PROCESS_RESTARTED), without redelivery.
*/也就是说,已插入但还没被抢占的 Run 重新排队执行;执行到一半进程没了的,记为失败,错误码 PROCESS_RESTARTED,不再投递,避免同一个 prompt 被送两次。启动时如果关闭了执行(restorePersistedJobExecution 为假),调度器只刷新下一次触发时间用于展示,不挂定时器(scheduler/index.ts:272)。
三代存储
| 代 | 存储 | 执行 |
|---|---|---|
| 最早 | 每个任务一个 Markdown 文件,<dataDir>/agents/<agentName>/crons/<cronName>.md,YAML frontmatter 写计划,正文写 prompt | 旧守护进程 |
| 第一代运行时 | SQLite 表 local_runtime_crons(packages/local-runtime/src/cron/store.ts:42),启动时一次性导入旧的 .md,不改原文件(packages/local-runtime/src/cron/legacy-md-migration.ts:23) | packages/agent-modules/cron(包名 @mavis/cron)的 CronRegistry 与 CronExecutor |
| v2 | 上文的三张表,迁移 migration-0002-copy-legacy-cron-data.ts 从 local_runtime_crons 复制旧任务(packages/local-runtime-v2/src/infra/db/migrations/cron/migration-0002-copy-legacy-cron-data.ts:77) | CronService、CronExecutor 与通用 Scheduler |
@mavis/cron 的头注释说它是从守护进程的 daemon/src/cron 搬出来的(packages/agent-modules/cron/src/index.ts:1)。它有两样 v2 没有的东西:一是会话忙时的 BusyQueue,同一任务同一会话只留最后一条,排队超过 30 分钟丢弃(packages/agent-modules/cron/src/busy-queue.ts:3);二是每日活动时段 active_hours。v2 没有忙时队列,投递走转向,会话在忙就并入正在跑的回合;活动时段也不再支持,mavis 工具仍收这个参数,但会以 CRON_PARAM_UNSUPPORTED 拒绝(local-mavis-cron-adapter.ts:171)。
会话标记的公共定义放在 shared 包里:cron: 开头的 purpose 前缀与解析函数,以及守护进程托管的记忆清理任务名 memory-cleanup(packages/shared/src/cron-purpose.ts:2)。
下一篇:技能——SKILL.md 从哪些目录被发现,怎样进入提示词,又怎样被模型和斜杠命令调用。