diff --git a/scripts/state-db-stabilize.py b/scripts/state-db-stabilize.py index 476eecd1..3fc288bc 100755 --- a/scripts/state-db-stabilize.py +++ b/scripts/state-db-stabilize.py @@ -71,8 +71,9 @@ def stabilize(dry_run: bool = False) -> int: log("\n[1/5] 完整性体检") report = {"started_at": now_iso(), "actions": [], "warnings": []} - # 用只读连接快速体检(不持有写锁,避免和 gateway 抢) - health_conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True, timeout=10) + # 用 immutable 只读连接快速体检(完全跳过 wal/lock,gateway 运行时零风险) + # 🔴 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") @@ -124,6 +125,58 @@ def stabilize(dry_run: bool = False) -> int: 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] 应用稳定修复") @@ -221,10 +274,14 @@ def stabilize(dry_run: bool = False) -> int: def main() -> int: - p = argparse.ArgumentParser(description="state.db 止血脚本") - p.add_argument("--dry-run", action="store_true", help="只体检,不修改") + 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() - return stabilize(dry_run=args.dry_run) + # 🔴 安全默认反转(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__":