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

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

Index 12: Task System — TaskRecord / blockedBy / 磁盘持久化

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

系列

使用Kotlin从0开发一个ClaudeCode

系列

使用Kotlin从0开发一个ClaudeCode

进度 12 / 21

使用Kotlin从0开发一个ClaudeCode

上一篇

Index 11: Error Recovery — 重试 / fallback / token 升级

下一篇

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

目标

s05 的 TodoStore 是内存、无 id、无依赖的极简模型(TodoItem(content, status)),服务于"先计划后执行"的会话内流程。但它的两个短板决定了 s12 必须存在:

  1. 重启即失 —— 会话结束 todos 全部丢失,跨会话的任务没法沉淀
  2. 无依赖表达 —— 无法表达"任务 B 依赖任务 A",也就无法自动判断"哪些任务现在能执行"

Index 12 引入可持久化的依赖感知任务系统:

  1. TaskRecord —— 带 id、状态、blockedBy 依赖、时间戳的持久任务模型
  2. TaskStore —— kotlinx-serialization JSON 磁盘持久化,任务跨会话存活
  3. TaskScheduler —— 依赖调度纯函数:就绪任务、拓扑排序、环检测
  4. TaskPromoter —— todo → task 桥,把会话内待办提升为持久任务
  5. /task 命令 —— REPL 里管理任务(list/add/show/update/delete/ready/order/import)

完成后的效果——任务离开会话也能存活,且依赖关系自动决定执行顺序:

TEXT
$ cat-code
> /task add 重构 UserService
Task created: task-1
> /task add 写测试
Task created: task-2
> /task update task-2 block task-1
task-2 now blocked by: task-1
> /task ready
Ready tasks (1):
  task-1  -  重构 UserService
> exit  ← 重启 REPL
$ cat-code
> /task list
Tasks (2):
  task-1  -  重构 UserService  (PENDING)
  task-2  -  写测试  (BLOCKED) [depends: task-1]

为什么需要

现实问题

s05 的 TodoStore 服务于会话内规划,但它有三个结构性局限:

  • 无 id —— TodoItem 只有 content + status,更新/删除只能靠位置或内容匹配。s05 的 KDoc 自己写明"未来 s12 持久化时再扩展 id、createdAt、priority"
  • 无依赖 —— 无法表达任务间的先后关系。一个依赖三个前置步骤的任务,用户得自己记着"等它们完成才能开始"
  • 内存态 —— 会话结束全部消失。昨天规划的五步重构,今天打开 REPL 一无所知

而依赖图表明,s12 是后续 Index 的地基:

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

s13 要在后台执行任务、s14 要定时触发任务、s17 要让自治智能体认领任务——它们都需要一个持久、依赖感知、可查询就绪状态的任务存储。

设计原则

  • BLOCKED 是计算状态 —— 存储不持久化 BLOCKED,由 TaskScheduler 从依赖实时推导。依赖完成 → 状态自动从 BLOCKED 变回 PENDING,无需手动改
  • 单 JSON 文件持久化 —— 项目已有 kotlinx-serialization(设计文档指定"标准库序列化到磁盘"),任务量小,全量重写可靠
  • Scheduler 纯函数 —— 就绪/排序/环检测都是确定性算法,不依赖 I/O,高度可测
  • task → todo 通过 TaskPromoter 真实实现 —— 会话内待办可提升为持久任务,让"计划"沉淀为"任务"
  • 严格迭代 —— 不做优先级、预估时长、负责人;这些留给 s17 按需扩展

核心设计与实现

架构全景

TEXT
                    ┌─────────────────────────────────────────┐
                    │        ~/.cat-code/tasks.json           │
                    │  {"tasks": [{"id": "task-1", ...}]}     │
                    └──────────────────┬──────────────────────┘
                                       │ 写穿透 + 原子写
                    ┌──────────────────▼──────────────────────┐
                    │               TaskStore                 │
                    │  内存缓存 + 构造加载 + 临时文件原子 move   │
                    └──────────────────┬──────────────────────┘
                                       │
        ┌──────────────────────────────┼──────────────────────────────┐
        │                              │                              │
  /task 命令                      TaskScheduler                 TaskPromoter
  (ReplCommand)                  (纯函数:就绪/排序/环)          (todo → task 桥)
        │                              │                              │
  list/add/show/update/          readyTasks / effectiveStatus    TodoItem → TaskRecord
  delete/ready/order/import      executionOrder / findCycle      (状态映射 + id 生成)

  ReplContext.taskStore ← s05 TodoStore(/task import 读取)

核心模型:TaskRecord + TaskStatus

KOTLIN
enum class TaskStatus { PENDING, IN_PROGRESS, COMPLETED, BLOCKED, CANCELLED }

