Published on

Agent Harness 工程:Task System 与持久化任务图

Authors
  • avatar
    Name
    XiaoLeiJun
    Twitter

Agent Harness 工程:Task System 与持久化任务图

上一篇为 Agent Harness 增加了错误恢复能力。模型请求遇到输出截断、上下文超限或临时故障时,Harness 可以识别失败类型,选择对应的恢复动作,并通过重试预算避免无限循环。

这些恢复都发生在一次运行过程之内。

如果 Agent 进程退出、终端关闭或机器重启,当前 messages、Todo 和执行进度仍然可能全部消失。下一次启动时,Agent 只知道用户重新发来的消息,并不知道上次做到了哪里、哪些工作已经完成、哪些任务还在等待依赖。

第 6 章实现的 TodoWrite 可以帮助 Agent 在当前会话里规划步骤,但它还不是任务系统。

本章继续向前一步:把一次性的 Todo 列表升级为 Task System。每个任务拥有稳定 ID、生命周期和依赖关系,状态会持久化到磁盘;Agent 可以创建、查询、认领、释放、更新 checkpoint 和完成任务。即使会话中断,新的 Agent 也能从任务文件恢复工作。

TodoWrite 不是 Task System

TodoWrite 和 Task System 都在描述“接下来做什么”,但它们解决的问题不同。

对比维度TodoWriteTask System
作用范围当前会话或当前执行过程跨会话、跨进程的长期任务
结构通常是一份扁平清单带依赖关系的有向无环图
状态pending / in_progress / completed完整生命周期、认领信息和执行结果
存储内存或当前 sessionJSON、SQLite 或数据库
并发通常由一个 Agent 更新可以被多个 Agent 查询和认领
恢复依据Todo 快照和当前上下文任务记录、checkpoint 和 artifact

TodoWrite 更像执行某个任务时的行动清单。例如 Agent 认领了“实现搜索接口”这个任务之后,可以用 TodoWrite 记录:阅读现有代码、实现接口、补充测试、运行构建。

Task System 管理的是更高一层的工作单元:哪些任务可以并行、哪些任务依赖其它任务、当前由谁执行、失败后能否重试,以及进程重启后如何恢复。

两者不是替代关系:Task System 管理跨会话的任务图,TodoWrite 管理单个任务内部的即时执行步骤。

任务更像一张图

复杂工作通常不是严格线性的。

假设要交付一个搜索功能,可以拆成下面几个任务:

  • analyze:确认需求和接口约束;
  • prepare-data:准备测试数据;
  • build-api:实现搜索 API,依赖 analyze
  • build-ui:实现搜索界面,依赖 analyze
  • integration-test:执行集成测试,依赖 prepare-databuild-apibuild-ui

build-apibuild-uianalyze 完成后可以并行执行,而 integration-test 必须等三个前置任务都完成。

Task System 的任务依赖图

依赖边从前置任务指向后续任务。已经完成的节点会自动解锁下游任务;互不依赖的就绪节点可以由不同 Agent 并行认领。

这种结构是一张有向无环图,也就是 DAG(Directed Acyclic Graph)。

它需要满足几个基本约束:

  1. 一个任务不能依赖自己;
  2. 依赖的任务必须真实存在;
  3. 依赖关系不能形成环;
  4. 只有前置任务 completed,依赖才算满足;
  5. 前置任务 failedcancelled 时,下游任务仍然保持阻塞。

如果出现 A -> B -> C -> A,任何任务都无法成为真正的起点,整个任务图也就无法推进。

持久化状态与派生状态

任务状态可以先保持为五种:

from typing import Literal


TaskStatus = Literal[
    "pending",
    "in_progress",
    "completed",
    "failed",
    "cancelled",
]

TASK_STATUSES = {"pending", "in_progress", "completed", "failed", "cancelled"}

它们表示需要持久化的生命周期:

状态含义
pending尚未开始
in_progress已被某个 Agent 认领并正在执行
completed已完成并保存结果
failed执行失败,等待重试或人工处理
cancelled已取消,不再继续执行

这里没有把 readyblocked 放进 TaskStatus

