从零搭建 Agent Harness 系列(二十三)Session 落盘与断档修补
系列二十二让一次 Run 能干净停住。阶段十要解决的是进程死了之后:同一 sessionID 还能否接上聊天,以及历史里半截工具调用会不会把上下文弄坏。
路线图里阶段十还写了「当前 Run 状态」「卡在审批 / 工具执行中」。那一刀没做。磁盘上看不出工具做到哪了,重跑 bash / write_file 可能重复副作用。这一篇只收两刀:能读回历史和 Token;断档只补 Observation。
本文对应 go-tiny-claw 两次提交:
1 | b961e9e feat: 将会话历史和 Token 累计落到工作区磁盘 |
一、先能接上聊天,不恢复「做到一半」
Grant 早就写在 .claw/grants.json。Session 一直在内存里,REPL 一退就没了。阶段十的第一刀只存这些:
1 | Session 元数据(id、workdir、时间) |
不存这些:
1 | 正在等模型 |
「等待模型」重启后下一句会再打,没问题。「等待审批」没结果就当没点过 y。「工具执行中」是危险区:文件可能已经写完,Observation 还没 Append。
二、一个会话一个文件,对齐 Grant 的原子写
授权是整份 grants.json。会话按 ID 拆开,避免改一个会话重写全部:
1 | .claw/sessions/<id>.json |
文件名拒绝 /、\、..,避免 sessionID=../../etc/passwd 写到工作区外面。CLI 现在的 terminal_default 可以直接当文件名。
快照就是 Session 里已经有的字段,外加 history。schema.Message 自带 JSON tag,工具参数里的 json.RawMessage 也能过。写入用 MarshalIndent,嵌套 arguments 会被重新排版,读回来要比语义,不要比原始字节。
写盘抄 Grant:先 .tmp,再 Rename。半截写不会留下半个 JSON。文件不存在或空文件当成新会话;JSON 坏了往上抛,不要假装空会话再覆盖坏文件。
Append / RecordUsage / Clear 在持有 Session.mu 时写盘。先 Unlock 再写,两个 Append 并发时旧快照会盖掉新的。落盘失败第一刀只打日志,不改函数签名;内存仍保留。Clear 本来就不清 Token,落盘后预算也不会因 /clear 归零。
第一次 GetOrCreate 不写空文件,等第一次变更再出现。RunSub 用的是裸 NewSession("run-sub", ""),没有 persistDir,子智能体历史不进磁盘。
三、新建会话必须挂 persistDir,Factory 的 nil 才能走文件
只在 loadSession 里设 persistDir 不够。新建会话的 Append 会看到空目录,直接 return,第一份文件永远出不来:
1 | GetOrCreate → NewSession(没挂 persistDir) |
GetOrCreate 新建时补上一行:sess.persistDir = sm.persistDir。GlobalSessionMgr 的目录仍是空串,Engine 测试不碰磁盘。
cmd/claw 和 cmd/claw_server 都是 NewRuntimeFactory(..., nil)。以前 nil 会先换成 GlobalSessionMgr,后面的懒创建 FileSessionManager 是死代码。现在 nil 原样进去,sessionManager() 才走到 .claw/sessions。测试里显式传入 GlobalSessionMgr 的,仍然纯内存。
GetOrCreate 改成返回 error。坏文件必须让 NewRuntime 失败。读盘后用调用方 workDir 覆盖文件里的路径:工作区搬家后,工具不要锁死在过期目录。CreatedAt 和 Token 仍用文件里的。
MaxTokens 按 Session 累计。重启后预算接着算,杀进程洗不掉额度。
四、断档只补 Observation,不重跑工具
活着的那一轮,助手带 ToolCalls 入库之后,取消路径已经用 EnsureToolObservations 补「工具调用已取消」。那是当前这一轮的平行数组,不管落盘后的聊天记录。
进程死在这里:
1 | session.Append(助手 + ToolCalls) // 已落盘 |
下次 GetWorkingMemory 会把未配对的 ToolCalls 送给模型。Provider 可能直接拒;模型也可能以为工具已经跑过。
工具重放是按当时的参数再 Execute 一次。磁盘上看起来都一样:助手有调用,后面没有结果。分不清「做完了没记账」和「还没做」:
| 实际情况 | 重放的后果 |
|---|---|
write_file 已经写完 | 再写一次,文件被覆盖 |
bash 已经 rm / git commit | 再删一次、再提交一次 |
| 工具其实没跑到 | 重放碰巧是对的 |
所以只插入一条「工具调用未完成(上次运行中断)」。文案不和活着的「已取消」混用,模型不应当成已经执行成功,可以自己决定要不要再调。只读的 read_file 重放相对安全,但收益小,模型看见未完成自己会再读。
RecoveryManager 是工具报错提示,不管崩溃。GetWorkingMemory 开头丢掉的是窗口边缘的孤儿结果,不是孤儿调用。
五、插入,不要 Append 到末尾
Runtime.Start 会先把新的用户那一句写进历史,再进 Engine.Run。如果补丁加在末尾:
1 | 助手 [call-1] |
RepairIncompleteTools 扫一遍谁已经有 ToolCallID,缺的插到该助手后面、已有 Observation 的后面、下一条非 Observation 的前面。同一助手缺两条,从后往前插,顺序仍是 call-1、call-2。已经成对则不动 UpdatedAt、不写盘。
两处调用:
1 | GetOrCreate 读盘成功 → Repair(磁盘先合法) |
因为是插入,即使 Start 已经写下「下一句」,Observation 仍会插回助手后面。RunSub 不用加:跟父会话时父 Run 已经修过;独立跑是空会话。
活着的取消仍走 EnsureToolObservations。两套不要合成一套。
六、测试要证明什么
落盘:
Append+RecordUsage后换一个FileSessionManager,历史、Token、费用字段、CreatedAt还在- 第一次
GetOrCreate不写空文件 - 工具调用和
ToolCallID能过 JSON Clear后历史空、Token 还在- 重载时用调用方
workDir - 空 workDir、非法 ID、坏 JSON 失败;空文件当新会话
GlobalSessionMgr不创建.claw/sessions- Factory 传
nil,Run("你好")后新 Factory 再用同一 ID,模型请求里能看到上一句
断档:
- 两条 ToolCall 只回了一条,只补缺的那条,已有结果不改
- 助手后面已经是「下一句」,Observation 插在中间
- 已经成对,不再插入,不改
UpdatedAt - 磁盘上只有断档助手,新 Manager
GetOrCreate读回来已经成对 Engine.Run直调:发给模型的 messages 里该ToolCallID已有 Observation
七、阶段十还没做完的
1 | 当前 Run 状态(等模型 / 等审批 / 工具执行中) |
路线图说「只有状态和 Observation 一致才能继续」。现在的一致是:不一致就标成未完成,让模型重想,不让运行时重做。审批卡在中间同理,不要当成用户点过 y。
真要重放,得先有检查点:执行前写 intent,成功后再写结果。那是另一套设计。阶段十收到「落盘 + 断档修补」就可以停。下一阶段是路线图里的工具生态和 MCP / A2A。
总结
二十二解决「这一发何时停」。二十三解决「进程没了之后如何接上,又不把工具再跑一遍」:
1 | .claw/sessions/<id>.json,tmp + Rename |
阶段十一再谈外部工具和远程 Agent。费用记账仍不是前置条件。