@Serializable
data class TaskRecord(
    val id: String,                    // 稳定标识,供 blockedBy 引用
    val title: String,                 // 任务描述
    val status: TaskStatus = TaskStatus.PENDING,
    val blockedBy: List<String> = emptyList(),  // 依赖的其他任务 id
    val createdAt: Long = System.currentTimeMillis(),
    val updatedAt: Long = System.currentTimeMillis()
)

三个关键设计点:

  1. blockedBy 存任务 id 列表 —— 依赖表达。任务 B 的 blockedBy 含 A 的 id,表示"A 完成后 B 才可执行"
  2. BLOCKED 不持久化 —— 枚举里存在(供展示),但存储中任务是 PENDING/IN_PROGRESS 等实际状态;BLOCKED 由调度器实时推导
  3. updatedAt 在 update 时刷新(最终审查 I3 修复)—— 最初它构造后从不更新,成了死数据

TaskStore:磁盘 JSON 持久化

单 JSON 文件,内存缓存 + 写穿透:

KOTLIN
class TaskStore(private val file: Path) {
    private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
    private val cache = mutableListOf<TaskRecord>()

    init { reloadFromDisk() }

    fun loadAll(): List<TaskRecord>   // 按 createdAt 升序(同时刻按 id 决胜键)
    fun save(record: TaskRecord): Boolean      // 新增(同名替换)
    fun update(record: TaskRecord): Boolean    // 按 id 更新,保留 createdAt,刷新 updatedAt
    fun delete(id: String): Boolean
    fun findById(id: String): TaskRecord?
    fun replaceAll(records: List<TaskRecord>)
}

三个实现细节值得展开:

1. 原子写(最终审查 I1 修复)——最初的 Files.writeString 直接截断覆盖,进程崩溃会留下损坏文件,下次加载 fail-open 到空 = 静默丢失全部任务。改为临时文件 + 原子 move:

KOTLIN
    private fun writeToDisk() {
        Files.createDirectories(file.parent)
        val tmp = file.resolveSibling("${file.fileName}$TMP_SUFFIX")
        try {
            Files.writeString(tmp, json.encodeToString(TaskStoreData(cache)))
            Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
        } catch (e: Exception) {
            logger.error(e) { "Failed to write task store: $file" }
            Files.deleteIfExists(tmp)
        }
    }

崩溃发生在 write 与 move 之间时,磁盘上仍是上次完好的文件,不会丢数据。

2. 排序决胜键(计划缺陷修复)——loadAll 最初用 sortedBy { it.createdAt },但测试里多个任务 createdAt 落在同一毫秒时,sortedBy 对相等键保持插入序 → 结果不确定。改为带 id 决胜键的稳定排序:

KOTLIN
    fun loadAll(): List<TaskRecord> =
        cache.sortedWith(compareBy<TaskRecord> { it.createdAt }.thenBy { it.id })

同时刻的任务按 id 排序,结果确定。

3. fail-open 加载 —— 文件缺失视为空;损坏文件跳过并 warn(与 s09 MemoryStore 一致)。

TaskScheduler:依赖调度纯函数

这是 s12 的算法核心。五个方法全部纯函数、确定性:

KOTLIN
object TaskScheduler {

    /** 就绪任务:PENDING/IN_PROGRESS 且无未完成依赖 */
    fun readyTasks(tasks: List<TaskRecord>): List<TaskRecord>

    /** 有效状态:终态(COMPLETED/CANCELLED)保持;否则 isBlocked → BLOCKED;否则自身状态 */
    fun effectiveStatus(record: TaskRecord, allTasks: List<TaskRecord>): TaskStatus

    /** 是否有未完成依赖:依赖缺失或非 COMPLETED → true */
    fun isBlocked(record: TaskRecord, allTasks: List<TaskRecord>): Boolean

    /** 拓扑排序(Kahn):被依赖的先执行;缺失依赖视为已完成;有环返回 null */
    fun executionOrder(tasks: List<TaskRecord>): List<TaskRecord>?

    /** 检测依赖环(DFS 三色标记),返回环上 id 序列 */
    fun findCycle(tasks: List<TaskRecord>): List<String>?
}

effectiveStatus 的终态守卫(计划缺陷修复)——最初实现 if (isBlocked) BLOCKED else status,会让一个 COMPLETED 任务因"有未完成依赖"被误算成 BLOCKED。补上终态短路:

KOTLIN
    fun effectiveStatus(record: TaskRecord, allTasks: List<TaskRecord>): TaskStatus =
        if (record.status == TaskStatus.COMPLETED || record.status == TaskStatus.CANCELLED) {
            record.status   // 终态任务即使有未完成依赖也保持原状态
        } else if (isBlocked(record, allTasks)) {
            TaskStatus.BLOCKED
        } else {
            record.status
        }

