构建mini Claude Code:07 - 一切皆文件:持久化任务系统

90 阅读13分钟

构建mini Claude Code:07 - 一切皆文件:持久化任务系统

📍 导航指南

这是「从零构建 Claude Code」系列的第七篇。根据你的背景,选择合适的阅读路径:


目录

第一部分:理论基础 🧠

第二部分:TaskManager 设计 ⚙️

第三部分:代码实现 💻

第四部分:扩展方向 🔭

附录


引言

上一篇我们解决了 Agent 的「撑死」问题——三层压缩流水线让 Agent 能长时间运行。

但压缩带来了一个新问题:TodoWrite 里的任务列表,在压缩后消失了。

更深层的问题是:当 Agent 派生出子 Agent 处理子任务时,子 Agent 根本看不到父 Agent 的任务列表。两个 Agent 各自维护各自的 todos,互相不知道对方在做什么。

v6_agent.py 用一个极其朴素的方案解决了这两个问题:把任务写成文件


第一部分:理论基础 🧠

TodoWrite 的两个致命缺陷

TodoWrite 是一个优秀的工具——它让 Agent 在单次会话中追踪多步骤任务,给用户可见的进度反馈。

但它有两个根本性的局限:

TodoWrite 的本质:
  - 存储位置: 内存(Python 对象)
  - 生命周期: 当前会话
  - 可见范围: 当前 Agent

这意味着:
  - 上下文压缩 → 内存不变,但 Agent 「忘记」了 todos 的存在
  - 子 Agent 启动 → 新的进程,看不到父 Agent 的内存

问题一:压缩后失忆

回顾上一篇的 auto_compact:它把整个 messages 替换为一段摘要。

压缩前:
  messages = [
    ...50 条消息...,
    {role: "user", content: [tool_result: "TodoWrite 结果: [>] 步骤3 <- 正在执行"]}
  ]

压缩后:
  messages = [
    {role: "user", content: "摘要: 已完成步骤1和2,正在执行步骤3..."},
    {role: "assistant", content: "Understood. Continuing."}
  ]

摘要里可能提到「正在执行步骤3」,但 TodoManager 对象里的 items 列表已经和 messages 脱节了。

更糟的是:Agent 在压缩后继续工作,它可能重新调用 TodoWrite 创建一个全新的任务列表,和之前的任务列表毫无关联。任务的连续性断了。

问题二:多 Agent 孤岛

当主 Agent 派生子 Agent 时:

# 主 Agent 调用 Task 工具
run_task(
    description="实现用户认证模块",
    prompt="实现 JWT 认证,包括登录、注册、token 刷新",
    subagent_type="general-purpose"
)

子 Agent 在 run_task 函数里启动,它有自己的 sub_messages,但:

  • 看不到主 Agent 的 TodoWrite 列表
  • 不知道主 Agent 还有哪些任务在等待
  • 完成后只能通过返回文本告知主 Agent 结果

这是一个信息孤岛问题。每个 Agent 都在自己的上下文泡泡里工作,无法共享任务状态。

主 Agent:
  TodoWrite: [>] 实现认证模块, [ ] 实现用户管理, [ ] 写测试
  ↓ 派生子 Agent
  子 Agent:
    TodoWrite: [>] 实现 JWT, [ ] 实现登录接口, [ ] 实现注册接口
    (主 Agent 完全不知道子 Agent 的进度)

核心洞察:状态外置

v6_agent.py 的注释里有一句话,是整个设计的核心:

Key insight: "State that survives compression -- because it's outside the conversation."

状态外置:把需要持久化的状态放到对话上下文之外。

对话上下文(messages)是易失的——它会被压缩、被截断、被替换。但文件系统是持久的。

易失的(会被压缩):
  - messages 列表
  - TodoManager.items(内存对象)
  - 工具调用结果

持久的(压缩后依然存在):
  - .tasks/ 目录下的 JSON 文件
  - .transcripts/ 目录下的对话记录
  - 代码文件、配置文件

这个洞察不复杂,但它解决了根本问题:任何 Agent,在任何时刻,都可以读取 .tasks/ 目录,获得完整的任务状态。


第二部分:TaskManager 设计 ⚙️

一切皆文件:设计哲学

Unix 哲学有一条:「一切皆文件」。

TaskManager 把这个哲学用到了极致:

.tasks/
  task_1.json   ← 一个任务 = 一个文件
  task_2.json
  task_3.json

为什么是文件,而不是数据库?

文件的优势:
  ✅ 零依赖(不需要安装任何数据库)
  ✅ 人类可读(直接打开 JSON 文件查看)
  ✅ Agent 可读(read_file 工具直接读取)
  ✅ 天然持久化(文件系统保证)
  ✅ 天然共享(多个 Agent 读同一个目录)
  ✅ 天然版本控制(可以 git 追踪)

文件的代价:
  ❌ 并发写入需要注意(但 Agent 通常是串行的)
  ❌ 查询不如数据库灵活(但任务数量通常很少)

对于 Agent 的任务管理场景,文件是最合适的选择。

任务结构:最小化 JSON

每个任务文件的结构:

{
  "id": 1,
  "subject": "实现用户认证模块",
  "description": "包括 JWT 登录、注册、token 刷新",
  "status": "in_progress",
  "blockedBy": [],
  "blocks": [2, 3],
  "owner": ""
}

字段设计遵循最小化原则:

字段类型说明
idint自增 ID,文件名也用这个
subjectstr一句话描述任务
descriptionstr可选的详细说明
statusstrpending / in_progress / completed
blockedBylist[int]被哪些任务阻塞(前置依赖)
blockslist[int]阻塞哪些任务(后置依赖)
ownerstr任务负责人(可选)

没有时间戳、没有标签、没有优先级——这些都可以加,但最小化设计让核心逻辑保持清晰。

依赖图:blockedBy / blocks

任务之间的依赖关系是 TaskManager 最有价值的设计。

任务依赖示例:
  task_1: 设计数据库 Schema
  task_2: 实现 Repository   (blockedBy: [1])
  task_3: 实现 Service      (blockedBy: [2])
  task_4: 实现 API 接口       (blockedBy: [3])
  task_5: 写集成测试          (blockedBy: [4])

当 task_1 完成时,TaskManager 自动从 task_2 的 blockedBy 列表中移除 task_1:

if status == "completed":
    for f in self.dir.glob("task_*.json"):
        t = json.loads(f.read_text())
        if task_id in t.get("blockedBy", []):
            t["blockedBy"].remove(task_id)
            self._save(t)

这个自动解锁机制让 Agent 可以查询「哪些任务现在可以开始」:

task_list 输出:
  [x] #1: 设计数据库 Schema
  [ ] #2: 实现 Repository 层        ← blockedBy 已清空,可以开始
  [ ] #3: 实现 Service 层           (blocked by: [2])
  [ ] #4: 实现 API 接口             (blocked by: [3])
  [ ] #5: 写集成测试                (blocked by: [4])

Agent 看到这个列表,知道下一步应该做 task_2。

四个工具:CRUD 接口

TaskManager 通过四个工具暴露给 Agent:

task_create  → 创建新任务(写文件)
task_update  → 更新状态/依赖(改文件)
task_list    → 查看所有任务(读目录)
task_get     → 查看单个任务详情(读文件)

这是最标准的 CRUD 接口,Agent 很容易理解和使用。

关键设计:子 Agent 不能使用这四个工具

def get_tools_for_agent(agent_type: str) -> list:
    excluded = {"compact", "task_create", "task_update", "task_list", "task_get"}
    if allowed == "*":
        return [t for t in BASE_TOOLS if t["name"] not in excluded]

为什么?因为任务的创建和状态管理应该由主 Agent 负责。子 Agent 专注于执行,主 Agent 负责协调。这避免了子 Agent 随意修改任务状态导致的混乱。


第三部分:代码实现 💻

TaskManager 类

class TaskManager:
    def __init__(self, tasks_dir: Path):
        self.dir = tasks_dir
        self.dir.mkdir(exist_ok=True)
        self._next_id = self._max_id() + 1

    def _max_id(self) -> int:
        ids = [int(f.stem.split("_")[1]) for f in self.dir.glob("task_*.json")]
        return max(ids) if ids else 0

    def _load(self, task_id: int) -> dict:
        path = self.dir / f"task_{task_id}.json"
        if not path.exists():
            raise ValueError(f"Task {task_id} not found")
        return json.loads(path.read_text())

    def _save(self, task: dict):
        (self.dir / f"task_{task['id']}.json").write_text(json.dumps(task, indent=2))

