SQLite 数据库损坏恢复 (state.db / 任意运行中库)
When to Use
- 报错
database disk image is malformed/resume failed/No reply: session storage could not be written - SQLite 库损坏后需要从损坏备份中抢救数据
- 需要对运行中的 SQLite 库做安全备份或写操作
- quick_check 报
Rowid out of order/btreeInitPage error/never used页
三禁令(绝对不违反——2026-08-04 事故教训)
| 禁令 | 原因 |
|---|---|
禁止文件级 cp 备份/覆盖运行中的 SQLite 库 |
WAL 模式下快照不一致;cp 覆盖主文件会绕过 wal 盐校验 → 主文件与 WAL 不一致 → 后续 checkpoint 损坏(本次事故直接根因) |
禁止 rm 运行中库的 -wal/-shm 文件 |
gateway 正在使用(Device or resource busy);删除导致 WAL 数据丢失/索引不一致 |
| 禁止批量脚本逐次全量复制库 | session-move.py 的 _do_move 每次 move 都 copy2 整个 state.db(~346MB)——N 个会话 = N 次全量复制与 gateway 并发写,是损坏放大器。批量迁移前先修脚本设计或停 gateway |
安全备份(替代 cp)
import sqlite3
cur = sqlite3.connect(r"$HERMES_HOME/state.db", timeout=60)
cur.execute("PRAGMA busy_timeout=60000")
cur.execute(f"VACUUM INTO 'C:/.../state.db.pre-fix-<ts>.vacuum'") # SQLite 原生备份
或用 CLI:sqlite3 state.db ".backup 'target.db'"。VACUUM INTO 产物 quick_check 可验证。
六步工作流
1. 检测与基线
cur.execute("PRAGMA quick_check") # 'ok' 或错误清单
quick_check 输出 Rowid X out of order / never used 页 → 页错位/写中断型损坏(非文件丢失)。
2. 定位根因(只读,先查日志再动库)
- Hermes 日志:
$HERMES_HOME\logs\errors.log:写失败/FTS 损坏检测时间窗(grep -E "2026-08-04 1[67]:" errors.log | grep -iE "sqlite|malform|corrupt")gateway.log+gateway-exit-diag.log:gateway 生命周期(lifecycle_ledger的prior_start_time可作 epoch→本地时间换算锚点)gateway-restart.log:重启记录
- 损坏模式判断:
fts5: corruption found reading blob <N> from table "messages_fts_trigram"→ 起点是 FTS 索引 → 传播为整体 malformed
3. 从损坏备份抢救数据(核心技巧)
损坏库(如 Pi Agent 的 malformed-backup-*)COUNT 可数但整体 SELECT 报错——用逐 rowid 点查跳过坏页:
mal = sqlite3.connect("file:...malformed?mode=ro", uri=True)
saved = []
for rid in range(94300, 94901): # 已知目标会话 rowid 区间
try:
row = mal.execute("SELECT rowid, <完整列> FROM messages WHERE rowid=?", (rid,)).fetchone()
if row and row[2] == <session_id>: # ⚠️ 列索引:rowid,id,session_id,role,content,tool_call_id,...,timestamp(第8)
saved.append(row)
except sqlite3.DatabaseError:
pass # 坏页跳过
⚠️ Hermes messages 表列序:id, session_id, role, content, tool_call_id, tool_calls, tool_name, timestamp, ...——id = rowid 的字符串(非独立主键语义),会话归属判断必须用 session_id 列。SELECT rowid, <COLS> 后 row[2] 才是 session_id,timestamp 在 row[8]。
4. 内容判重(决定哪些写回)
两侧必须用相同截断规则——一侧 4000 截断、一侧完整内容 → 哈希必然不同 → 全量误判为"缺失"(v4 事故)。正确写法:
def key(role, ts, content):
return (role, round(ts, 2), hashlib.md5((content or '').encode('utf-8','ignore')).hexdigest())
# c_keys = 当前库 (role, ts, substr(content,1,4000)) 的 key 集合
# m_keys = malformed (role, ts, substr(content,1,4000)) 的 key 集合 ← 截断一致!
to_restore = [r for r in m_rows if key(r[3], r[8], r[4][:4000]) not in c_keys]
真重复判定:(role, ts, content全文 + tool_calls + reasoning) 哈希相同。content 为空的 assistant 消息(只有 tool_calls/reasoning)会因 role+空content+同毫秒ts 哈希碰撞误报重复——对比时加 tool_calls/reasoning 长度。
5. 写回(rowid 冲突自适应)
max_rowid = cur.execute("SELECT COALESCE(MAX(rowid),0) FROM messages").fetchone()[0]
for row in sorted(to_restore, key=lambda r: r[8]): # 按 timestamp 保持时序
max_rowid += 1
rid = max_rowid # 原 rowid 已被新消息占用时
cur.execute(f"INSERT INTO messages (rowid, {COLS}) VALUES (?, {placeholders})",
(rid, str(rid)) + tuple(row[2:])) # id = str(rid) 与 Hermes 惯例对齐
- 原 rowid 空闲可保留原值;被占用(修复后新消息占了区间)→ 新 rowid + id=str(rid)
- 显式
BEGIN/COMMIT,失败 ROLLBACK,不重试放大
6. 重建索引与验证
- FTS 由触发器自动维护:Hermes 有
messages_fts_insert/delete/update+messages_fts_trigram_*触发器——写回/删除后 FTS 自动同步,无需手动 rebuild - 全量重建(幂等安全):
INSERT INTO messages_fts(messages_fts) VALUES('rebuild'),trigram 同理 - 行数语义(新版 schema):
messages_fts= 消息总数;messages_fts_trigram= 非 tool 消息数(external content 视图messages_fts_trigram_src带WHERE role <> 'tool')——trigram < messages 总数是正常,别误判为不完整 - 验证清单:
PRAGMA quick_check # ok SELECT COUNT(*) FROM messages_fts # == messages 总数 SELECT COUNT(*) FROM messages_fts_trigram # == 非 tool 数 SELECT COUNT(*) FROM messages WHERE session_id=? # 恢复后消息数 # MATCH 搜索实测(写回消息的关键词应能命中)
Pitfalls
- cp/rm 触碰运行中库 → 见三禁令。备份只用 VACUUM INTO / .backup
- 判重截断不一致 → 两侧同一截断规则
- messages.id 误当会话 id → id=rowid 字符串;用 session_id 列
- COUNT 可用 ≠ 数据可读 → malformed 库必须逐 rowid 点查
- trigram 行数少 ≠ 损坏 → 新版 schema 语义(非 tool 行)
- 清 sessions.git_repo_root 会被 gateway 重写 → gateway 在线时该字段自动维护(common repo root 语义),清字段只在 gateway 停止时必要;live probe 正常时 lane 由 worktree_root 决定
- epoch 换算 → 用 gateway-exit-diag.log 的
prior_start_time做锚点,别凭感觉估算 - 写回后 UI 需刷新(重新打开会话)才能看到恢复的消息
Verification Checklist
- 备份已用 VACUUM INTO(非 cp),产物可 quick_check
- 根因已从日志定位(有时间窗证据)
- 判重两侧截断一致;写回无重复(完整哈希断言)
- quick_check ok;FTS 行数与数据语义一致
- 搜索 MATCH 实测通过
- 全程未触碰 SOUL/AGENTS/config