AGAgent 学习路线
第 17 / 20
CHAPTER 17 · GPT 生成学习页

工作窃取与自治

让空闲队友从共享 SQLite 任务板原子认领已解锁任务,降低 Lead 分活成本。

01 / 路线

先看它怎样跑起来

从输入到验收

这一章不是几个孤立知识点,而是一条会产生结果的因果链。

关键判断

工作窃取与自治

亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。

  • 自治不是“收到命令会执行”,而是空闲时能找到可做工作。
  • 任务图切换为独立 SQLite store。
  • claim token 证明当前所有权。
1查询 ready 任务
2稳定排序
3原子 claim token
4完成后解锁依赖
点击播放,观察动作怎样传递0 / 4
02 / 正文

顺着原文把边界看清

21 个小节0 组代码21 行表格

按原文顺序阅读。摘要只负责定位,真正的边界、例外和代码都在展开内容里。

01导读:问题背景与本章目标队友能持续运行、能收发消息、能提交计划之后,Lead 仍然可能卡在一个很朴素的瓶颈上:分活。

队友能持续运行、能收发消息、能提交计划之后,Lead 仍然可能卡在一个很朴素的瓶颈上:分活。

10 个任务手动发 10 次,勉强还能接受。任务之间一旦有依赖,Lead 还得盯着谁完成了、谁解锁了、下一项该交给谁。队友越多,中心协调者反而越忙。

真正的自治不是“收到命令后会执行”,而是“空闲时能从共享任务板找到当前可做的工作,并且只让一个 worker 获得它”。

第 17 章在第 16 章的持续 Teammate、typed Protocol 和硬计划门控之上,只增加两项能力:

  • TASK_DAG_SQLITE:把本章任务图切换到独立 SQLite store;
  • WORK_STEALING:让 idle Teammate 按稳定顺序原子认领已解锁任务。
图片
图片

02先固定验收场景先不要急着写 polling loop。一个可验证的最小场景是:

先不要急着写 polling loop。一个可验证的最小场景是:

  1. Lead 创建无依赖任务 A、B;
  2. Lead 创建任务 C,并声明 C 同时依赖 A、B;
  3. Lead 启动 Alice 和 Bob,不给任何一人指定具体任务;
  4. 两个队友并发进入 idle scanning;
  5. Alice 和 Bob 分别认领 A、B,不能拿到同一项;
  6. A 或 B 单独完成时,C 仍然不可认领;
  7. A、B 都完成后,C 只被第一个成功提交事务的空闲队友认领;
  8. shutdown 或计划响应已经送达时,协议先处理,不能先拿新任务。

这组结果同时验证了依赖过滤、稳定选择、跨 worker 互斥、协议优先和生命周期,不是只断言某个函数“返回了非空值”。


03为什么不能继续“先扫 JSON,再写 owner”第 12-16 章的 JSON Task DAG 适合展示任务领域模型和人可读持久化,但 work stealing 引入了新的原子边界:

第 12-16 章的 JSON Task DAG 适合展示任务领域模型和人可读持久化,但 work stealing 引入了新的原子边界:

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

如果“扫描”和“认领”是两个独立步骤,就会出现经典 TOCTOU:

~~~text Alice 扫描:C 可认领 Bob 扫描:C 可认领 Alice 写入:owner = alice Bob 写入:owner = bob ~~~

给单个文件补一个 fcntl.flock 也不是本项目的答案。它既没有覆盖“跨多个任务选第一项”的事务,又不是 Windows 11 的统一锁接口。

P17 直接使用 SQLite:

~~~text BEGIN IMMEDIATE -> 回收已到期 lease -> 在同一快照中加载并验证 DAG -> 按数据库 sequence 查找首个 ready task -> 条件 UPDATE pending -> in_progress -> COMMIT ~~~

BEGIN IMMEDIATE 在写事务开始时取得保留锁。两个进程可以同时发起认领,但状态选择和条件更新会被串行化;后进入事务的 worker 会看到前一个事务已经提交的结果。

相关实现集中在:

  • [SQLite TaskStore](code/chapters/ch17/src/adapters/task-sqlite.ts)
  • [lease、claim 与工具定义](code/chapters/ch17/src/features/work-stealing.ts)
  • [Teammate idle runtime](code/chapters/ch17/src/features/teammates.ts)
  • [章节组合根](code/chapters/ch17/src/bootstrap.ts)
  • [CLI 组合入口](code/chapters/ch17/src/cli.ts)

