Skip to content

Checkpoint 与 Resume

本课交付结果

你将交付 CheckpointStore.save/loadLatestshouldReplay:检查点包含 schema、sequence、state 和已执行副作用指纹;保存采用临时文件加 rename,恢复选择最大 sequence 并拒绝未知 schema。

岗位问题

只重放 JSONL 可能很慢,更危险的是恢复点落在“命令已执行、完成事件未写入”之间。若 Runtime 无法识别该副作用,resume 会再次安装、写文件或调用外部服务。

前置检查

前置知识快照

先验证 JSONL:

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 22

理解检查点是性能与恢复边界,不替代事件日志;side-effect fingerprint 必须在动作执行协议中稳定生成。

JSONL保存完整事实,Checkpoint保存某个sequence时已经归约好的state快照。恢复先加载最新合法checkpoint,再重放其后事件,减少启动时间。检查点损坏时仍可回退事件日志,因此两者职责不能倒置。

副作用fingerprint标识一次精确动作,例如命令规范载荷、工作区、策略版本与幂等id的hash。它不是错误fingerprint:前者防止重复执行,后者检测重复失败。命名相似但控制目的不同。

原理拆解

save 先验证 schema,用补零 sequence 形成可读文件名,写唯一 .tmp 后 rename。loadLatest 不依赖目录返回顺序,而是提取数字降序。shouldReplay 只在指纹未记录时返回 true。

mermaid
flowchart TD
  A[Checkpoint input] --> B[schema parse与复制]
  B --> C[checkpoint-000010.json]
  C --> D[同目录唯一tmp]
  D --> E[完整write]
  E --> F[rename原子发布]
  G[loadLatest] --> H[过滤正式文件名]
  H --> I[按数字sequence降序]
  I --> J[读取并验证schema]
  J --> K[state + sideEffectFingerprints]
  K --> L{fingerprint已存在?}
  L -- 是 --> M[不重放]
  L -- 否 --> N[允许执行]

schemaVersion必须精确支持。未知版本可能改变state与fingerprint语义,不能“尽量读取”后执行副作用。解析器先确认普通对象、版本一、正整数sequence和字符串数组,再创建新对象与新数组,隔离调用方引用。

文件名补零让人工目录排序直观,但loadLatest仍解析数字,不依赖readdir顺序或纯字典比较。sequence十必须胜过二,即使文件系统返回任意顺序。临时文件与其他名称通过严格正则排除。

save采用同目录随机tmp+rename,防止读到半JSON。写失败或rename失败catch中best-effort rm临时文件,正式旧checkpoint保持可用。与第16课同样,rename提供可见性原子,不等于断电持久性;高可靠模式需要fsync文件与目录。

多个sequence checkpoint可以共存,latest选择最大。保存相同sequence的覆盖语义要明确:参考rename可能替换旧文件,生产可使用独占创建或比较内容hash,避免两个worker为同一sequence写不同state。

sideEffectFingerprints复制进checkpoint,避免保存后调用方修改原数组。真正不可变存储还应去重、排序或保持执行顺序,并限制数量;长期集合可用事件索引或幂等表,不能让checkpoint无限增长。

shouldReplay拒绝空fingerprint,因为空值会让所有未知动作共享身份。已包含返回false,新值true。这个纯函数只做集合判断,实际执行协议必须在副作用前检查、完成后原子记录,否则仍有崩溃窗口。

经典窗口是“动作已经发生,fingerprint尚未checkpoint”。只靠事后保存无法绝对解决;外部系统使用幂等key,或本地用write-ahead intent与完成事件,恢复查询实际结果。课程假设已执行fingerprint进入最新checkpoint,展示最小去重机制,不夸大成exactly-once。

代码实验

失败实现

直接覆盖单一checkpoint并按文件名取最后一个不安全:

ts
await writeFile(join(root, "latest.json"), JSON.stringify(checkpoint));
const latest = (await readdir(root)).sort().at(-1);

正确保存先解析,再原子发布唯一sequence文件:

ts
const valid = parse(checkpoint);
const name = `checkpoint-${String(valid.sequence).padStart(6, "0")}.json`;
const target = join(this.root, name);
const temporary = join(this.root, `.${name}.${randomUUID()}.tmp`);
try {
  await writeFile(temporary, `${JSON.stringify(valid)}\n`, "utf8");
  await rename(temporary, target);
} catch (error) {
  await rm(temporary, { force: true });
  throw error;
}

