定时任务与循环
cron 工具的固定节奏任务与自定节奏循环怎样登记,表达式与抖动怎样计算,跨进程锁与任务文件怎样工作,到点后怎样注入会话,以及进程不在时会发生什么。
有些事不是一问一答能办完的:每五分钟看一眼 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 的别名):
| 动词 | 主要参数 | 作用 |
|---|---|---|
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 精简。
表达式一律按 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)的规则:
- 急停开关打开,或绑定的会话正忙,什么也不做。
- 持锁进程看文件任务加内存任务,不持锁的进程只看内存任务(
scheduler.ts:158)。 - 第一次见到某个任务时,以
lastFiredAt(没有就用createdAt)为起点算出下次触发时刻,缓存在内存里。 - 到点就调
onFire,每个 tick 最多触发一个任务(scheduler.ts:170)。注释解释了原因:会话忙碌标志要等注入的消息把状态改成 busy 才翻转,而那发生在异步回调里,同一个 tick 的后续循环看不到。 - 循环任务触发后从当前时刻重新计算下一次,文件任务顺便写回
lastFiredAt;一次性任务触发后删除。 - 循环任务创建满 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)的做法:
- 以独占方式(
wx)创建.mimocode/.cron-lock,写入本进程的pid与启动时刻(cron-lock.ts:162)。 - 文件已存在时:内容损坏就接管;是自己写的就直接成功;持有者还活着就放弃;已经死了就接管。
- 判断死活(
isPidAlive,cron-lock.ts:131):先process.kill(pid, 0),报 EPERM(进程存在但属于别的用户)也算活着;在 Linux 上再从/proc/<pid>/stat算出该进程的启动时刻,与锁里记录的相差超过 2 秒就认定 PID 已被复用(cron-lock.ts:155)。其他平台没有这层检查。 - 接管时先写临时文件再
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)做三件事:
- 订阅会话状态(
cron-bridge.ts:167):维护供 tick 判断的忙碌标志;状态从 busy 变成别的,就视为一轮结束,触发 keepalive 检查。只有busy算忙,retry(等待重试)与notice状态都算不忙。 - 订阅压缩事件(
cron-bridge.ts:195):主智能体的上下文被压缩后,清空哨兵的内容缓存。 - 把触发接到会话(
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):
- 这一轮模型重新登记过的,失误计数清零;
- 下次触发时刻还没到的,跳过,否则无关的用户轮次也会被记成失误(
cron-bridge.ts:99); - 已经过点又没续约的:失误数没到预算
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 本身没有代码,是一份写给模型的操作说明:
- 解析间隔:开头形如
5m、2h的记号,或者结尾的every 20m、every 5 minutes;都没有就用10m。 - 换算成整齐的表达式:分钟数能整除 60 时写
*/N * * * *,整小时写0 */N * * *,1 或 2 天写0 0 */N * *,秒数向上取整到分钟。除不尽的就近取整并告诉用户,比如7m取整到6m,因为*/7在整点处间隔不均。 - 用
cron schedule登记循环任务,不设one_shot、不设durable,把任务 ID 告诉用户,方便日后/loops cancel <id>。 - 不等第一次触发,立刻执行一次提示词;提示词以
/开头就当作斜杠命令调用。
所以 /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 部分,看主智能体怎样派生、等待和回收子智能体。