猫咖
首页博客工具
搜索
语言
切换网站风格
选择主题颜色
点击特效
主题

猫咖 · 持续更新中,源码见 GitHub。

Index 14: Cron Scheduler - 持久化调度 / 会话级触发

2026年8月13日
AI智能体Kotlin后端

系列

使用Kotlin从0开发一个ClaudeCode

系列

使用Kotlin从0开发一个ClaudeCode

进度 14 / 21

使用Kotlin从0开发一个ClaudeCode

上一篇

Index 13: Background Tasks - 线程执行 / 通知队列

下一篇

Index 15: Agent Teams — MessageBus / 收件箱 / 权限冒泡

目标

s13 让智能体能"启动一个 shell 进程且不阻塞",但触发时机仍是手动的--用户或 LLM 主动调 background_bash。有一类需求它覆盖不了:按时间规则自动触发。"每天 9 点跑一次测试"、"每周一 8 点生成报告"、"5 分钟后提醒我检查部署"--这些需要的是定时器,不是手动派发。

Index 14 引入持久化 cron 调度 + 会话级触发,对齐 Claude Code 的 CronCreate:

  1. CronExpr -- 5 字段 cron 表达式解析器 + nextFire 字段逐级推进算法
  2. CronStore -- ~/.cat-code/cron.json 磁盘持久化(任务定义跨会话存活)
  3. CronScheduler -- 会话级轮询,到期任务派发到 s13 BackgroundRunner 执行
  4. CronExecutor -- 触发动作抽象(fun interface),解耦调度与执行
  5. /cron 命令 -- list/add/once/show/enable/disable/delete/next/test
  6. cron_create/cron_list/cron_delete 工具 -- 让 LLM 在对话中调度定时任务

完成后的效果--任务定义离开会话也存活,且到点自动在后台执行:

TEXT
$ cat-code
> /cron add nightly-build 0 9 * * * ./gradlew test
Cron job created: cron-1 (recurring)
  next fire: 2026-08-08 09:00
> /cron test 0 9 * * *              ← 先验证表达式
Expression: 0 9 * * *
  1. 2026-08-08 09:00
  2. 2026-08-09 09:00
  3. 2026-08-10 09:00
> exit                              ← 退出 REPL,任务定义已持久化

$ cat-code                          ← 次日 9 点重启 REPL
🐱 Cat-Code · ... · s14: Cron Scheduler
> /cron list
Cron jobs (1):
  cron-1  [ON/recur]  0 9 * * *  nightly-build  next=2026-08-09 09:00
                                   ↑ lastFiredAt 已记 2026-08-08 09:00(今天已触发)
🔔 cron-1 - completed (exit code 0)  ← 回合间 drain 出来的完成通知

为什么需要

现实问题

s13 的 BackgroundRunner 解决了"不阻塞地执行",但触发仍是命令式的。三个场景它无能为力:

  • 周期性维护 -- "每天跑测试"、"每周清理日志"。没定时器就得人手动派发,违背"自动化"初衷
  • 延时提醒 -- "30 分钟后检查构建结果"。后台任务能跑命令,但"30 分钟后"这个时间约束无处表达
  • 跨会话的调度意图 -- 用户今天设的"每天 9 点跑测试",明天重启 REPL 后应该还在。s13 后台任务是内存态、会话结束即清理,无法承载这种持久意图

而依赖图表明,s14 是阶段三执行层的最后一块:

TEXT
s05 TodoWrite -> s12 TaskSystem -> s13 BackgroundTasks -> s14 CronScheduler
                              ↘ s17 AutonomousAgents

s13 给了"执行原语"(不阻塞派发 shell),s14 给了"触发规则"(何时派发)。两者正交:s14 决定"何时",s13 决定"怎么跑"。s17 自治智能体将进一步用 s12 的 readyTasks + s13 的后台执行 + s14 的定时,组合出"按需 + 定时"的自治调度。

与 Claude Code CronCreate 的对齐与取舍

Claude Code 的 CronCreate 工具:5 字段 cron、会话级触发("Jobs only fire while the REPL is idle")、不写磁盘("the job is gone when Claude exits")。cat-code 的 s14 在此基础上做了一个关键取舍:

维度Claude Code CronCreatecat-code s14
任务定义会话内存态,退出即失持久化 ~/.cat-code/cron.json
触发时机REPL 空闲时REPL 活跃时(回合间轮询)
触发动作注入 prompt(重跑 agent)派发 shell 命令到 s13 BackgroundRunner
一次性任务支持且自动删除支持且自动删除

为何持久化任务定义:cat-code 是 CLI 工具,用户期望"今天设的每天提醒,明天还在"。Claude Code 的会话态 cron 是因为它绑定 claude.ai 会话模型;cat-code 的 REPL 重启频繁,持久化更符合 CLI 心智。但触发仍是会话级--没有守护进程,REPL 不跑就不触发。这是"持久化调度 + 会话级触发"的精确含义:意图持久,执行随会话。

为何触发 shell 命令而非注入 prompt:s13 已经提供了成熟的"派发 shell + 流式输出 + 通知"管线,s14 直接复用即可(s13 博客已预告"CronScheduler 可以直接复用 BackgroundRunner.launch")。注入 prompt 需要把任务塞进 AgentLoop 的消息队列、处理"用户正打字时插入"的竞态,复杂度高且与 s13 执行模型割裂。shell 命令型触发覆盖了"定时跑测试/构建/脚本"的核心场景;prompt 型触发留给 s17/s20。

对话入口对齐:s14 后续补齐了 cron_create/cron_list/cron_delete 三个 LLM 工具(见下文「对话式 cron 管理」),让智能体在对话中直接调度,对齐 Claude Code CronCreate 的工具入口。/cron 命令面向终端用户,工具面向 LLM--两者共享同一 CronStore/CronScheduler/CronJob.build,行为一致。

