- Published on
Agent Harness 工程:Task System 与持久化任务图
- Authors

- Name
- XiaoLeiJun
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 都在描述“接下来做什么”,但它们解决的问题不同。
| 对比维度 | TodoWrite | Task System |
|---|---|---|
| 作用范围 | 当前会话或当前执行过程 | 跨会话、跨进程的长期任务 |
| 结构 | 通常是一份扁平清单 | 带依赖关系的有向无环图 |
| 状态 | pending / in_progress / completed | 完整生命周期、认领信息和执行结果 |
| 存储 | 内存或当前 session | JSON、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-data、build-api和build-ui。
build-api 和 build-ui 在 analyze 完成后可以并行执行,而 integration-test 必须等三个前置任务都完成。
依赖边从前置任务指向后续任务。已经完成的节点会自动解锁下游任务;互不依赖的就绪节点可以由不同 Agent 并行认领。
这种结构是一张有向无环图,也就是 DAG(Directed Acyclic Graph)。
它需要满足几个基本约束:
- 一个任务不能依赖自己;
- 依赖的任务必须真实存在;
- 依赖关系不能形成环;
- 只有前置任务
completed,依赖才算满足; - 前置任务
failed或cancelled时,下游任务仍然保持阻塞。
如果出现 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 | 已取消,不再继续执行 |
这里没有把 ready 和 blocked 放进 TaskStatus。
它们更适合作为派生状态:
pending且所有依赖都已完成,当前视图是ready;pending但仍有依赖未完成,当前视图是blocked;in_progress但认领租约已经过期,当前视图是reclaimable。
任务文件只保存稳定的生命周期状态。ready、blocked 和 reclaimable 根据依赖与租约实时计算,避免同一事实被重复存储后发生不一致。
从执行角度看,核心路径只有一条:
create -> pending -> ready -> claim -> in_progress -> checkpoint -> completed
-> 解锁下游任务
release、failed 和租约过期都是这条主路径上的恢复分支。先理解主路径,再看这些分支,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"
}
这些字段可以分成四组:
| 字段组 | 字段 | 作用 |
|---|---|---|
| 身份与说明 | id、title、description | 稳定识别任务并说明目标 |
| 图结构 | dependencies | 表达前置任务 |
| 执行状态 | status、claimed_by、claim_token、lease_expires_at | 管理生命周期与执行所有权 |
| 恢复信息 | checkpoint、artifacts、result、error | 支持恢复、交接和结果汇总 |
| 元数据 | schema_version、created_at、updated_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。
更稳妥的步骤是:
- 在同一目录创建临时文件;
- 写入完整 JSON 并执行
fsync(); - 用
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 |
release、complete 和 fail 都要校验 claim_token,并在成功后清空 claimed_by、claim_token 与 lease_expires_at。retry 不会自动认领任务,下一次执行仍要重新经过依赖检查和 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_seconds;agent_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_list 和 task_get 返回模型前应该移除 claim_token,避免把内部 fencing token 变成模型可修改的普通参数。
认领失败不应该触发模型 API 的指数退避;它通常表示任务被其他 Agent 抢先认领,或者依赖尚未完成。模型应该重新调用 task_list,选择另一项就绪任务。
Task 工具与权限边界
Task 工具修改的是 Harness 的持久化状态,仍然应该经过 Hook 和审计。
可以采用下面的初始策略:
task_list、task_get是只读操作,默认允许;task_create、task_claim、task_checkpoint、task_release默认允许,但记录审计日志;task_complete、task_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:
- 为当前进程生成唯一
claim_token; - 优先查找相同 Agent 上次留下且租约已过期的任务;
- 如果没有待恢复任务,再选择普通
ready任务; - 认领时重新校验状态,跳过已经被其他 Worker 抢占的候选;
- 读取 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 可以围绕同一张任务图协作:
- 主 Agent 根据目标创建任务 DAG;
- Worker 查询当前
ready任务; - Worker 原子认领其中一个任务;
- 执行过程中持续写 checkpoint 并续租;
- 完成后写入 result 和 artifacts;
- 下游任务根据依赖状态自动解锁;
- 其他 Worker 继续认领新的就绪任务。
Task System 不负责决定任务应该如何完成。它负责的是协调:什么任务存在、哪些任务可以开始、由谁执行、执行到哪里,以及结果是否已经持久化。
本章的 JSON + flock 实现适合本地 Harness 和单机多 Agent。真正的分布式执行还需要数据库事务、条件更新、心跳、时钟偏差处理、事件通知和权限隔离。这些能力可以逐步替换存储与调度层,而不必改变任务模型本身。
常见坑
第一,把 Task System 写成持久化 Todo。 只有一个 JSON 数组和三种状态,还不足以表达依赖、认领、checkpoint 和跨 Agent 协作。
第二,把 ready 和 blocked 持久化。 它们应该根据依赖实时计算,否则前置任务完成后容易留下过期状态。
第三,允许任意修改依赖。 依赖变化可能引入环,也会改变已经执行中的任务语义。初始版本最好让依赖在创建后保持不可变。
第四,认领任务却没有租约。 Agent 崩溃后,任务会永久停在 in_progress。
第五,只用 agent_id 校验所有权。 同一逻辑 Agent 的两个并发进程会被误认为同一个执行者,还需要每次运行唯一的 claim_token 隔离旧实例。
第六,只有租约却没有 checkpoint。 新 Agent 虽然能重新认领任务,却不知道上一个 Agent 做到了哪里。
第七,直接覆盖任务文件。 写入中断可能留下损坏 JSON;至少要通过临时文件和 os.replace() 原子替换。
第八,用通用文件工具修改 Task Store。 这会绕过 DAG、状态转换、所有权和审计边界。
第九,把完整任务图注入提示词。 任务数量增长后会迅速占满上下文。提示词只保留当前任务和少量就绪任务,其余通过工具按需读取。
第十,把文件锁当作分布式事务。 flock 只适合单机协作;多机器部署需要真正支持并发控制的存储层。
小结
本文把 TodoWrite 之上的长期协调能力拆成了一个最小 Task System:
- 用 DAG 表达任务依赖,让互不依赖的任务可以并行推进;
- 只持久化
pending、in_progress、completed、failed和cancelled,实时推导ready、blocked与reclaimable; - 每个任务保存为独立 JSON,并通过临时文件、
fsync()和os.replace()原子写入; - 用存储锁保护认领等读改写操作;
- 用
claimed_by、claim_token和租约避免多个 Agent 或旧进程重复执行同一任务; - 用 checkpoint、artifact、result 和 error 支持恢复与交接,并通过显式重试恢复失败任务;
- 通过 Task 工具维护状态,禁止通用文件工具绕过系统约束;
- 启动时从磁盘恢复任务,再为当前任务建立 TodoWrite 执行计划;
- 只把精简任务状态注入运行时提示词,完整详情通过工具按需读取。
至此,Agent 已经不再只处理一次性请求,而是可以管理一张跨会话存在的任务图。任务会等待依赖、被 Agent 认领、持续保存进度,并在完成后解锁下游工作。
但当前工具调用仍然是同步的。如果某个任务需要运行二十分钟测试、等待远端构建或处理大批数据,Agent 不能一直阻塞在那里,也不能让租约在等待期间悄悄过期。
下一章讲 后台任务:让耗时操作启动后立即返回 job_id,由后台执行器继续运行;Agent 可以先处理其它就绪任务,并在后台任务完成、失败或需要输入时收到通知。