注意 _max_id():每次启动时扫描 .tasks/ 目录,找到最大 ID,从那里继续计数。这保证了跨会话的 ID 连续性——即使 Agent 重启,也不会创建重复 ID 的任务。

    def create(self, subject: str, description: str = "") -> str:
        task = {"id": self._next_id, "subject": subject, "description": description,
                "status": "pending", "blockedBy": [], "blocks": [], "owner": ""}
        self._save(task)
        self._next_id += 1
        return json.dumps(task, indent=2)

create 返回完整的任务 JSON——Agent 立刻知道新任务的 ID,可以在后续调用中引用它。

    def update(self, task_id: int, status: str = None,
               add_blocked_by: list = None, add_blocks: list = None) -> str:
        task = self._load(task_id)
        if status:
            if status not in ("pending", "in_progress", "completed"):
                raise ValueError(f"Invalid status: {status}")
            task["status"] = status
            if status == "completed":
                # 自动解锁:从所有依赖此任务的任务中移除阻塞
                for f in self.dir.glob("task_*.json"):
                    t = json.loads(f.read_text())
                    if task_id in t.get("blockedBy", []):
                        t["blockedBy"].remove(task_id)
                        self._save(t)
        if add_blocks:
            task["blocks"] = list(set(task["blocks"] + add_blocks))
            for bid in add_blocks:
                try:
                    b = self._load(bid)
                    if task_id not in b["blockedBy"]:
                        b["blockedBy"].append(task_id)
                        self._save(b)
                except ValueError:
                    pass
        self._save(task)
        return json.dumps(task, indent=2)

update 里有一个双向同步:当设置 add_blocks=[2, 3] 时,不仅更新当前任务的 blocks 字段,还自动更新 task_2 和 task_3 的 blockedBy 字段。依赖关系始终保持双向一致。

    def list_all(self) -> str:
        tasks = [json.loads(f.read_text()) for f in sorted(self.dir.glob("task_*.json"))]
        if not tasks:
            return "No tasks."
        lines = []
        for t in tasks:
            marker = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}.get(t["status"], "[?]")
            blocked = f" (blocked by: {t['blockedBy']})" if t.get("blockedBy") else ""
            lines.append(f"{marker} #{t['id']}: {t['subject']}{blocked}")
        return "\n".join(lines)

list_all 的输出格式和 TodoWrite 类似,Agent 很容易理解:

[x] #1: 设计数据库 Schema
[>] #2: 实现 Repository[ ] #3: 实现 Service 层 (blocked by: [2])
[ ] #4: 实现 API 接口 (blocked by: [3])

工具定义

{"name": "task_create",
 "description": "Create a persistent task (survives context compression). Stored in .tasks/.",
 "input_schema": {"type": "object",
                  "properties": {"subject": {"type": "string"},
                                 "description": {"type": "string"}},
                  "required": ["subject"]}},

{"name": "task_update",
 "description": "Update a persistent task's status or dependencies.",
 "input_schema": {"type": "object",
                  "properties": {"task_id": {"type": "integer"},
                                 "status": {"type": "string",
                                            "enum": ["pending", "in_progress", "completed"]},
                                 "addBlockedBy": {"type": "array", "items": {"type": "integer"}},
                                 "addBlocks": {"type": "array", "items": {"type": "integer"}}},
                  "required": ["task_id"]}},

工具描述里明确写了「survives context compression」——这是给 Agent 的提示:当你需要追踪跨压缩的任务时,用 task_* 工具,而不是 TodoWrite。

主循环集成

TaskManager 的集成非常轻量——它只是在 execute_tool 里增加了四个分支:

def execute_tool(name: str, args: dict) -> str:
    # ... 其他工具 ...
    if name == "task_create":   return TASKS.create(args["subject"], args.get("description", ""))
    if name == "task_update":   return TASKS.update(args["task_id"], args.get("status"),
                                                     args.get("addBlockedBy"), args.get("addBlocks"))
    if name == "task_list":     return TASKS.list_all()
    if name == "task_get":      return TASKS.get(args["task_id"])

主循环本身不需要任何修改。TaskManager 是一个纯粹的工具——Agent 决定什么时候用它,主循环只负责路由调用。


第四部分:扩展方向 🔭

方向一:多 Agent 任务看板