加载时只接受 /^checkpoint-\d+\.json$/,按提取的数字降序,随后再parse内容。文件名sequence与内容sequence也应交叉验证,参考实现尚未做,生产需防错放或篡改。

打开本课 Lab

bash
pnpm --dir bootcamps/coding-agent/labs/23-checkpoint-resume/starter test
pnpm --dir bootcamps/coding-agent/labs/23-checkpoint-resume/solution test

Starter 应声明 实现副作用重放判断;Solution 应通过 5 项测试。

副作用测试直接表达恢复决策:

ts
const checkpoint = {
  schemaVersion: 1 as const,
  sequence: 1,
  state: { step: 1 },
  sideEffectFingerprints: ["fx-1"],
};
expect(shouldReplay(checkpoint, "fx-1")).toBe(false);
expect(shouldReplay(checkpoint, "new-effect")).toBe(true);

关键实现讲解

中断场景:fx-1 已执行并写进 checkpoint,进程随后退出。resume 读取最新 checkpoint 后,shouldReplay(cp, "fx-1") 必须为 false,而新指纹才可执行。临时文件不能参与 latest 选择。

state是unknown意味着Store只负责持久化容器,领域层必须先生成可序列化、版本匹配的任务快照。parse参考实现不深验证state,生产按schemaVersion调用具体reducer schema,拒绝函数、循环引用和超大对象。

loadLatest若最高sequence文件损坏,参考实现直接失败,不自动退到次新。这是保守选择:损坏可能表示存储问题,静默回退会丢已完成副作用记录并导致重放。恢复工具可在人工确认后选择旧checkpoint,再严格重放JSONL补足。

文件名与内容sequence应一致,否则攻击者或运维误操作可让旧state冒充最新。读取后比较解析文件名数字和checkpoint.sequence,冲突停止。再校验checksum,证明内容完整;rename只能防半可见,不能防后续字节损坏。

fingerprint列表查询是线性,Lab规模足够。长会话使用Set在内存查询,序列化时稳定排序或保留事件顺序;更大规模用幂等表/Bloom filter配合精确存储。Bloom单独使用可能误判已执行而跳过必要动作,不适合作为唯一事实。

运行与验证

真实运行输出

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 23
pnpm --filter @coding-agent/state test

确认 sequence 10 比 2 新,保存后目录无 .tmp,schemaVersion 2 被明确拒绝,已记录副作用不会重放。

真实Solution五项应全部通过:

text
solution: 5 passed, 0 failed
覆盖: 保存与加载 / 数字最大sequence / 无tmp残留 / 未知schema拒绝 / 副作用去重

再注入write与rename失败、损坏最新文件、文件名/内容sequence冲突、重复fingerprint、空fingerprint和调用方修改原数组。每条失败检查旧checkpoint仍可读且无临时残留。

常见失败与排查

故障案例 1

恢复选中了sequence二而不是十。

症状:任务回到更早步骤,已经完成的工具与审批再次出现。

根因:依赖readdir顺序或未补零文件名的字典排序,没有解析数字。

定位:只保存2和10两个checkpoint,打乱目录返回顺序,检查latest.sequence。

修复:严格过滤正式文件名,提取整数降序;内容sequence再与文件名交叉验证。

故障案例 2

崩溃后latest.json只剩半段。

症状:唯一checkpoint无法JSON.parse,整个长任务失去恢复锚点。

根因:直接truncate并覆盖固定最终文件,写入中断破坏旧版本。

定位:注入部分write或进程终止,检查旧正式checkpoint是否仍完整。

修复:同目录唯一tmp完整写入并sync,再rename发布;失败清理tmp并保留旧文件。

故障案例 3

恢复后重复执行安装或发布。

症状:进程重启后同一命令再次运行,锁文件或外部服务出现重复副作用。

根因:checkpoint只保存step,没有记录精确side-effect fingerprint,或指纹不绑定参数。

定位:保存含fx-1的checkpoint,恢复时对同指纹调用shouldReplay,监控执行次数。

修复:动作使用稳定规范fingerprint与幂等key,已记录返回false;执行协议缩小记录窗口。

课后作业

