跳到主要内容

第 2 章 · 三块共享状态

ClawTeam 号称「零基础设施」。本章拆开它的三块共享状态——任务板、信箱、工作区——看清楚为什么纯文件就够用,以及其中处理并发的巧思。

2.1 数据模型先行

所有协作状态都是 Pydantic 模型,序列化成 JSON 文件。三个核心模型(clawteam/team/models.py):

模型是什么关键字段
TeamConfig一支队伍members(成员列表)、lead_agent_id(谁是 leader)
TaskItem一张任务卡statusownerblocked_byblockslocked_by
TeamMessage一条消息typefromtocontentrequest_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合回基底分支,冲突自动 abortmerge --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 不是。 peekconsume=False 路径只读不删(mailbox.py:239-242)。
  • 传输后端在收发时按 env/config 动态决定。 _default_transportCLAWTEAM_TRANSPORT,是 p2p 就连 ZeroMQ(并带文件回退),否则用文件(mailbox.py:15-29)。

下一章看「为什么任意 CLI agent 都能接进来」→ 03-cli-agnostic.md