04独立后端,不迁移也不双写.agenttutorial/.tasks/{uuid}.json

P17 数据写入:

~~~text .agent_tutorial/tasks.sqlite3 ~~~

第 12-16 章仍使用:

~~~text .agent_tutorial/.tasks/{uuid}.json ~~~

两个目录属于不同章节的数据面。P17 不扫描旧 JSON、不自动迁移、不同时写两份,也不把旧 JsonTaskStore 包成兼容层。

这个边界看起来严格,但它避免了更危险的假象:如果 SQLite claim 成功、JSON 双写失败,到底哪一份才是 owner 的权威来源?本章只有一个答案:P17 的 SQLite 数据库。

Bootstrap 因此会:

  • P12-P16 要求显式 JSON taskStore
  • P17 要求显式 workStealingRuntime
  • P17 收到 JSON taskStore 时直接拒绝;
  • P16 或更早章节收到 work-stealing runtime 时直接拒绝。

05TaskClaim:认领结果不能只返回一句字符串“Claimed”不是协议。调用方还需要知道认领了什么、凭什么完成、租约何时失效。

“Claimed”不是协议。调用方还需要知道认领了什么、凭什么完成、租约何时失效。

P17 使用独立的严格结果类型:

~~~typescript export interface TaskClaim { readonly task: Task; readonly claimToken: string; readonly leaseExpiresAtUtc: Date; }

export interface LeasedTaskStore { createTask(input: CreateTaskInput): Promise<Task>; getTask(taskId: string): Promise<Task>; listTasks(): Promise<readonly Task[]>; claimTask(taskId: string, owner: string): Promise<TaskClaim>; claimNext(owner: string): Promise<TaskClaim | undefined>; completeTask(taskId: string, owner: string, claimToken: string): Promise<TaskCompletion>; } ~~~

Task 仍然表达业务状态:pending / in_progress / completed、owner 和依赖。claim token 与 lease 不伪装成可选业务字段,而是由 TaskClaim 明确要求。

这样可以保持第 12-16 章 JSON schema 不变,同时让 P17 的缺失 token 成为输入校验错误,而不是运行到完成阶段才猜测。

06TaskClaimService 与 wire format:owner 不能被工具参数伪造认领不是由 LeasedTaskStore 直接暴露给工具层。TaskClaimService 先把 ToolContext.identity 映射为 owner:

认领不是由 LeasedTaskStore 直接暴露给工具层。TaskClaimService 先把 ToolContext.identity 映射为 owner:

~~~typescript export interface TaskClaimService { readonly store: LeasedTaskStore; claimTask(taskId: string, context: ToolContext): Promise<TaskClaim>; claimNext(owner: string): Promise<TaskClaim | undefined>; completeTask(taskId: string, claimToken: string, context: ToolContext): Promise<TaskCompletion>; } ~~~

DirectTaskClaimService 是默认实现:手动认领调用 store.claimTask(taskId, context.identity),自动扫描调用 store.claimNext(owner),完成时调用 store.completeTask(taskId, context.identity, claimToken)。工具输入里即使写 owner: "alice",也不会被采用;模型只能以当前 Runner 身份认领和完成。这也让自动认领与手动工具调用走同一条身份边界。

WorkStealingRuntime 构造时用 isLeasedTaskStoreisTaskClaimService 做结构校验,并要求 claimService.store === store。这不是防御正常调用,而是为了在组合根里提前暴露“Lead 写数据库 A、Teammate 扫数据库 B”的分叉。

内部 wire payload 也保持严格命名:Task 在内存中使用 blockedBy,工具结果使用 blocked_byTaskClaim 使用 camelCase 的 claimTokenleaseExpiresAtUtc,序列化时统一映射为 claim_tokenlease_expires_at_utctaskPayloadclaimPayload 是这两个边界转换的唯一点,教程后文看到的 JSON 就是模型真正收到的格式。


07SQLite schema:稳定顺序、依赖和 token 历史数据库把任务状态、依赖和 token 历史分表保存,另用 taskmetadata 记录 schemaversion:

数据库把任务状态、依赖和 token 历史分表保存,另用 task_metadata 记录 schema_version

~~~sql CREATE TABLE IF NOT EXISTS task_metadata(key TEXT PRIMARY KEY, value TEXT NOT NULL); INSERT OR IGNORE INTO task_metadata(key, value) VALUES ('schema_version', '1');

CREATE TABLE IF NOT EXISTS tasks( sequence INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE, subject TEXT NOT NULL, description TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('pending', 'in_progress', 'completed')), owner TEXT, claim_token TEXT UNIQUE, lease_expires_at_utc TEXT, CHECK( (status = 'pending' AND owner IS NULL AND claim_token IS NULL AND lease_expires_at_utc IS NULL) OR (status = 'in_progress' AND owner IS NOT NULL AND claim_token IS NOT NULL AND lease_expires_at_utc IS NOT NULL) OR (status = 'completed' AND owner IS NOT NULL AND claim_token IS NULL AND lease_expires_at_utc IS NULL) ) );

CREATE TABLE IF NOT EXISTS task_dependencies( task_id TEXT NOT NULL, dependency_id TEXT NOT NULL, position INTEGER NOT NULL CHECK(position >= 0), PRIMARY KEY(task_id, dependency_id), UNIQUE(task_id, position), FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE, FOREIGN KEY(dependency_id) REFERENCES tasks(id) ON DELETE RESTRICT );

CREATE TABLE IF NOT EXISTS task_claim_tokens( token TEXT PRIMARY KEY, task_id TEXT NOT NULL, FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE RESTRICT ); ~~~

sequence 是真正的创建顺序。随机 UUID 只负责身份,不能决定公平顺序;即使后创建任务的 UUID 字典序更小,list_tasksSqliteTaskStore.claimNext() 仍按 sequence 工作。

task_dependencies 保存依赖顺序,并使用外键拒绝不存在的 dependency。每次读取图时仍会验证缺节点、自依赖和环,不能把损坏状态当成“暂时 blocked”跳过。

task_claim_tokens 保存整个数据库生命周期内出现过的 token。任务完成或 lease 到期后,active token 会从 tasks 清空,但历史 token 不删除。生成器若再次给出旧 token,插入历史表会失败,整个 claim 事务回滚。

这项约束不只是防极低概率的随机 UUID 碰撞。测试可以注入确定性生成器,直接证明同一个 task 再次分给同一个 owner 时,旧 worker 也不能靠历史 token 完成新租约。