当前实现中,子 Agent 不能读写任务。但如果我们允许子 Agent 读取任务(只读),就能实现一个简单的任务看板:

Agent 创建任务:
  task_1: 实现认证模块  → 派生子 Agent A
  task_2: 实现用户管理  → 派生子 Agent B
  task_3: 写集成测试    → 派生子 Agent C(等待 AB 完成)

子 Agent A 完成后:
  主 Agent: task_update(1, status="completed")
  → task_3blockedBy 自动减少
  → 主 Agent 检查 task_list,发现 task_3 可以开始
  → 派生子 Agent C

这是一个简单的 DAG(有向无环图)任务调度器,完全基于文件实现。

方向二:任务事件通知

当前的任务状态变更是「拉取」模式——Agent 需要主动调用 task_list 才能知道状态变化。

可以扩展为「推送」模式:在任务状态变更时写入事件文件:

def _emit_event(self, task_id: int, event_type: str):
    events_dir = self.dir / "events"
    events_dir.mkdir(exist_ok=True)
    event = {"task_id": task_id, "type": event_type, "timestamp": time.time()}
    (events_dir / f"{int(time.time())}_{task_id}_{event_type}.json").write_text(
        json.dumps(event)
    )

Agent 可以定期检查 events/ 目录,响应任务状态变化。这是一个极简的事件总线,同样基于文件。

方向三:任务历史与审计

当前实现中,任务完成后文件依然存在(只是 status 变为 completed)。可以扩展为保留完整的状态变更历史:

def _append_history(self, task: dict, change: dict):
    history_path = self.dir / f"task_{task['id']}_history.jsonl"
    with open(history_path, "a") as f:
        f.write(json.dumps({"timestamp": time.time(), **change}) + "\n")

这样每个任务都有完整的审计日志:什么时候创建、什么时候开始、什么时候完成、中间经历了哪些状态变化。


常见问题 FAQ

Q: task_ 工具和 TodoWrite 应该怎么选?*

A: 两者定位不同,可以同时使用。

TodoWrite:
  - 当前会话内的短期任务追踪
  - 给用户实时的进度反馈
  - 不需要跨会话或跨 Agent 共享

task_* 工具:
  - 需要跨上下文压缩的长期任务
  - 需要多个 Agent 协作的任务
  - 有依赖关系的复杂任务
  - 需要在会话结束后继续追踪的任务

典型用法:用 task_* 管理「项目级」任务,用 TodoWrite 管理「当前步骤」的子任务。

Q: 任务文件会越来越多吗?

A: 会。但这通常不是问题——任务文件很小(几百字节),几千个任务也只占几 MB。如果需要清理,可以手动删除 completed 状态的任务文件,或者写一个归档脚本把旧任务移到 .tasks/archive/

Q: 多个 Agent 同时写同一个任务文件会有冲突吗?

A: 理论上会,但实践中很少发生。当前实现中,子 Agent 不能写任务文件,只有主 Agent 可以写。如果需要真正的并发安全,可以用文件锁(fcntl.flock)或者原子写入(写临时文件再重命名)。

Q: 为什么不用 SQLite 而用 JSON 文件?

A: 零依赖。JSON 文件不需要安装任何库,不需要初始化数据库,不需要管理连接。对于 Agent 的任务管理场景,任务数量通常在几十到几百之间,JSON 文件完全够用。SQLite 的优势(事务、索引、复杂查询)在这个场景下用不上。


📝 结语

TaskManager 的设计体现了一个简单但深刻的原则:

对话上下文是易失的,文件系统是持久的。
需要持久化的状态,就放到文件里。

这不是什么新思想——Unix 几十年前就这么做了。但在 AI Agent 的语境下,这个原则解决了两个具体问题:

问题                    解决方案
─────────────────────────────────────────────────────
压缩后 todos 消失    → 任务写文件,压缩不影响文件
子 Agent 看不到任务  → 任务在文件系统,任何 Agent 都能读

更深层的意义是:Agent 的「记忆」不应该只存在于对话上下文里。对话上下文是工作记忆,文件系统是长期记忆。两者各司其职,Agent 才能真正「长时间工作」。

这和上一篇的上下文压缩是互补的:压缩解决了「工作记忆太满」的问题,持久化任务解决了「压缩后状态丢失」的问题。两者合在一起,Agent 才能在复杂的长任务中保持连贯性。

系列导航