把事件 sequence 与 checkpoint sequence 对齐,增加 checksum 和保留策略;设计“副作用执行—指纹持久化”之间仍崩溃时的幂等 key 协议。

进阶要求是实现checkpoint manifest与两代保留:保存后校验读取和checksum,再原子更新latest指针,最后清理更旧版本。模拟每一步崩溃,恢复总能选择某个完整且与JSONL连续的锚点。

验收 Rubric

维度通过标准常见扣分
原子性临时文件后 rename直接覆盖最终文件
新旧选择按数字 sequence依赖目录或字典序
Schema未知版本拒绝猜测字段含义
重放已知副作用返回 false恢复后重复执行

总项目增量

总项目包:@coding-agent/state

总项目路径:packages/state/src/checkpoint-store.ts

总项目验证命令:pnpm --filter @coding-agent/state test

恢复状态有了可靠锚点;下一课加载与当前目标路径真正相关的仓库指令。

延伸阅读

方案对比与工程取舍

只重放事件最简单且事实完整,长日志启动慢;只存快照启动快,却丢失审计与迁移路径;snapshot+log结合让checkpoint是优化锚点,日志仍为事实源。课程采用第三种,复杂度来自两者sequence一致性与保留策略。

覆盖单一latest节省磁盘,但一次损坏无回退;每sequence保存便于调试与选择,长期文件多;保留最近N个加归档是常见折中。清理只在新checkpoint写入、校验并与日志对齐后进行,不能先删旧文件再保存。

manifest原子记录最新文件名、sequence、checksum和schema,load无需扫描大量目录;manifest损坏时仍可扫描。它本身也用tmp+rename并保留前代。索引是优化,不替代每个checkpoint内容验证。

checkpoint频率按事件数、时间或状态大小触发。太频繁增加I/O和副作用集合复制,太稀疏增加重放时间与崩溃窗口。用实际恢复SLO选择,例如每一百事件或一分钟,并在关键副作用完成后强制保存。

副作用完成后强制checkpoint仍不能提供exactly-once,因为执行与本地写不是同一事务。外部API支持idempotency key时,把fingerprint作为key,重试返回原结果;本地文件编辑使用transaction afterHash查询;命令则记录可验证输出或避免自动重放非幂等动作。

write-ahead方案先写intent事件与fingerprint,再执行,完成后写done。崩溃恢复看到intent无done时查询外部结果,不能简单认为未执行。对于无法查询的非幂等动作,要求用户确认。Exactly-once通常是幂等与协调协议效果,不是一个boolean列表实现。

fingerprint必须绑定规范command、argv、cwd、输入hash、目标资源、策略版本与operation id。只hash工具名会把不同文件写入误判同一动作,只hash自由JSON又受字段顺序影响。使用稳定schema序列化和版本前缀。

同一业务动作在恢复时参数无变化,fingerprint稳定;若用户批准新参数,视为新动作。旧fingerprint保留,Trace关联supersedes。不能为了让恢复继续而修改已有指纹,那会篡改执行事实。

state与fingerprint集合必须对应同一sequence。先保存新state后补集合或反之都会产生不一致;构造单个Checkpoint对象后一次原子序列化。领域reducer在事件sequence边界拍快照,不能从正在变化的可变对象异步读取。

并发save两个sequence时,较小可能后完成,但文件名并存,load按数字仍选大;若有manifest,更新必须CAS只前进不后退。保存同sequence不同内容是冲突,应报错与告警,不采用最后写获胜。

state对象需要深复制。parse参考只复制fingerprint数组,state引用从JSON.parse时自然独立;save传入调用方对象后JSON.stringify前可能被异步修改。save入口先schema parse成不可变快照,再await mkdir和write。

检查点包含敏感任务状态,目录与JSONL一样放控制面私有存储、最小mode、必要时加密。文件名只含sequence,不含用户任务标题。清理临时文件和旧版本遵守数据保留与安全删除要求。

checksum可检测意外损坏,不防有权限攻击者重写内容与checksum;需要防篡改可用HMAC或签名,密钥不在工作区。hash链将checkpoint绑定日志末事件hash,恢复验证两份事实未分叉。

schema迁移有三种策略:旧Runtime拒绝新版本;新Runtime读取旧版本并纯函数迁移;离线工具生成新checkpoint但保留旧文件。迁移不执行副作用,不改变日志;迁移后的state通过当前不变量验证。