它们更适合作为派生状态

  • pending 且所有依赖都已完成,当前视图是 ready
  • pending 但仍有依赖未完成,当前视图是 blocked
  • in_progress 但认领租约已经过期,当前视图是 reclaimable
任务生命周期、依赖与租约

任务文件只保存稳定的生命周期状态。readyblockedreclaimable 根据依赖与租约实时计算,避免同一事实被重复存储后发生不一致。

从执行角度看,核心路径只有一条:

create -> pending -> ready -> claim -> in_progress -> checkpoint -> completed
                                                                  -> 解锁下游任务

releasefailed 和租约过期都是这条主路径上的恢复分支。先理解主路径,再看这些分支,Task System 就不会显得像一组彼此无关的状态操作。

这是 Task System 中非常重要的建模选择。

如果把 blocked 直接写进文件,那么前置任务完成时,Harness 必须找到所有下游任务并逐个改成 ready。只要中间某次写入失败,任务状态和依赖关系就会不一致。

如果 ready 是计算结果,前置任务完成后不需要修改任何下游文件。下一次查询任务图时,它们会自然变为可认领状态。

每个任务保存为一个 JSON 文件

最小实现可以把任务保存在项目目录下:

.agent/
└── tasks/
    ├── analyze.json
    ├── prepare-data.json
    ├── build-api.json
    ├── build-ui.json
    └── integration-test.json

一个任务文件可以写成:

{
  "schema_version": 1,
  "id": "build-api",
  "title": "实现搜索 API",
  "description": "实现搜索接口,并补充参数校验和单元测试。",
  "status": "in_progress",
  "dependencies": ["analyze"],
  "claimed_by": "agent-backend-01",
  "claim_token": "claim-7f3c2a",
  "lease_expires_at": "2026-07-09T10:35:00+00:00",
  "checkpoint": "接口已完成,正在补充分页测试。",
  "artifacts": ["src/search.py", "tests/test_search.py"],
  "result": null,
  "error": null,
  "created_at": "2026-07-09T10:00:00+00:00",
  "updated_at": "2026-07-09T10:30:00+00:00"
}

这些字段可以分成四组:

字段组字段作用
身份与说明idtitledescription稳定识别任务并说明目标
图结构dependencies表达前置任务
执行状态statusclaimed_byclaim_tokenlease_expires_at管理生命周期与执行所有权
恢复信息checkpointartifactsresulterror支持恢复、交接和结果汇总
元数据schema_versioncreated_atupdated_at支持迁移、排序和审计

checkpoint 不应该保存模型的完整推理过程。它只需要记录恢复工作所需的事实:已经完成什么、当前卡在哪里、下一步是什么、相关文件或外部资源在哪里。

定义 Task 数据结构

先用 dataclass 表达任务:

from dataclasses import asdict, dataclass, field


@dataclass
class Task:
    id: str
    title: str
    description: str
    status: TaskStatus = "pending"
    dependencies: list[str] = field(default_factory=list)
    claimed_by: str | None = None
    claim_token: str | None = None
    lease_expires_at: str | None = None
    checkpoint: str | None = None
    artifacts: list[str] = field(default_factory=list)
    result: str | None = None
    error: str | None = None
    created_at: str = ""
    updated_at: str = ""
    schema_version: int = 1

    @classmethod
    def from_dict(cls, data: dict) -> "Task":
        if not isinstance(data, dict):
            raise ValueError("task file must contain a JSON object")
        if data.get("schema_version") != 1:
            raise ValueError("unsupported task schema version")

        task = cls(**data)
        if task.status not in TASK_STATUSES:
            raise ValueError(f"invalid task status: {task.status}")
        if not isinstance(task.dependencies, list) or not all(
            isinstance(item, str) for item in task.dependencies
        ):
            raise ValueError("task dependencies must be a list of strings")
        return task

    def to_dict(self) -> dict:
        return asdict(self)

时间统一使用 UTC:

from datetime import datetime, timezone


def utc_now() -> datetime:
    return datetime.now(timezone.utc)


def utc_now_text() -> str:
    return utc_now().isoformat(timespec="seconds")

