主题
Task State 与 JSONL
本课交付结果
你将交付 JsonlSessionStore.append/read:并发 append 按调用顺序串行化并分配连续 sequence;读取时允许最后一行因中断而不完整,但任何中间坏行或序号断裂都会报错。
岗位问题
长运行 Agent 不能只把状态留在内存。整个 JSON 数组每轮重写成本高且容易整体损坏;JSONL 可追加、可流式读取,但进程可能恰好在写到半行时退出。恢复逻辑必须区分“尾部撕裂”和“历史被破坏”。
前置检查
前置知识快照
先完成任务状态机:
bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 21理解 Promise queue、append-only log 和 sequence invariant。
事件日志保存“发生过什么”,不是每轮覆盖“现在是什么”。任务创建、开始、完成与阻塞各写一行,恢复时按sequence重放得到当前状态。append-only让历史可审计,也避免重写巨大JSON数组时一次中断损坏全部状态。
JSONL每行是独立JSON对象并以换行提交。进程可能写到半行崩溃,因此最后一条不完整有合理解释;中间坏行后还有合法事件,说明历史已被修改、写入交错或磁盘损坏,不能静默跳过。
原理拆解
每个 append 排入同一 Promise 链,轮到它时读取或复用 nextSequence,再一次写入完整 JSON 加换行。read 按行解析:若文件不以换行结束且最后一行 JSON 无效,忽略该尾巴;中间解析错误或 sequence 不连续立即失败。
mermaid
flowchart TD
A[append(type,data)] --> B[进入共享Promise队列]
B --> C[读取/初始化nextSequence]
C --> D[构造sequence事件]
D --> E[JSON.stringify + 换行]
E --> F[appendFile]
F --> G[返回事件并推进队列]
H[read] --> I[按行解析]
I --> J{坏行是否为未换行尾行?}
J -- 是 --> K[忽略撕裂尾巴]
J -- 否 --> L[历史损坏失败]
I --> M[验证schema与连续sequence]并发调用的难点不只I/O,还包括sequence分配。三个append若在await前都读取末尾序号一,会生成重复sequence;即使文件系统把三段原子追加,历史仍非法。共享Promise queue把“读next—加一—写行”整个临界区串行化。
调用顺序通过同步更新queue保存。每次operation=this.queue.then(...),再立即令queue指向operation的settled void版本;Promise.all只是等待,真实执行仍按a、b、c注册顺序。不要先await其他异步工作再入队,否则调用顺序可能丢失。
队列在一次写失败后必须继续可用。this.queue=operation.then(()=>undefined,()=>undefined)吞掉的是内部链状态,不是当前append返回错误;调用者仍收到reject,后续操作却能执行诊断或重试。若queue保持rejected,所有未来then都跳过,存储永久堵死。
nextSequence首次append时调用read,取最后合法事件+1。已有尾部撕裂时read忽略它,下一append直接追加会接在半行后,形成更坏内容;生产恢复应先截断撕裂尾部或写新文件。Lab聚焦读取容忍,工程版必须在恢复后修复提交边界。
read对ENOENT与空文件返回空数组,其他读取错误继续抛出。把permission error当空历史会从sequence一重新写,造成数据分叉。错误分类必须准确,只有“不存在”代表新会话。
terminated由文件是否以换行结束决定。split后若terminated移除最后空元素;未terminated时最后一段若JSON parse失败可视为撕裂并break。若最后一段JSON恰好完整却无换行,参考实现接受它;提交协议严格版也可要求换行并把它视为未提交,选择要明确。
中间空行参考实现continue,但sequence仍依据events长度检查。生产可把空行视为损坏,因为正常append永远不会写空行;宽容异常格式可能掩盖手工编辑。课程门禁主要锁住无效中间JSON不能跳过。
schema至少要求对象、整数sequence与字符串type;data可以unknown。type空白在append前拒绝,读取旧事件还应拒绝空type,参考schema只检查string是可扩展点。生产使用事件类型到data schema的版本化映射。
sequence必须严格等于events.length+1,因此从一开始连续、无重复、无缺口。单纯检查递增会允许1、3,恢复无法知道事件二是丢失还是从未存在。连续性是日志完整性的低成本证明。
代码实验
失败实现
无序实现让并发调用各自读文件并分配编号:
ts
async function unsafeAppend(type: string, data: unknown) {
const events = await read();
const event = { sequence: events.length + 1, type, data };
await appendFile(path, JSON.stringify(event) + "\n");
return event;
}正确实现把完整操作接到共享队列:
ts
async append(type: string, data: unknown): Promise<SessionEvent> {
if (!type.trim()) throw new Error("事件类型不能为空");
const operation = this.queue.then(async () => {
if (this.nextSequence === undefined) {
const events = await this.read();
this.nextSequence = (events.at(-1)?.sequence ?? 0) + 1;
}
const event = { sequence: this.nextSequence++, type, data };
await mkdir(dirname(this.path), { recursive: true });
await appendFile(this.path, `${JSON.stringify(event)}\n`, "utf8");
return event;
});
this.queue = operation.then(() => undefined, () => undefined);
return operation;
}注意参考实现先递增nextSequence再await写。如果appendFile失败,会留下内存编号洞,后续写出sequence+1而read拒绝。生产应只在写成功后提交计数,或失败后重新从磁盘初始化;故障路径需要专门测试。
bash
pnpm --dir bootcamps/coding-agent/labs/22-task-state/starter test
pnpm --dir bootcamps/coding-agent/labs/22-task-state/solution testStarter 应声明 实现 JSONL 撕裂恢复;Solution 应通过 5 项测试。
尾部与中间损坏要成对测试:
ts
it("只忽略撕裂尾行", async () => {
await store.append("ok", 1);
await appendFile(path, '{"sequence":2');
expect((await store.read()).map((event) => event.type)).toEqual(["ok"]);
});
it("拒绝中间坏行", async () => {
await writeFile(path,
'{"sequence":1,"type":"a","data":1}\nnot-json\n' +
'{"sequence":3,"type":"c","data":3}\n');
await expect(store.read()).rejects.toThrow("第 2 行");
});关键实现讲解
三个并发调用不能先各自计算 sequence 再写,否则都可能得到 1。队列既序列化编号也序列化 I/O。失败后队列用 resolved 分支继续,避免一次写错永久堵死后续诊断操作。
JSON.stringify也可能失败,例如data含BigInt或循环引用。它发生在队列内部且尚未append,当前请求reject;nextSequence已递增问题同样存在。边界先用JSON schema规范data并序列化成字符串,再分配sequence与写入,可减小失败窗口。
单次appendFile并不保证任意大小跨进程原子。一个进程内queue避免交错,多进程writer仍需文件锁、单writer服务或数据库。课程对象只能有一个实例负责路径,生产架构把写能力集中而非依赖文件系统侥幸。
中断示例:合法第一行后只留下 {"sequence":2,read 返回第一条;若 not-json 位于两条合法记录中间,则必须指出第 2 行,不能静默跳过。
运行与验证
真实运行输出
bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 22
pnpm --filter @coding-agent/state test观察并发事件类型顺序为 a、b、c,sequence 为 1、2、3;尾部撕裂恢复后历史仍可重放。
真实Solution五项应全部通过:
text
solution: 5 passed, 0 failed
覆盖: append/replay · 连续sequence · 并发调用顺序 · 尾部撕裂 · 中间坏行拒绝再加入append失败后的队列恢复、非法data、sequence重复/缺口、空文件与完整未换行尾行测试。检查错误带行号但不回显整行敏感data。
常见失败与排查
故障案例 1
并发事件都获得sequence一。
症状:Promise.all完成后日志有三行,但read报告第二行sequence不连续。
根因:每个append独立读取末尾并在写前计算编号,没有覆盖整个临界区的队列。
定位:并发调用a、b、c,检查返回sequence和文件物理行顺序。
修复:单writer Promise链同时序列化编号与I/O;多进程使用更强锁或服务。
故障案例 2
一次磁盘错误让所有后续append失败。
症状:首个请求因权限reject,修复权限后新请求仍立即reject且未执行。
根因:共享queue保持rejected,后续只挂成功then,回调永不运行。
定位:注入一次写失败,再执行第二次,检查第二个operation是否进入。
修复:当前operation保留reject给调用方,内部queue用成功/失败双分支恢复resolved。
故障案例 3
历史中间坏行被静默跳过。
症状:恢复看似成功,却缺少一个任务转换,最终状态与真实执行不一致。
根因:解析器catch所有JSON错误后continue,把损坏当可容忍尾部撕裂。
定位:在两条合法事件之间插入not-json,期望明确第2行失败。
修复:只有未换行的最后无效片段可忽略;中间坏行、schema错和sequence断裂全部停止。
- Promise.all 直接 append:顺序和编号竞争。
- 忽略所有坏行:篡改或磁盘损坏被掩盖。
- 最后一行合法但没换行也丢弃:把完整事件误判撕裂。
- 不检查 sequence:缺失事件无法发现。
课后作业
增加 fsync 策略、session id 和校验和;模拟写入 100 个事件后每个字节位置崩溃,证明只可能丢失最后一条而不接受中间腐败。
验收 Rubric
| 维度 | 通过标准 | 常见扣分 |
|---|---|---|
| 并发 | 追加按调用顺序串行 | sequence 冲突 |
| 恢复 | 只忽略不完整尾行 | 跳过任意坏行 |
| 完整性 | 序号严格连续 | 不检测缺口 |
| 诊断 | 错误包含具体行号 | 只报 JSON.parse 失败 |
总项目增量
总项目包:@coding-agent/state
总项目路径:packages/state/src/jsonl-session-store.ts
总项目验证命令:pnpm --filter @coding-agent/state test
JSONL 保存细粒度历史;下一课保存可快速恢复的检查点,并阻止副作用重放。
延伸阅读
方案对比与工程取舍
整份JSON快照读取简单,却每次重写全量且中断可能整体损坏;JSONL可追加、流式和局部恢复,查询最新状态需重放;SQLite提供事务、并发与索引,部署多一个数据库层;专用事件存储适合分布式规模,运维更重。本地Agent以JSONL加checkpoint取得可理解平衡。
追加日志应尽可能先序列化、再分配序号、最后一次append完整行。若序列化失败不消耗编号,写失败可从磁盘重算。更强实现使用临时journal或长度前缀与checksum,确认落盘后更新内存nextSequence。
fsync决定持久性。append Promise resolve只表示数据交给操作系统,不一定断电安全;高可靠模式打开文件句柄、write、sync,再返回。每事件fsync成本高,可批量提交但会扩大丢失窗口。产品应声明durability等级。
尾部修复可以在read返回有效字节偏移,下一次写前truncate到最后换行。必须只截断经过验证的撕裂尾部,保留原文件备份或hash。读取函数若仅忽略不修,后续append会把JSON接在半行后,历史永久损坏。
校验和能发现合法JSON但字节被改成另一合法值。每事件包含previousHash与eventHash,形成hash chain;sequence防缺口,hash链防替换与重排。它仍不是防恶意管理员的签名,但提高磁盘损坏与篡改可见性。
事件schema需要version。全局schemaVersion或每type版本让reducer知道如何迁移;未知版本停止而不是猜。迁移最好生成新日志或在读取层转换,原事件保持不可变。第23课checkpoint也会显式拒绝未知schema。
data不能无界。append前按事件schema限制字段与序列化字节,避免模型或工具把完整日志、源码和秘密塞入状态。大内容存对象仓库,事件只保存hash与引用。JSONL不是通用dump目录。
敏感字段在写入前脱敏或加密。事件日志长期存在,权限比临时模型上下文更严格;文件mode设为仅当前用户,根目录位于应用状态区。不要把环境变量、token和完整命令输出作为data。
多session每个独立文件减少锁竞争,但目录中大量小文件需要清理与索引。文件名使用不可猜或授权session id,路径经过安全解析,防止用户id变成路径穿越。读取前验证所有权。
single writer可以是Actor:所有append消息进入一个队列,它分配sequence并写磁盘;读取从已提交文件或内存缓存。进程崩溃后Actor从日志恢复。Promise chain是这种架构的最小单进程实现。
跨进程文件锁在NFS与平台上语义复杂,数据库通常更可靠。若桌面应用只运行一个主进程,用进程锁防第二实例;云服务使用事务数据库,不要把课程Promise queue直接扩展为分布式保证。
read不应在每次append全量重放,nextSequence可从启动恢复后缓存;文件外部被修改时缓存失效。单writer拥有文件,禁止外部编辑;调试工具只读。需要尾部快速读取可维护索引或checkpoint。
事件顺序与调用顺序未必等于真实世界时间。调用方在异步操作完成后append结果,sequence表示提交顺序;事件含occurredAt与causationId可表达发生关系,但时间戳不作为完整性依据。单调sequence仍是重放主轴。
幂等append需要eventId。进程写成功但响应丢失,调用方重试可能重复事件;存储检查已见eventId,返回原sequence。扫描全日志成本高,可维护索引或在checkpoint保存最近ID。任务状态转换也用expectedVersion防重复。
read返回新对象数组,不能让调用方修改缓存。参考每次JSON.parse自然创建对象,但data嵌套仍可被之后消费者改而只影响本次数组;若共享缓存,必须深复制/冻结。事件事实对外应readonly。
大日志重放采用流式逐行而非readFile全量。流解析器处理chunk跨行、单行上限与UTF-8边界,记录精确字节offset。课程readFile适合小Lab,生产会话达到阈值后用checkpoint和流式验证。
压缩旧日志会破坏随机访问与append。可以在checkpoint确认后把已覆盖段归档压缩,新事件写当前segment;manifest原子指向segments。恢复验证每段hash和sequence连续。不要删除事件直到checkpoint完整性与保留策略确认。
可观测指标包括append延迟、queue深度、写失败、撕裂恢复、中间损坏、sequence冲突、日志字节与重放时间。出现中间损坏必须高优先级告警,不能只让单个任务静默失败。
测试注入JSON.stringify失败、mkdir失败、append部分写、并发一百事件、撕裂完整JSON无换行、空行、schema错误、重复与缺失sequence。属性测试随机截断文件每个字节,只有最后行截断可恢复,其余都拒绝。
变异测试移除queue、只序列化write不序列化编号、让queue保留reject、忽略所有坏行、只检查单调不检查连续。门禁应逐一失败,证明保护的是事件日志完整性。
用最小成功路径看物理文件。第一次append task事件,read发现ENOENT,nextSequence设一,mkdir父目录,写入一行以换行结尾的JSON。再次read逐行parse,schema合法、sequence等于events长度加一,返回一项。文件内容本身就是可检查证据,不需要私有二进制格式。
连续append a、b时,第二次复用nextSequence二,不必重读文件。read得到一、二;若进程重启,新Store的nextSequence未定义,首次append先read最后sequence二,再写三。内存缓存只是优化,磁盘历史决定恢复起点。
Promise.all a、b、c在同一同步调用栈依次调用append,每个立即捕获当时queue;即使a写较慢,b与c只在前序settled后进入。返回Promise的完成顺序因此也是a、b、c。调用方若在不同异步源随机到达,日志忠实记录到达Store的顺序,不声称外部全局顺序。
撕裂例子先有合法第一行换行,随后手工追加 {"sequence":2 无换行。read解析第一行,最后一段JSON.parse失败且满足“最后+未终止”,于是break返回第一条。若这段后面还有换行和sequence三,它就不再是尾部,必须第2行失败。
完整JSON但缺最后换行是微妙边界。参考实现parse成功并接受,sequence连续;这意味着换行不是唯一提交标志,JSON完整性也可表示完成。若严格写前日志需要区分已fsync,必须加长度/checksum或commit record,不能只靠换行猜测。
sequence断裂即使所有JSON合法也拒绝。例如一、三两行意味着事件二丢失,状态重放可能跳过permission或副作用记录。错误消息指出物理行和预期/实际序号,内部诊断足够;对模型不回显data。
重复sequence同样拒绝。两个并发writer都写二,第二个物理行期望三却看到二。不要自动重新编号已落盘事件,这会改变可能被其他hash或审批引用的身份;停止并要求恢复工具处理。
事件type应使用稳定snake_case或命名空间,不能随本地化文案变化。todo_started携带todoId与expectedFrom,reducer验证;UI中文从type映射。自由字符串若没有注册schema,未知事件在旧Runtime中应停止或安全跳过取决于向前兼容策略。
事件时间由Store写入更可信,但时钟可以回拨,因此排序永远用sequence。时间只用于展示与延迟分析;需要跨进程因果用correlation/causation id。不要用timestamp替代单writer序号。
数据序列化前要复制。调用方传对象后在append等待队列期间修改,若JSON.stringify在轮到时才执行,日志记录修改后值而非调用时意图。更强实现append入口立即schema解析并深复制data,再入队;Lab未测但生产必须防异步引用竞态。
同理type在入口trim并捕获。参考只用trim做空检查,却保存原始含空白type;正式实现保存clean值,避免task与task成为不同事件。边界标准化要在ID和规则使用前完成。
write失败后的nextSequence洞是参考实现的重要局限。修复方式是在写成功后递增,但event要先拿当前值;并发仍由queue保护。若write可能部分成功再抛,不能简单复用编号,必须read/repair尾部判断是否已提交。文件I/O错误的“未知完成状态”需要幂等eventId。
queue长度可能因大量append增长,内存中Promise链仍会释放已settled节点,但待处理data占内存。设置最大队列与背压,调用方等待或拒绝budget,而不是接受无限事件。状态日志也要有资源边界。
read与append并发时,read没有进入queue,可能看到append前或完整后,也可能理论上看到撕裂行。由于尾部容忍,它返回最近已解析前缀;若需要线性一致snapshot,read也排队到writer后。API应区分eventual read与consistent read。
备份与同步工具不能在append中途复制文件,否则拿到撕裂尾部仍可恢复,但中间segments组合可能断裂。通过文件句柄快照、checkpoint或暂停writer创建一致备份。恢复先验证全链,再让Agent继续。
权限上,日志目录禁止仓库代码写入。若工作区脚本能修改session.jsonl,恶意项目可伪造approval与side-effect事件。状态存储放应用私有目录,用最小文件mode,事件必要时签名。工作区和控制面必须隔离。
日志轮换以checkpoint sequence为安全点。新segment从下一连续序号开始,manifest原子更新;恢复按segment顺序验证无缺口。简单按文件大小切割却不记录边界,会让最后一行跨文件撕裂难以解释。
调试CLI应只读并默认脱敏,能够显示sequence、type、todoId和摘要,检查坏行与hash链。修复操作先复制原文件,明确截断offset并生成审计记录,不能自动跳过中间损坏“尽量恢复”。
最终验收随机启动上百并发append,完成后物理行数、sequence与调用顺序一致;随机截断最后一行每个位置都只丢最后事件;修改任意中间字节导致失败。三组性质共同证明有序追加、有限恢复与损坏可见。
再模拟真实任务:create、start、tool_called、tool_result、complete依次写入,重启后reducer恢复同一Todo snapshot;随后在最后complete事件中途截断,恢复结果停在in_progress并提示需要resume,而不是凭聊天文本猜已完成。事件日志的价值就在于让恢复基于已提交事实。
如果tool副作用已经发生而tool_result未提交,仅靠JSONL无法判断是否重放。这不是read容忍可以解决的问题,必须让副作用拥有稳定fingerprint并写入Checkpoint或幂等外部系统。下一课将这个崩溃窗口显式建模,避免把“历史可读”等同于“动作可安全重做”。
发布门禁还应检查日志文件不在Git工作树、权限最小、大小上限生效、敏感data被拒绝或脱敏。持久化提升可靠性也扩大数据风险,安全存储与恢复正确性必须同时验收。
最后保存一份规范读取报告:有效事件数、最后sequence、最后完整字节offset、是否发现撕裂尾部、日志hash与schema版本。恢复器和运维工具都消费同一报告,避免一个宽容、一个严格。任何中间损坏或序号断裂不生成可运行状态,只生成诊断并阻止Executor。可读历史必须先证明完整,才能成为下一阶段的控制事实。
下一课衔接
JSONL能重放完整历史,但长会话每次从sequence一开始成本高;崩溃还可能发生在副作用完成与事件提交之间。下一课引入原子Checkpoint,保存最新state和已执行副作用fingerprint。恢复从最大sequence检查点开始,旧副作用不会重复执行,再继续重放后续JSONL。
- Write-ahead log 与 torn write。
- fsync、page cache 和 durability。
- Append-only log 的校验链。