xiaowei-system/scripts/state-db-stabilize.py

288 lines
13 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
state-db-stabilize.py — state.db 止血脚本2026-09-03
目的:在不修改 hermes-agent 源码的前提下,降低"structural corruption"误报 + 真正预防损坏。
触发:每次 hermes-gateway.service 启动前systemd ExecStartPre由 systemd 调用。
依据(来自 hermes-agent/hermes_state.py 代码注释 + 真实运行观察):
- hermes-agent 自己已经在多处设置 `PRAGMA busy_timeout=0`line 1607, 1612
→ 写冲突立即抛错 → gateway 误以为是"structural corruption"
→ 触发自愈流程 → 自愈本身有副作用 → 越修越糟
- state.db 在9-01~9-02 之间经历6 次损坏 + 6 次 gateway 重启高度相关
- 当前 DB 实际 `integrity_check = ok`,但每次重启都触发一次
铁律:
- 不修改 hermes-agent 源码AGENTS.md
- 不删表、不重建 FTS、不 VACUUM最小动作
- 出错不删原文件,全部走 backup-then-replace 模式
用法:
python3 ~/.hermes/scripts/state-db-stabilize.py
python3 ~/.hermes/scripts/state-db-stabilize.py --dry-run # 只检测,不动
"""
from __future__ import annotations
import argparse
import datetime
import json
import os
import shutil
import sqlite3
import sys
from pathlib import Path
DB_PATH = Path(os.path.expanduser("~/.hermes/state.db"))
WAL_PATH = DB_PATH.with_suffix(DB_PATH.suffix + "-wal") # state.db-wal
SHM_PATH = DB_PATH.with_suffix(DB_PATH.suffix + "-shm") # state.db-shm
BACKUP_DIR = Path(os.path.expanduser("~/.hermes/backups/state-db"))
def now_iso() -> str:
return datetime.datetime.now().isoformat(timespec="seconds")
def log(msg: str) -> None:
print(f"[{now_iso()}] {msg}", flush=True)
def safe_run(conn: sqlite3.Connection, sql: str) -> tuple[bool, str]:
"""运行 PRAGMA/SQL捕获错误"""
try:
cur = conn.execute(sql)
row = cur.fetchone()
return True, str(row[0]) if row else ""
except sqlite3.OperationalError as e:
return False, f"OperationalError: {e}"
except sqlite3.DatabaseError as e:
return False, f"DatabaseError: {e}"
def stabilize(dry_run: bool = False) -> int:
log(f"=== state-db-stabilize 启动 ===")
log(f"目标 DB: {DB_PATH}")
log(f"存在: {DB_PATH.exists()} 大小: {DB_PATH.stat().st_size / 1024 / 1024:.1f}MB" if DB_PATH.exists() else "(不存在)")
if not DB_PATH.exists():
log("DB 文件不存在,跳过(首次启动由 hermes-agent 自己创建)")
return 0
# === 阶段 1完整性体检 ===
log("\n[1/5] 完整性体检")
report = {"started_at": now_iso(), "actions": [], "warnings": []}
# 用 immutable 只读连接快速体检(完全跳过 wal/lockgateway 运行时零风险)
# 🔴 2026-09-04 教训gateway 运行中普通只读连接mode=ro 非 immutable也会触发 wal 访问
health_conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro&immutable=1", uri=True, timeout=10)
health_conn.execute("PRAGMA busy_timeout=5000")
ok, integrity = safe_run(health_conn, "PRAGMA integrity_check")
report["integrity_check"] = integrity
if not ok or integrity != "ok":
log(f" ⚠️ integrity_check 异常: {integrity}")
report["warnings"].append(f"integrity_check={integrity}")
else:
log(f" ✅ integrity_check = ok")
ok, jm = safe_run(health_conn, "PRAGMA journal_mode")
report["journal_mode"] = jm
log(f" journal_mode = {jm}")
ok, sync = safe_run(health_conn, "PRAGMA synchronous")
report["synchronous"] = sync
log(f" synchronous = {sync}")
ok, bt = safe_run(health_conn, "PRAGMA busy_timeout")
report["busy_timeout_ms"] = bt
log(f" busy_timeout = {bt}ms ← hermes-agent 默认设的 0 是问题源头")
# FTS 行数对齐检查
try:
messages_count = health_conn.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
fts_count = health_conn.execute("SELECT COUNT(*) FROM messages_fts").fetchone()[0]
report["messages_count"] = messages_count
report["messages_fts_count"] = fts_count
log(f" messages = {messages_count} 行, messages_fts = {fts_count}")
if messages_count != fts_count:
log(f" ⚠️ FTS 与主表行数不一致 (差 {messages_count - fts_count})")
report["warnings"].append(f"fts_mismatch diff={messages_count - fts_count}")
except sqlite3.OperationalError as e:
log(f" ⚠️ FTS 检查失败: {e}")
report["warnings"].append(f"fts_check_error={e}")
health_conn.close()
# === 阶段 2检查 0 字节 WAL/SHM 残留(来自上次损坏)===
log("\n[2/5] 检查 WAL/SHM 残留")
wal_size = WAL_PATH.stat().st_size if WAL_PATH.exists() else -1
shm_size = SHM_PATH.stat().st_size if SHM_PATH.exists() else -1
report["wal_size"] = wal_size
report["shm_size"] = shm_size
log(f" state.db-wal = {wal_size} 字节")
log(f" state.db-shm = {shm_size} 字节")
# 0 字节 WAL 通常是上次损坏后 SQLite 重建但 checkpoint 没跑
if wal_size == 0 and WAL_PATH.exists():
log(f" ⚠️ 发现 0 字节 WAL 残留将清理不破坏数据SQLite 会自动重建)")
report["warnings"].append("zero_wal_residue")
# === 阶段 1.5损坏自动恢复2026-09-03 新增 — 防止 gateway 启动失败导致 agent 失联)===
# ExecStartPre 场景gateway 尚未启动)若检测到真损坏 → 从最近健康快照恢复
# 前提:无 gateway 进程持有 DB手动运行时保护
if (not ok or integrity != "ok") and not dry_run:
import subprocess as _sp
_gw = _sp.run(["pgrep", "-f", "hermes_cli.main gateway run"],
capture_output=True, text=True)
if _gw.returncode == 0:
log(" ⚠️ gateway 正在运行,跳过自动恢复(避免覆盖活动 DB")
else:
SNAP_DIR = Path.home() / ".hermes" / "backups" / "state-db-snap"
if SNAP_DIR.exists():
snaps = sorted(SNAP_DIR.glob("state-*.db"))
if snaps:
latest = snaps[-1]
try:
_sc = sqlite3.connect(f"file:{latest}?mode=ro", uri=True, timeout=10)
_sok = _sc.execute("PRAGMA integrity_check").fetchone()[0]
_sc.close()
except sqlite3.Error as _e:
_sok = None
log(f" ⚠️ 快照 {latest.name} 读取失败: {_e}")
if _sok == "ok":
import shutil as _sh
_ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
_bad = DB_PATH.with_name(f"state.db.bad-auto-{_ts}")
_sh.copy2(DB_PATH, _bad)
_sh.copy2(latest, DB_PATH)
for _p in (WAL_PATH, SHM_PATH):
if _p.exists():
try:
_p.unlink()
except OSError:
pass
log(f" 🔄 自动恢复DB 损坏 → 从 {latest.name} 恢复(坏库备份 {_bad.name}")
report["actions"].append(f"auto_restore_from={latest.name}")
# 重新体检
try:
_hc = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True, timeout=10)
_ok2, _int2 = safe_run(_hc, "PRAGMA integrity_check")
report["integrity_after_restore"] = _int2
log(f" 恢复后 integrity_check = {_int2}")
_hc.close()
except sqlite3.Error as _e:
log(f" ⚠️ 恢复后体检失败: {_e}")
else:
log(f" ⚠️ 最近快照 {latest.name} 也不健康({_sok}),跳过自动恢复")
else:
log(" ⚠️ 无可用快照backups/state-db-snap 为空),跳过自动恢复")
else:
log(" ⚠️ 无快照目录 backups/state-db-snap跳过自动恢复")
# === 阶段 3核心修复写连接序列化===
log("\n[3/5] 应用稳定修复")
# 检查是否有 gateway 进程正在持有 DB避免抢锁
import subprocess
try:
lsof_out = subprocess.run(["lsof", str(DB_PATH)], capture_output=True, text=True, timeout=5)
other_pids = []
for line in lsof_out.stdout.split("\n")[1:]:
parts = line.split()
if len(parts) >= 2 and parts[0] == "hermes":
try:
pid = int(parts[1])
# 排除自己
if pid != os.getpid():
other_pids.append(pid)
except ValueError:
pass
if other_pids:
log(f" ⚠️ 检测到 gateway 进程 PID={other_pids} 正在持有 DB")
log(f" → 跳过写操作,避免抢锁;只做体检(已做完)")
report["skipped_writes_due_to_gateway"] = other_pids
# 仍然写报告
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
report_path = BACKUP_DIR / f"stabilize-report-{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False))
log(f" 报告已写: {report_path}")
return 0
else:
log(f" ✅ 没有 gateway 持有 DB可以安全写入")
except FileNotFoundError:
log(f" (无 lsof跳过进程检查")
except subprocess.TimeoutExpired:
log(f" lsof 超时,跳过)")
# 打开写连接,应用修复
fix_conn = sqlite3.connect(str(DB_PATH), timeout=30, isolation_level=None) # autocommit
fix_conn.execute("PRAGMA busy_timeout=30000") # ← 关键:覆盖 hermes-agent 的 0
if dry_run:
log(" --dry-run跳过实际写入")
else:
# 关键修复 Abusy_timeout
ok, _ = safe_run(fix_conn, "PRAGMA busy_timeout=30000")
report["actions"].append("busy_timeout=30000")
log(f" ✅ busy_timeout 改为 30000ms")
# 关键修复 Bjournal_size_limit 限定 WAL 大小(防止"3GB WAL 撑爆磁盘"
ok, _ = safe_run(fix_conn, "PRAGMA journal_size_limit=67108864") # 64 MiB
report["actions"].append("journal_size_limit=64MiB")
log(f" ✅ journal_size_limit 改为 64 MiB防 WAL 撑爆)")
# 关键修复 C清理 0 字节 WAL 残留(如果有)
if wal_size == 0 and WAL_PATH.exists():
try:
WAL_PATH.unlink()
log(f" ✅ 清理 0 字节 WAL 残留")
report["actions"].append("cleanup_zero_wal")
except OSError as e:
log(f" ⚠️ 清理 WAL 失败: {e}")
# 关键修复 D手动 checkpoint TRUNCATE回收空间
ok, ckpt = safe_run(fix_conn, "PRAGMA wal_checkpoint(TRUNCATE)")
log(f" wal_checkpoint(TRUNCATE) = {ckpt}")
report["actions"].append(f"wal_checkpoint={ckpt}")
fix_conn.close()
# === 阶段 4体检后状态 ===
log("\n[4/5] 验证修复效果")
verify_conn = sqlite3.connect(str(DB_PATH), timeout=10)
verify_conn.execute("PRAGMA busy_timeout=5000")
ok, bt2 = safe_run(verify_conn, "PRAGMA busy_timeout")
ok, jsl = safe_run(verify_conn, "PRAGMA journal_size_limit")
report["busy_timeout_after"] = bt2
report["journal_size_limit_after"] = jsl
log(f" busy_timeout = {bt2}ms ← 应为 30000")
log(f" journal_size_limit = {jsl} ← 应为 67108864")
verify_conn.close()
# === 阶段 5写报告 ===
log("\n[5/5] 写报告")
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
report_path = BACKUP_DIR / f"stabilize-report-{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
report["finished_at"] = now_iso()
report["dry_run"] = dry_run
report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False))
log(f" 报告: {report_path}")
log(f"\n=== 完成({len(report['warnings'])} 个警告,{len(report['actions'])} 个动作)===")
if report["warnings"]:
for w in report["warnings"]:
log(f" ⚠️ {w}")
return 0 if not report["warnings"] else 1
def main() -> int:
p = argparse.ArgumentParser(description="state.db 止血脚本(安全默认:只体检 dry-run--apply 才执行修改)")
p.add_argument("--apply", action="store_true", help="执行实际修改(默认只体检不修改)")
p.add_argument("--dry-run", action="store_true", help="强制只体检(兼容旧用法)")
args = p.parse_args()
# 🔴 安全默认反转2026-09-04无参数跑 = 只体检。显式 --apply且未 --dry-run才执行修改/自动恢复
dry_run = (not args.apply) or args.dry_run
log(f"模式: {'DRY-RUN 只体检(--apply 才执行修改)' if dry_run else 'APPLY 执行修改'}")
return stabilize(dry_run=dry_run)
if __name__ == "__main__":
sys.exit(main())