设计原则

  • 意图持久、执行随会话 -- 任务定义写磁盘(跨会话存活),轮询协程只活一程会话。无守护进程、不脱离 REPL 生命周期
  • 不追赶错过的周期 -- recurring 任务从 now 起算下一次匹配,离线期间错过的周期不补触发(会话级语义,无后台执行能力);one-shot 若 fireAt 已过,下次 tick 立即触发一次再删除(尽力补救)
  • 执行解耦 -- CronExecutor 是 fun interface,调度器不直接依赖 BackgroundRunner。生产注入"派发到 BackgroundRunner + 推送通知"的实现,测试注入记录型假实现,无需启动真实进程
  • tick 是同步纯逻辑 -- 调度算法(判断到期、推进 nextFire)不含协程,可注入 clock 固定时间单测;start/stop 仅管理轮询协程生命周期
  • 严格迭代 -- cron 解析只支持数字表达式(不支持 JAN..DEC 名称、L、#),覆盖常见场景;不支持 prompt 型触发、不支持实时打断式提醒。这些留给后续 Index

核心设计与实现

架构全景

TEXT
        ┌───────────────────────────────────────────────┐
        │             ~/.cat-code/cron.json             │
        │ {"jobs":[{"id":"cron-1","cron":"0 9 * * *",…}]}│
        └──────────────────────┬────────────────────────┘
                               │ 原子写 + fail-open 读(同 s12)
        ┌──────────────────────▼────────────────────────┐
        │                  CronStore                     │
        │   内存缓存 + 构造加载 + 临时文件原子 move        │
        └──────────────────────┬────────────────────────┘
                               │ loadAll / save / update / delete
        ┌──────────────────────▼────────────────────────┐
        │               CronScheduler                   │
        │  nextFireCache: id -> (cron, nextFire)         │
        │  tick(): 读 store -> 判到期 -> fire -> 推进     │
        │  start(scope): 轮询协程每 30s 调 tick()         │
        └──────┬───────────────────────────┬────────────┘
   handleRecurring│                  handleOneShot│
   (cache miss:   │                  (now>=fireAt │
    nextFire(now  │                   -> fire+delete)
    -1min))       │                             │
               fire(job) via CronExecutor        │
        ┌────────▼────────────────────────────────▼─────┐
        │   CronExecutor (fun interface, ReplLoop 注入)  │
        │   -> BackgroundRunner.launch(job.command)      │
        │   -> NotificationQueue.push("cron 'x' fired")  │
        └────────────────────────────────────────────────┘
                               │ s13 通知队列
                               ▼
                    ReplLoop.drainNotifications()
                    "🔔 cron:1 - cron 'nightly' fired -> bg-3"

        CronExpr(纯算法):parse("0 9 * * 1-5") -> Set<Int>×5 + restricted flags
                          nextFire(from) -> 字段逐级推进 -> LocalDateTime?

模块依赖(遵守 agent 包不依赖增强层的约束):

TEXT
repl -> cron, background, tool, agent, ...
cron -> kotlinx.coroutines, kotlinx.serialization(不依赖 background/agent)

cron 包只依赖标准库 + 协程 + 序列化,不依赖 background--执行通过 CronExecutor 接口注入,调度逻辑与执行引擎解耦。这让 CronScheduler 的测试无需启动真实进程。

核心模型:CronJob

KOTLIN
// cron/CronJob.kt
@Serializable
data class CronJob(
    val id: String,                     // "cron-N"
    val name: String,                   // 人类可读标签
    val cron: String,                   // 5 字段 cron 表达式
    val command: String,                // 触发时执行的 shell 命令
    val enabled: Boolean = true,
    val oneShot: Boolean = false,       // true=一次性(触发后自动删除)
    val fireAt: Long? = null,           // one-shot 目标时刻(epoch millis);recurring 为 null
    val createdAt: Long = System.currentTimeMillis(),
    val lastFiredAt: Long? = null
)

两种触发模式的区别:

  • recurring(oneShot=false):按 cron 周期触发,nextFire 由 CronScheduler 在内存缓存中计算/推进,每次触发后更新 lastFiredAt
  • one-shot(oneShot=true):fireAt 在创建时由 CronExpr.nextFire(now) 计算并固化(epoch millis),到达后触发一次、store.delete、自动清除

fireAt 只对 one-shot 有意义:它把"创建时的下一次匹配"固化下来,避免 recurring 那种"从 now 重算"导致 one-shot 滚到下一年。one-shot 的语义是"创建后的下一次匹配时刻,到了就触发并消失"。

与 s13 BackgroundTask 的对比:BackgroundTask 是内存态、代表活进程、不持久化;CronJob 是持久态、代表调度意图、跨会话存活。s14 持久化"意图",s13 承载"执行"--每次 cron 触发产生一个临时的 BackgroundTask,意图长存而执行瞬时。

CronExpr:cron 解析 + nextFire 算法

这是 s14 的算法核心。两部分:解析(parse)与下次触发计算(nextFire)。

解析:5 字段 -> Set<Int>×5 + restricted 标志

KOTLIN
// cron/CronExpr.kt:113
fun parse(expr: String): CronExpr {
    val fields = expr.trim().split(Regex("\\s+"))
    require(fields.size == 5) { "cron expression must have 5 fields, got ${fields.size}: '$expr'" }

    val minutes = parseField(fields[0], 0, 59)
    val hours = parseField(fields[1], 0, 23)
    val daysOfMonth = parseField(fields[2], 1, 31)
    val months = parseField(fields[3], 1, 12)
    // 周允许 0-7(0 和 7 都是周日),解析后对 7 取模归一
    val daysOfWeek = parseField(fields[4], 0, 7).map { it % 7 }.toSet()

    return CronExpr(
        ...,
        domRestricted = fields[2] != "*",   // 日是否被限制(非 *)
        dowRestricted = fields[4] != "*",   // 周是否被限制
        raw = expr.trim()
    )
}

parseField 支持五种语法:*、n、a-b、a,b,c、*/n/a-b/n/a/n(步进)。每个字段解析成 Set<Int> 合法值集合。domRestricted/dowRestricted 记录"该字段是否被显式限制"--这是日/周 OR 语义的关键。

日 + 周 OR 语义(标准 cron 经典规则):

KOTLIN
// cron/CronExpr.kt:60
private fun dayOk(t: LocalDateTime): Boolean {
    val cronDow = t.dayOfWeek.value % 7   // java DayOfWeek 周日=7 -> cron 周日=0
    val domMatch = t.dayOfMonth in daysOfMonth
    val dowMatch = cronDow in daysOfWeek
    return when {
        domRestricted && dowRestricted -> domMatch || dowMatch  // 都限制:任一匹配
        domRestricted -> domMatch                               // 只限日:用日
        dowRestricted -> dowMatch                               // 只限周:用周
        else -> true                                            // 都 *:每天
    }
}

java.time 的 DayOfWeek 周日=7,cron 周日=0,故 % 7 归一。parse 时 7 也被映射成 0,所以 0 和 7 都表示周日。

nextFire:字段逐级推进

KOTLIN
// cron/CronExpr.kt:78
fun nextFire(from: LocalDateTime): LocalDateTime? {
    var t = from.plusMinutes(1).truncatedTo(ChronoUnit.MINUTES)   // 严格大于 from
    var iters = 0
    while (iters < MAX_ITERATIONS) {
        iters++
        if (t.monthValue !in months) {           // 月不匹配 -> 跳到下月 1 号 00:00
            t = t.withDayOfMonth(1).withHour(0).withMinute(0).plusMonths(1); continue
        }
        if (!dayOk(t)) {                          // 日不匹配 -> 跳到次日 00:00
            t = t.withHour(0).withMinute(0).plusDays(1); continue
        }
        if (t.hour !in hours) {                   // 时不匹配 -> 跳到下一小时 :00
            t = t.withMinute(0).plusHours(1); continue
        }
        if (t.minute !in minutes) {               // 分不匹配 -> 跳到下一分钟
            t = t.plusMinutes(1); continue
        }
        return t
    }
    return null   // 无解:迭代上限内未找到匹配(如 2 月 30 日)
}

核心思路:从 from 的下一分钟起,按 月 -> 日 -> 时 -> 分 顺序校验,不匹配则跳到下一个该字段的合法起点。相比"逐分钟扫描",对稀疏调度(如 0 0 29 2 * = 2 月 29 日)也能在常数级迭代内命中--每次不匹配至少跳一个分钟/小时/天/月,而非一分钟一分钟地扫。

为何严格大于 from:nextFire 的契约是"返回严格大于 from 的下一个匹配"。这让"推进"语义清晰:触发后 nextFire(刚触发的时刻) 给出下一次。但这也引出一个调度上的 off-by-one(见下文 CronScheduler 的 minusMinutes(1) 修复)。

MAX_ITERATIONS 上限:无解表达式(如 0 0 30 2 * = 2 月 30 日,不存在)会无限循环--2 月永远没有 30 号,算法会一直推进月份。设 MAX_ITERATIONS = 5000(覆盖多个 4 年闰年周期)后返回 null,调用方据此标记任务无解。2 月 29 日(闰年)在 4 年内必命中,远低于上限。

CronStore:磁盘持久化

完全复用 s12 TaskStore 的范式:单 JSON 文件、内存缓存 + 写穿透、临时文件 + 原子 move、fail-open 加载。

KOTLIN
// cron/CronStore.kt
class CronStore(private val file: Path) {
    private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
    private val cache = mutableListOf<CronJob>()

    init { reloadFromDisk() }

    fun loadAll(): List<CronJob>   // 按 createdAt 升序(同时刻按 id 决胜键,确定性)
    fun save(job: CronJob): Boolean
    fun update(job: CronJob): Boolean   // 保留 createdAt
    fun delete(id: String): Boolean
    fun findById(id: String): CronJob?
    fun replaceAll(jobs: List<CronJob>)
}

loadAll 的排序决胜键(compareBy { createdAt }.thenBy { id })直接沿用 s12 的修复--同毫秒创建时按 id 排序,保证确定性。原子写(Files.move(..., ATOMIC_MOVE))和 fail-open(损坏文件 warn 后空启)与 s12/s13 一致。

CronConfig 提供 ~ 展开与轮询间隔:

KOTLIN
// cron/CronConfig.kt
data class CronConfig(
    val cronFile: String = "~/.cat-code/cron.json",
    val pollIntervalSeconds: Long = 30
) {
    fun resolvedCronFile(): Path { /* 展开 ~ */ }
    val pollIntervalMillis: Long get() = pollIntervalSeconds * 1000
}

30s 轮询 + 分钟级 cron 粒度,最坏延迟 30s 触发,对 shell 命令型定时任务足够。

CronExecutor:执行解耦

KOTLIN
// cron/CronExecutor.kt
fun interface CronExecutor {
    fun fire(job: CronJob)
}

一个 fun interface。把"触发时做什么"抽象出来,CronScheduler 不直接依赖 BackgroundRunner:生产注入派发到 BackgroundRunner 的实现,测试注入记录型假实现。这让调度逻辑(何时触发)与执行引擎(怎么跑)各自可测。

CronScheduler:会话级轮询 + tick 算法

调度器的核心是 tick(同步纯逻辑)与 start(轮询协程)。

tick:一次调度检查

KOTLIN
// cron/CronScheduler.kt:100
fun tick(now: LocalDateTime = clock()): Int {
    val jobs = store.loadAll()           // 持久化的单一事实来源
    val liveIds = mutableSetOf<String>()
    var fired = 0
    for (job in jobs) {
        liveIds.add(job.id)
        if (!job.enabled) { nextFireCache.remove(job.id); continue }
        fired += if (job.oneShot) handleOneShot(job, now) else handleRecurring(job, now)
    }
    nextFireCache.keys.retainAll(liveIds)   // 清理已删除任务的缓存
    return fired
}

每次 tick 从 store 重读全部任务--新增/修改/删除的任务在下个 tick 自动生效(store 是单一事实来源)。nextFireCache 仅缓存"下次触发时间"以避免每 tick 重算,任务删除时清理。

handleRecurring:minusMinutes(1) 修复

KOTLIN
// cron/CronScheduler.kt:135
private fun handleRecurring(job: CronJob, now: LocalDateTime): Int {
    val expr = try { CronExpr.parse(job.cron) } catch (e: IllegalArgumentException) {
        logger.warn { "Invalid cron expression for ${job.id} ...; skipped" }
        nextFireCache.remove(job.id); return 0
    }
    val cached = nextFireCache[job.id]
    val next = if (cached == null || cached.cron != job.cron) {
        // 新任务或 cron 已修改 -> 从 now 起算下一次匹配。
        // 用 now-1min 让 nextFire(严格大于 from)把"当前分钟"纳入候选:
        // 若 now 恰处于某匹配分钟内(如 now=09:00:30、cron=0 9 * * *),
        // nextFire(now-1min)=09:00,本 tick 即触发该分钟的一次 tick。
        val n = expr.nextFire(now.minusMinutes(1)) ?: run { ...; return 0 }
        nextFireCache[job.id] = CachedNext(job.cron, n); n
    } else cached.nextFire

    return if (!next.isAfter(now)) {   // next <= now -> 到期
        fireAndRecord(job, updateStore = true)
        val advanced = expr.nextFire(next) ?: run { ...; return 1 }   // 推进到下一次(严格大于刚触发的)
        nextFireCache[job.id] = CachedNext(job.cron, advanced); 1
    } else 0
}

minusMinutes(1) 是 s14 最关键的设计点。nextFire 是"严格大于 from",所以 nextFire(now) 在 now 恰好处于匹配分钟时(如 now=09:00、cron=0 9 * * *)会跳过 09:00、返回次日 09:00--错过本分钟的触发。用 nextFire(now.minusMinutes(1)) 让当前分钟纳入候选:

TEXT
now=09:00:00, cron="0 9 * * *"
  nextFire(now)       = 2026-08-08 09:00  ← 错过今天 09:00(错误)
  nextFire(now-1min)  = 2026-08-07 09:00  ← 命中今天 09:00(正确)
  !next.isAfter(now)  = !(09:00 > 09:00) = true → 触发
  advance: nextFire(09:00) = 2026-08-08 09:00

这个 off-by-one 在测试 now 精确到分钟时立即暴露(首轮 4 个调度测试全挂),是典型的"算法契约(严格大于)与调度语义(含当前分钟)"错位。修复后用注入 clock 的测试覆盖了 09:00 精确触发、08:00 未到不触发、同窗口不重复触发三个边界。

不追赶错过的周期:触发后 nextFire(next)(严格大于刚触发的时刻)给出下一次,而非从 now 重算。若离线期间错过了多个周期,重启后只算下一次,不补触发。这是会话级语义的自然结果--离线时没有执行能力,补触发既无意义也可能造成"触发风暴"。

cron 变更感知:CachedNext 存了 cron 字符串,若用户改了 cron 表达式(store.update),cached.cron != job.cron 触发重算,旧 nextFire 作废。

handleOneShot:fireAt 到期即触发并删除

KOTLIN
// cron/CronScheduler.kt:123
private fun handleOneShot(job: CronJob, now: LocalDateTime): Int {
    val fireAt = job.fireAt ?: return 0
    if (now.toEpochMillis() >= fireAt) {
        fireAndRecord(job, updateStore = false)   // 即将删除,无需更新 lastFiredAt
        store.delete(job.id)
        nextFireCache.remove(job.id)
        return 1
    }
    return 0
}

one-shot 用 now >= fireAt 的含等号比较(与 recurring 的"含当前分钟"语义一致)。fireAt 是创建时固化的,所以重启后若已过点,下次 tick 立即触发再删除--尽力补救一次。updateStore = false 因为即将 delete,写 lastFiredAt 是浪费。

start/stop:轮询协程

KOTLIN
// cron/CronScheduler.kt:68
fun start(scope: CoroutineScope) {
    if (pollJob?.isActive == true) return
    pollJob = scope.launch {
        try {
            while (isActive) {
                val fired = tick()
                if (fired > 0) logger.info { "Cron tick fired $fired job(s)" }
                delay(config.pollIntervalMillis)
            }
        } catch (e: CancellationException) {
            logger.info { "Cron scheduler stopped" }; throw e
        }
    }
}

start 在注入的 scope 上启动轮询协程,每 pollIntervalMillis 调一次 tick。stop 取消协程。CancellationException 重新抛出(遵守结构化并发,与 s13 BackgroundRunner 一致)。tick 与 start 分离是可测性的关键:tick 是同步纯逻辑,测试直接调 tick(clock) 验证触发时机,无需协程/真实延时。

executor 异常隔离

KOTLIN
// cron/CronScheduler.kt:193
private fun fireAndRecord(job: CronJob, updateStore: Boolean) {
    try {
        executor.fire(job)
    } catch (e: Exception) {
        logger.error(e) { "Cron executor threw for ${job.id}; execution skipped but schedule continues" }
    }
    if (updateStore) store.update(job.copy(lastFiredAt = System.currentTimeMillis()))
}

executor.fire 抛异常被 catch 并 log,不影响其他任务和调度循环。即使某次派发失败(如 BackgroundRunner 并发满被拒),调度照常推进,下个周期重试。这符合"调度与执行解耦"--执行失败不阻塞调度。

/cron 命令

TEXT
/cron                       列出所有任务(含下次触发)
/cron list                  同上
/cron add <name> <5-field-cron> <command>   新增周期任务
/cron once <name> <5-field-cron> <command>  新增一次性任务(触发后自动删除)
/cron show <id>             显示详情
/cron enable <id> / disable <id>   启用/禁用
/cron delete <id>           删除
/cron next [id]             显示下次触发时间
/cron test <5-field-cron>   解析测试,显示接下来 3 次匹配(不保存)

/cron test 是实用功能:输入表达式即时验证,显示接下来 3 次匹配时刻,避免写错表达式(如把 0 9 * * 1 写成 0 9 * * 0 导致周日而非周一触发)。

add 的参数切分(开发过程踩坑修复):<name> <m> <h> <dom> <mon> <dow> <command...> 共 7 段,用 split(" ", limit = 7),parts[6] 是命令余量。初版误用 limit = 8,多切一刀导致命令丢首词(echo hello 变成 hello)。

对话式 cron 管理(cron_create / cron_list / cron_delete 工具)

/cron 命令面向终端用户;但智能体本身无法在对话中创建定时任务--用户得手动敲 /cron add,打断对话流。s14 补齐了对话入口:三个 LLM 工具,让智能体理解"每天早上 9 点跑备份"后直接调度,无需用户切到命令模式。

架构:工具层复用既有原语

三工具是薄包装,业务逻辑全部复用 s14 既有原语,不引入新状态:

TEXT
          ┌─────────────┐  register   ┌──────────────┐
LLM ─────▶│ ToolRegistry │────────────▶│  AgentLoop   │(自然发现,走 s03 权限 + s04 hooks)
          └──────┬──────┘             └──────────────┘
        cron_create / cron_list / cron_delete
                 │
    ┌────────────┼─────────────────────┐
    ▼            ▼                     ▼
┌────────┐  ┌──────────────┐    ┌──────────────┐
│CronStore│  │CronScheduler │    │ CronJob.build │(factory:parse + 算 fireAt)
│ nextId │  │ nextFireAt    │    └───────┬──────┘
│ save   │  └──────────────┘            │
│ delete │                          ┌────┴────┐
│loadAll │                          ▼         ▼
└────────┘                    CronExpr.parse  CronExpr.nextFire
       │
       ▼
  ~/.cat-code/cron.json(持久化,跨会话)

工具层只做「参数解析 + 调原语 + 拼返回文本」三件事,无独立状态机。这让工具与 /cron 命令的行为天然一致--共享同一 CronStore 与 CronScheduler 实例(ReplLoop 注入)。

与 s13 background 三件套对称

background(s13)cron(s14)语义isReadOnly
background_bashcron_create派发(即时 / 定时)false
background_outputcron_list查询状态 / 列出任务true
background_stopcron_delete取消运行中 / 删除持久定义false

结构一致:写操作串行、读操作可并行、每工具职责单一。两者都靠工具 description 自描述用法,不改 system prompt--LLM 从 description 字段读到"Use cron_list to view, cron_delete to remove"自行发现调用链。

与 Claude Code CronCreate 的工具语义对齐

维度Claude Codecat-code s14 工具
工具拆分CronCreate 单一创建cron_create + cron_list + cron_delete 三件套
创建入参cron + promptcron + command + name + one_shot
触发动作注入 prompt 重跑 agent派发 shell 到 s13 BackgroundRunner
删除入口CronDelete 单独工具cron_delete
列表入口无(会话内可见)cron_list(持久化需显式查)

cat-code 多了 cron_list:因为任务定义持久化跨会话,用户/LLM 重启后需要重新查看存量任务;Claude Code 会话态 cron 随会话消失,不存在这个需求。

复用而非重写:CronJob.build factory

/cron add|once 与 cron_create 都需要「parse cron + 算 one-shot fireAt + 构造 CronJob」。为避免两处重复,提取共享 factory(cron/CronJob.kt):

KOTLIN
companion object {
    const val ID_PREFIX = "cron"

    fun build(
        id: String, name: String, cron: String, command: String,
        oneShot: Boolean, now: LocalDateTime
    ): CronBuildResult {
        val expr = try {
            CronExpr.parse(cron)
        } catch (e: IllegalArgumentException) {
            return CronBuildResult.InvalidExpr(e.message ?: "invalid cron expression")
        }
        val fireAt = if (oneShot) {
            expr.nextFire(now)?.toEpochMillis() ?: return CronBuildResult.NoFutureMatch
        } else {
            null
        }
        return CronBuildResult.Ok(
            CronJob(id = id, name = name, cron = cron, command = command, oneShot = oneShot, fireAt = fireAt)
        )
    }
}

返回 CronBuildResult 三态 sealed interface,让命令与工具用 when 穷尽处理,编译器保证三种情况都覆盖:

KOTLIN
sealed interface CronBuildResult {
    data class Ok(val job: CronJob) : CronBuildResult
    data class InvalidExpr(val message: String) : CronBuildResult
    data object NoFutureMatch : CronBuildResult
}

三态如何映射到不同反馈(同一判定,两种呈现):

CronBuildResult/cron add 命令cron_create 工具
Ok(job)store.save + println 创建摘要store.save + ToolResult 成功文本
InvalidExpr(msg)println("Invalid cron expression ...: $msg")ToolResult(..., isError = true)
NoFutureMatchprintln("... no future match")ToolResult(..., isError = true)

factory 不 save --持久化是 CronStore 职责。这保持「构造」与「存储」分离:factory 纯函数(输入 -> CronBuildResult)极易单测,不碰磁盘;store 职责单一(只管持久化)。recurring 任务不在 factory 里预检「是否有未来匹配」(交给 CronScheduler tick 处理并 warn),只有 one-shot 必须算出 fireAt,否则 NoFutureMatch。

CronStore.nextId() 同步提取,让命令与工具共用 id 分配:

KOTLIN
fun nextId(): String {
    val max = loadAll()
        .mapNotNull { it.id.removePrefix("${CronJob.ID_PREFIX}-").toIntOrNull() }
        .maxOrNull() ?: 0
    return "${CronJob.ID_PREFIX}-${max + 1}"
}

单调递增,不复用已删 id 的小号--避免历史日志里的旧 id 被新任务复用造成歧义。nextId 基于 loadAll() 实时算(不缓存历史最大),删除最大 id 后会回退到剩余任务的最大值 +1。

三工具实现

cron_create(写,isReadOnly = false)。LLM 看到的 JSON Schema(最复杂的一个,含 5 字段 cron 的格式说明与示例):

JSON
{
  "name": "cron_create",
  "description": "Schedule a recurring or one-shot cron job that runs a shell command on a time schedule. The job persists across sessions and fires while the REPL is active. Pass a 5-field cron expression... Set one_shot=true to fire once at the next match then auto-delete. Use cron_list to view jobs and cron_delete to remove one.",
  "input_schema": {
    "type": "object",
    "properties": {
      "name": { "type": "string", "description": "Human-readable label for the job (shown in cron_list)" },
      "cron": { "type": "string", "description": "5-field cron expression: minute hour day-of-month month day-of-week. Ranges: 0-59 0-23 1-31 1-12 0-6 (0=Sunday). Supports *, single values, a-b ranges, a,b,c lists, and */n steps." },
      "command": { "type": "string", "description": "Shell command to execute when the job fires" },
      "one_shot": { "type": "boolean", "description": "If true, fires once at the next matching time then auto-deletes. Default false (recurring).", "default": false }
    },
    "required": ["name", "cron", "command"]
  }
}

cron 用单字符串字段(如 "0 9 * * 1-5")而非 5 个独立字段--CronExpr.parse 本就吃字符串,LLM 传一个字段比拼 5 个字段自然。execute 复用 factory:

KOTLIN
// cron/CronCreateTool.kt
override suspend fun execute(input: JsonObject): ToolResult {
    val name = input["name"]?.jsonPrimitive?.contentOrNull
        ?: return ToolResult("", "Error: name is required", isError = true)
    val cron = input["cron"]?.jsonPrimitive?.contentOrNull
        ?: return ToolResult("", "Error: cron is required", isError = true)
    val command = input["command"]?.jsonPrimitive?.contentOrNull
        ?: return ToolResult("", "Error: command is required", isError = true)
    val oneShot = input["one_shot"]?.jsonPrimitive?.booleanOrNull ?: false

    val id = store.nextId()
    val job = when (val result = CronJob.build(id, name, cron, command, oneShot, LocalDateTime.now())) {
        is CronBuildResult.InvalidExpr ->
            return ToolResult("", "Error: invalid cron expression '$cron': ${result.message}", isError = true)
        CronBuildResult.NoFutureMatch ->
            return ToolResult("", "Error: cron expression has no future match: '$cron'", isError = true)
        is CronBuildResult.Ok -> result.job
    }
    store.save(job)
    // 返回 id + 模式 + schedule + command + 下次触发(recurring 用 scheduler.nextFireAt,one-shot 用 fireAt)
    ...
}

缺参逐个 ?: return 报错(对齐 BackgroundBashTool 风格),而非一次报全--LLM 会逐个补齐,逐个报错更早暴露首个缺失。

cron_list(读,isReadOnly = true)。无参数,store.loadAll() 空则提示用 cron_create;非空逐行展示:

KOTLIN
// cron/CronListTool.kt
override suspend fun execute(input: JsonObject): ToolResult {
    val jobs = store.loadAll()
    if (jobs.isEmpty()) {
        return ToolResult("", "No cron jobs scheduled. Use cron_create to add one.")
    }
    val text = buildString {
        append("Cron jobs (${jobs.size}):")
        for (j in jobs) {
            val state = if (j.enabled) "ON" else "OFF"
            val mode = if (j.oneShot) "once" else "recur"
            append("\n  ${j.id}  [$state/$mode]  ${j.cron}  ${j.name}")
            // 仅 enabled 任务展示触发时刻;disabled 不算 nextFire
            if (j.enabled) {
                if (j.oneShot) {
                    j.fireAt?.let { append("  fires=${epochMillisToLocal(it).formatCronTime()}") }
                } else {
                    val nxt = scheduler.nextFireAt(j.id)
                    if (nxt != null) append("  next=${nxt.formatCronTime()}")
                }
            }
        }
    }
    return ToolResult("", text)
}

one-shot 用 fires=(固定 fireAt),recurring 用 next=(nextFireAt 动态计算);disabled 任务不展示触发时刻--nextFireAt 对 disabled 返回 null,展示也无意义。

cron_delete(写,isReadOnly = false)。参数 id,store.delete(id):

KOTLIN
// cron/CronDeleteTool.kt
override suspend fun execute(input: JsonObject): ToolResult {
    val id = input["id"]?.jsonPrimitive?.contentOrNull
        ?: return ToolResult("", "Error: id is required", isError = true)
    return if (store.delete(id)) {
        logger.info { "cron_delete: $id" }
        ToolResult("", "Cron job deleted: $id")
    } else {
        ToolResult("", "Error: cron job not found: $id", isError = true)
    }
}

未知 id 返回 isError = true(对齐 BackgroundStopTool)--删除不存在的任务不是工具调用本身的错误,但 isError 让 LLM 知道操作未生效、应重新 cron_list 确认 id。

对话端到端

TEXT
> 帮我每天早上 9 点跑备份脚本 ./backup.sh
[LLM 调用 cron_create: name="daily-backup", cron="0 9 * * *", command="./backup.sh"]
Cron job created: cron-1 (recurring)
  schedule: 0 9 * * *
  command:  ./backup.sh
  next fire: 2026-08-11 09:00

> 看看现在有哪些定时任务
[LLM 调用 cron_list]
Cron jobs (1):
  cron-1  [ON/recur]  0 9 * * *  daily-backup  next=2026-08-11 09:00

> 那个备份不用了,删掉
[LLM 调用 cron_list 找到 id,再 cron_delete: id="cron-1"]
Cron job deleted: cron-1

LLM 一次对话完成"创建 -> 查看 -> 删除"全流程,无需用户敲 /cron。任务定义持久化到 ~/.cat-code/cron.json,退出后下次启动仍在,CronScheduler 接续轮询触发。工具仍穿过 s03 权限管线与 s04 Pre/PostToolUse hooks--是普通工具,无特殊 Hook 处理。

ReplLoop 集成

三处接入(repl/ReplLoop.kt):

1. 构造 store + scope + executor + scheduler:

KOTLIN
// repl/ReplLoop.kt:202
val cronConfig = CronConfig()
cronStore = CronStore(cronConfig.resolvedCronFile())
cronScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val cronExecutor = CronExecutor { job: CronJob ->
    when (val result = backgroundRunner.launch(job.command)) {
        is BackgroundLaunchResult.Success -> notificationQueue.push(
            BackgroundNotification("cron:${job.id}", BackgroundStatus.RUNNING,
                "cron '${job.name}' fired -> ${result.taskId}"))
        is BackgroundLaunchResult.Rejected -> notificationQueue.push(
            BackgroundNotification("cron:${job.id}", BackgroundStatus.FAILED,
                "cron '${job.name}' fire rejected: ${result.reason}"))
    }
}
cronScheduler = CronScheduler(cronStore, cronExecutor, cronConfig)
cronScheduler.start(cronScope)

executor 把 cron 触发桥接到 s13 BackgroundRunner:派发命令 + 推送通知。复用 s13 的 NotificationQueue 和 BackgroundNotification,完成通知在回合间 drainNotifications() 显示为 🔔 cron:1 - cron 'nightly' fired -> bg-3。cronScope 用 Dispatchers.Default(CPU 轻量轮询,区别于 s13 backgroundScope 的 Dispatchers.IO)。

cronStore 与 cronScheduler 加入 ReplContext,供 /cron 命令访问。

对话工具注册(紧随 cronScheduler.start 之后):

KOTLIN
// repl/ReplLoop.kt(cronScheduler.start(cronScope) 之后)
toolRegistry.register(CronCreateTool(cronStore, cronScheduler))
toolRegistry.register(CronListTool(cronStore, cronScheduler))
toolRegistry.register(CronDeleteTool(cronStore))

三工具走 ToolRegistry,由 AgentLoop 自然发现,仍穿过 s03 权限管线与 s04 Pre/PostToolUse hooks,不需改 system prompt(工具 description 自描述用法,与 background 工具一致)。

2. 退出时清理:

KOTLIN
// repl/ReplLoop.kt:301(finally 块)
cronScheduler.stop()
cronScope.cancel()

stop 取消轮询协程,cancel 清理 scope。会话结束 = 调度停止,无残留协程。任务定义已在磁盘,下次启动恢复。

3. 命令注册 + banner:register(CronCommand(), "/cron"),banner 加 · s14: Cron Scheduler。

错误处理

失败场景行为谁处理
cron 表达式无效CronExpr.parse 抛 IllegalArgumentException;/cron add 报错不保存;tick 跳过该任务并 warnCronExpr/CronCommand/CronScheduler
cron 表达式无解(如 2 月 30 日)nextFire 返回 null;/cron add 报错;tick 跳过并 warnCronExpr/CronCommand/CronScheduler
存储文件损坏fail-open:warn 后空启CronStore
写盘失败log error,删临时文件,缓存仍更新(内存可用)CronStore
executor.fire 抛异常catch + log,调度继续推进CronScheduler
BackgroundRunner 并发满派发被拒,推送 FAILED 通知,下周期重试CronExecutor 实现
任务 disabledtick 跳过,nextFireAt 返回 nullCronScheduler
会话退出stop + cancel,任务定义已在磁盘ReplLoop
cron_create 缺参逐个 ?: return,返回 isError + 缺失参数名CronCreateTool
cron_create 表达式无效/无解CronJob.build 返回 InvalidExpr/NoFutureMatch,工具返回 isError 不保存CronCreateTool/CronJob.build
cron_delete 未知 idstore.delete 返回 false,工具返回 isError(提示重新 list)CronDeleteTool

端到端流程

一次完整的周期任务生命周期(持久化 + 会话级触发 + s13 执行):

TEXT
会话 A(2026-08-07 14:00):
  > /cron add nightly 0 9 * * * ./gradlew test
    └─ CronCommand.addJob:
         ├─ CronExpr.parse("0 9 * * *") 校验通过
         ├─ job = CronJob(id=cron-1, recurring, fireAt=null)
         ├─ cronStore.save -> 写入 ~/.cat-code/cron.json
         └─ "Cron job created: cron-1, next fire: 2026-08-08 09:00"
  > exit
    └─ cronScheduler.stop() + cronScope.cancel()
       任务定义已持久化(cron.json 含 cron-1)

  [REPL 关闭,cronScope 取消,不再触发。任务定义在磁盘沉睡]

会话 B(2026-08-08 09:00:15,次日早上重启):
  启动 -> CronStore 从 cron.json 加载 cron-1
       -> cronScheduler.start(cronScope): 轮询协程启动

  轮询协程(每 30s):
    tick(now=09:00:15):
      job=cron-1 (recurring, enabled)
      handleRecurring: cache miss -> nextFire(09:00:15 - 1min = 08:59:15) = 09:00 today
      !09:00.isAfter(09:00:15) = !(false) = true → 到期
        fireAndRecord:
          executor.fire -> backgroundRunner.launch("./gradlew test") -> bg-1
          notificationQueue.push("cron 'nightly' fired -> bg-1")
          store.update(cron-1, lastFiredAt=09:00:15)
        advance: nextFire(09:00) = 2026-08-09 09:00 → cache
      return 1

  > /cron list                       ← 用户查看
  Cron jobs (1):
    cron-1  [ON/recur]  0 9 * * *  nightly  next=2026-08-09 09:00

  [后台 bg-1 跑测试,40 秒后完成]
  下一轮 REPL 回合结束 -> drainNotifications():
    🔔 cron:1 - cron 'nightly' fired -> bg-1     ← cron 触发通知
    🔔 bg-1 - completed (exit code 0)            ← s13 完成通知

一次性任务(延时提醒):

TEXT
> /cron once deploy-check 30 14 7 8 * echo check-the-deploy
  └─ addJob(oneShot=true):
       ├─ next = nextFire(now) = 14:30 today
       ├─ fireAt = 14:30 的 epoch millis
       └─ "Cron job created: cron-2 (once), fires at: 2026-08-07 14:30"

[14:30 到达,tick 触发]
  handleOneShot: now>=fireAt → fire + store.delete(cron-2)
  🔔 cron:2 - cron 'deploy-check' fired -> bg-5
  [cron-2 已自动删除,不再触发]

表达式验证(防错):

TEXT
> /cron test 0 9 * * 1
Expression: 0 9 * * 1
  1. 2026-08-10 09:00     ← 周一
  2. 2026-08-17 09:00
  3. 2026-08-24 09:00
> /cron test 0 0 30 2 *
Expression: 0 0 30 2 *
  (no more matches)        ← 2 月 30 日不存在,nextFire 返回 null

测试策略

测试类覆盖点
CronExprTestparse 5 字段/字段数错误/越界拒绝;matches 字面值/星号/列表/区间/步进;nextFire 下一分钟/跨日/跨周/严格大于 from/月限制/闰日 2-29 命中/无解 2-30 返回 null/周日 7 归 0;DOM+DOW OR 语义/仅 DOM 限制
CronConfigTest默认文件/默认轮询 30s/~ 展开/millis 换算/自定义路径
CronStoreTestsave/loadAll、createdAt+id 排序确定性、同 id 替换、update 保留 createdAt、update 未知 id 插入、delete 返回值、findById、replaceAll、磁盘重载、损坏文件 fail-open、缺失文件空启、lastFiredAt 往返、nextId 空启为 cron-1/递增过 max/删除后按剩余重算
CronSchedulerTest空任务不触发、recurring 未到不触发且 nextFire 正确、到期触发并推进 nextFire、同窗口不重复触发、disabled 跳过、one-shot 到期触发并删除、one-shot 未到不触发、one-shot 过点补救触发、无效 cron 跳过不崩、新增任务下 tick 被接管、cron 变更重算 nextFire、executor 异常不阻塞调度
CronCommandTest空 list 提示、add 创建+list 显示、多词命令完整捕获、参数不足用法、无效 cron 报错不保存、once 创建带 fireAt、show 详情、show 未知、enable/disable 切换、delete、next 全部、test 解析+不保存、test 无效、未知子命令用法、空参等价 list
CronJobBuildTestrecurring Ok(无 fireAt)/one-shot Ok(含 fireAt)/recurring 不预检匹配/InvalidExpr 保留 message/NoFutureMatch/ID_PREFIX 常量
CronCreateToolTest缺 name/cron/command 各自报错、无效表达式报错、one-shot 无解报错、recurring 成功含 id+mode+next、one-shot 成功含 fires at、跨实例持久化
CronListToolTest空提示含 cron_create、多条目含 ON/OFF+recur/once+next/fires
CronDeleteToolTest缺 id 报错、未知 id 报错、删除成功且 store 清空

测试设计要点:

  1. 注入 clock 固定时间 -- CronScheduler(store, exec, clock = { fixedTime })。所有调度测试用固定 LocalDateTime,无真实时间依赖,零 flaky。tick(now) 直接传时间,完全确定性
  2. RecordingExecutor 记录触发 -- class RecordingExecutor : CronExecutor { val fired = mutableListOf<CronJob>() }。断言触发的任务列表,无需启动真实进程或 BackgroundRunner
  3. tick 是同步纯逻辑 -- 测试直接 sched.tick() shouldBe 1,不涉及协程/延时。start/stop 的协程生命周期不在单测范围(集成层由 ReplLoop 管理)
  4. 3× 重复运行验证稳定性 -- CronSchedulerTest(含触发时机算法)重复跑 3 次,0 失败
  5. CronExpr 边界用真实日期 -- 测试用 2026-08-07(周五)等具体日期验证 nextFire,确保日/周/月推进正确,不依赖"今天"
  6. 工具测试复用 fixture 范式 -- cron_create/list/delete 工具测试沿用调度测试的真实 CronStore(临时文件)+ RecordingExecutor + 固定 clock 的 CronScheduler,断言 ToolResult.content 的子串(shouldContain)。工具是薄包装,测试重点放「参数解析 + CronJob.build 三态映射 + 持久化跨实例」,不重复测调度逻辑(那已由 CronSchedulerTest 覆盖)

开发过程:设计取舍与踩坑记录

s14 的算法核心(CronExpr)是所有 Index 里最需要数学严谨的,踩的坑集中在两处:nextFire 的 off-by-one 和 KDoc 的 */ 陷阱。

设计阶段的关键取舍:

  • 持久化任务定义 + 会话级触发 -- 最初想完全模仿 Claude Code(会话态、不写磁盘)。但 cat-code 是 CLI,重启频繁,会话态 cron 用户每天得重设,不实用。最终持久化定义、触发随会话--意图长存、执行随会话。这是"持久化调度 + 会话级触发"的精确语义
  • 触发 shell 命令而非注入 prompt -- 复用 s13 BackgroundRunner 是最省力且一致的路径(s13 博客已预告)。注入 prompt 需处理消息队列竞态,复杂度高,留给 s17/s20
  • CronExecutor 解耦 -- 一开始想直接在 CronScheduler 里调 backgroundRunner.launch。但这样调度器测试就得启动真实进程。抽出 fun interface 后,测试用 RecordingExecutor,调度逻辑完全可单测
  • 不支持 cron 名称/L/# -- 标准 cron 的 JAN..DEC/L(last)/#(nth weekday) 增加解析复杂度。⭐⭐ 复杂度下,数字表达式已覆盖常见场景,名称语法留后续

实现中的坑:

  • nextFire 的 off-by-one(4 个测试全挂) -- nextFire 契约是"严格大于 from"。tick 初版用 nextFire(now),当 now 恰处匹配分钟(如 09:00)时跳过本分钟、返回次日。改用 nextFire(now.minusMinutes(1)) 让当前分钟纳入候选。这是"算法契约"与"调度语义"的经典错位--nextFire 的严格大于对"推进"正确,对"首次计算"需偏移一分钟
  • KDoc 里的 */ 陷阱 -- CronExpr 的 KDoc 写了 */n(步进语法示例)和 */15,KDoc 词法器把 */ 当注释结束符,导致类声明前的 KDoc 提前终止、整个文件解析错乱(报一堆"Expecting top level declaration")。改为文字描述"星号斜杠加步长"避开字面 */。这是 KDoc 的经典坑:注释体内不能出现字面 */
  • add 命令 split 多切一刀 -- <name> + 5 cron 字段 + command 共 7 段,初版用 split(" ", limit = 8),多切一刀导致 parts[7] 丢失命令首词(echo hello → hello)。改成 limit = 7、command = parts[6]。与 s12 /task add 标题被截断的坑同源--split 的 limit 参数必须精确对应"需要的段数"
  • CronCommand 嵌套字符串模板 -- 初版大量用 ${x ?: "(none)"} 嵌套字符串模板,虽合法但可读性差且易错。重写时全部提取为 local val(val nxtStr = if (nxt != null) fmt(nxt) else "(none)"),更清晰

对话工具的 DRY 提取:

补齐 cron_create/list/delete 工具时,发现「parse cron + 算 one-shot fireAt + 构造 CronJob」的逻辑在 /cron add|once 和 cron_create 两处重复。提取 CronJob.build factory + CronBuildResult 三态 sealed interface,命令与工具共享判定、各自映射反馈(println vs ToolResult)。同步把 nextId 移入 CronStore、ID_PREFIX 移入 CronJob、formatCronTime 提为包扩展--三处时间格式化(命令 + create + list 工具)共用一个 yyyy-MM-dd HH:mm 常量。这是「补功能时顺带去重」的典型:新需求暴露了既有重复,提取后命令与工具行为严格一致(同一 factory、同一格式)。

与 s12/s13 的范式复用:

s14 的持久化层(CronStore)几乎是 s12 TaskStore 的复制:单 JSON + 原子写 + fail-open + createdAt/id 排序决胜键。这印证了 s12 博客说的"留给后续最大的礼物是一个可靠的持久化范式"。而执行层完全复用 s13 BackgroundRunner + NotificationQueue。s14 自身的增量只是 CronExpr 算法 + tick 调度逻辑--前序 Index 的范式沉淀让 s14 的实现量远小于从零写。


下一站

s14 补上了"按时间自动触发"这一块,阶段三"系统可靠性"至此完成:错误自愈(s11)、任务持久化(s12)、后台执行(s13)、定时调度(s14)。它是 s17 自治智能体的关键拼图:

  • s17 Autonomous Agents -- 自治智能体用 s12 TaskScheduler.readyTasks 认领就绪任务,用 s13 BackgroundRunner 后台执行,用 s14 CronScheduler 做定时巡检。三者组合出"按需 + 定时"的自治调度:空闲时认领就绪任务、定时触发巡检任务、后台执行不阻塞
  • s15 Agent Teams / s16 Team Protocols -- 团队协作中的"定时汇报"、"定时检查队友状态"可用 s14 表达
  • s20 Comprehensive Agent -- 全机制集成时,cron 是"智能体自主设置提醒"的基础

s14 留给后续最大的礼物是**"持久化意图 + 会话级触发 + 解耦执行"的调度范式**:CronExpr 纯算法、tick 同步可测、CronExecutor 注入执行。它和 s12(持久任务)、s13(后台执行)一起,构成了 cat-code 的"时间与执行"三件套--任务系统管"做什么"(依赖调度)、后台执行管"怎么跑"(不阻塞)、cron 管"何时做"(时间规则)。

下一篇:Index 15: Agent Teams -- MessageBus / 收件箱 / 权限冒泡,进入多智能体协作阶段。

目录

当前章节:目标

  • 1. 目标
  • 2. 为什么需要
  • 3. 现实问题
  • 4. 与 Claude Code CronCreate 的对齐与取舍
  • 5. 设计原则
  • 6. 核心设计与实现
  • 7. 架构全景
  • 8. 核心模型:CronJob
  • 9. CronExpr:cron 解析 + nextFire 算法
  • 10. CronStore:磁盘持久化
  • 11. CronExecutor:执行解耦
  • 12. CronScheduler:会话级轮询 + tick 算法
  • 13. /cron 命令
  • 14. 对话式 cron 管理(croncreate / cronlist / crondelete 工具)
  • 15. ReplLoop 集成
  • 16. 错误处理
  • 17. 端到端流程
  • 18. 测试策略
  • 19. 开发过程:设计取舍与踩坑记录
  • 20. 下一站
回到顶部

相关推荐

查看全部文章
Index 21: Terminal UX —— Claude Code 风格终端交互

Index 21: Terminal UX —— Claude Code 风格终端交互

2026年8月21日

Index 21 将 cat-code 终端交互升级为 Claude Code 风格,解决旧 REPL 黑箱、无中断及输入体验差的问题。通过 JLine3 与 Mordant 实现历史补全、实时工具可见性、Spinner 状态及 Esc 中断。核心采用 UI 与 AgentLoop 解耦的事件流架构,支持内联权限菜单与优雅降级。同时配置 logback 收敛控制台日志,确保 TUI 清爽且功能无损,显著提升可用性。

Index 20: Comprehensive Agent —— 全机制集成(收口)

Index 20: Comprehensive Agent —— 全机制集成(收口)

2026年8月13日

作为收官之作,把 s01-s19 的二十个核心机制整合为一个全面智能体:统一的状态查询与运行周期、自治认领与工作区隔离的贯通,以及 /status 命令的全局可视化。全机制协同运转,标志着 Cat-Code 从零完整复刻 Claude Code 核心能力的收官。

Index 19: MCP Plugin —— 多传输 / 通道路由 / 工具池组装

Index 19: MCP Plugin —— 多传输 / 通道路由 / 工具池组装

2026年8月13日

引入 MCP(Model Context Protocol)插件机制,通过多传输适配与通道路由,把外部 MCP 服务器的工具动态接入智能体的工具池。工具注册从静态编译期扩展为运行时动态组装,让智能体能力随外部服务即插即用。