使用带时区的 ISO 8601 字符串,可以避免不同 Agent 所在时区不同而导致租约判断错误。

实现磁盘 Task Store

直接调用 Path.write_text() 虽然简单,但进程可能在写到一半时退出,留下无法解析的 JSON。

更稳妥的步骤是:

  1. 在同一目录创建临时文件;
  2. 写入完整 JSON 并执行 fsync()
  3. os.replace() 原子替换目标文件。

同时,认领任务属于“读取当前状态,再写入新状态”的复合操作。如果两个本地 Agent 同时读取到 pending,它们都可能认为自己认领成功。因此单机实现还需要一把存储锁。

import fcntl
import json
import os
import re
import tempfile
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator


TASK_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")


class TaskStoreError(RuntimeError):
    pass


class TaskUnavailableError(TaskStoreError):
    pass


class TaskStore:
    def __init__(self, root: Path):
        self.root = root.resolve()
        self.root.mkdir(parents=True, exist_ok=True)
        self.lock_path = self.root / ".lock"

    def _task_path(self, task_id: str) -> Path:
        if not TASK_ID_PATTERN.fullmatch(task_id):
            raise TaskStoreError(f"invalid task id: {task_id}")
        return self.root / f"{task_id}.json"

    @contextmanager
    def locked(self) -> Iterator[None]:
        with self.lock_path.open("a+", encoding="utf-8") as lock_file:
            fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
            try:
                yield
            finally:
                fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)

    def _load_path_unlocked(self, path: Path) -> Task:
        try:
            data = json.loads(path.read_text(encoding="utf-8"))
            task = Task.from_dict(data)
            if self._task_path(task.id) != path.resolve():
                raise ValueError("task id does not match file name")
            return task
        except (OSError, TypeError, ValueError, TaskStoreError) as exc:
            raise TaskStoreError(f"cannot read task file {path.name}: {exc}") from exc

    def _read_unlocked(self, task_id: str) -> Task:
        path = self._task_path(task_id)
        if not path.exists():
            raise TaskStoreError(f"task does not exist: {task_id}")
        return self._load_path_unlocked(path)

    def _list_unlocked(self) -> list[Task]:
        return [
            self._load_path_unlocked(path)
            for path in sorted(self.root.glob("*.json"))
        ]

    def _write_unlocked(self, task: Task) -> None:
        task.updated_at = utc_now_text()
        target = self._task_path(task.id)
        temp_path: Path | None = None

        try:
            with tempfile.NamedTemporaryFile(
                mode="w",
                encoding="utf-8",
                dir=self.root,
                prefix=f".{task.id}.",
                suffix=".tmp",
                delete=False,
            ) as temp_file:
                json.dump(
                    task.to_dict(),
                    temp_file,
                    ensure_ascii=False,
                    indent=2,
                    sort_keys=True,
                )
                temp_file.write("\n")
                temp_file.flush()
                os.fsync(temp_file.fileno())
                temp_path = Path(temp_file.name)

            os.replace(temp_path, target)
        finally:
            if temp_path is not None and temp_path.exists():
                temp_path.unlink()

    def get(self, task_id: str) -> Task:
        with self.locked():
            return self._read_unlocked(task_id)

    def list(self) -> list[Task]:
        with self.locked():
            return self._list_unlocked()

TASK_ID_PATTERN 不只是格式约束,也是一条路径安全边界。任务 ID 不能包含 /.. 或其它可能逃出任务目录的内容。

get()list() 共用 _load_path_unlocked(),因此损坏 JSON、未知 Schema 版本、非法状态和文件名不匹配都会统一转换为 TaskStoreError。一个错误任务文件仍会阻止本次列表读取,但不会绕过工具层的错误处理。

fcntl.flock() 适合 macOS 和 Linux 上的单机多进程协作。Windows 需要替换为对应的文件锁实现;如果多个 Agent 运行在不同机器上,则应该使用 SQLite、PostgreSQL 或其它支持事务与条件更新的存储,而不是依赖本地 JSON 文件锁。

创建任务并保护 DAG

最容易维护的第一版规则是:

任务依赖只在创建时写入,并且只能引用已经存在的任务。

