auto: 일일 백업 2026-07-22 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""codex 폴백 감지 → iMessage 알림 (LLM 미경유).
|
||||
|
||||
codex 주 모델(openai/gpt-5.5)이 죽으면 게이트웨이가 조용히 유료 OpenRouter로 폴백해
|
||||
토큰비가 새는 걸 관리자님이 모르는 문제 대응. 각 에이전트 세션 trajectory.jsonl의
|
||||
`model.completed` 이벤트(실제 턴을 완료한 승자 모델)를 스캔해, 승자 provider가
|
||||
`openrouter`면 = codex 다운 → 유료 대체 중으로 판단하고 클로(main) 텔레그램으로 즉시 알림.
|
||||
텔레그램은 Bot API에 HTTP POST만 하는 직접 발송이라 LLM(모델 세션) 미경유.
|
||||
|
||||
- 신호원: agents/*/sessions/*.trajectory.jsonl (on-disk, 무비용)
|
||||
- 정상: model.completed provider=openai model=gpt-5.5 (codex 하네스)
|
||||
- 폴백: provider=openrouter/* (config fallback = claude-haiku/sonnet, 유료)
|
||||
- dedupe: last_ts 워터마크 이후 이벤트만, 알림은 COOLDOWN당 1회(장기 다운 스팸 방지)
|
||||
- 결정론적, 실패해도 절대 raise 안 함(모니터가 워크플로 안 깨게)
|
||||
|
||||
CLI: python3 codex_fallback_monitor.py [--dry-run] [--status]
|
||||
launchd: ai.openclaw.codex-fallback-monitor (5분 주기)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
ROOT = os.path.expanduser("~/.openclaw")
|
||||
STATE_PATH = os.path.join(ROOT, "workspace/state/codex_fallback_monitor.json")
|
||||
CONFIG_PATH = os.path.join(ROOT, "openclaw.json")
|
||||
TELEGRAM_ACCOUNT = "default" # 클로(main) 봇
|
||||
TRAJ_GLOB = os.path.join(ROOT, "agents/*/sessions/*.trajectory.jsonl")
|
||||
|
||||
LOOKBACK_SEC = 3 * 3600 # mtime 이 구간 내 파일만 스캔
|
||||
COOLDOWN_SEC = 30 * 60 # 알림 최소 간격 (장기 다운 스팸 방지)
|
||||
FALLBACK_PROVIDERS = {"openrouter"} # 정상 primary=openai(codex). openrouter 승자 = 폴백
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
return datetime.now(tz=KST).timestamp()
|
||||
|
||||
|
||||
def _parse_ts(ts: str) -> float:
|
||||
"""trajectory ts (UTC ISO, e.g. 2026-07-20T01:34:11.915Z) → epoch."""
|
||||
try:
|
||||
return datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp()
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _load_state() -> dict:
|
||||
try:
|
||||
with open(STATE_PATH, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_state(st: 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(st, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, STATE_PATH)
|
||||
except Exception as e:
|
||||
print(f"[state] save 실패: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
def _telegram_targets() -> tuple[str | None, list[str]]:
|
||||
"""openclaw.json에서 클로(main) 봇 토큰 + chat_id 목록 (LLM 미경유 직접 발송용)."""
|
||||
try:
|
||||
with open(CONFIG_PATH, encoding="utf-8") as f:
|
||||
acct = json.load(f)["channels"]["telegram"]["accounts"][TELEGRAM_ACCOUNT]
|
||||
return acct.get("botToken") or None, acct.get("allowFrom") or []
|
||||
except Exception as e:
|
||||
print(f"[tg] config 읽기 실패: {e}", file=sys.stderr)
|
||||
return None, []
|
||||
|
||||
|
||||
def scan_fallbacks(since_ts: float) -> tuple[list[dict], float]:
|
||||
"""since_ts 이후 model.completed 이벤트에서 폴백 건 추출.
|
||||
|
||||
반환: (폴백 이벤트 리스트, 관찰한 최대 이벤트 ts)
|
||||
"""
|
||||
events: list[dict] = []
|
||||
max_ts = since_ts
|
||||
cutoff_mtime = _now() - LOOKBACK_SEC
|
||||
for path in glob.glob(TRAJ_GLOB):
|
||||
try:
|
||||
if os.path.getmtime(path) < cutoff_mtime:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
agent = path.split("/agents/", 1)[-1].split("/", 1)[0]
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="ignore") as f:
|
||||
for line in f:
|
||||
if '"model.completed"' not in line:
|
||||
continue
|
||||
try:
|
||||
d = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if d.get("type") != "model.completed":
|
||||
continue
|
||||
ets = _parse_ts(d.get("ts", ""))
|
||||
if ets <= since_ts:
|
||||
continue
|
||||
if ets > max_ts:
|
||||
max_ts = ets
|
||||
provider = (d.get("provider") or "").lower()
|
||||
if provider in FALLBACK_PROVIDERS:
|
||||
events.append({
|
||||
"agent": agent,
|
||||
"ts": ets,
|
||||
"provider": d.get("provider"),
|
||||
"model": d.get("modelId"),
|
||||
"runId": d.get("runId", ""),
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[scan] {path} 읽기 실패: {e}", file=sys.stderr)
|
||||
return events, max_ts
|
||||
|
||||
|
||||
def _compose(events: list[dict]) -> str:
|
||||
latest = max(events, key=lambda e: e["ts"])
|
||||
when = datetime.fromtimestamp(latest["ts"], tz=KST).strftime("%m-%d %H:%M")
|
||||
agents = sorted({e["agent"] for e in events})
|
||||
models = sorted({f"{e['provider']}/{e['model']}" for e in events})
|
||||
return (
|
||||
"⚠️ codex 폴백 감지\n"
|
||||
"codex 주 모델(gpt-5.5) 응답 실패 → 유료 OpenRouter로 대체 중입니다.\n"
|
||||
f"최근: {when} KST\n"
|
||||
f"대체모델: {', '.join(models)}\n"
|
||||
f"에이전트: {', '.join(agents)} (총 {len(events)}건)\n"
|
||||
"→ codex 점검 필요 (토큰비 누수)"
|
||||
)
|
||||
|
||||
|
||||
def _send_telegram(text: str) -> bool:
|
||||
token, chat_ids = _telegram_targets()
|
||||
if not token or not chat_ids:
|
||||
print("[tg] 봇 토큰/chat_id 없음 — 발송 skip", file=sys.stderr)
|
||||
return False
|
||||
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||
ok = True
|
||||
for chat_id in chat_ids:
|
||||
data = urllib.parse.urlencode({
|
||||
"chat_id": chat_id,
|
||||
"text": text[:4000],
|
||||
"disable_web_page_preview": "true",
|
||||
}).encode()
|
||||
try:
|
||||
req = urllib.request.Request(url, data=data, method="POST")
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
if r.status != 200:
|
||||
ok = False
|
||||
print(f"[tg] HTTP {r.status}", file=sys.stderr)
|
||||
except Exception as e:
|
||||
ok = False
|
||||
print(f"[tg] 발송 오류: {e}", file=sys.stderr)
|
||||
return ok
|
||||
|
||||
|
||||
def main() -> None:
|
||||
dry = "--dry-run" in sys.argv
|
||||
st = _load_state()
|
||||
|
||||
if "--status" in sys.argv:
|
||||
print(json.dumps(st, ensure_ascii=False, indent=2))
|
||||
return
|
||||
|
||||
# 첫 실행: 과거(이미 해결된 건) 알림 방지 — 워터마크만 now로 세팅
|
||||
if "last_ts" not in st:
|
||||
st["last_ts"] = _now()
|
||||
st["last_alert_ts"] = 0
|
||||
_save_state(st)
|
||||
print("[init] 첫 실행 — 워터마크 설정, 과거 건 무시")
|
||||
return
|
||||
|
||||
since = float(st.get("last_ts", 0))
|
||||
events, max_ts = scan_fallbacks(since)
|
||||
st["last_ts"] = max(since, max_ts)
|
||||
|
||||
if not events:
|
||||
_save_state(st)
|
||||
print(f"[ok] 폴백 없음 (since {datetime.fromtimestamp(since, tz=KST):%m-%d %H:%M})")
|
||||
return
|
||||
|
||||
text = _compose(events)
|
||||
print(text)
|
||||
|
||||
now = _now()
|
||||
last_alert = float(st.get("last_alert_ts", 0))
|
||||
if dry:
|
||||
print("[dry-run] 발송 안 함")
|
||||
elif now - last_alert < COOLDOWN_SEC:
|
||||
print(f"[cooldown] {int((now-last_alert)/60)}분 전 알림 — 이번엔 발송 skip")
|
||||
else:
|
||||
if _send_telegram(text):
|
||||
st["last_alert_ts"] = now
|
||||
print("[tg] 발송 완료")
|
||||
_save_state(st)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
print(f"[fatal] {e}", file=sys.stderr)
|
||||
sys.exit(0) # 모니터는 절대 비정상 종료로 시끄럽게 안 함
|
||||
Reference in New Issue
Block a user