# 定时任务与循环

> cron 工具的固定节奏任务与自定节奏循环怎样登记，表达式与抖动怎样计算，跨进程锁与任务文件怎样工作，到点后怎样注入会话，以及进程不在时会发生什么。

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

有些事不是一问一答能办完的：每五分钟看一眼 PR，明早提醒提交周报，部署期间隔一会儿查一次状态。MiMo 为此加了一个 `cron` 工具和一个进程内调度器：模型登记一个带时间的提示词，到点后调度器把它当作一条新的用户消息送进会话，主循环照常跑一轮。上游 OpenCode 没有这套东西。代码分三块：`packages/opencode/src/cron/` 下的调度核心，`cron` 工具本身，以及把两者接到会话上的 `CronBridge`。

`/loop` 管“多久跑一次”，[Goal](https://daiw.org/manual/mimo-code/goal) 管“什么时候可以停”，两者互相独立。

## 怎么用

**开关**。`MIMOCODE_EXPERIMENTAL_CRON` 名字里带 experimental，却默认开启（`packages/opencode/src/flag/flag.ts:366`），设为 `false` 或 `0` 才关闭。它在进程里只读一次，决定 `cron` 工具是否注册（`packages/opencode/src/tool/registry.ts:310`）、`/loops` 命令是否存在（`packages/opencode/src/command/index.ts:207`）以及调度器是否启动。`MIMOCODE_DISABLE_CRON` 是运行时急停，每次 tick 都现读环境变量（`packages/opencode/src/session/cron-bridge.ts:21`）。

**斜杠命令**。

- `/loop [interval] <prompt>`：内置技能 `loop`（`packages/opencode/src/skill/builtin/.bundle/loop/SKILL.md:14`），技能会以同名斜杠命令出现在命令列表里（`command/index.ts:264`）。例如 `/loop 5m /babysit-prs`、`/loop check the deploy every 20m`。
- `/loops`：列出本会话的任务，带 `cancel <id>` 时删除（`packages/opencode/src/command/template/loops.txt:19`）。

**工具**。其余需求由模型直接调用 `cron`，六个动词（`packages/opencode/src/tool/cron.ts:11`，另有 `cancel` 作为 `delete` 的别名）：

| 动词 | 主要参数 | 作用 |
| --- | --- | --- |
| `schedule` | `cron`、`prompt`，可选 `one_shot`、`durable` | 按 5 字段表达式登记，默认循环执行、只存在内存里 |
| `loop` | `delay_seconds`、`prompt`，可选 `reason` | 自定节奏：隔若干秒触发一次，每次都要重新登记 |
| `list` | 可选 `kind`、`durable_only` | 列出本会话创建的任务 |
| `get`、`delete`、`rename` | `id`，`rename` 另需新的 `prompt` | 查看、删除、替换提示词 |

每个动词都接受 `session_id`。工具同时提供 JSON 与命令行两种调用形式（`cron.ts:437`），命令行形式如 `cron schedule "7 * * * *" "check the deploy" --durable`，动词拼错时按编辑距离给出建议（`cron.ts:30`），语法见[命令行式工具调用与 Token 精简](https://daiw.org/manual/mimo-code/token-efficient-shell)。

<Callout type="warn">
表达式一律按 UTC 解释，没有时区配置（`packages/opencode/src/cron/cron-expr.ts:57`）。给用户看的内置文档写明了这一点（`packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/guide.md:162`），但给模型看的工具说明、`loop` 技能和 `/loops` 模板都没提 UTC。工具说明把“every morning around 9”举例为 `57 8 * * *`（`packages/opencode/src/tool/cron.txt:23`），实际触发在 UTC 8 点 57 分，也就是北京时间 16 点 57 分。
</Callout>

## 任务存在哪

`CronTask`（`packages/opencode/src/cron/cron-task.ts:9`）的主要字段：`id`（UUID 去掉连字符后的前 8 位）、`cron`、`prompt`、`createdAt`、`lastFiredAt`、`recurring`、`permanent`、`kind`（唯一取值 `loop`）、`createdBySessionId`、`durable`。任务存两处：

- **内存**：模块级的 `SESSION_STORE`（`cron-task.ts:92`）。不带 `durable` 的 `schedule` 和所有 `loop` 都在这里，进程退出即消失。
- **文件**：`.mimocode/scheduled_tasks.json`（`cron-task.ts:26`），`durable` 为 true 的 `schedule` 写在这里，读写时丢弃表达式解析不了的条目。

任务文件与锁文件都按 `process.cwd()` 定位，因为桥启动调度器时没有传 `dir`（`cron-bridge.ts:273`）；后文的 `loop.md` 却按工作树根目录查找，在仓库子目录里启动时两者会分开。第一次抢锁时，如果 `.mimocode/.gitignore` 还不存在，会写一份忽略 `.cron-lock` 与 `scheduled_tasks.json` 的清单（`packages/opencode/src/config/gitignore.ts:4`）。

`permanent` 会被调度器读取（为 true 的循环任务满 7 天也不删），但工具没有对应参数，代码里也没有写入它的地方，只能手工改文件；`createdByPid`、`createdByProcStart` 同样只有定义、没有写入。

## 调度器与跨进程锁

整体结构如下：

```mermaid
flowchart LR
  T["cron 工具"] -->|schedule| M["内存任务表"]
  T -->|durable| D[".mimocode/scheduled_tasks.json"]
  T -->|loop| L["armLoop 与 LoopState"]
  L --> M
  K["每秒一次 tick"] --> M
  K -->|持锁进程才读| D
  K -->|到点且会话不忙| B["CronBridge onFire"]
  B --> S["展开哨兵，加时间戳"]
  S --> P["injectScheduledPrompt"]
  P --> R["主循环跑一轮"]
  R -->|一轮结束| A["keepalive 检查"]
  A -->|替模型续约| L
```

`Scheduler.start`（`packages/opencode/src/cron/scheduler.ts:232`）先尝试拿锁，再用 `setInterval` 每秒跑一次 `tick`（`scheduler.ts:272`）。`tick`（`scheduler.ts:150`）的规则：

1. 急停开关打开，或绑定的会话正忙，什么也不做。
2. 持锁进程看文件任务加内存任务，不持锁的进程只看内存任务（`scheduler.ts:158`）。
3. 第一次见到某个任务时，以 `lastFiredAt`（没有就用 `createdAt`）为起点算出下次触发时刻，缓存在内存里。
4. 到点就调 `onFire`，每个 tick 最多触发一个任务（`scheduler.ts:170`）。注释解释了原因：会话忙碌标志要等注入的消息把状态改成 busy 才翻转，而那发生在异步回调里，同一个 tick 的后续循环看不到。
5. 循环任务触发后从当前时刻重新计算下一次，文件任务顺便写回 `lastFiredAt`；一次性任务触发后删除。
6. 循环任务创建满 7 天（`recurringMaxAgeMs`，`packages/opencode/src/cron/cron-jitter.ts:19`）又不是 `permanent` 的，这次触发后删除，并记一条 `aged_out`（`scheduler.ts:186`）。

同一目录下可能同时开着好几个 `mimo`，文件任务只能由其中一个触发。`tryAcquireSchedulerLock`（`packages/opencode/src/cron/cron-lock.ts:198`）的做法：

1. 以独占方式（`wx`）创建 `.mimocode/.cron-lock`，写入本进程的 `pid` 与启动时刻（`cron-lock.ts:162`）。
2. 文件已存在时：内容损坏就接管；是自己写的就直接成功；持有者还活着就放弃；已经死了就接管。
3. 判断死活（`isPidAlive`，`cron-lock.ts:131`）：先 `process.kill(pid, 0)`，报 EPERM（进程存在但属于别的用户）也算活着；在 Linux 上再从 `/proc/<pid>/stat` 算出该进程的启动时刻，与锁里记录的相差超过 2 秒就认定 PID 已被复用（`cron-lock.ts:155`）。其他平台没有这层检查。
4. 接管时先写临时文件再 `rename`，然后读回确认确实是自己（`cron-lock.ts:179`），避免两个进程同时接管、都以为自己持锁。

由此有两个后果。锁只在 `start` 时抢一次（`scheduler.ts:236`），而一个进程只启动一次调度器，所以持锁进程退出后，已经在运行的其他进程不会接手，要等之后新启动的进程来抢。不持锁的进程照样能登记 `durable` 任务（写的是同一个文件），但触发由持锁进程负责，注入的也是那个进程绑定的会话。

## 表达式与抖动

`parseCronExpression`（`cron-expr.ts:41`）支持标准 5 字段：逗号列表、`a-b` 区间、`/n` 步长和 `*`；不认月份与星期的英文缩写，星期只接受 0 到 6。日和星期都受限时按 Vixie cron 取“或”（`cron-expr.ts:7`）。`computeNextCronRun` 从下一整分钟起逐分钟往后试，最多试 365 天（`cron-expr.ts:60`），找不到就返回 null。`schedule` 对两种情况给出警告但照样登记：表达式一年内永远不匹配（如 2 月 30 日的 `0 0 30 2 *`），或者一次性任务的下次触发在 30 天之后（`cron.ts:339`）。

触发时刻还要叠一层抖动。抖动量由任务 ID 的哈希决定（`hashUnit`，`cron-jitter.ts:26`，取值在 0 到 1 之间），所以同一个任务每次的偏移一致：

| 任务 | 规则 | 默认效果 |
| --- | --- | --- |
| 循环，一般表达式 | 推迟“哈希 × 0.5 × 周期”，封顶 30 分钟（`cron-jitter.ts:54`） | 每小时、每天的任务都最多晚 30 分钟 |
| 循环，形如 `*/N * * * *` 且触发分钟是 5 的倍数 | 改为提前“哈希 × 15 秒”（`cron-jitter.ts:42`） | `*/5` 这类任务最多早 15 秒 |
| 一次性，触发分钟是 :00 或 :30 | 提前“哈希 × 90 秒”，但不早于创建时刻（`cron-jitter.ts:63`） | 最多早 90 秒 |
| 一次性，其他分钟 | 不调整 | 准点 |

第二行用到的常量叫 `CACHE_CLIFF_MINUTES` 和 `cacheLeadMs`，代码里没有注释说明用意。除了代码层面的抖动，工具说明还在提示词层面要求模型避开 :00 和 :30，除非用户点名要那个时刻（`cron.txt:21`）。

## 到点之后：注入会话

桥的挂载时机有点特别：顶层会话（没有 `parentID`）每轮的第一步都会尝试启动它（`packages/opencode/src/session/prompt.ts:3816`），`started` 守卫保证一个进程只挂载一次（`cron-bridge.ts:133`）。也就是说，调度器要等进程里第一个顶层会话跑起来才启动，并从此绑定在这个会话上。`CronBridge.start`（`cron-bridge.ts:127`）做三件事：

1. **订阅会话状态**（`cron-bridge.ts:167`）：维护供 tick 判断的忙碌标志；状态从 busy 变成别的，就视为一轮结束，触发 keepalive 检查。只有 `busy` 算忙，`retry`（等待重试）与 `notice` 状态都算不忙。
2. **订阅压缩事件**（`cron-bridge.ts:195`）：主智能体的上下文被压缩后，清空哨兵的内容缓存。
3. **把触发接到会话**（`onFire`，`cron-bridge.ts:213`）：先展开哨兵，再在前面加上 `[cron fire @ 2026-09-17T01:00:00Z]` 这样的 UTC 时间戳（`cron-bridge.ts:235`），最后交给 `injectScheduledPrompt`。

`injectScheduledPrompt`（`prompt.ts:5625`）走的是与用户打字相同的入口：

```ts
export const injectScheduledPrompt = (input: InjectScheduledPromptInput) =>
  Effect.gen(function* () {
    const sp = yield* Service
    yield* Effect.asVoid(
      sp.prompt({
        sessionID: input.sessionID,
        source: "hook",
        parts: [
          {
            type: "text",
            text: input.value,
            synthetic: input.isMeta ?? true,
            metadata: {
              origin: input.origin,
              priority: input.priority ?? "later",
            },
          },
        ],
      }),
    )
  })
```

所以一次触发就是一轮完整的主循环。`source` 为 `hook` 时，跳过用户入口处的撤销清理，以及对孤儿回复和卡在运行中的工具部件的清扫（`prompt.ts:2928`）。TUI 认出 `metadata.origin.kind` 为 `cron` 的部件，去掉时间戳前缀，渲染成带时钟图标的 `cron fire` 行，时间按本地时区显示（`packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:1655`）。

有两点值得留意：

- 工具说明称触发会以 later 优先级进入消息队列、不会抢占用户输入（`cron.txt:2`），但 `priority` 只写进了部件元数据，代码里没有找到读取它的地方；“不抢占”实际靠 tick 在会话忙时跳过。
- 桥只绑定一个会话：`onFire` 注入的始终是挂载时的 `sessionID`，调度器触发时也不看任务的 `createdBySessionId`。同一进程里在别的会话登记的任务，到点后仍会进入最先挂载的那个会话。

## 自定节奏：cron loop 与 keepalive

`schedule` 的节奏在登记时就定死了；`loop` 让模型每次看完情况再决定下一次隔多久，工具说明建议有变化时隔 60 到 300 秒、例行轮询隔 300 到 1800 秒。`armLoop` 在 `scheduler.ts:421` 起计算目标时刻并替换旧任务：

```ts
const clamped = Math.max(60, Math.min(3600, input.delay_seconds))
const wasClamped = clamped !== input.delay_seconds

const target = new Date(now + clamped * 1000)
target.setUTCSeconds(0, 0)
if (target.getTime() <= now) target.setUTCMinutes(target.getUTCMinutes() + 1)

const prior = getSessionCronTasks().filter(
  (t) => t.kind === "loop" && t.prompt === input.prompt,
)
if (prior.length > 0) {
  removeSessionCronTasks(prior.map((p) => p.id))
  if (rt) for (const p of prior) rt.nextFireAt.delete(p.id)
}

const id = newId()
const cron = `${target.getUTCMinutes()} ${target.getUTCHours()} * * *`
addSessionCronTask({
  id,
  cron,
  prompt: input.prompt,
  createdAt: now,
  kind: "loop",
  recurring: false,
})
```

- 延迟夹在 60 到 3600 秒之间；目标时刻的秒数清零，若因此不晚于现在就再加一分钟，然后写成 `分 时 * * *` 形式的一次性 UTC 表达式。只精确到分钟，所以实际间隔可能比请求的短，最多短将近一分钟。
- 同一提示词的旧 loop 任务会被替换，循环靠提示词文本识别。`LoopState`（`packages/opencode/src/cron/loop-state.ts:9`）同样以提示词为键，在内存里记着 `startedAt`、`lastScheduledFor` 与 `keepaliveStrikes`。
- 下次触发时刻直接钉在目标上，绕开一次性任务的提前抖动（`scheduler.ts:454`）。
- 从第一次登记算起满 7 天，`armLoop` 清掉任务与状态并返回 null（`scheduler.ts:403`），工具回复“Loop aged out”。调度器未启动或急停开关打开时同样返回 null，工具给出的也是这一句（`cron.ts:366`）。

模型忘了续约怎么办？绑定的会话每结束一轮，桥就检查所有 `LoopState`（`cron-bridge.ts:76`）：

1. 这一轮模型重新登记过的，失误计数清零；
2. 下次触发时刻还没到的，跳过，否则无关的用户轮次也会被记成失误（`cron-bridge.ts:99`）；
3. 已经过点又没续约的：失误数没到预算 `MIMOCODE_LOOP_KEEPALIVE_BUDGET`（默认 1，`flag.ts:373`）就记一次失误，并替模型按 `MIMOCODE_LOOP_KEEPALIVE_DELAY_S`（默认 1200 秒，`flag.ts:376`）续约；到了预算就以 `model_stopped` 结束循环。

默认参数下，某次触发后模型没有再调用 `cron loop`，桥会替它在 20 分钟后补一次；补的那次模型仍不续约，循环就此结束。结束只写一条 `loop_ended` 日志（`cron-bridge.ts:254`），注释写明界面暂不显示。

实现上还有一处缺口：`armLoop` 登记的任务不带 `createdBySessionId`，而工具的 `list` 总是按当前会话过滤（`cron.ts:380`），所以 `cron list` 与 `/loops` 都看不到 `cron loop` 登记的循环。测试里按会话过滤的 `list` 只用来查 `schedule` 任务，列 loop 任务时都没传会话，覆盖不到这种情况。另外，`get`、`delete`、`rename` 只有显式带上 `session_id` 时才核对创建者（`cron.ts:397`），不带就能按 ID 操作任意任务。

## 哨兵：把提示词放进文件

`packages/opencode/src/cron/sentinel.ts:3` 起定义了四个特殊提示词：`<<loop.md>>`、`<<loop.md-dynamic>>`、`<<autonomous-loop>>`、`<<autonomous-loop-dynamic>>`。任务里只存哨兵，到点时才由 `resolveAtFireTime`（`sentinel.ts:78`）展开：

- **loop.md 类**：先找工作树下的 `.mimocode/loop.md`，再找 `~/loop.md`，内容超过 25000 就截断并附警告（`packages/opencode/src/cron/loop-file.ts:16`；常量名和警告写的是字节，代码比较的是字符串长度）。第一次触发发全文，外面包一层围栏，并提醒执行其中的指令前先核实意图；之后文件没变就只发一句“unchanged since last fire”；文件不见了就提示模型结束循环。带 `-dynamic` 的版本在末尾多一句：还有事要做就用 `cron loop` 安排下一次。
- **autonomous-loop 类**：第一次发一段自主循环前言（`sentinel.ts:37`），之后每次只发一句简短提醒。前言约定每个 tick 检查信号、按需行动、再用 `cron loop` 安排下一次；连续三个 tick 无事可做，或卡在需要用户决定的问题上，就不再续约。

内容缓存按“会话加工作区”分键，上下文一被压缩就清空（`resetOnCompaction`，`sentinel.ts:105`，由桥的压缩订阅调用），下次重新发全文。按注释的说法，“首次全文、之后一句话”是为了在 `loop.md` 不变时保住提示词前缀缓存（`sentinel.ts:73`）。

代码里没有任何地方主动生成哨兵。它们只出现在工具命令行说明的示例里（与 `cron.txt` 同目录的 `cron.shell.txt:22`）和 `loop` 技能里，要靠模型自己把哨兵当作提示词传进来。`loop` 技能说，用户只给间隔、并且项目开启了自主循环模式时，提示词用 `<<autonomous-loop>>`，而这个模式是“运行时设置的会话级开关”（`packages/opencode/src/skill/builtin/.bundle/loop/SKILL.md:24`）；代码里没有找到这个开关。

## /loop 与 /loops

`/loop` 本身没有代码，是一份写给模型的操作说明：

1. 解析间隔：开头形如 `5m`、`2h` 的记号，或者结尾的 `every 20m`、`every 5 minutes`；都没有就用 `10m`。
2. 换算成整齐的表达式：分钟数能整除 60 时写 `*/N * * * *`，整小时写 `0 */N * * *`，1 或 2 天写 `0 0 */N * *`，秒数向上取整到分钟。除不尽的就近取整并告诉用户，比如 `7m` 取整到 `6m`，因为 `*/7` 在整点处间隔不均。
3. 用 `cron schedule` 登记循环任务，不设 `one_shot`、不设 `durable`，把任务 ID 告诉用户，方便日后 `/loops cancel <id>`。
4. 不等第一次触发，立刻执行一次提示词；提示词以 `/` 开头就当作斜杠命令调用。

所以 `/loop` 建出来的是 `schedule` 循环任务，不是 `cron loop`：它不受 keepalive 管，满 7 天自动删除，并吃上面那张表里的抖动——`/loop 5m` 每次最多早 15 秒，`/loop 2h` 每次最多晚 30 分钟。内置文档说循环会在 keepalive 预算耗尽或满 7 天时停止（`guide.md:166`），对 `/loop` 建的任务只有后一条成立。

`/loops` 的模板则要求模型调用 `cron list` 并渲染成表格，参数里有 `cancel <id>`（或 delete、stop、kill）就逐个删除，绝不执行任务里的提示词。

## 进程不在时

调度器只活在 `mimo` 进程里，代码里没有注册任何系统级定时器。进程退出以后：

- 内存任务与所有 `cron loop` 循环直接消失。
- `durable` 任务留在文件里，等下一个进程启动调度器——前面说过，这要等某个顶层会话跑起第一步。抢到锁之后：
  - 已经过点的一次性任务立即补发一次，并从文件里删除（`findMissedTasks`，`cron-task.ts:100`；调用处在 `scheduler.ts:253`）。补发发生在启动过程中，不经过 tick 的会话忙检查。
  - 循环任务不会把错过的次数逐一补上：第一次 tick 以 `lastFiredAt` 为起点算出的触发时刻已经过去，于是立刻触发一次，之后从当前时刻重新排期。
  - 创建满 7 天的循环任务，在这次触发后删除。

下一篇：[Actor：子智能体的生成与回收](https://daiw.org/manual/mimo-code/actors)——进入第 4 部分，看主智能体怎样派生、等待和回收子智能体。