在这个约束下,新任务只能指向旧任务,天然不会形成环:

def create_task(
    store: TaskStore,
    task_id: str,
    title: str,
    description: str,
    dependencies: list[str] | None = None,
) -> Task:
    dependency_ids = sorted(set(dependencies or []))
    normalized_title = title.strip()
    normalized_description = description.strip()

    if not normalized_title:
        raise TaskStoreError("task title is required")
    if not normalized_description:
        raise TaskStoreError("task description is required")

    with store.locked():
        target = store._task_path(task_id)
        if target.exists():
            existing_task = store._read_unlocked(task_id)
            same_definition = (
                existing_task.title == normalized_title
                and existing_task.description == normalized_description
                and existing_task.dependencies == dependency_ids
            )
            if same_definition:
                return existing_task
            raise TaskStoreError(f"task id conflicts with existing task: {task_id}")

        existing = {task.id: task for task in store._list_unlocked()}
        missing = [item for item in dependency_ids if item not in existing]
        if missing:
            raise TaskStoreError(f"unknown dependencies: {missing}")

        if task_id in dependency_ids:
            raise TaskStoreError("task cannot depend on itself")

        now = utc_now_text()
        task = Task(
            id=task_id,
            title=normalized_title,
            description=normalized_description,
            dependencies=dependency_ids,
            created_at=now,
            updated_at=now,
        )
        store._write_unlocked(task)
        return task

这意味着 Agent 创建整张任务图时,需要按拓扑顺序创建:先创建没有依赖的根任务,再创建依赖它们的后续任务。

依赖 ID 先排序并去重,因此输入顺序不会影响任务定义。稳定 task_id 同时充当幂等键:如果任务已经存在且定义完全相同,重复的 task_create 直接返回原任务;如果同一个 ID 对应不同定义,则明确报告冲突。这样即使首次创建已经落盘、工具响应却在返回途中丢失,Agent 重试时也不会创建重复任务。

如果以后允许批量创建或修改 dependencies,则必须在写入前对整张图执行 DFS 或拓扑排序。只要发现回边或最终无法得到完整拓扑序,就拒绝本次更新。本文通过“只能依赖已存在任务”这个更小的约束保证无环,因此不再展开通用环检测代码。

根据依赖计算任务是否就绪

读取全部任务后,可以实时计算未完成依赖:

def unresolved_dependencies(
    task: Task,
    tasks: dict[str, Task],
) -> list[str]:
    return [
        dependency_id
        for dependency_id in task.dependencies
        if tasks[dependency_id].status != "completed"
    ]


def task_view_state(task: Task, tasks: dict[str, Task]) -> str:
    if task.status == "pending":
        blocked_by = unresolved_dependencies(task, tasks)
        return "blocked" if blocked_by else "ready"

    if task.status == "in_progress" and not lease_is_active(task):
        return "reclaimable"

    return task.status

列出可执行任务也很直接:

def list_ready_tasks(store: TaskStore) -> list[Task]:
    tasks = store.list()
    task_map = {task.id: task for task in tasks}

    return [
        task
        for task in tasks
        if task_view_state(task, task_map) in {"ready", "reclaimable"}
    ]

这里没有 unlock_task()

依赖意义上的“解锁”不是一个写操作,而是前置任务完成后自然得到的计算结果。只有执行所有权需要显式释放,对应后面会实现的 task_release

用认领和租约避免重复执行

多个 Agent 查询任务图时,可能同时看到同一个 ready 任务。真正开始执行前,必须先认领任务。

只保存 claimed_by 还不够。如果认领任务的 Agent 崩溃,这个任务会永远停留在 in_progress。因此认领需要设置租约过期时间:

from datetime import timedelta


def lease_is_active(task: Task, now: datetime | None = None) -> bool:
    if not task.lease_expires_at:
        return False

    current = now or utc_now()
    expires_at = datetime.fromisoformat(task.lease_expires_at)
    return expires_at > current

认领操作必须在同一把存储锁内完成:

