第 17 章 从人肉派发到自驱轮询 · Agent架构实操十七 开始测验

从「人肉派发」到「自驱轮询」:去中心化协作

10个任务手动发10次勉强,有依赖就得盯着谁完成、谁解锁、下一项给谁。队友越多中心协调者越忙。真正的自治:空闲时从共享任务板找到当前可做的工作,且只让一个 worker 获得它。
⏱ 约 15 分钟 🗂 SQLite TaskStore 🔄 work stealing +TASK_DAG_SQLITE+WORK_STEALING

🎯 学习目标

学完能答
  • 为什么不能继续「先扫 JSON 再写 owner」?TOCTOU 经典问题是什么?
  • 为什么用 SQLite + BEGIN IMMEDIATE?给单个文件补 fcntl.flock 行吗?
  • work stealing 的事务步骤是什么?
  • claim token / lease 各是什么?lease 过期怎么办?
  • 协议优先是什么?shutdown/plan 响应送达时能先拿新任务吗?
  • P17 是迁移还是独立后端?会双写吗?

🧠 核心概念

点击展开。
1 · 为什么不能「先扫 JSON 再写 owner」

第12-16章的 JSON Task DAG 适合展示领域模型,但 work stealing 引入新原子边界:

读取全部 pending task
-> 过滤依赖已完成项
-> 按稳定顺序选第一项
-> 写入 owner、claim token、lease

如果「扫描」和「认领」是两个独立步骤,出现经典 TOCTOU:

Alice 扫描:C 可认领
Bob   扫描:C 可认领
Alice 写入:owner = alice
Bob   写入:owner = bob
fcntl.flock 不行 它没覆盖「跨多个任务选第一项」的事务,也不是 Windows 11 的统一锁接口。
2 · SQLite + BEGIN IMMEDIATE 事务
BEGIN IMMEDIATE
-> 回收已到期 lease
-> 在同一快照中加载并验证 DAG
-> 按数据库 sequence 查找首个 ready task
-> 条件 UPDATE pending -> in_progress
-> COMMIT

BEGIN IMMEDIATE 在写事务开始时取得保留锁。两进程可同时发起认领,但状态选择和条件更新被串行化;后进入事务的 worker 会看到前一个事务已提交的结果。条件 UPDATE 是最终互斥点:UPDATE ... WHERE status='pending' AND owner IS NULL,只认 getRowsModified() === 1,为 0 时返回 undefined 不抛错。三层互斥各管一段:进程内 tail-chain 管同进程并发,proper-lockfile 管跨进程,BEGIN IMMEDIATE 管数据库内部——去掉任何一层都有对应失败场景。

3 · claim token 和 lease

claim token:认领成功后生成的凭证,证明「这项任务现在归我」。后续 complete_task 必须携带正确 token,防止他人冒充完成。token 必须全生命周期唯一:task_claim_tokens 历史表让 token 在任务完成后、租约过期后都不可复用;插入历史表排在条件 UPDATE 之前,冲突发生在 UPDATE 前,回滚不需要补偿。token 同时是本轮 Runner 的幂等键。

lease:租约,半开区间 [claimed_at, lease_expires_at)——释放用 <=,完成检查用 >=,边界正好差一毫秒。worker 崩溃或长时间不完成时,lease 过期后任务可被回收(事务开始时先回收已到期 lease),重新变为可认领。这避免任务被死 worker 永久占用。

4 · 协议优先

shutdown 或计划响应已经送达时,协议先处理,不能先拿新任务。idle Teammate 在 scanning 前先检查是否有待处理的协议消息(shutdown_request/plan_approval_response)。

这保证控制信号优先:Lead 让 Alice 停,Alice 不会先抢一个新任务再停;Lead 批准了 Bob 的计划,Bob 先看到批准再决定是否认领新任务。

5 · 独立后端,不迁移不双写

P17 数据写入 .agent_tutorial/tasks.sqlite3,不读取也不写入第12章的 JSON Task DAG 文件。不是迁移,不是双写:P17 的 SQLite 是本章任务图的唯一后端。

组合根根据 capability 选择 store:有 task_dag_sqlite 用 SQLite,否则用 JSON。同一 workspace 不会同时有两个后端活跃。

6 · idle loop 四级优先级与工具裁剪

idle Teammate 每轮的固定顺序:① Mailbox 有消息先处理;② typed 协议请求不进模型、直接回响应;③ planAllowsEffectful() 为假只睡觉(计划门禁排在 claimNext 之前,零写入);④ claimNext() 认领,有活就干,没活就睡。控制平面永远优先于任务板,shutdown 不会被 ready task 饿死。

自动认领到任务后它不会自己完成——模型仍须显式调 complete_task 并出示 claim token。空转到 maxIdlePolls(默认 12)就退出循环,idleTimeoutCount 可观察,drainEvents() 是空的;唤醒不消耗空转预算。owner 来自 ToolContext.identity,claim_task 的 schema 里没有 owner 字段,strictObject 让伪造直接失败。工具裁剪:队友没有 create_task(只有 Lead 建图),有 get/list/claim/complete 四个任务工具。

▶️ 交互演示:work stealing

点「Alice 抢」「Bob 抢」看 SQLite 事务如何保证只有一个 worker 认领同一任务。C 依赖 A、B,都完成后才可认领。

🔑 一句话总结

SQLite BEGIN IMMEDIATE 把「扫描+选+认领」变成一个原子事务,TOCTOU 消失,只有一个 worker 拿到

QA 测验