定时任务与循环

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

作者 David更新于 第 17 篇(共 33 篇)

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

/loop 管“多久跑一次”,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 的别名):

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

每个动词都接受 session_id。工具同时提供 JSON 与命令行两种调用形式(cron.ts:437),命令行形式如 cron schedule "7 * * * *" "check the deploy" --durable,动词拼错时按编辑距离给出建议(cron.ts:30),语法见命令行式工具调用与 Token 精简。

表达式一律按 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 分。

任务存在哪

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 同样只有定义、没有写入。

调度器与跨进程锁

整体结构如下:

图表加载中…

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)走的是与用户打字相同的入口:

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 起计算目标时刻并替换旧任务:

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:子智能体的生成与回收——进入第 4 部分,看主智能体怎样派生、等待和回收子智能体。

本页目录