def claim_task(
    store: TaskStore,
    task_id: str,
    agent_id: str,
    claim_token: str,
    lease_seconds: int = 300,
) -> Task:
    claim_token = claim_token.strip()
    if not claim_token:
        raise TaskStoreError("claim token is required")

    lease_seconds = max(30, min(lease_seconds, 3600))

    with store.locked():
        tasks = {task.id: task for task in store._list_unlocked()}
        task = tasks.get(task_id)
        if task is None:
            raise TaskUnavailableError(f"task does not exist: {task_id}")

        if task.status in {"completed", "failed", "cancelled"}:
            raise TaskUnavailableError(f"task cannot be claimed: {task.status}")

        if task.status == "in_progress" and lease_is_active(task):
            same_claim = (
                task.claimed_by == agent_id and task.claim_token == claim_token
            )
            if same_claim:
                return task
            raise TaskUnavailableError(f"task is claimed by {task.claimed_by}")

        blocked_by = unresolved_dependencies(task, tasks)
        if blocked_by:
            raise TaskUnavailableError(f"task is blocked by: {blocked_by}")

        now = utc_now()
        task.status = "in_progress"
        task.claimed_by = agent_id
        task.claim_token = claim_token
        task.lease_expires_at = (
            now + timedelta(seconds=lease_seconds)
        ).isoformat(timespec="seconds")
        store._write_unlocked(task)
        return task

如果任务仍在有效租约内,其他 Agent 无法认领。租约过期后,任务会呈现为 reclaimable,新的 Agent 可以读取旧 checkpoint 并接手工作。

agent_id 表示逻辑身份,claim_token 则由 Harness 为本次执行生成,并在认领重试时保持不变。同一组 agent_id + claim_token 重复认领会直接返回当前任务;新的进程即使沿用相同 agent_id,也无法凭不同 token 取得仍在有效期内的任务。

claim_token 是防止旧执行者继续写入的 fencing token,不代替权限认证。任务被重新认领后会得到新 token,后续 checkpoint、release、complete 和 fail 都必须同时校验 Agent 身份与 token。

租约不是任务超时。它只表示某个 Agent 在一段时间内拥有执行权。长任务需要定期续租,否则仍可能被其他 Agent 误认为已经失去所有者。

checkpoint、释放与完成

仅仅把状态持久化为 in_progress,还不足以跨会话恢复。Agent 还需要定期保存 checkpoint。

def ensure_task_owner(task: Task, agent_id: str, claim_token: str) -> None:
    if task.status != "in_progress":
        raise TaskStoreError("task is not in progress")
    if task.claimed_by != agent_id:
        raise TaskStoreError(f"task is claimed by {task.claimed_by}")
    if task.claim_token != claim_token:
        raise TaskStoreError("task claim token does not match")
    if not lease_is_active(task):
        raise TaskStoreError("task lease has expired")


def checkpoint_task(
    store: TaskStore,
    task_id: str,
    agent_id: str,
    claim_token: str,
    summary: str,
    artifacts: list[str] | None = None,
    renew_seconds: int = 300,
) -> Task:
    summary = summary.strip()
    renew_seconds = max(30, min(renew_seconds, 3600))
    if not summary:
        raise TaskStoreError("checkpoint summary is required")
    if len(summary) > 4000:
        raise TaskStoreError("checkpoint summary is too long")
    if artifacts is not None and not all(
        isinstance(item, str) and item.strip() for item in artifacts
    ):
        raise TaskStoreError("artifacts must be non-empty strings")

    with store.locked():
        task = store._read_unlocked(task_id)
        ensure_task_owner(task, agent_id, claim_token)

        task.checkpoint = summary
        new_artifacts = [item.strip() for item in artifacts or []]
        task.artifacts = list(
            dict.fromkeys([*task.artifacts, *new_artifacts])
        )[:20]
        task.lease_expires_at = (
            utc_now() + timedelta(seconds=renew_seconds)
        ).isoformat(timespec="seconds")
        store._write_unlocked(task)
        return task

保存 checkpoint 时顺便续租,可以让正在稳定推进的 Agent 继续持有任务。

新传入的 artifact 会与历史记录合并后去重,而不是覆盖旧列表。这样后续 checkpoint 只需要提交本轮新增的文件或外部资源,也不会丢失之前的恢复线索。

