从零搭建 Agent Harness 系列(二十三)Session 落盘与断档修补

系列二十二让一次 Run 能干净停住。阶段十要解决的是进程死了之后:同一 sessionID 还能否接上聊天,以及历史里半截工具调用会不会把上下文弄坏。

路线图里阶段十还写了「当前 Run 状态」「卡在审批 / 工具执行中」。那一刀没做。磁盘上看不出工具做到哪了,重跑 bash / write_file 可能重复副作用。这一篇只收两刀:能读回历史和 Token;断档只补 Observation。

本文对应 go-tiny-claw 两次提交:

1
2
b961e9e  feat: 将会话历史和 Token 累计落到工作区磁盘
626cdfd feat: 为中断的工具调用补齐 Observation

一、先能接上聊天,不恢复「做到一半」

Grant 早就写在 .claw/grants.json。Session 一直在内存里,REPL 一退就没了。阶段十的第一刀只存这些:

1
2
3
Session 元数据(id、workdir、时间)
Message 历史
Token 累计(费用字段带着,值仍是 0)

不存这些:

1
2
3
正在等模型
正在等审批
工具执行到一半

「等待模型」重启后下一句会再打,没问题。「等待审批」没结果就当没点过 y。「工具执行中」是危险区:文件可能已经写完,Observation 还没 Append

二、一个会话一个文件,对齐 Grant 的原子写

授权是整份 grants.json。会话按 ID 拆开,避免改一个会话重写全部:

1
.claw/sessions/<id>.json

文件名拒绝 /\..,避免 sessionID=../../etc/passwd 写到工作区外面。CLI 现在的 terminal_default 可以直接当文件名。

快照就是 Session 里已经有的字段,外加 historyschema.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
2
3
4
5
GetOrCreate → NewSession(没挂 persistDir)

Append / RecordUsage 不写盘

重启再 GetOrCreate → 文件不存在 → 又是空会话

GetOrCreate 新建时补上一行:sess.persistDir = sm.persistDirGlobalSessionMgr 的目录仍是空串,Engine 测试不碰磁盘。

cmd/clawcmd/claw_server 都是 NewRuntimeFactory(..., nil)。以前 nil 会先换成 GlobalSessionMgr,后面的懒创建 FileSessionManager 是死代码。现在 nil 原样进去,sessionManager() 才走到 .claw/sessions。测试里显式传入 GlobalSessionMgr 的,仍然纯内存。

GetOrCreate 改成返回 error。坏文件必须让 NewRuntime 失败。读盘后用调用方 workDir 覆盖文件里的路径:工作区搬家后,工具不要锁死在过期目录。CreatedAt 和 Token 仍用文件里的。

MaxTokens 按 Session 累计。重启后预算接着算,杀进程洗不掉额度。

四、断档只补 Observation,不重跑工具

活着的那一轮,助手带 ToolCalls 入库之后,取消路径已经用 EnsureToolObservations 补「工具调用已取消」。那是当前这一轮的平行数组,不管落盘后的聊天记录。

进程死在这里:

1
2
3
session.Append(助手 + ToolCalls)     // 已落盘

审批 / Execute / Append(Observation) // 还没写上

下次 GetWorkingMemory 会把未配对的 ToolCalls 送给模型。Provider 可能直接拒;模型也可能以为工具已经跑过。

工具重放是按当时的参数再 Execute 一次。磁盘上看起来都一样:助手有调用,后面没有结果。分不清「做完了没记账」和「还没做」:

实际情况重放的后果
write_file 已经写完再写一次,文件被覆盖
bash 已经 rm / git commit再删一次、再提交一次
工具其实没跑到重放碰巧是对的

所以只插入一条「工具调用未完成(上次运行中断)」。文案不和活着的「已取消」混用,模型不应当成已经执行成功,可以自己决定要不要再调。只读的 read_file 重放相对安全,但收益小,模型看见未完成自己会再读。

RecoveryManager 是工具报错提示,不管崩溃。GetWorkingMemory 开头丢掉的是窗口边缘的孤儿结果,不是孤儿调用

五、插入,不要 Append 到末尾

Runtime.Start 会先把新的用户那一句写进历史,再进 Engine.Run。如果补丁加在末尾:

1
2
3
助手 [call-1]
用户:下一句
用户:工具未完成 ← 错,Observation 必须紧跟助手

RepairIncompleteTools 扫一遍谁已经有 ToolCallID,缺的插到该助手后面、已有 Observation 的后面、下一条非 Observation 的前面。同一助手缺两条,从后往前插,顺序仍是 call-1call-2。已经成对则不动 UpdatedAt、不写盘。

两处调用:

1
2
GetOrCreate 读盘成功 → Repair(磁盘先合法)
Engine.Run 进循环前 → Repair(测试直调 Run、内存命中也能盖住)

因为是插入,即使 Start 已经写下「下一句」,Observation 仍会插回助手后面。RunSub 不用加:跟父会话时父 Run 已经修过;独立跑是空会话。

活着的取消仍走 EnsureToolObservations。两套不要合成一套。

六、测试要证明什么

落盘:

  1. Append + RecordUsage 后换一个 FileSessionManager,历史、Token、费用字段、CreatedAt 还在
  2. 第一次 GetOrCreate 不写空文件
  3. 工具调用和 ToolCallID 能过 JSON
  4. Clear 后历史空、Token 还在
  5. 重载时用调用方 workDir
  6. 空 workDir、非法 ID、坏 JSON 失败;空文件当新会话
  7. GlobalSessionMgr 不创建 .claw/sessions
  8. Factory 传 nilRun("你好") 后新 Factory 再用同一 ID,模型请求里能看到上一句

断档:

  1. 两条 ToolCall 只回了一条,只补缺的那条,已有结果不改
  2. 助手后面已经是「下一句」,Observation 插在中间
  3. 已经成对,不再插入,不改 UpdatedAt
  4. 磁盘上只有断档助手,新 Manager GetOrCreate 读回来已经成对
  5. Engine.Run 直调:发给模型的 messages 里该 ToolCallID 已有 Observation

七、阶段十还没做完的

1
2
3
4
5
当前 Run 状态(等模型 / 等审批 / 工具执行中)
工具重放或补偿流程
Clear 时连 Token 一起归零
TotalCostCNY 仍是 0(阶段九尾巴)
Claude HTTP 还没套重试(入口没用它)

路线图说「只有状态和 Observation 一致才能继续」。现在的一致是:不一致就标成未完成,让模型重想,不让运行时重做。审批卡在中间同理,不要当成用户点过 y

真要重放,得先有检查点:执行前写 intent,成功后再写结果。那是另一套设计。阶段十收到「落盘 + 断档修补」就可以停。下一阶段是路线图里的工具生态和 MCP / A2A。

总结

二十二解决「这一发何时停」。二十三解决「进程没了之后如何接上,又不把工具再跑一遍」:

1
2
3
4
5
6
7
8
9
.claw/sessions/<id>.json,tmp + Rename

新建会话挂 persistDir;Factory nil 走文件

重启后历史和 Token 还在,预算接着算

孤儿 ToolCall 插入「未完成」,不 Execute

插在助手后面,不要 Append 到新用户消息之后

阶段十一再谈外部工具和远程 Agent。费用记账仍不是前置条件。