loadLatest遇到最高版本schema未知应停止,不应回退旧版本继续执行,因为新checkpoint可能记录了已完成副作用。用户升级Runtime或用迁移工具。安全恢复宁可暂停,也不重放不可见动作。

状态校验包括Todo单一in_progress、nextId、history sequence与pending审批。快照JSON合法不代表领域合法;load后reducer验证不变量。若失败,标记存储损坏并从更早checkpoint+日志诊断,不能任意修值。

恢复流程:锁定session,读取日志完整前缀,加载最大合法checkpoint,验证checkpoint.sequence不超过日志,验证对应event hash,重放后续事件,检查副作用表,再恢复调度。任何一步不确定都不启动Executor。

空目录loadLatest返回undefined是新会话或尚无checkpoint,调用方从JSONL sequence一重放。非ENOENT目录错误继续抛,不能当无checkpoint。区分“没有数据”和“无法读取”防止错误新建重复会话。

测试故障注入tmp write、rename、fsync、manifest更新、旧文件清理;每个断点恢复至少一个完整版本。再测试sequence 2/10、100万、临时伪文件、未知schema、checksum错、fingerprint重复和并发save。

指标包括save耗时与失败、checkpoint大小、重放事件数、恢复时间、schema拒绝、checksum错误、跳过副作用数与需要人工确认数。skip突然增多可能是重放循环或fingerprint过粗,需要审计。

变异测试直接覆盖最终文件、按字典取latest、接受未知schema、不复制数组、空fingerprint通过、最新损坏自动回退。门禁应全部变红,证明恢复不会以可用性名义牺牲事实。

用sequence一走一遍保存。parse确认版本一、正整数、fingerprint数组字符串,mkdir根目录,构造 checkpoint-000001.json 与带UUID的点临时文件,写完整JSON换行后rename。readdir只看到正式文件,loadLatest解析并返回与输入等价的新对象。调用方修改原fingerprint数组不应改变磁盘内容。

再保存sequence二和十。文件名补零本已能正确字典排序,但实现仍从正则提取整数计算,避免未来位数或外部文件影响。latest读取十,state.step十。测试故意按2后10保存,证明选择不是“最后写入时间”而是逻辑sequence。

若sequence十先保存、二后保存,load仍选十。检查点保存顺序可能因并发I/O与后台压缩不同,逻辑时间不能依赖mtime。只有manifest CAS需要防较旧sequence覆盖latest指针;扫描式选择天然按数字。

临时文件名包含正式name,若过滤只用includes("checkpoint-")会误选。严格从头到尾正则且扩展名json,点tmp、备份和manifest都排除。目录可能有攻击者伪造巨大数字文件,读取后仍校验内容sequence、schema与checksum。

未知schema用手工写入版本二latest,load必须报schema而不是返回undefined或旧版本。返回undefined会让调用方从头新建,风险最大;回退旧文件可能重放版本二已记录副作用。暂停并要求升级是正确安全行为。

保存时JSON.stringify若遇到BigInt或循环state,发生在try中后清理tmp,但parse若不验证state,错误较晚。生产schema在创建tmp前完成序列化到内存并检查字节上限,再写;无效checkpoint不产生任何文件。

checkpoint大小需要硬上限。state若包含完整聊天、工具日志和源码,写入慢、恢复泄密;快照只保留任务状态、引用hash、序号与必要幂等信息。大内容进入对象存储,checkpoint引用不可变id和checksum。

fingerprint数组去重很重要。每次保存若复制全部历史且重复,体积二次增长;构造Set验证无重复,或使用按sequence分段幂等索引。排序会失去执行顺序但查询稳定,若审计需要顺序则保存对象 {fingerprint,sequence}

includes严格大小写,fingerprint应由规范hash生成小写,不接受任意自由字符串。schema可要求固定前缀、版本和六十四位hex,例如 fx:v1:...,减少空白、大小写与不同算法混用。shouldReplay只消费验证后身份。

副作用指纹不能包含随机attempt id,否则恢复每次都新;operation id应在首次计划时生成并持久化,重试沿用。参数真正改变时生成新动作id。幂等key生命周期与业务动作一致,不与进程生命周期绑定。

