主题
Plan-and-Execute
本课交付结果
你将交付 TodoManager.create/start/complete/block/snapshot:任务获得稳定顺序 ID,同一时刻最多一个 in_progress,只允许合法转换;所有返回值和快照都是不可变副本。
岗位问题
自然语言清单不能支撑长任务:模型可能同时声称执行多个步骤、跳过 pending 直接 complete,或在 UI 修改返回对象后污染 Runtime。Plan-and-Execute 首先是状态机,不是漂亮的 Markdown 列表。
前置检查
前置知识快照
先理解第 5 课 Agent Loop 的终止状态:
bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 05区分任务身份、展示标题与生命周期状态。ID 一旦分配不得随数组位置变化。
任务ID用于Trace、事件日志、审批和恢复引用,标题只用于人类理解。删除、排序或改标题都不能改变身份,因此不能用数组索引作为ID。todo-001顺序编号在单进程Lab中可重复预测,持久化版本会从事件或检查点恢复下一编号。
状态机把“允许发生什么”写成代码:create只到pending,start只从pending到in_progress,complete和block只从in_progress出发。不存在的边表示非法操作,不靠提示词劝模型遵守。
原理拆解
create 只产生 pending;start 要先证明没有其他 in_progress;complete 与 block 只接受 in_progress。内部 Map 保存当前事实,对外始终复制并冻结,防止调用方绕过转换方法。
mermaid
stateDiagram-v2
[*] --> pending: create
pending --> in_progress: start
in_progress --> completed: complete
in_progress --> blocked: block(reason)
completed --> [*]
blocked --> [*]同一时刻最多一个in_progress,是课程版Plan-and-Execute的核心不变量。它迫使Agent明确当前焦点,UI和恢复逻辑也无需推断多个并发步骤的依赖。需要并行任务时应引入显式依赖图和调度器,而不是删掉检查让状态变得含糊。
start先扫描现有任务是否有进行项,再验证目标pending。检查顺序影响错误信息,但不能让第二任务进入。单线程JavaScript内同步方法足够;跨进程持久化后,需要事务或compare-and-swap避免两个worker同时通过扫描。
complete不接受pending,因为“计划中直接完成”会丢失实际执行开始证据;blocked也只接受active并要求非空原因,让用户知道为什么停。若任务在开始前就不可行,可先start再block,或未来增加cancelled状态及明确转换。
状态转换构造新Todo,不原地修改内部对象。对外返回 Object.freeze({...todo}),snapshot冻结数组与每个条目。只冻结外层数组仍允许UI改 snapshot[0].status,绕过Manager并污染后续事实。
Map保留创建顺序,snapshot因此稳定按计划顺序返回。排序展示应由UI在副本上完成,不能修改内部顺序。状态更新用相同id替换Map value,不改变插入位置。
blocked reason先trim并保存清洁文本,空白拒绝。生产reason应结构化包含failure fingerprint、需要的用户动作与诊断id,避免自由文本成为唯一恢复依据。本Lab先证明非空解释契约。
代码实验
失败实现
普通可变数组允许任何调用方直接跳状态:
ts
const todos: Todo[] = [];
function complete(index: number) {
todos[index]!.status = "completed";
}正确实现集中转换并返回冻结副本:
ts
private transition(id: string, from: TodoStatus, to: TodoStatus, reason?: string): Todo {
const current = this.get(id);
if (current.status !== from) {
throw new Error(`任务不能从 ${current.status} 转为 ${to}`);
}
const next: Todo = {
id: current.id,
title: current.title,
status: to,
...(reason === undefined ? {} : { reason }),
};
this.todos.set(id, next);
return Object.freeze({ ...next });
}
snapshot(): readonly Todo[] {
return Object.freeze([...this.todos.values()].map((todo) => Object.freeze({ ...todo })));
}bash
pnpm --dir bootcamps/coding-agent/labs/21-plan-and-execute/starter test
pnpm --dir bootcamps/coding-agent/labs/21-plan-and-execute/solution testStarter 应声明 实现任务状态转换;Solution 应通过 5 项测试。
不变量测试既检查错误也检查事实未变:
ts
it("已有进行项时拒绝启动第二项", () => {
const manager = new TodoManager();
const first = manager.create("A");
const second = manager.create("B");
manager.start(first.id);
expect(() => manager.start(second.id)).toThrow("已有进行中");
expect(manager.snapshot().find((item) => item.id === second.id)?.status)
.toBe("pending");
});关键实现讲解
ID 使用 todo-001 顺序生成,便于 Trace 和恢复引用。转换函数统一验证来源状态并构造新对象;blocked 必须有非空原因。snapshot 冻结数组及每个 Todo,而不是只冻结外层。
create清理标题并先拒绝空值,只有成功后才递增nextId,避免失败创建留下编号洞。参考实现把递增写在对象构造中,但校验在前;并发持久化版本需要序列化ID分配。
未知ID错误应在任何转换前发生,且不泄露全部任务内容。生产API返回稳定code TODO_NOT_FOUND,message用于展示。状态冲突使用current/expected/target字段,方便调用方重新snapshot而非解析中文。
运行与验证
真实运行输出
bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 21
pnpm --filter @coding-agent/state test检查第二个任务无法在已有进行项时 start,pending 不能直接 complete,修改 snapshot 元素会抛 TypeError。
真实Solution五项应全部通过:
text
solution: 5 passed, 0 failed
覆盖: 稳定顺序ID / 单一进行项 / 合法转换 / 阻塞原因 / 深层不可变快照再连续创建十二项确认补零与递增,尝试空标题、未知ID、重复complete、空reason和修改返回对象;每次失败后snapshot与next合法状态保持一致。
常见失败与排查
故障案例 1
两个步骤同时显示“进行中”。
症状:Agent开始B时A尚未完成,恢复后无法判断当前焦点。
根因:start只修改目标,没有检查全局in_progress不变量。
定位:创建两项依次start,检查第二次是否抛错以及B是否仍pending。
修复:在转换前扫描active任务;并发持久化时用事务或单writer序列化。
故障案例 2
任务从pending直接completed。
症状:计划看似全部完成,却没有任何开始、执行或验证事件。
根因:complete只写目标状态,没有校验来源。
定位:对新任务直接complete,查看是否成功以及Trace是否缺start。
修复:集中transition验证精确from,非法边拒绝且不改变状态。
故障案例 3
UI改标题导致Runtime事实变化。
症状:渲染层为了截断标题修改snapshot,后续日志出现被改文本。
根因:snapshot返回内部对象引用或只冻结数组外层。
定位:检查数组和元素Object.isFrozen,尝试赋值后重新snapshot。
修复:每次返回新对象并冻结,数组也冻结;生产嵌套details使用深拷贝schema。
- ID 使用数组索引:删除或重排后身份漂移。
- 只冻结数组:元素仍能被外部改写。
- complete 接受任意状态:状态机失去证据价值。
- block 不记录原因:恢复时不知道缺少什么。
课后作业
增加取消和重新打开规则,并生成事件流而非只保存最终快照;写性质测试证明任何操作序列都不会出现两个 in_progress。
验收 Rubric
| 维度 | 通过标准 | 常见扣分 |
|---|---|---|
| 身份 | ID 稳定且连续 | 使用可变数组位置 |
| 转换 | 只允许明确状态边 | 任意状态可跳转 |
| 并发语义 | 最多一个进行项 | 多任务同时 in_progress |
| 不可变性 | 数组与元素都冻结 | 暴露内部对象 |
总项目增量
总项目包:@coding-agent/state
总项目路径:packages/state/src/todo-manager.ts
总项目验证命令:pnpm --filter @coding-agent/state test
本课让计划可执行;下一课把每次状态变化追加到可恢复 JSONL 会话。
延伸阅读
方案对比与工程取舍
自然语言Markdown清单适合展示,却没有合法转换与机器身份;数据库状态表易查询,但若只保存最新值会丢失过程;事件溯源完整可回放,复杂度更高。课程先用内存Map证明状态机,第22课再把转换作为JSONL事件持久化,逐层增加能力。
随机UUID适合分布式唯一,日志难读且测试不稳定;顺序ID可预测、便于教学和Trace,但跨会话需命名空间或持久化计数。生产可用sessionId加顺序号,兼顾全局唯一与局部可读。身份策略不能依赖数组位置。
单一in_progress简化调度,牺牲并行吞吐。复杂Agent可允许多个无依赖任务并行,但必须有DAG、资源锁、独立workspace和确定合并。没有这些机制时,并行只是把状态竞争隐藏起来。课程选择可证明的串行基线。
blocked作为终态还是可恢复状态取决于产品。Lab不提供unblock,用户提供权限后可创建新任务或扩展 blocked -> pending,同时记录原因解决事件。直接把blocked改回in_progress会丢失重新评估与调度证据。
completed也不应随意重开。验收失败说明原“完成”声明有误,可以新增reopened转换并保留版本,或创建修复任务。覆盖状态会让审计看不到曾经误判。状态机扩展必须通过事件历史而非对象突变。
标题不是执行指令的全部。生产Todo包含目标、验收标准、依赖、风险、允许路径与来源,但对外仍用schema验证和冻结快照。自由对象越大,越容易共享可变引用;只保存执行所需稳定字段,并把大上下文放引用存储。
计划可以由模型提出,但状态转换由Runtime强制。模型说“我已经完成第三步”只是Action,Manager检查第三步是否active、验证证据是否存在,再决定complete。不能让自然语言响应直接成为状态事实。
complete前通常需要验收token或evidence,例如测试报告hash、Diff review和用户批准。Lab只检查from状态,生产Manager可让complete接收completionEvidence并验证。状态机是扩展安全门禁的汇合点。
block原因应区分等待用户、外部服务、策略拒绝与技术失败。结构化kind驱动UI与恢复,文本解释供人读。若所有blocked都只是字符串,调度器无法知道哪些可自动唤醒、哪些必须人工处理。
快照的一致性在异步系统中需要version。读取snapshot version五后提交start时,其他worker可能已更新;命令带expectedVersion,CAS失败后重新读取。内存同步Lab没有await窗口,生产持久化必须显式处理。
事件日志中任务转换包含todoId、from、to、reason、sequence和actor。恢复按顺序重放并再次验证合法边,若历史出现pending到completed直接失败,而不是接受损坏状态。状态机同时验证实时请求和持久化历史。
计划修改也要建模。新增任务可create,改标题需要rename事件,重排需要priority或依赖事件;不要直接替换整个数组。细粒度事件让并发冲突、审计和回放更清楚,但事件种类增加,schema版本必须治理。
删除任务通常应改成cancelled,而不是从Map移除。历史引用和审批仍需要身份;UI可隐藏终态。真正数据清理由保留策略执行,与业务状态分开。物理删除会让Trace中的todoId变成孤儿。
快照冻结只保护进程内引用,不防序列化后调用方伪造新对象再提交。所有写操作只接受id与动作,Manager查内部current,不接受完整Todo覆盖。API形状比冻结更重要:读取能力不能隐含写能力。
测试可生成所有状态与动作组合,断言只有白名单边成功。每个非法转换比较前后snapshot相同,成功转换检查id/title保持、reason规则正确。穷尽矩阵比零散例子更能保护新增状态。
并发测试用两个Promise同时start不同任务,持久化Manager必须只有一个成功。简单先读后写会双赢;单writer队列、数据库唯一约束或事务CAS可以保证。课程下一课的Promise queue展示同类序列化思想。
指标包括任务创建到开始等待、进行时长、blocked类别、重开率、无证据complete和非法转换尝试。高blocked可能说明权限设计或计划拆分问题,高重开说明验收不足。状态机数据为Agent改进提供结构化事实。
变异测试删除active检查、允许pending complete、返回内部对象、只冻结数组、空reason通过或ID基于length。门禁应全部变红。能阻止典型错误实现,才能宣称状态不变量受保护。
用一个三步任务完整走读。创建“读取失败日志”“修改实现”“运行测试”,依次得到todo-001、002、003,全部pending。start第一项后,尝试start第二项被拒;complete第一项后才能start第二项。第二项因缺少写权限block并保存原因,第三项仍pending。snapshot稳定保留创建顺序和每一步事实,Agent与用户都能准确知道停在哪里。
若用户随后批准权限,不应修改blocked对象让它突然in_progress。扩展状态机可以执行unblock到pending,再由调度器start;事件历史显示“曾阻塞—条件满足—重新排队”。每一次边都有原因与actor,恢复后不依赖聊天文本猜测。
ID补零主要提升字典排序可读性。todo-010会排在todo-002之后;补零宽度六或动态位数适合更大任务。达到999不是复用或溢出的理由,字符串可以继续1000,只是展示宽度变化。生产ID与session组合防跨会话冲突。
create空标题必须在nextId递增前拒绝。否则一次无效请求消耗todo-001,第一条合法任务从002开始;这未必破坏唯一性,却让事件与用户疑惑。ID分配通常与成功创建同一事务,失败不产生身份和事件。
start的全局检查应忽略目标自身吗?目标pending时不可能已in_progress;重复start同一id时扫描会先报告“已有进行中”而不是来源状态冲突。两者都拒绝且不变,但稳定错误code可更精确:先获取目标,若非pending报INVALID_TRANSITION,再检查其他active。课程测试只约束安全结果,生产要设计诊断优先级。
complete后再次complete拒绝,因为completed不是in_progress。幂等API也可让重复complete返回当前状态,但必须带相同operation id,避免隐藏调用方逻辑错误。默认严格转换更容易发现重放;第23课会用副作用指纹处理有证据的幂等恢复。
block空reason在transition前拒绝,active任务保持active。若先改状态再验证文本,失败会留下无解释blocked。所有输入校验和不变量检查在副作用前完成,这与前几周编辑事务的prepare原则一致。
snapshot返回新数组意味着两个snapshot引用不同但内容相等。调用方可以安全filter、map,但sort被冻结数组会抛错,应先复制。文档明确只读,UI使用 const view=[...snapshot].sort(...),不要求Manager为每个展示创建新顺序。
冻结并不让Map内部不可变;真正保护来自private字段和所有写入经过方法。JavaScript private或TypeScript private在不同编译目标保护强度不同,模块不要导出内部引用。生产可用闭包或真正#todos减少运行时绕过。
Todo对象字段目前都是标量,浅冻结等同深层。未来加入dependencies数组或evidence对象时,Object.freeze({...todo})不足;在schema边界深复制/冻结,或把嵌套数据改为不可变ID引用。测试应跟随schema新增嵌套篡改用例。
标题trim只去首尾空白,不应自动压缩内部换行或解释Markdown。生产可限制长度、禁止控制字符并保留原始用户意图;展示层负责转义,防止标题注入终端或HTML。状态Manager不执行标题内容。
Manager的get错误应区分格式错误与不存在,避免通过大量猜ID枚举敏感任务。在本地单用户影响小,多租户API先验证session权限,再查询id;错误对外可统一not found,内部Trace保留原因。
计划生成后可要求用户确认,再开始第一个任务。确认对象绑定snapshot hash;模型在确认后新增或改任务会使hash变化,需重新批准。Plan-and-Execute不只是模型内部自言自语,也能成为人机协作合同。
每个任务最好有验收条件,例如“测试命令退出零且Diff仅含src/a.ts”。complete方法验证关联evidence,而不是信任Agent一句完成。课程后续Trace与Eval会提供报告hash,状态机可以引用,不复制大内容。
blocked与failed也应分开。blocked通常等待外部条件,failed表示尝试结束但可由Recovery处理;课程只有blocked,简化为所有未完成终态。生产taxonomy映射到更丰富状态时,先定义转换和调度语义,避免状态名只为UI颜色存在。
任务依赖图中,只有所有prerequisite completed的pending任务可start;blocked依赖会传播等待,但不一定把下游标blocked。调度器计算ready集合,Manager仍验证单active。先保持串行,再逐步引入DAG,能沿用相同身份和事件设计。
计划重排不改变ID。用户把第三项拖到第二,记录priority change;Trace中todo-003仍代表原任务。若用数组索引作ID,重排后历史指向错误标题,这是为何身份必须与位置分离。
恢复时从JSONL重建Map和nextId。遍历create事件获得最大序号,转换事件逐条验证;若日志最后状态有一个in_progress,Runtime可继续或标记需要resume。若两个active,历史损坏,不能任意选一个。
checkpoint会保存snapshot和nextId,但事件日志仍是事实源。加载checkpoint后从sequence+1重放,状态机规则继续验证。性能优化不能绕过不变量;未知schema或非法事件应停止恢复。
不同actor包括model、user、system和recovery。用户block或取消与系统因权限block原因不同,审计要保留。actor不改变合法边,只有策略决定谁有权请求某个动作。Manager可接收可信Context而非模型自报actor。
计划粒度影响效果。任务太大无法判断进展,太小事件和审批噪声多。好的任务每项有可独立验证结果,通常对应读取/实现/测试/审查阶段,而不是每个函数调用。状态机不替代规划质量,却让质量可测。
评测可检查计划覆盖需求、非法转换次数、block恢复率、每项证据与最终任务成功。仅统计completed比例会鼓励模型跳过难项;严格状态机与evidence要求让“完成”成为可验证声明。
安全上,任务标题和reason都可能来自不可信仓库指令。它们进入模型上下文和UI前要标注来源、限制长度与转义;不能让reason内容改变Runtime策略。数据是证据,不是可执行指令。
最终验收用状态转换表逐格验证,再并发start、序列化snapshot、重放事件和篡改返回对象。只有实时、持久化和UI三个边界都遵守同一事实,Plan-and-Execute才真正从清单升级为执行控制面。
可以额外做一次属性测试:随机生成create、start、complete、block动作序列,无论多少非法请求,任意时刻in_progress数量都不超过一,每个completed/blocked任务历史中必然先出现start,所有ID唯一且递增。属性比手写几条路径更能发现组合边界。
当非法动作发生时,Manager应保持强异常安全:Map、nextId和已有对象都不变。测试在调用前后计算规范snapshot hash,失败后hash相同。这个模式与编辑事务的“验证失败零副作用”一致,是整个Agent Runtime可推理性的共同原则。
持久化集成还要保证状态更新与事件append的顺序。先改内存后日志失败会产生无法恢复的幽灵状态,先写事件后应用失败会留下非法历史。单writer在验证后写事件,再由事件reducer生成状态,或用事务outbox保证二者一致。第22课从append-only事实开始解决。
多进程服务可把Todo聚合版本作为乐观锁:命令携带expectedSequence,存储在同一事务追加事件,只有当前sequence匹配才成功。冲突调用重新加载snapshot,不自动覆盖。顺序事件同时解决ID分配与状态转换竞争。
用户看到blocked时应获得明确下一步:授权、补充输入、等待外部服务或手工接管。reason结构化后,UI显示动作按钮,点击产生新事件。只显示红色标签会让状态机正确却产品不可用,可解释性必须贯穿控制与展示。
上线指标发现大量pending从未start,可能计划过度拆分或任务中途取消;大量in_progress超时说明缺少心跳或恢复;大量blocked无reason说明协议被绕过。状态机的不变量让这些指标可信,团队才能据此改进规划器。
最终不要把Manager当完整项目管理器。它解决单会话执行顺序与不可变事实,跨团队协作、优先级、依赖和长期归档属于更高层系统。保持核心小而严,扩展通过事件和显式状态,而不是不断添加可变字段。
下一课衔接
内存Manager在进程退出后会丢失所有任务。下一课把create/start/complete/block等事实写入append-only JSONL,为并发append分配连续sequence,并只容忍崩溃撕裂的最后一行。状态机定义合法语义,事件日志提供持久化顺序。
- Finite-state machine 与 transition invariant。
- Event sourcing 与 snapshot 的区别。
- 不可变数据在异步 UI 中的价值。