Kahn 拓扑排序(executionOrder)——核心思路:

TEXT
1. 每个任务计算"未完成依赖数"(入度);依赖缺失或 COMPLETED 的依赖不计入
2. 入度为 0 的任务入队(就绪)
3. 依次出队加入 order,把"依赖它的任务"入度 -1;减到 0 就入队
4. 若 order 大小 == 任务数 → 无环,返回 order;否则有环 → null

DFS 环检测(findCycle)——三色标记(0 未访问 / 1 访问中 / 2 已访问)。DFS 沿依赖走,遇到"访问中"的节点说明发现环,从路径中该节点位置截取环序列(首尾相同,如 [a, b, a])。

缺失依赖的语义分歧(最终审查 I2)——这是一个值得注意的设计决策:

方法对缺失依赖的处理哲学
isBlocked / readyTasks视为阻塞(任务不可执行)保守——图不完整就谨慎
executionOrder视为已完成(不阻塞排序)乐观——让能执行的任务先跑

同一任务可能同时"不可就绪"(readyTasks 不含它)又"排在最前"(executionOrder 首位)。这是有意的分工,但两个函数的 KDoc 都加了交叉引用警告,供 s17 使用方注意。

TaskPromoter:todo → task 桥

实现设计文档的 task → todo 依赖:

KOTLIN
object TaskPromoter {
    fun promote(todos: List<TodoItem>, idPrefix: String = DEFAULT_ID_PREFIX): List<TaskRecord> =
        todos.mapIndexed { index, todo ->
            TaskRecord(
                id = "$idPrefix-${index + 1}",
                title = todo.content,
                status = mapStatus(todo.status)
            )
        }

    private fun mapStatus(status: TodoStatus): TaskStatus = when (status) {
        TodoStatus.PENDING -> TaskStatus.PENDING
        TodoStatus.IN_PROGRESS -> TaskStatus.IN_PROGRESS
        TodoStatus.COMPLETED -> TaskStatus.COMPLETED
    }
}

会话内用 /task import 把 todo 提升为持久任务——"先计划后执行"的 todo 沉淀为"跨会话存活"的任务。

/task 命令

十个子命令,覆盖任务生命周期:

TEXT
/task list                      列出所有任务(含有效状态)
/task add <title>               新增任务(自动生成 id)
/task show <id>                 显示任务详情
/task update <id> done|pending|in_progress|cancelled   更新状态
/task update <id> block <depIds>  设置依赖(逗号分隔)
/task update <id> unblock       清除依赖
/task delete <id>               删除任务
/task ready                     列出就绪任务
/task order                     显示依赖排序(环时提示)
/task import                    把会话内 todo 提升为持久任务

update 用 effectiveStatus 展示状态,ready 用 readyTasks,order 用 executionOrder + findCycle——命令层直接消费调度器的纯函数。

id 生成用 max-id+1(最终审查 C1 修复)——nextId 扫描现有 id 取最大序号 +1:

KOTLIN
    private fun maxIdNumber(store: TaskStore): Int =
        store.loadAll().mapNotNull { it.id.removePrefix("$ID_PREFIX-").toIntOrNull() }.maxOrNull() ?: 0

最初 importTodos 用 loadAll().size 续排——删除产生空洞时(如剩 task-1/task-3),count=2 会生成 task-3/task-4 撞号。统一为 max+1 后消除。


端到端流程

一次完整的任务生命周期:

TEXT
会话 A:
  > /task add 重构 UserService        → task-1 创建,写入 tasks.json
  > /task add 写测试                  → task-2 创建
  > /task update task-2 block task-1  → task-2.blockedBy = [task-1]
  > /task ready
  Ready tasks (1):
    task-1  -  重构 UserService       ← task-2 被阻塞
  → 退出 REPL(任务已持久化到 ~/.cat-code/tasks.json)

会话 B(重启后):
  > /task list
  Tasks (2):
    task-1  -  重构 UserService  (PENDING)
    task-2  -  写测试  (BLOCKED) [depends: task-1]   ← 依赖关系从磁盘恢复
  > /task update task-1 done
  > /task ready
  Ready tasks (1):
    task-2  -  写测试                  ← 依赖完成,自动解除阻塞

依赖排序(有环时):

TEXT
  > /task order
  Cannot order tasks: dependency cycle detected: task-1 → task-2 → task-1

测试策略