文件编辑副作用可以用transaction id与afterHash验证:checkpoint记fx,恢复时目标hash已等于after即跳过;若fx已记但hash不符,状态与工作区分叉,停止而非盲跳。外部API则用idempotency key查询结果。shouldReplay是第一问,领域验证是第二问。

命令副作用最难查询。只读测试可安全重跑,安装、发布或迁移未知完成状态应人工确认或在容器快照内执行。不要因为checkpoint机制存在就把所有命令视为exactly-once;分类器需知道动作幂等等级。

恢复锁防止两个进程同时resume同一session。它们都读相同checkpoint并判断new-effect可重放,会执行两次。单恢复者锁、外部幂等key和原子事件提交共同防并发;单纯数组includes没有并发保证。

checkpoint与JSONL sequence的关系必须是“快照包含截至N的所有已提交事件”。若checkpoint N大于日志末尾,可能状态伪造;若小于,重放N+1开始;若对应event hash不匹配,日志分叉。保存时在单writer队列的事件边界拍快照。

恢复完成后不要立即删除旧checkpoint。先成功重放、验证状态并保存新版本,再按保留策略清理;至少保留最近两个和关键里程碑。清理失败只产生告警,不影响新checkpoint事实,但磁盘配额需要后台治理。

崩溃测试可以枚举:tmp创建前、写一半、write后rename前、rename后manifest前、manifest后清理前。每种重启后扫描得到旧或新完整checkpoint,无半JSON被选。只有在此证据下才能声称保存协议原子。

断电测试比进程throw更强,需要真实fsync与文件系统环境。单元mock验证控制流,集成测试kill进程,平台测试断电语义通常难自动化;文档明确保证到rename或durable级别。工程诚实比过度宣传重要。

state迁移必须纯函数且可重复,不执行工具。版本一到二增加字段可从旧state计算,迁移后写新checkpoint sequence不变或带migration metadata;副作用集合原样保留。无法确定语义时拒绝,而不是填默认后继续执行。

安全上,checkpoint可能证明某命令已执行,但不能作为用户授权本身。审批token通常一次性消费,checkpoint只记录结果;恢复跳过已执行动作,不得用旧token批准新参数。状态恢复与权限授予分离。

最终演练启动任务、执行带fx-1的无害写入、保存checkpoint后kill进程,重启loadLatest,shouldReplay fx-1为false,new-effect为true;重放后工作区hash与state一致。然后篡改latest schema和checksum,Runtime必须停止且零副作用。这组路径证明性能锚点与安全恢复同时成立。

运维工具应能列出所有checkpoint的sequence、版本、大小、checksum和验证状态,但默认不显示state敏感内容。人工选择旧版本恢复时创建新的审计事件,明确可能遗漏的副作用范围,不直接删除最新文件。修复操作本身也要可追踪和可撤销。

指标中“shouldReplay返回false”要区分正常恢复去重与指纹碰撞/过粗。对跳过动作抽样验证实际外部状态,发现不一致立即停止会话。幂等机制若误判已执行,会漏掉必要步骤,风险与重复执行同样严重。

负载测试连续保存数千checkpoint,确保latest选择不受目录顺序、文件数量和补零宽度影响;清理策略把扫描保持在可控范围。多session并行使用独立目录,任何路径由受信session id映射,防止一个会话读取另一个检查点。

完成标准可归纳为五个不变量:正式文件永远是完整JSON;最大数字sequence决定latest;未知schema不恢复;checkpoint与日志边界一致;已确认副作用不会被同一恢复者重复执行。每项都有直接测试与故障注入,才算Resume有可靠锚点。

这五项还要在真实kill/restart集成测试中联合成立,而不只是分别mock。恢复后的首个外部动作前再次核对session锁、workspace、指令hash与审批状态,确保“状态可加载”不会被误当成“环境仍可直接继续”。

恢复安全优先于自动继续。每一次继续执行都必须建立在可验证的状态与环境之上。

下一课衔接

状态恢复有了可靠锚点,Agent还需恢复当前目标路径适用的仓库规则。下一课Instruction Loader沿每个target祖先链加载AGENTS.md,根规则在前、局部规则在后,多目标共享祖先去重,并用realpath与总字节预算阻止工作区外指令和上下文膨胀。

  • Exactly-once 的现实限制与幂等 key。
  • Snapshot + log replay。
  • Schema migration 和向后兼容。

从零实现 Mini Code Agent Runtime