其余操作都遵循同一个模板:在存储锁内读取任务,校验当前状态和所有者,修改有限字段,再原子写回。区别只在状态转换规则:

操作前置条件写入结果
task_release当前 Agent 持有有效租约回到 pending,保留 checkpoint 和 artifact
task_complete当前 Agent 持有有效租约进入 completed,写入 result
task_fail当前 Agent 持有有效租约进入 failed,写入 error
task_retry当前状态为 failed回到 pending,清除 error

releasecompletefail 都要校验 claim_token,并在成功后清空 claimed_byclaim_tokenlease_expires_atretry 不会自动认领任务,下一次执行仍要重新经过依赖检查和 task_claim

这些转换也应该具备基本幂等性。例如任务已经以相同 result 完成时,重复的 task_complete 直接返回现有记录;如果完成结果不同,则报告状态冲突,而不是覆盖第一次结果。

任务进入 completed 后,依赖它的下游任务会在下一次查询时自动变成 ready。任务进入 failed 后,下游任务继续保持阻塞,直到失败任务被显式重试或任务图被人工调整。

暴露为 Agent 工具

Task System 需要给模型提供一组边界清晰的操作,而不是一个可以任意覆盖 JSON 的 task_update

工具作用
task_create创建任务并写入不可变依赖
task_list按状态或就绪性列出任务
task_get读取任务详情、checkpoint 和结果
task_claim认领就绪任务并创建租约
task_checkpoint保存进度、artifact 并续租
task_release释放当前 Agent 的执行所有权
task_complete保存结果并完成任务
task_fail保存错误并把任务标记为失败
task_retry把失败任务重置为待执行状态

工具 Schema 沿用前几章的 Chat Completions 格式。模型调用 task_claim 时只需要提供 task_id 和范围为 30~3600 秒的 lease_secondsagent_id 与本进程唯一的 claim_token 由 Harness 运行态注入。所有修改已认领任务的工具都由处理函数注入同一组身份信息。Task 工具随后加入原有注册表:

TASK_TOOLS = [
    TASK_CREATE_TOOL,
    TASK_LIST_TOOL,
    TASK_GET_TOOL,
    TASK_CLAIM_TOOL,
    TASK_CHECKPOINT_TOOL,
    TASK_RELEASE_TOOL,
    TASK_COMPLETE_TOOL,
    TASK_FAIL_TOOL,
    TASK_RETRY_TOOL,
]

TOOLS.extend(TASK_TOOLS)
TOOL_HANDLERS.update(
    {
        "task_create": run_task_create,
        "task_list": run_task_list,
        "task_get": run_task_get,
        "task_claim": run_task_claim,
        "task_checkpoint": run_task_checkpoint,
        "task_release": run_task_release,
        "task_complete": run_task_complete,
        "task_fail": run_task_fail,
        "task_retry": run_task_retry,
    }
)

每个处理函数只负责参数转换、注入运行时身份、调用 Task Store,并把 TaskStoreError 包装成类似 Error[task_claim]: ... 的简短工具结果。task_listtask_get 返回模型前应该移除 claim_token,避免把内部 fencing token 变成模型可修改的普通参数。

认领失败不应该触发模型 API 的指数退避;它通常表示任务被其他 Agent 抢先认领,或者依赖尚未完成。模型应该重新调用 task_list,选择另一项就绪任务。

Task 工具与权限边界

Task 工具修改的是 Harness 的持久化状态,仍然应该经过 Hook 和审计。

可以采用下面的初始策略:

  • task_listtask_get 是只读操作,默认允许;
  • task_createtask_claimtask_checkpointtask_release 默认允许,但记录审计日志;
  • task_completetask_fail 必须经过状态和所有权校验;
  • task_retry 必须确认原状态为 failed,并记录重试原因;
  • 删除任务、取消任务、修改依赖等高影响操作需要单独审批。

还要保护 .agent/tasks 目录,禁止模型通过通用文件工具直接改写任务 JSON:

TASK_ROOT = (WORKDIR / ".agent" / "tasks").resolve()