测试类覆盖点
TaskSchedulerTestreadyTasks 筛选、isBlocked 四分支(无依赖/未完成/已完成/缺失)、effectiveStatus(BLOCKED 计算 + 终态守卫 COMPLETED/CANCELLED + else 分支)、executionOrder(依赖先执行/缺失视为完成/COMPLETED 不计入度/有环 null)、findCycle
TaskStoreTestsave/update/delete/loadAll/findById/replaceAll、update 保留 createdAt + 刷新 updatedAt、磁盘重载、损坏文件跳过、显式 createdAt 排序(确定性)
TaskPromoterTestTodoItem→TaskRecord 映射、状态转换、id 生成
TaskConfigTest~ 展开、相对路径转绝对
TaskCommandTestadd+list、update done、ready、import、delete、unknown 子命令、import 删除空洞后不撞号

测试设计要点:

  1. 调度器用显式测试数据验证算法边界 —— Kahn 排序、环检测、终态守卫,每个边界都有针对性用例
  2. TaskStore 测试验证真实磁盘往返 —— reloads from disk on construction 与 replaceAll overwrites and persists 都重建 TaskStore 从磁盘读回
  3. 排序测试用显式 createdAt(最终审查 C2 修复)——最初依赖 System.currentTimeMillis() 的同毫秒巧合,跨毫秒就 flaky(确认 1/5 次失败);改显式 1k/2k/3k 毫秒后完全确定

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

s12 经历了完整的 spec → plan → subagent-driven execution 流程,真实踩过的坑比其他 Index 更多——几乎每个任务都暴露了计划缺陷:

计划阶段发现的缺陷(4 处,均被实现者正确识别、调度方确认后修复):

  • readyTasks 测试期望错 —— 数据里 b 的依赖 a 是 COMPLETED,b 必然就绪,期望应为 ["b", "d"] 而非 ["d"]
  • effectiveStatus 缺终态守卫 —— 实现 if (isBlocked) BLOCKED 会让 COMPLETED 任务误算成 BLOCKED,与 KDoc 矛盾
  • loadAll 排序同毫秒不稳定 —— sortedBy 对相等 createdAt 保持插入序,测试结果不确定;加 id 决胜键
  • add 标题被 split 截断 —— args.split(" ", limit = 3) 把 add Fix the bug 截成 Fix,改 substringAfter 取完整剩余

最终全分支审查发现并修复(2 Critical + 5 Important):

  • C1 importTodos id 撞号 —— count 偏移生成 id,删除空洞时撞号静默损坏;统一为 max-id+1
  • C2 loadAll 排序测试仍然 flaky —— id 决胜键只在同毫秒时生效,跨毫秒仍失败;改显式 createdAt
  • I1 非原子写 —— 崩溃丢全部任务;改临时文件 + ATOMIC_MOVE
  • I2 ready/order 缺失依赖语义分歧 —— KDoc 交叉引用警告(保守 vs 乐观)
  • I3 updatedAt 死数据 —— update 时刷新
  • I4/I5 测试缺口 —— 补 CANCELLED 终态、COMPLETED 依赖不计入度

几个被采纳的关键设计决策:

  • BLOCKED 计算而非存储 —— 依赖完成自动解除,无需手动改状态
  • 依赖缺失双语义 —— readyTasks 保守(阻塞)、executionOrder 乐观(放行),分工明确
  • id 用 max+1 而非计数 —— 删除空洞不撞号
  • fail-open 加载 + 原子写 —— 读时容错、写时保稳

下一站

s12 让任务离开会话也能存活、且依赖关系自动决定执行顺序。它是阶段三"系统可靠性"的持久化基石:

  • s13 Background Tasks —— 后台线程执行任务,TaskStore 成为任务的唯一事实来源
  • s14 Cron Scheduler —— 定时创建/触发任务
  • s17 Autonomous Agents —— 用 TaskScheduler.readyTasks 认领就绪任务,实现自治

s12 留给后续最大的礼物是一个可靠的持久化 + 依赖调度范式:单 JSON 文件 + 原子写 + fail-open 读、纯函数调度器、id 稳定标识。s13/s14 的后台与定时系统将直接建立在它之上。

下一篇:Index 13: Background Tasks —— 线程执行 / 通知队列,让任务在后台跑起来。

目录

当前章节:目标

  • 1. 目标
  • 2. 为什么需要
  • 3. 现实问题
  • 4. 设计原则
  • 5. 核心设计与实现
  • 6. 架构全景
  • 7. 核心模型:TaskRecord + TaskStatus
  • 8. TaskStore:磁盘 JSON 持久化
  • 9. TaskScheduler:依赖调度纯函数
  • 10. TaskPromoter:todo → task 桥
  • 11. /task 命令
  • 12. 端到端流程
  • 13. 测试策略
  • 14. 开发过程:设计取舍与踩坑记录
  • 15. 下一站
回到顶部

相关推荐

查看全部文章
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 服务器的工具动态接入智能体的工具池。工具注册从静态编译期扩展为运行时动态组装,让智能体能力随外部服务即插即用。