08原子认领:选择和状态迁移在一个事务里async claimNext(owner: string): Promise<TaskClaim | undefined> {

核心流程可以压缩成下面这段:

~~~typescript async claimNext(owner: string): Promise<TaskClaim | undefined> { return await this.#transaction(true, (database) => { const now = this.#now(); releaseExpired(database, now); const graph = loadGraph(database); for (const id of graph.order) { const task = graph.tasks.get(id); if (task === undefined || task.status !== TaskStatus.PENDING) continue; if (task.blockedBy.some((dependency) => graph.tasks.get(dependency)?.status !== TaskStatus.COMPLETED)) { continue; } return this.#claim(database, graph, task, owner, now); } return undefined; }); } ~~~

#transaction() 在 yield 前执行 BEGIN IMMEDIATE,正常返回时 commit,任意异常时 rollback。

#claim() 还会执行带条件的更新:

~~~sql UPDATE tasks SET status = 'in_progress', owner = ?, claim_token = ?, lease_expires_at_utc = ? WHERE id = ? AND status = 'pending' AND owner IS NULL; ~~~

只有 rowcount == 1 才能构造 TaskClaim。依赖检查、历史 token 登记和这条 UPDATE 全在同一个事务中;任何一步失败都不会留下半认领状态。

真正落到 SqliteTaskStore.#transaction() 时,P17 不是只依赖 SQLite 的 BEGIN IMMEDIATE,而是在进入事务前还叠加两层互斥:

  • withProcessMutex(database):按数据库路径把同一进程内的异步操作串成 tail-chain,避免本进程多个 worker 同时抢文件锁;
  • proper-lockfileacquireFileLock:跨进程拿 .lock 文件锁;Windows 上遇到 EBUSYENOTEMPTYEPERM 这类短暂竞争错误时会短等待重试。

这三层分别处理“单进程并发”“跨进程启动竞争”和“SQLite 内存库快照”。sql.js 本身运行在内存中,提交后必须通过 database.export() 导出字节,再写入临时文件并 renametasks.sqlite3;这样即使进程在落盘前崩溃,也不会把半份数据库当成正式结果。

09SQLite 的 schema、路径和落盘边界P17 的适配器不是“打开后裸写”。每次 openDatabase() 都会:

P17 的适配器不是“打开后裸写”。每次 openDatabase() 都会:

  • PRAGMA foreign_keys = ON:让 task_dependenciestask_claim_tokens 的外键真正生效;
  • task_metadata 并写入 schema_version = 1,打开后先校验版本;
  • CHECK 把状态不变量压进数据库:pending 必须无 owner/token/lease,in_progress 必须三者齐全,completed 必须保留 owner 且清空 token/lease;
  • 用 FK 禁止依赖不存在的 task,也禁止删除仍被 claim token 或依赖引用的任务。

#transaction() 提交后,sql.js 仍只持有内存数据库,因此 persistDatabase() 先把字节写入 .agent_tutorial/.{uuid}.tmp。临时文件使用 open("wx") 避免覆盖其他临时文件,再 sync() 到磁盘,最后 rename()tasks.sqlite3finally 中会清理残留临时文件。rename 在同一目录内原子替换正式文件,进程中断不会留下半份数据库。

写文件之前还有路径边界:workspace、.agent_tutorial、数据库和 lock 都先经 realpath 解析;数据库必须是 regular file 且 nlink === 1。symlink 或 hardlink 指向 workspace 外的文件都会被拒绝,这正是“数据库不能把状态写到 workspace 外”的测试依据。普通 Windows 用户可能没有创建 symlink 的权限,测试会 skip 对应 case;hardlink 不需要额外权限,仍会真实运行。


10lease 是半开区间[claimedatutc, leaseexpiresatutc)

一次 claim 的有效区间是:

~~~text [claimed_at_utc, lease_expires_at_utc) ~~~

也就是说,到达 lease_expires_at_utc 的那个瞬间就已经过期,不再接受完成。

complete_task 必须同时满足:

  • task 当前仍为 in_progress
  • owner 与当前 worker 完全匹配;
  • claim token 与当前租约完全匹配;
  • 当前 UTC 时间严格早于 lease 截止时刻。

到期任务在下一次 get、list、claim 或 complete 事务中原子回到:

~~~text status = pending owner = NULL claim_token = NULL lease_expires_at_utc = NULL ~~~

随后其他 worker 可以获得新 token。旧 owner 即使晚到,也只能得到明确的 lease expired 或 claim mismatch,不能覆盖新 owner 的结果。

本章没有增加 heartbeat 或 renew_lease 工具。任务若超过当前 lease,可能被重新执行;因此外部副作用仍必须传播稳定 idempotency key,不能把数据库事务描述成全局 exactly-once。


11五个工具,但不同角色拿到的集合不同P17 工具名与 P12 保持一致,completetask schema 则明确增加 claimtoken:

P17 工具名与 P12 保持一致,complete_task schema 则明确增加 claim_token

工具Effect关键输入结果
create_taskwritesubject、description、blocked_by新建 pending task
get_taskreadtask_id单个 task
list_tasksread空对象按 sequence 返回 DAG
claim_taskwritetask_idTaskClaim,含 token 与 lease
complete_taskwritetask_id、claim_tokencompleted task 与刚解锁的直接下游

角色暴露面如下:

角色Task 工具
Lead五个工具全部可用
一次性 Subagent五个工具全部可用,不获得递归 task
持续 Teammateget_tasklist_tasksclaim_taskcomplete_task

Teammate 不获得 create_task。Lead 负责建立任务图,worker 负责读取、认领和完成,避免普通队友在自治循环里无界扩张工作队列。

在组合根中,这套工具不是硬编码在 AgentRunner 里的。registerLeasedTaskTools 把五个 definitions 注册给 Lead 和一次性 Subagent;registerTeammateLeasedTaskTools 拿到同一数组后跳过 create_task,只注册 get_tasklist_tasksclaim_taskcomplete_task。两个注册函数都接收同一个 WorkStealingRuntime.storeclaimService,因此工具执行时永远写同一个 SQLite 数据库。

这些 definitions 来自 leasedTaskToolDefinitions(store, claimService):它返回定长五元组 [create, get, list, claim, complete],数组顺序就是 Lead/Teammate 的工具分界。所有 input schema 都用 z.strictObject()blocked_by 必须是 canonical UUID 数组;handler 统一把内存中的 blockedBy 映射成 wire 上的 blocked_by,已知 TaskError 转成稳定 errorCode,未知异常继续上抛而不是被工具边界吞掉。


12idle loop 的真实顺序P17 没有新建 daemon thread,也没有复制第二套 Agent Loop。它只在 TeammateRuntime 已有 worker 生命周期里增加一个分支:

P17 没有新建 daemon thread,也没有复制第二套 Agent Loop。它只在 TeammateRuntime 已有 worker 生命周期里增加一个分支:

~~~text 持有 teammate registry lock -> 检查 runtime 是否关闭 -> 先 claim Mailbox -> 有 typed Protocol:确定性路由 -> 无消息:查询最新 plan 是否允许副作用 -> 允许时调用 SQLite claimNext(owner) -> 仍无工作:进入可唤醒等待 ~~~

这个顺序有三层含义。

第一,Mailbox 先于任务板。shutdown request、plan response 和普通 Lead 消息不会被不断出现的 ready task 饿死。

第二,Protocol 仍走第 16 章的 typed router。P17 不把 plan response 退化成普通文本,也不自己解析 request_id

第三,自动认领属于 effectful 状态迁移。最新计划为 pending 或 rejected 时,planAllowsEffectful(name) 返回 false,runtime 不调用 claimNext(owner),SQLite 保持零写入;无计划或最新计划 approved 才允许继续。

手动调用 claim_taskcomplete_task 时,同样还要经过现有 PreToolUse、PermissionPolicy、approval 和 audit。自动 claim 没有构造虚假 tool call 绕过计划状态,而是在确定性 runtime 边界复用同一个最新计划判定。


13自动认领后,模型仍须显式完成WorkStealingRuntime 把 TaskClaim 序列化为一个确定性的 user message:

WorkStealingRuntimeTaskClaim 序列化为一个确定性的 user message:

~~~text <auto-claimed-task> {"claim_token":"...","lease_expires_at_utc":"...","task":{...}} </auto-claimed-task> ~~~

该 claim token 同时作为这次 AgentRunner.run() 的 idempotency key。队友据此执行工具,并在完成业务结果后显式调用:

~~~json { "task_id": "00000000-0000-4000-8000-000000001701", "claim_token": "00000000-0000-4000-8000-000000001711" } ~~~

runtime 不会因为模型说了一句“完成了”就私自把 task 改成 completed。若模型未调用 complete_task,租约保持 in progress,直到到期后可被重新认领。

队友的最终文本仍通过持久 Mailbox 发回 Lead;任务状态和团队消息是两个独立状态机,不能用一条 summary 代替数据库完成迁移。


14有界 polling,不伪造 shutdownWorkStealingSleeper 是可注入的 async 边界。生产实现等待 timeout 或 worker 传入的 AbortSignal wakeup;测试实现可以精确推进,不依赖真实 sleep。

生产默认值是:

~~~text pollIntervalSeconds = 5 maxIdlePolls = 12 ~~~

WorkStealingSleeper 是可注入的 async 边界。生产实现等待 timeout 或 worker 传入的 AbortSignal wakeup;测试实现可以精确推进,不依赖真实 sleep。

AsyncioWorkStealingSleeper 是生产实现:它同时监听 setTimeoutwakeupabort 事件。timer 到点或 Lead 发消息触发 abort 时,都会 clearTimeout 并 resolve,因此 idle worker 不必等满 5 秒就能回到同一优先顺序重新扫描 Mailbox。

每次等待后,worker 回到同一优先顺序重新检查 Mailbox、Protocol、plan 和任务板。Lead 向 idle 队友发送消息时会触发 abort,提前结束等待,不必等满 5 秒。

达到轮询上限后:

  • worker 状态保持 idle
  • idleTimeoutCount(name) 增加;
  • waitForIdleTimeout(name) 可观察;
  • 当前受管 poll task 结束,JobSupervisor 不留 active worker;
  • 不生成 shutdown request;
  • 不生成 shutdown response;
  • 不伪造 summary。

“60 秒没找到任务”不是“Lead 已批准关机”。协议终态只能由真实的 typed shutdown handshake 产生。


15组合根必须共享同一个 runtimeconst store = new SqliteTaskStore(workspace);

P17 CLI 只构造一个 SQLite store:

~~~typescript const store = new SqliteTaskStore(workspace); const workStealing = new WorkStealingRuntime({ store }); ~~~

Bootstrap 先验证 Background、Cron、Teammate 和 Protocol 的既有共享关系,再执行:

~~~typescript teammateRuntime.configureProtocol(protocolRuntime); teammateRuntime.configureWorkStealing(workStealingRuntime); teammateRuntime.configureRunnerFactory(teammateRunnerFactory); ~~~

configureWorkStealing() 必须在 configureProtocol() 之后、start() 之前调用,且只能配置一次。没有 ProtocolRuntime 时直接拒绝,因为自动认领依赖 plan gate;启动后再配置也会抛 TeammateStateError

然后:

  • Lead 注册该 runtime 的五个 leased definitions;
  • Subagent factory 注册同一组五工具;
  • Teammate factory 在已有的 shellread_filewrite_filesend_messagesubmit_plan 之后再追加四个 worker 工具;
  • TeammateRuntime.workStealingRuntime 必须就是传入的同一对象。

不能让 Lead 写数据库 A、Teammate 扫数据库 B,也不能在 CLI 里偷偷创建第二个 SqliteTaskStore

真实配置仍先于任何 runtime 状态构造。缺少 OpenAI 配置时,两个入口都会在网络、.agent_tutorial 和 SQLite 文件创建前退出。


16两个运行入口Set-Location 'F:\笔记\Agent实操\code'

固定章节入口:

~~~powershell Set-Location 'F:\笔记\Agent实操\code' npm run ch17 -- --prompt "创建 A、B 和依赖二者的 C,再启动 alice、bob 自主认领并完成" ~~~

通用入口:

~~~powershell Set-Location 'F:\笔记\Agent实操\code' npm run agent-tutorial -- run --chapter 17 --prompt "创建 A、B 和依赖二者的 C,再启动 alice、bob 自主认领并完成" ~~~

真实写操作仍受第 3 章起的终端审批策略约束。自驱找活不会降低文件写入、Shell、Hook 或 plan gate 的权限要求。

17从 ai-agent-book 学到什么:共享任务板与原子认领这一章与仓库内 ai-agent-book 第 10 章的多 Agent 协作框架对照尤其有价值。第 10 章区分了共享上下文/不共享上下文、Orchestration/Choreography/Decentralized,并用 实验 10-4 展示了一个真实并行搜索系统:Manager 分配页面,多个同构 worker 各自持有浏览器 context。第一个 targetfound 在锁与幂等 settleonce 下结算,只广播一次 terminate,losing worker 在安全点取消、ack 并关闭资源。实现见 agents.py 与 messagebus.py。

这一章与仓库内 ai-agent-book 第 10 章的多 Agent 协作框架对照尤其有价值。第 10 章区分了共享上下文/不共享上下文、Orchestration/Choreography/Decentralized,并用 [实验 10-4](ai-agent-book/chapter10/parallel-web-research/README.md) 展示了一个真实并行搜索系统:Manager 分配页面,多个同构 worker 各自持有浏览器 context。第一个 target_found 在锁与幂等 settle_once 下结算,只广播一次 terminate,losing worker 在安全点取消、ack 并关闭资源。实现见 [agents.py](ai-agent-book/chapter10/parallel-web-research/agents.py) 与 [message_bus.py](ai-agent-book/chapter10/parallel-web-research/message_bus.py)。

P17 不照抄它的 MessageBus,而是把它学到的原则翻译到任务板:

ai-agent-book 的设计P17 的对应
共享文件系统是并发冲突高发区,需要乐观锁或 worktree 隔离tasks.sqlite3 是单一任务状态源,用三层互斥保证只有一个 worker 能 claim;同一 workspace 的文件隔离仍留给第 18 章
数据平面与控制平面分开,消息总线承载控制/结果事件Mailbox 与 typed Protocol 仍是 Lead/worker 的控制平面;SQLite 只保存任务状态,不扮演 broadcast bus
settle_once 必须是幂等且由锁或事务保护BEGIN IMMEDIATE + 条件 UPDATE + task_claim_tokens 历史表保证一次 claim 只成功一次
首个“已验证成功”才结算,而不是首个声称成功worker 必须显式调用 complete_task 并带当前 claim_token;不过 P17 没有独立验证器,验证仍来自工具调用本身
terminate -> 安全点清理 -> ack -> 退出,强制终止兜底P16 的 typed shutdown handshake 优先于 ready task;idle timeout 不伪造 shutdown
循环失控需要预算与取消机制maxIdlePolls 是有界轮询预算,lease 防止 worker 卡住任务;本章不做全局限额或 heartbeat
并行协调适合用消息总线做广播(如 terminate)P17 刻意不实现 BROADCAST;一对多通知和 A2A 状态同步留给后续章节

这一点比“加一个 polling loop”更关键:work stealing 把调度从 Lead 的判断移到了共享任务板上,但它仍然不是一个没有控制平面的自组织网络。任务怎么认领可以是去中心化的,消息、计划审批和关机仍需要确定性的 typed 状态机。

18与 Claude Code 的差异参照前几章对 Claude Code 真实实现的分析视角,P17 的 work stealing 在教学简化上与 Claude Code 的真实自治机制存在以下差异:

参照前几章对 Claude Code 真实实现的分析视角,P17 的 work stealing 在教学简化上与 Claude Code 的真实自治机制存在以下差异:

空闲机制。 P17 用 AsyncioWorkStealingSleeper 做 5 秒轮询,默认 12 次后进入可观察的 idle timeout。CC 不是单一 idle_poll(),而是 sendIdleNotification()waitForNextPromptOrShutdown() 500ms mailbox 轮询、useTaskListWatcher 文件监听与 tryClaimNextTask() 主动认领的组合。P17 的 waitForPoll() 还接收 AbortSignal wakeup,Lead 发消息时可以提前结束等待。

任务认领与锁定。 CC 教学版 scan + claim 使用 JSON 文件且没有文件锁。CC 真实源码用 proper-lockfile 的任务级锁和 task-list 锁,在锁内完成读-检查-改-写。P17 则把 JSON 换成独立 SQLite store,用进程内 tail-chain、proper-lockfile 跨进程文件锁、SQLite BEGIN IMMEDIATE 三层互斥,把“选择第一个 ready task”和“条件更新”放进同一个事务。

claim 语义。 CC 教学版 claim_task 返回 "Claimed {id} ({subject})" 文本。P17 返回 TaskClaim,内部显式携带 taskclaimTokenleaseExpiresAtUtc;序列化给模型时统一映射为 wire format 的 claim_tokenlease_expires_at_utc。token 还作为本轮 AgentRunner.run() 的 idempotency key,模型重试不会重复完成外部副作用。

token 生命周期。 P17 有 task_claim_tokens 历史表,token 在数据库整个生命周期内保持唯一。任务完成或 lease 过期后,旧 token 也不可复用。CC 的 owner 字符串没有 token 历史,只能靠文件锁和状态检查避免误完成。

lease 截止。 P17 的租约是半开区间 [claimed_at, lease_expires_at),到期后自动回到 pending 并清空 owner/token。CC 的 claim 没有 lease,任务若卡住需要人工介入或靠其他机制处理。

自动认领 gate。 P17 在 claimNext() 前先检查 planAllowsEffectful(),pending/rejected 的最新计划会让自动认领保持零写入。CC 教学版靠命令语义和工具检查,没有把 plan gate 与 claim 绑成同一个确定性边界。

身份保持。 CC 教学版在 len(messages) <= 3 时手动重注入 <identity>,真实 CC 的 context compaction 会保留 system prompt。P17 每次 run() 由 Runner 注入稳定 identity,自动认领则额外把 claim payload 作为当前回合输入。

工具面。 CC 教学版给队友 8 个工具(增加 list_tasksclaim_taskcomplete_task),真实 CC 队友还能使用任务创建/更新类工具。P17 的 Teammate 只暴露 get_tasklist_tasksclaim_taskcomplete_task,刻意不开放 create_task,避免队友在自治循环里无限扩张任务图。


19如何验证本章测试把 UUID、UTC clock、lease、sleeper、模型和多进程启动方式全部变成可控制边界:

本章测试把 UUID、UTC clock、lease、sleeper、模型和多进程启动方式全部变成可控制边界:

  • [SQLite DAG、租约与工具测试](code/chapters/ch17/tests/ch17-task-sqlite.test.ts)
  • [Work stealing 契约与工具面测试](code/chapters/ch17/tests/ch17-work-stealing.test.ts)
  • [Teammate 自驱认领与协议优先测试](code/chapters/ch17/tests/ch17-runtime.test.ts)
  • [Bootstrap 精确工具面测试](code/chapters/ch17/tests/ch17-bootstrap.test.ts)
  • [双入口与共享 runtime 测试](code/chapters/ch17/tests/ch17-entry.test.ts)

运行聚焦验证:

~~~powershell Set-Location 'F:\笔记\Agent实操\code' npm run test:ch17 npm run typecheck npm run lint npm run format:check npm run build ~~~

关键断言包括:

  • SQLite 与旧 JSON backend 相互独立,顺序按 sequence 而不是 UUID;
  • A、B 被两个队友并发认领,C 只在二者完成后由一个队友认领;
  • Windows spawn 多进程竞争同一任务时恰好一个成功;
  • exact lease expiry 已过期,旧 owner/token 不能完成新租约;
  • active 或历史 token 碰撞都会回滚,不产生半认领;
  • 数据库 symlink/hardlink 不能把状态写到 workspace 外;
  • shutdown 已送达时 task 保持 pending,协议请求不进入模型;
  • pending/rejected 最新计划让自动 claim 零写入,approved response 先更新 gate;
  • idle timeout 可观察,但不生成虚假协议或 summary;
  • Lead、Subagent、Teammate 的工具集合和 complete_task required schema 精确;
  • CLI 只构造一个 SQLite store,配置失败前不创建状态目录。

普通用户无权创建文件 symlink 的 Windows 环境会明确 skip 对应 symlink case;无需额外权限的 hardlink 逃逸测试仍真实运行。


20本章明确不做什么- JSON 到 SQLite 的自动迁移、读取回退或双写;

P17 只解决共享任务板上的原子自治调度,没有提前实现:

  • JSON 到 SQLite 的自动迁移、读取回退或双写;
  • lease heartbeat、续租工具或无限等待;
  • 把 SQLite 事务描述成外部副作用 exactly-once;
  • 超时后伪造 graceful shutdown;
  • Worktree/branch 绑定和并发文件隔离;
  • 动态 MCP 工具发现;
  • daemon thread、进程级全局 BUS 或无人持有的 async task。

尤其要注意最后一个尚未解决的问题:Alice 和 Bob 已经不会认领同一个 task,但仍可能在同一个 workspace 修改同一个文件。任务所有权不等于文件系统隔离。


21小结从手工 assign 到 work stealing,真正新增的不是一个 while True,而是一组可恢复、可并发验证的边界:

从手工 assign 到 work stealing,真正新增的不是一个 while True,而是一组可恢复、可并发验证的边界:

  1. P17 使用独立 SQLite store,不迁移、不双写旧 JSON。
  2. BEGIN IMMEDIATE 把依赖过滤、稳定选择和条件认领放进一个事务。
  3. TaskClaim 携带 task(含 owner)、claimToken 和半开 lease;wire format 使用 claim_tokenlease_expires_at_utc
  4. token 在数据库生命周期内保持唯一,旧 worker 不能完成新租约。
  5. Mailbox 与 typed Protocol 先于任务板,最新计划继续控制副作用。
  6. 自动 claim token 传播为 Runner idempotency key,模型仍须显式完成任务。
  7. polling 由受管 async runtime 持有,timeout 可观察但不冒充关机。
  8. Lead、Subagent 和 Teammate 共用一个 runtime,只暴露各自需要的工具。

至此,Lead 可以只建立 DAG 和启动队友,依赖解锁、稳定选择和并发认领由 worker 自主完成。下一章再解决“拿到任务以后,多个队友究竟在哪里安全地改文件”:Git Worktree 隔离。

03 / 自测

换个场景,你还会判断吗?

答完再看理由

每题只测一个边界。先做决定,再看解释。

SCENARIO CHECK01 / 030 分

准备开始