第 十七 章 Agent 架构实操 深入学习 · 交互式 含 QA 测试

从“人肉派发”到“自驱轮询”:多智能体(Agent Team)去中心化协作实战

💡 TASK_DAG_SQLITE + WORK_STEALING:原子认领 + claim token + 半开 lease。
让 idle Teammate 按稳定顺序原子认领已解锁任务。扫描与认领必须在一个事务里(避免 TOCTOU);SQLite BEGIN IMMEDIATE + 条件 UPDATE 保证一次 claim 只成功一次。
本章进度
0%
1 本章要掌握的目标
  • SQLite 独立后端,不迁移、不双写旧 JSON。
  • BEGIN IMMEDIATE 把依赖过滤、稳定选择、条件认领放进同一事务。
  • TaskClaim 携带 task、claimToken、半开 lease([claimed_at, lease_expires))。
  • token 在数据库生命周期内唯一;旧 worker 不能完成新租约。
  • Mailbox/Protocol 先于任务板;最新计划继续控制自驱认领。
2 核心知识点
为什么不能『先扫 JSON 再写 owner』

扫描和认领是两个独立步骤会出现 TOCTOU:两个 worker 都读到可认领,都写入 owner。P17 用 SQLite:BEGIN IMMEDIATE → 回收过期 lease → 加载验证 DAG → 找第一个 ready → 条件 UPDATE → COMMIT。

BEGIN IMMEDIATE
  → 回收已到期 lease
  → 同一快照加载并验证 DAG
  → 按 sequence 找首个 ready task
  → 条件 UPDATE pending -> in_progress
  → COMMIT
SQLite schema 要点

sequence 是真正创建顺序(不等同 UUID 字典序);task_dependencies 用外键拒绝不存在依赖;task_claim_tokens 保存历史 token,旧 token 不可复用;CHECK 把状态不变量压进数据库。

CREATE TABLE tasks(
  sequence INTEGER PRIMARY KEY AUTOINCREMENT,
  status TEXT CHECK(status IN ('pending','in_progress','completed')),
  owner TEXT, claim_token TEXT UNIQUE,
  lease_expires_at_utc TEXT, ...
);
-- CHECK: pending 无 owner/token/lease;
--         in_progress 三者齐全;completed 清空 token/lease
原子认领 + 条件更新

claimNext 在同一事务内选择并迁移;条件 UPDATE ... WHERE status='pending' AND owner IS NULL 是最终互斥点,只认 getRowsModified() === 1,否则说明被人抢先,返回 undefined 不抛错。认领凭证插入历史表在条件 UPDATE 之前——token 冲突发生在 UPDATE 之前,回滚不需要补偿。

UPDATE tasks SET status='in_progress', owner=?, claim_token=?,
  lease_expires_at_utc=?
WHERE id=? AND status='pending' AND owner IS NULL;
-- 只有 rowcount == 1 才成功
lease 是半开区间

[claimed_at, lease_expires_at)。到达截止瞬间已过期:释放用 <=,完成检查用 >=,边界正好差一毫秒。expired 任务在下一次事务原子回到 pending(清空 owner/token/lease),旧 owner 只能得到 lease expired/claim mismatch。owner 来自 ToolContext.identity,claim_task 的 schema 里根本没有 owner 字段,strictObject 让伪造尝试直接失败。

到期任务原子恢复:
  status = 'pending', owner = NULL,
  claim_token = NULL, lease_expires_at_utc = NULL
idle loop 的真实顺序

持有 teammate registry lock → 检查是否关闭 → 先 claim Mailbox → 有 Protocol 就确定性路由 → 无消息查最新 plan 是否允许 effectful → 允许才 SQLite claimNext → 仍无工作则进入可唤醒等待。自动认领到任务后它不会自己完成——模型仍须显式调 complete_task 并出示 claim token(token 同时是本轮幂等键)。空转到 maxIdlePolls 就退出,idleTimeoutCount 可观察,drainEvents() 是空的;唤醒不消耗空转预算。

Mailbox 先于任务板(shutdown/plan 不被 ready task 饿死)
plan 未批准 → 不调用 claimNext,SQLite 零写入
pollIntervalSeconds=5, maxIdlePolls=12(可注入)
3 机制流程
1
Lead 建 DAG

create_task 建 A、B、C(C 依赖 A/B)。

2
启动 alice/bob

两人并发进 idle scanning。

3
原子认领

A、B 被不同 worker 认领;C 不可同时认领。

4
完成

A/B 都完成才解锁 C;C 只被第一个提交的事务认领。

4 术语表
TaskClaimtask + claimToken + leaseExpiresAtUtc;token 同时是 Runner 幂等键。
BEGIN IMMEDIATE写事务开始时取保留锁,串行化竞争;普通 BEGIN 到首次写才拿锁,中间窗口会出错。
半开 lease[claimed_at, lease_expires_at),到点即过期,自动回 pending。
task_claim_tokenstoken 历史表,全生命周期唯一;tasks.claim_token 的 UNIQUE 不够。
三层互斥进程内 tail-chain + 跨进程 proper-lockfile + SQLite BEGIN IMMEDIATE;去掉任何一层都有对应失败场景。
owner 防伪造来自 ToolContext.identity;schema 无 owner 字段,strictObject 直接拒绝。
工具裁剪队友无 create_task(只有 Lead 建图),有 get/list/claim/complete 四个任务工具。
5 QA 测试环节(自测题)
已完成 0 / 6 · 答对 0
Q1. 为什么不用『扫描 JSON + 单独写 owner』?
Q2. 一次 claim 只能成功一个靠?
Q3. claim 的顺序依据是?
Q4. lease 到达截止瞬间?
Q5. 旧 worker 用历史 token 完成新租约会?
Q6. plan 未批准时 idle 队友会?
6 验证与实验
  • npm run test:ch17:54 个测试文件 / 369 个用例(含前 16 章累积,旧 JSON TaskStore 行为完全不变是硬要求;本章 5 文件 20 用例,全程离线)。
  • 验证并发唯一认领(getRowsModified===1)、lease 过期、token 历史、owner 防伪造、计划 gate 排在认领之前、idle timeout 不伪造 shutdown。
  • npm run ch17 -- --prompt "创建 A、B 和依赖二者的 C,再启动 alice、bob 自主认领并完成"