第 2 章 · 三块共享状态
ClawTeam 号称「零基础设施」。本章拆开它的三块共享状态——任务板、信箱、工作区——看清楚为什么纯文件就够用,以及其中处理并发的巧思。
2.1 数据模型先行
所有协作状态都是 Pydantic 模型,序列化成 JSON 文件。三个核心模型(clawteam/team/models.py):
| 模型 | 是什么 | 关键字段 |
|---|---|---|
TeamConfig | 一支队伍 | members(成员列表)、lead_agent_id(谁是 leader) |
TaskItem | 一张任务卡 | status、owner、blocked_by、blocks、locked_by |
TeamMessage | 一条消息 | type、from、to、content、request_id |
任务有四个状态(TaskStatus,clawteam/team/models.py:36-41):pending → in_progress → completed,外加 blocked。消息类型则很丰富(MessageType,第 50-62 行):普通消息、入队请求/批准、计划审批、关停请求、闲置通知、广播等——协作协议本身就编码在消息类型里。
2.2 任务板:依赖链 + 自动解锁 + 抢锁
它要解决的小问题
多个 agent 共享一块看板。难点有三:① 任务 B 依赖任务 A,A 没完成时 B 不该被领走;② A 一完成,B 要自动变成可领;③ 两个 agent 不能同时认领同一张卡。全部要在「没有数据库、只有文件」的前提下做到。
存储布局
每个任务是一个独立 JSON 文件:~/.clawteam/tasks/<team>/task-<id>.json(clawteam/store/file.py:_task_path,第 33-34 行)。一个任务一个文件,意味着列任务 = glob("task-*.json"),改一个任务不碰别的文件。
自动解锁:依赖图怎么工作
任务 B 的 blocked_by 列表里放着它依赖的任务 id。创建时若有 blocked_by,状态直接设为 blocked(clawteam/store/file.py:create,第 97-98 行)。
当任务 A 被更新为 completed,update() 会调用 _resolve_dependents_unlocked,扫描所有任务,把 A 从它们的 blocked_by 里移除;谁的 blocked_by 因此空了且当前是 blocked,就翻回 pending:
# clawteam/store/file.py:362-375 _resolve_dependents_unlocked 节选
for f in root.glob("task-*.json"):
task = TaskItem.model_validate(json.loads(f.read_text()))
if completed_task_id in task.blocked_by:
task.blocked_by.remove(completed_task_id)
if not task.blocked_by and task.status == TaskStatus.blocked:
task.status = TaskStatus.pending # 自动解锁!
self._save_unlocked(task)
这就是 README 演示里「architect 完成 → backend1/backend2 自动 unblock」的真实机制(README.md:320-323)。
环检测:不让你建出死锁
加依赖前会跑一次 DFS 环检测,把整张依赖图(现有任务 + 拟加的边)走一遍,有环就拒绝(_validate_blocked_by_unlocked,第 316-344 行;还单独挡掉「任务依赖自己」)。这是个教科书式的「三色 DFS 找环」。
抢锁:两个 agent 不能同时领同一张卡
当一个 agent 把任务设为 in_progress,会尝试抢占式加锁:把 locked_by 设成调用者。如果卡已经被别人锁着,默认报 TaskLockError——除非那个持锁者已经死了:
# clawteam/store/file.py:241-251 _acquire_lock 节选
if task.locked_by and task.locked_by != caller and not force:
from clawteam.spawn.registry import is_agent_alive
alive = is_agent_alive(self.team_name, task.locked_by)
if alive is not False: # 还活着(或不确定)→ 拒绝
raise TaskLockError(f"Task '{task.id}' is locked by '{task.locked_by}' ...")
task.locked_by = caller or "" # 死了 → 可以抢
这里把「任务锁」和「进程存活探测」联动起来:死掉的 agent 不会永久占着任务。还有 release_stale_locks() 专门做批量回收(第 253-268 行)。
并发安全:OS 文件锁 + 原子写
所有写操作包在 _write_lock() 里——一个用 fcntl.flock(Windows 用 msvcrt.locking)实现的跨平台进程级排他锁,锁的是 .tasks.lock 文件(clawteam/store/file.py:54-75)。写任务文件本身用「写临时文件 + os.replace」原子替换(_save_unlocked,第 346-360 行),避免别的进程读到写一半的 JSON。
2.3 信箱:原子投递 + claim/ack/quarantine
它要解决的小问题
agent A 要给 agent B 发消息,而且:消息别丢、别被读一半、同一条别被消费两遍、坏消息别毒死收件方。
分层:Transport 只搬字节,Mailbox 才懂消息
ClawTeam 把信箱拆成两层,职责清晰:
Transport(传输层) 只负责「把不透明的字节投到某个收件人」「取回字节」(clawteam/transport/base.py:8-34,接口只有deliver/fetch/count/list_recipients)。它不解析消息。MailboxManager(信箱层) 负责把字节解析成TeamMessage、决定坏消息怎么办(clawteam/team/mailbox.py)。
这层分离让你可以换传输后端(默认 file,可选 p2p ZeroMQ),而信箱逻辑不变。
文件传输:一条消息一个文件
FileTransport.deliver 把消息写成 ~/.clawteam/teams/<team>/inboxes/<agent>/msg-<时间戳>-<uid>.json,同样是临时文件 + rename 原子投递(clawteam/transport/file.py:138-151)。
claim/ack/quarantine:精确一次消费
最有意思的是消费路径。普通做法「读文件→删文件」有竞态:两个消费者可能读到同一条。ClawTeam 的做法是先把 msg-*.json 重命名为 msg-*.consumed 来「认领(claim)」,再对它加文件锁——重命名是原子的,谁 rename 成功谁拥有这条:
msg-123.json ──rename──► msg-123.consumed ──flock──► 读取
(待领) (已认领,独占) │
├─ 解析成功 → ack() → 删除文件
└─ 解析失败 → quarantine() → 移进 dead_letters/
关键在 MailboxManager._parse_claimed_messages(clawteam/team/mailbox.py:204-214):认领回来的字节如果 JSON 解析失败,不是丢弃也不是卡住,而是 quarantine(...) 把它连同错误元数据搬进 dead_letters/ 目录(FileTransport._quarantine_bytes,第 192-224 行)。坏消息被隔离,不会反复毒死收件方——这是很多简单文件队列会忽略的健壮性。
广播 + 事件日志
broadcast 遍历所有收件人投递,自动排除自己(clawteam/team/mailbox.py:143-192)。另外每条 send/broadcast 都会额外写一份到 events/ 目录做不可消费的历史日志(_log_event,第 48-59 行)——board 的消息历史就是读这个。
2.4 工作区:worktree 的生命周期
工作区在 §1 已讲了创建。这里补全它的完整生命周期管理(clawteam/workspace/manager.py):
| 操作 | 干什么 | 底层 git |
|---|---|---|
create_workspace | 起隔离 worktree + 新分支 | worktree add -b |
checkpoint | 自动 add -A + commit(无改动则跳过) | commit_all |
merge_workspace | 合回基底分支,冲突自动 abort | merge --no-ff(失败 merge --abort) |
cleanup_workspace | 删 worktree + 删分支 + 从登记表移除 | worktree remove --force + branch -D |
所有 worktree 记在 ~/.clawteam/workspaces/<team>/workspace-registry.json(WorkspaceRegistry),merge 默认 --no-ff 保留合并历史。这套就是 README「leader 把所有 worktree 合进主分支」的实现(README.md:328)。
2.5 关键细节 / 坑
- 任务列表是 best-effort 读取。 列任务时遇到坏 JSON 直接
continue跳过(_list_tasks_unlocked,第 304-305 行),不会因为一个坏文件让整个task list崩掉。 - 信箱
receive是破坏性的,peek不是。peek用consume=False路径只读不删(mailbox.py:239-242)。 - 传输后端在收发时按 env/config 动态决定。
_default_transport读CLAWTEAM_TRANSPORT,是p2p就连 ZeroMQ(并带文件回退),否则用文件(mailbox.py:15-29)。
下一章看「为什么任意 CLI agent 都能接进来」→ 03-cli-agnostic.md。