def ensure_not_task_store_path(path: Path) -> None:
    target = path.resolve()
    if target.is_relative_to(TASK_ROOT):
        raise PermissionError("task files must be changed through Task System tools")

否则模型可以绕过依赖检查、所有权校验和原子写入,直接把任意任务改成 completed

跨会话恢复

Agent 再次启动时,不应该直接创建一份新的 Todo,而应该先读取 Task Store:

  1. 为当前进程生成唯一 claim_token
  2. 优先查找相同 Agent 上次留下且租约已过期的任务;
  3. 如果没有待恢复任务,再选择普通 ready 任务;
  4. 认领时重新校验状态,跳过已经被其他 Worker 抢占的候选;
  5. 读取 description、checkpoint 和 artifacts,重新创建 TodoWrite 执行计划。
def recover_agent_task(
    store: TaskStore,
    agent_id: str,
    claim_token: str,
) -> Task | None:
    tasks = store.list()
    task_map = {task.id: task for task in tasks}

    active = [
        task
        for task in tasks
        if task.status == "in_progress"
        and task.claimed_by == agent_id
        and task.claim_token == claim_token
        and lease_is_active(task)
    ]
    if active:
        return sorted(active, key=lambda task: task.updated_at)[-1]

    own_expired = sorted(
        (
            task
            for task in tasks
            if task.status == "in_progress"
            and task.claimed_by == agent_id
            and not lease_is_active(task)
        ),
        key=lambda task: task.updated_at,
        reverse=True,
    )
    own_expired_ids = {task.id for task in own_expired}

    ready = sorted(
        (
            task
            for task in tasks
            if task_view_state(task, task_map) == "ready"
        ),
        key=lambda task: task.id,
    )
    other_expired = [
        task
        for task in tasks
        if task_view_state(task, task_map) == "reclaimable"
        and task.id not in own_expired_ids
    ]

    for task in [*own_expired, *ready, *other_expired]:
        try:
            return claim_task(
                store=store,
                task_id=task.id,
                agent_id=agent_id,
                claim_token=claim_token,
            )
        except TaskUnavailableError:
            # 状态可能在 list 和 claim 之间被其它 Worker 改变。
            continue

    return None

agent_id 应该保持稳定,用来识别逻辑 Agent;claim_token 则在每个进程启动时重新生成,用来区分并发实例,例如 secrets.token_urlsafe(16)。新进程不能接管仍在有效租约内的旧任务,只能等待租约过期后重新认领。这一限制可以避免两个使用相同 agent_id 的进程同时执行同一任务。

跨会话恢复也不意味着恢复模型的完整思考过程。新的 Agent 应该根据任务目标、checkpoint 和真实 artifact 重新验证现场,再继续执行。

接入运行时提示词组装

第 11 章已经实现 Prompt Assembler。Task System 不应该把整张任务图全部塞进 system prompt,而应该只提供一份简短运行态:

  • 当前 Agent 正在执行的任务;
  • 当前 checkpoint;
  • 阻塞该任务的依赖;
  • 少量可认领任务 ID;
  • 如何通过 task_get 读取完整详情。
def render_task_state(
    store: TaskStore,
    agent_id: str,
    claim_token: str,
) -> str:
    tasks = store.list()
    task_map = {task.id: task for task in tasks}

    owned = [
        task
        for task in tasks
        if task.status == "in_progress"
        and task.claimed_by == agent_id
        and task.claim_token == claim_token
        and lease_is_active(task)
    ]
    ready = [
        task.id
        for task in tasks
        if task_view_state(task, task_map) in {"ready", "reclaimable"}
    ][:5]

    lines = [f"Agent id: {agent_id}"]
    if owned:
        current = owned[0]
        lines.append(f"Current task: {current.id} - {current.title}")
        lines.append(f"Checkpoint: {current.checkpoint or 'none'}")
    else:
        lines.append("Current task: none")

    lines.append("Ready tasks: " + (", ".join(ready) or "none"))
    lines.append("Use task_get before starting an unfamiliar task.")
    return "\n".join(lines)

这段文本可以作为动态 PromptSection,在每次模型调用前重新生成。

