目标
s05 的 TodoStore 是内存、无 id、无依赖的极简模型(TodoItem(content, status)),服务于"先计划后执行"的会话内流程。但它的两个短板决定了 s12 必须存在:
- 重启即失 —— 会话结束 todos 全部丢失,跨会话的任务没法沉淀
- 无依赖表达 —— 无法表达"任务 B 依赖任务 A",也就无法自动判断"哪些任务现在能执行"
Index 12 引入可持久化的依赖感知任务系统:
TaskRecord—— 带 id、状态、blockedBy 依赖、时间戳的持久任务模型TaskStore—— kotlinx-serialization JSON 磁盘持久化,任务跨会话存活TaskScheduler—— 依赖调度纯函数:就绪任务、拓扑排序、环检测TaskPromoter—— todo → task 桥,把会话内待办提升为持久任务/task命令 —— REPL 里管理任务(list/add/show/update/delete/ready/order/import)
完成后的效果——任务离开会话也能存活,且依赖关系自动决定执行顺序:
$ 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 的地基:
s05 TodoWrite → s12 TaskSystem → s13 BackgroundTasks → s14 CronScheduler
↘ s17 AutonomousAgentss13 要在后台执行任务、s14 要定时触发任务、s17 要让自治智能体认领任务——它们都需要一个持久、依赖感知、可查询就绪状态的任务存储。
设计原则
- BLOCKED 是计算状态 —— 存储不持久化 BLOCKED,由
TaskScheduler从依赖实时推导。依赖完成 → 状态自动从 BLOCKED 变回 PENDING,无需手动改 - 单 JSON 文件持久化 —— 项目已有 kotlinx-serialization(设计文档指定"标准库序列化到磁盘"),任务量小,全量重写可靠
- Scheduler 纯函数 —— 就绪/排序/环检测都是确定性算法,不依赖 I/O,高度可测
task → todo通过 TaskPromoter 真实实现 —— 会话内待办可提升为持久任务,让"计划"沉淀为"任务"- 严格迭代 —— 不做优先级、预估时长、负责人;这些留给 s17 按需扩展
核心设计与实现
架构全景
┌─────────────────────────────────────────┐
│ ~/.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
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()
)三个关键设计点:
blockedBy存任务 id 列表 —— 依赖表达。任务 B 的 blockedBy 含 A 的 id,表示"A 完成后 B 才可执行"BLOCKED不持久化 —— 枚举里存在(供展示),但存储中任务是 PENDING/IN_PROGRESS 等实际状态;BLOCKED 由调度器实时推导updatedAt在 update 时刷新(最终审查 I3 修复)—— 最初它构造后从不更新,成了死数据
TaskStore:磁盘 JSON 持久化
单 JSON 文件,内存缓存 + 写穿透:
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:
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 决胜键的稳定排序:
fun loadAll(): List<TaskRecord> =
cache.sortedWith(compareBy<TaskRecord> { it.createdAt }.thenBy { it.id })同时刻的任务按 id 排序,结果确定。
3. fail-open 加载 —— 文件缺失视为空;损坏文件跳过并 warn(与 s09 MemoryStore 一致)。
TaskScheduler:依赖调度纯函数
这是 s12 的算法核心。五个方法全部纯函数、确定性:
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。补上终态短路:
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)——核心思路:
1. 每个任务计算"未完成依赖数"(入度);依赖缺失或 COMPLETED 的依赖不计入
2. 入度为 0 的任务入队(就绪)
3. 依次出队加入 order,把"依赖它的任务"入度 -1;减到 0 就入队
4. 若 order 大小 == 任务数 → 无环,返回 order;否则有环 → nullDFS 环检测(findCycle)——三色标记(0 未访问 / 1 访问中 / 2 已访问)。DFS 沿依赖走,遇到"访问中"的节点说明发现环,从路径中该节点位置截取环序列(首尾相同,如 [a, b, a])。
缺失依赖的语义分歧(最终审查 I2)——这是一个值得注意的设计决策:
| 方法 | 对缺失依赖的处理 | 哲学 |
|---|---|---|
isBlocked / readyTasks | 视为阻塞(任务不可执行) | 保守——图不完整就谨慎 |
executionOrder | 视为已完成(不阻塞排序) | 乐观——让能执行的任务先跑 |
同一任务可能同时"不可就绪"(readyTasks 不含它)又"排在最前"(executionOrder 首位)。这是有意的分工,但两个函数的 KDoc 都加了交叉引用警告,供 s17 使用方注意。
TaskPromoter:todo → task 桥
实现设计文档的 task → todo 依赖:
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 命令
十个子命令,覆盖任务生命周期:
/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:
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 后消除。
端到端流程
一次完整的任务生命周期:
会话 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 - 写测试 ← 依赖完成,自动解除阻塞依赖排序(有环时):
> /task order
Cannot order tasks: dependency cycle detected: task-1 → task-2 → task-1测试策略
| 测试类 | 覆盖点 |
|---|---|
TaskSchedulerTest | readyTasks 筛选、isBlocked 四分支(无依赖/未完成/已完成/缺失)、effectiveStatus(BLOCKED 计算 + 终态守卫 COMPLETED/CANCELLED + else 分支)、executionOrder(依赖先执行/缺失视为完成/COMPLETED 不计入度/有环 null)、findCycle |
TaskStoreTest | save/update/delete/loadAll/findById/replaceAll、update 保留 createdAt + 刷新 updatedAt、磁盘重载、损坏文件跳过、显式 createdAt 排序(确定性) |
TaskPromoterTest | TodoItem→TaskRecord 映射、状态转换、id 生成 |
TaskConfigTest | ~ 展开、相对路径转绝对 |
TaskCommandTest | add+list、update done、ready、import、delete、unknown 子命令、import 删除空洞后不撞号 |
测试设计要点:
- 调度器用显式测试数据验证算法边界 —— Kahn 排序、环检测、终态守卫,每个边界都有针对性用例
- TaskStore 测试验证真实磁盘往返 ——
reloads from disk on construction与replaceAll overwrites and persists都重建 TaskStore 从磁盘读回 - 排序测试用显式 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 —— 线程执行 / 通知队列,让任务在后台跑起来。


