# 定时任务

> 命令行版为什么没有定时任务，以及桌面端的 Cron 怎样工作：cron 命令、表达式与时区、调度器、Run 变成回合的过程、停机后的处理与三代存储。

- 作者：David（道雾轩）
- 专栏：MiniMax Code 源码解读（https://daiw.org/manual/minimax-code.md）
- 最后更新：2026-09-19
- 原文：https://daiw.org/manual/minimax-code/cron
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

先说结论：在 `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`）：

```ts
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](https://daiw.org/manual/minimax-code/headless-and-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` 大致是这样（示意）：

```json
{
  "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`）：

```ts
  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 的数据库里（见[存储](https://daiw.org/manual/minimax-code/storage)）：

| 表 | 内容 | 出处 |
| --- | --- | --- |
| `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`）：

```ts
    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`。

```mermaid
flowchart LR
  T["croner 定时器到点"] --> P["插入 pending Run"]
  P --> C["claimExecution 抢占"]
  C --> S["解析目标会话"]
  S -->|"mode 为 new"| N["新建 cron 会话"]
  S -->|"mode 为 sessionId"| F["校验固定会话"]
  N --> D["steer 投递 prompt"]
  F --> D
  D -->|"受理"| OK["Run 记为 delivered"]
  D -->|"拒绝或异常"| ER["Run 记为 failed"]
```

`executePending` 的步骤（`executor.ts:448`）：

1. **抢占**：`claimExecution` 用比较并交换写入 `execution_claimed_at_ms`，抢不到说明别处在跑，本次跳过。
2. **读 Definition**：已被删除就记失败，错误码 `CRON_DEFINITION_DELETED`。
3. **解析目标会话**（`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 时，第一次运行新建的会话会被绑定为以后的固定目标。
4. **投递**：把 prompt 以转向的方式送进会话（`packages/local-runtime-v2/src/service/cron/runtime-delivery.ts:31`）：

```ts
  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 },
    },
  });
```

转向的语义见[回合系统](https://daiw.org/manual/minimax-code/turn-system)：会话空闲就开一个新回合，正在运行就并入当前回合；幂等键是 Run 的 id，重复投递不会送两次。被拒绝时，错误码写成 `CRON_DELIVERY_` 加原因的大写形式。

5. **收尾**：受理后把 Run 记为 `delivered`。这里的“已投递”只表示 prompt 被会话受理，Run 不跟踪那个回合最后成功与否。

## 进程不在时

调度器活在桌面应用的进程里，进程没在跑，什么也不会触发，错过的时间点也不补。启动时 `restoreJob` 逐个恢复活动任务（`scheduler/index.ts:305`）：

- 一次性计划的时间已过，直接标成 `expired`，不执行，只记一个 `expired_once` 指标。
- 周期计划从“现在”起算下一次触发，停机期间错过的那些次不会补跑。
- 已达 `maxRuns` 的转为 `completed`。

Run 层面也有收尾，见 `recoverPendingRuns` 的注释（`executor.ts:254`）：

```ts
/**
 * 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`）。

下一篇：[技能](https://daiw.org/manual/minimax-code/skills)——`SKILL.md` 从哪些目录被发现，怎样进入提示词，又怎样被模型和斜杠命令调用。
