#!/usr/bin/env python3 """로그 로테이션 — ~/.openclaw/logs 크기 상한 유지 (LLM 미경유). 로그가 무한정 쌓여 1.0GB 가 된 걸 정리하고, 그 상태로 유지한다. 대부분은 종료된 claude-session 로그였다(단일 파일 422MB). 정책 두 갈래: - **오래된 로그**(mtime > STALE_DAYS): 종료된 세션·고아 로그로 보고 trash. 살아있는 데몬 로그는 매 사이클 쓰이므로 mtime 이 항상 최신이라 여기 걸리지 않는다. - **크고 활성인 로그**(size > MAX_MB): 최근 KEEP_LINES 줄만 남기고 **in-place** 축소. ⚠️ **in-place 여야 한다** — rename 후 새 파일을 만들면 launchd 데몬은 이미 열어둔 옛 inode 에 계속 쓰므로 디스크는 그대로고 새 파일만 영원히 비어 있다. 그래서 같은 fd 를 유지한 채 앞으로 당겨쓰고 truncate 한다. ⚠️ 이 방식은 데몬이 **O_APPEND** 로 열었을 때만 안전하다(launchd 의 StandardOutPath/ StandardErrorPath 는 O_APPEND 로 연다). append 가 아니면 데몬 fd 의 offset 이 옛 위치에 남아 다음 write 가 그 자리에 떨어지며 sparse hole 로 파일 크기가 되돌아간다. 2026-09-10 behive-web(112MB, 매분 기록)으로 실측해 되돌아가지 않음을 확인했다. ⚠️ 게이트웨이 로그는 대상이 아니다 — 2026.9.3 이후 `~/Library/Logs/openclaw/` 로 옮겨가 본체가 자체 관리한다. `logs/gateway.*` 는 그때 남은 고아라 STALE 로 정리된다. CLI: python3 log_rotate.py [--dry-run] launchd: ai.openclaw.log-rotate (매주 일요일 04:00) """ from __future__ import annotations import argparse import os import subprocess import sys import time LOG_DIR = os.path.expanduser("~/.openclaw/logs") STALE_DAYS = 30 # 이보다 오래 안 쓰인 로그 = 종료된 세션·고아 → trash MAX_MB = 20 # 활성 로그 상한 KEEP_LINES = 3000 # 축소 시 남길 최근 줄 수 READ_CHUNK = 1 << 20 def _tail_bytes(path: str, keep_lines: int) -> bytes: """파일 끝에서 keep_lines 줄만 역방향으로 읽어 반환.""" with open(path, "rb") as f: f.seek(0, os.SEEK_END) end = f.tell() buf = b"" pos = end while pos > 0 and buf.count(b"\n") <= keep_lines: step = min(READ_CHUNK, pos) pos -= step f.seek(pos) buf = f.read(step) + buf lines = buf.splitlines(keepends=True) return b"".join(lines[-keep_lines:]) def _shrink(path: str, dry: bool) -> int: """최근 KEEP_LINES 줄만 남기고 in-place 축소. 회수한 바이트 반환.""" before = os.path.getsize(path) data = _tail_bytes(path, KEEP_LINES) if len(data) >= before: return 0 if not dry: # inode 유지 — 데몬이 열어둔 fd 가 계속 유효해야 한다 with open(path, "r+b") as f: f.write(data) f.truncate() return before - len(data) def _trash(paths: list[str], dry: bool) -> None: if dry or not paths: return subprocess.run(["/usr/bin/trash", *paths], check=False, capture_output=True) def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--dry-run", action="store_true") args = ap.parse_args() dry = args.dry_run if not os.path.isdir(LOG_DIR): print(f"[skip] {LOG_DIR} 없음", file=sys.stderr) return 0 now = time.time() stale_cut = now - STALE_DAYS * 86400 max_bytes = MAX_MB * 1024 * 1024 total_before = 0 stale: list[str] = [] shrunk: list[tuple[str, int]] = [] for name in sorted(os.listdir(LOG_DIR)): if not name.endswith(".log"): continue path = os.path.join(LOG_DIR, name) try: st = os.stat(path) except OSError: continue total_before += st.st_size if st.st_mtime < stale_cut: stale.append(path) elif st.st_size > max_bytes: freed = _shrink(path, dry) if freed: shrunk.append((name, freed)) stale_bytes = sum(os.path.getsize(p) for p in stale if os.path.exists(p)) _trash(stale, dry) tag = "[dry-run] " if dry else "" freed_total = stale_bytes + sum(f for _, f in shrunk) print(f"{tag}로그 {total_before/1048576:.0f}MB → " f"{(total_before - freed_total)/1048576:.0f}MB " f"(회수 {freed_total/1048576:.0f}MB)") if stale: print(f"{tag}오래된 로그 {len(stale)}개 정리 " f"({stale_bytes/1048576:.0f}MB, {STALE_DAYS}일 초과)") for p in stale[:5]: print(f" - {os.path.basename(p)}") if len(stale) > 5: print(f" … 외 {len(stale)-5}개") for name, freed in shrunk: print(f"{tag}축소 {name}: -{freed/1048576:.0f}MB " f"(최근 {KEEP_LINES}줄 보존)") if not stale and not shrunk: print(f"{tag}정리 대상 없음") return 0 if __name__ == "__main__": sys.exit(main())