任务详情仍然按需读取,避免任务图随着项目增长而占满上下文。这和 Skill、Memory、上下文压缩遵循同一个原则:系统状态可以很大,但进入模型上下文的内容必须经过选择。

多 Agent 如何共享任务图

有了依赖、持久化和认领,多个 Agent 可以围绕同一张任务图协作:

  1. 主 Agent 根据目标创建任务 DAG;
  2. Worker 查询当前 ready 任务;
  3. Worker 原子认领其中一个任务;
  4. 执行过程中持续写 checkpoint 并续租;
  5. 完成后写入 result 和 artifacts;
  6. 下游任务根据依赖状态自动解锁;
  7. 其他 Worker 继续认领新的就绪任务。

Task System 不负责决定任务应该如何完成。它负责的是协调:什么任务存在、哪些任务可以开始、由谁执行、执行到哪里,以及结果是否已经持久化。

本章的 JSON + flock 实现适合本地 Harness 和单机多 Agent。真正的分布式执行还需要数据库事务、条件更新、心跳、时钟偏差处理、事件通知和权限隔离。这些能力可以逐步替换存储与调度层,而不必改变任务模型本身。

常见坑

第一,把 Task System 写成持久化 Todo。 只有一个 JSON 数组和三种状态,还不足以表达依赖、认领、checkpoint 和跨 Agent 协作。

第二,把 readyblocked 持久化。 它们应该根据依赖实时计算,否则前置任务完成后容易留下过期状态。

第三,允许任意修改依赖。 依赖变化可能引入环,也会改变已经执行中的任务语义。初始版本最好让依赖在创建后保持不可变。

第四,认领任务却没有租约。 Agent 崩溃后,任务会永久停在 in_progress

第五,只用 agent_id 校验所有权。 同一逻辑 Agent 的两个并发进程会被误认为同一个执行者,还需要每次运行唯一的 claim_token 隔离旧实例。

第六,只有租约却没有 checkpoint。 新 Agent 虽然能重新认领任务,却不知道上一个 Agent 做到了哪里。

第七,直接覆盖任务文件。 写入中断可能留下损坏 JSON;至少要通过临时文件和 os.replace() 原子替换。

第八,用通用文件工具修改 Task Store。 这会绕过 DAG、状态转换、所有权和审计边界。

第九,把完整任务图注入提示词。 任务数量增长后会迅速占满上下文。提示词只保留当前任务和少量就绪任务,其余通过工具按需读取。

第十,把文件锁当作分布式事务。 flock 只适合单机协作;多机器部署需要真正支持并发控制的存储层。

小结

本文把 TodoWrite 之上的长期协调能力拆成了一个最小 Task System:

  1. 用 DAG 表达任务依赖,让互不依赖的任务可以并行推进;
  2. 只持久化 pendingin_progresscompletedfailedcancelled,实时推导 readyblockedreclaimable
  3. 每个任务保存为独立 JSON,并通过临时文件、fsync()os.replace() 原子写入;
  4. 用存储锁保护认领等读改写操作;
  5. claimed_byclaim_token 和租约避免多个 Agent 或旧进程重复执行同一任务;
  6. 用 checkpoint、artifact、result 和 error 支持恢复与交接,并通过显式重试恢复失败任务;
  7. 通过 Task 工具维护状态,禁止通用文件工具绕过系统约束;
  8. 启动时从磁盘恢复任务,再为当前任务建立 TodoWrite 执行计划;
  9. 只把精简任务状态注入运行时提示词,完整详情通过工具按需读取。

至此,Agent 已经不再只处理一次性请求,而是可以管理一张跨会话存在的任务图。任务会等待依赖、被 Agent 认领、持续保存进度,并在完成后解锁下游工作。

但当前工具调用仍然是同步的。如果某个任务需要运行二十分钟测试、等待远端构建或处理大批数据,Agent 不能一直阻塞在那里,也不能让租约在等待期间悄悄过期。

下一章讲 后台任务:让耗时操作启动后立即返回 job_id,由后台执行器继续运行;Agent 可以先处理其它就绪任务,并在后台任务完成、失败或需要输入时收到通知。