auto: 일일 백업 2026-09-11 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Executable
+230
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
"""자동화 헬스체크 — 조용히 죽은 잡을 찾아낸다 (LLM 미경유).
|
||||
|
||||
배경: 2026-09-10 점검에서 **하드 실패는 0건인데** 다른 것들이 썩고 있었다.
|
||||
`holiday_sync` 가 investing 403 이후 2개월간 stale, `codex_fallback_monitor` 가
|
||||
trajectory 의 sqlite 이관으로 조용히 무력화, TASKS.md 가 실제 트리거와 6곳 어긋남.
|
||||
공통점은 **프로세스가 정상 종료(exit 0)하는데 결과가 옛것**이라 아무 신호가 없다는 것.
|
||||
|
||||
그래서 exit code 가 아니라 **결과물의 mtime** 을 본다. 감시 대상은
|
||||
`TASKS.md` §4 표에서 읽는다 — 표가 곧 설정이라, 트리거를 바꾸고 표를 안 고치면
|
||||
알림이 떠서 문서 드리프트가 스스로 드러난다.
|
||||
|
||||
- 증거가 `daemon:<label>` 이면 mtime 대신 **PID 생존**을 본다(KeepAlive 데몬).
|
||||
⚠️ exit code 는 보지 않는다 — KeepAlive 데몬은 재시작 직후 옛 종료코드(-15 등)가
|
||||
남아 있어 매번 오탐이 난다. 지금 떠 있으면 정상이다.
|
||||
- 이상이 없으면 **아무것도 보내지 않는다**(정상 알림은 노이즈라 무시하게 된다).
|
||||
|
||||
⚠️ **같은 이상을 매일 다시 보내지 않는다** — 일간으로 도는 이상, 연휴 오탐 하나가
|
||||
닷새 연속 울리면 알림 자체를 무시하게 된다. 직전과 이상 목록이 같으면 침묵하되,
|
||||
REPEAT_SEC 이 지나면 "아직 안 고쳐졌다"는 뜻으로 한 번 더 보낸다.
|
||||
|
||||
CLI: python3 health_check.py [--dry-run] [--verbose]
|
||||
launchd: ai.openclaw.health-check (매일 09:00)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
ROOT = os.path.expanduser("~/.openclaw")
|
||||
TASKS_MD = os.path.join(ROOT, "TASKS.md")
|
||||
LOG_DIR = os.path.join(ROOT, "logs")
|
||||
STATE_PATH = os.path.join(ROOT, "workspace", "state", "health_check.json")
|
||||
LOG_LIMIT_MB = 200 # 로그 로테이션이 도는 한 넘을 일이 없다
|
||||
REPEAT_SEC = 7 * 86400 # 같은 이상을 다시 알릴 최소 간격
|
||||
|
||||
sys.path.insert(0, os.path.join(ROOT, "workspace", "scripts"))
|
||||
|
||||
|
||||
def _load_state() -> dict:
|
||||
try:
|
||||
with open(STATE_PATH, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_state(state: dict) -> None:
|
||||
try:
|
||||
os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True)
|
||||
tmp = STATE_PATH + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, STATE_PATH)
|
||||
except Exception as e:
|
||||
print(f"[state] 저장 실패: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
def _parse_period(text: str) -> float | None:
|
||||
"""'2h' / '100h' / '8d' / '-' → 초. 데몬('-')은 None."""
|
||||
text = text.strip()
|
||||
m = re.fullmatch(r"(\d+)([hd])", text)
|
||||
if not m:
|
||||
return None
|
||||
n = int(m.group(1))
|
||||
return n * 3600 if m.group(2) == "h" else n * 86400
|
||||
|
||||
|
||||
def parse_targets() -> list[tuple[str, str, float | None, str]]:
|
||||
"""TASKS.md §4 표 → [(작업명, 증거, 기대초, 기대표기)]."""
|
||||
with open(TASKS_MD, encoding="utf-8") as f:
|
||||
doc = f.read()
|
||||
m = re.search(r"^## 4\. 헬스체크.*?(?=^## 5\.)", doc, re.M | re.S)
|
||||
if not m:
|
||||
raise RuntimeError("TASKS.md §4 헬스체크 섹션을 찾지 못했습니다")
|
||||
|
||||
targets = []
|
||||
for line in m.group(0).splitlines():
|
||||
if not line.startswith("|"):
|
||||
continue
|
||||
cells = [c.strip() for c in line.strip("|").split("|")]
|
||||
if len(cells) != 3:
|
||||
continue
|
||||
name, ev, period = cells
|
||||
if name == "작업" or set(name) <= set("-: "):
|
||||
continue
|
||||
targets.append((name, ev.strip("`"), _parse_period(period), period))
|
||||
return targets
|
||||
|
||||
|
||||
def _daemon_pid(label: str) -> str | None:
|
||||
try:
|
||||
out = subprocess.run(["launchctl", "list"], capture_output=True,
|
||||
text=True, timeout=20).stdout
|
||||
except Exception:
|
||||
return None
|
||||
for line in out.splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 3 and parts[2].strip() == label:
|
||||
return parts[0] if parts[0] != "-" else None
|
||||
return None
|
||||
|
||||
|
||||
def _fmt_age(sec: float) -> str:
|
||||
if sec < 3600:
|
||||
return f"{sec/60:.0f}분"
|
||||
if sec < 86400:
|
||||
return f"{sec/3600:.1f}시간"
|
||||
return f"{sec/86400:.1f}일"
|
||||
|
||||
|
||||
def run_checks(verbose: bool) -> list[str]:
|
||||
now = time.time()
|
||||
issues: list[str] = []
|
||||
|
||||
for name, ev, expect, expect_raw in parse_targets():
|
||||
if ev.startswith("daemon:"):
|
||||
label = ev.split(":", 1)[1]
|
||||
if _daemon_pid(label) is None:
|
||||
issues.append(f"❌ {name}: 데몬이 떠 있지 않음 ({label})")
|
||||
elif verbose:
|
||||
print(f"[ok] {name}: 실행 중")
|
||||
continue
|
||||
|
||||
path = os.path.join(ROOT, ev)
|
||||
if not os.path.exists(path):
|
||||
issues.append(f"❌ {name}: 증거 파일 없음 ({ev})")
|
||||
continue
|
||||
if expect is None:
|
||||
continue
|
||||
age = now - os.path.getmtime(path)
|
||||
if age > expect:
|
||||
issues.append(f"⚠️ {name}: {_fmt_age(age)}째 갱신 없음 "
|
||||
f"(기대 {expect_raw}) — {ev}")
|
||||
elif verbose:
|
||||
print(f"[ok] {name}: {_fmt_age(age)} 전")
|
||||
|
||||
# 로그 총량 — 로테이션이 멈추면 여기서 드러난다
|
||||
if os.path.isdir(LOG_DIR):
|
||||
total = sum(os.path.getsize(os.path.join(LOG_DIR, f))
|
||||
for f in os.listdir(LOG_DIR)
|
||||
if os.path.isfile(os.path.join(LOG_DIR, f)))
|
||||
mb = total / 1048576
|
||||
if mb > LOG_LIMIT_MB:
|
||||
issues.append(f"⚠️ 로그 용량 {mb:.0f}MB (상한 {LOG_LIMIT_MB}MB) "
|
||||
f"— 로그 로테이션 점검 필요")
|
||||
elif verbose:
|
||||
print(f"[ok] 로그 용량: {mb:.0f}MB")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="텔레그램 발송 없이 결과만 출력")
|
||||
ap.add_argument("--verbose", "-v", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
issues = run_checks(args.verbose)
|
||||
except Exception as e:
|
||||
print(f"[error] 점검 실패: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
now = time.time()
|
||||
today = datetime.now(KST).strftime("%Y-%m-%d")
|
||||
state = _load_state()
|
||||
|
||||
# 매월 1일 — 코디 월간 점검 리마인더. 스크립트로는 못 잡는 외부 드리프트
|
||||
# (모델 deprecated·본체 업그레이드·데이터소스 차단)를 사람이 짚어야 해서 둔다.
|
||||
monthly = (datetime.now(KST).day == 1
|
||||
and state.get("last_monthly") != today)
|
||||
|
||||
if not issues:
|
||||
# 이상이 해소되면 서명을 지운다 — 안 지우면 같은 이상이 재발했을 때
|
||||
# 쿨다운에 걸려 조용히 넘어간다.
|
||||
if state.pop("last_signature", None) is not None:
|
||||
state.pop("last_sent", None)
|
||||
_save_state(state)
|
||||
if not monthly:
|
||||
print("[ok] 전체 정상")
|
||||
return 0
|
||||
|
||||
parts = []
|
||||
if issues:
|
||||
parts.append("🩺 OpenClaw 헬스체크\n" + "\n".join(issues))
|
||||
if monthly:
|
||||
parts.append("🗓 월간 점검일입니다 — 코디에게 "
|
||||
"`MAINTENANCE.md` 체크리스트를 맡겨주세요.")
|
||||
text = "\n\n".join(parts)
|
||||
print(text)
|
||||
if args.dry_run:
|
||||
return 0
|
||||
|
||||
# 같은 이상을 매일 다시 보내지 않는다(월간 리마인더가 낀 날은 그대로 보낸다)
|
||||
sig = "\n".join(sorted(issues))
|
||||
if issues and not monthly:
|
||||
if (sig == state.get("last_signature")
|
||||
and now - state.get("last_sent", 0) < REPEAT_SEC):
|
||||
print("[skip] 직전과 같은 이상 — 재발송 안 함 "
|
||||
f"(최소 간격 {REPEAT_SEC//86400}일)")
|
||||
return 0
|
||||
|
||||
try:
|
||||
from codex_fallback_monitor import _send_telegram
|
||||
sent = _send_telegram(text)
|
||||
except Exception as e:
|
||||
print(f"[tg] 발송 실패: {e}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
if sent:
|
||||
if issues:
|
||||
state["last_signature"] = sig
|
||||
state["last_sent"] = now
|
||||
if monthly:
|
||||
state["last_monthly"] = today
|
||||
_save_state(state)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user