게시판오목으로 이해하는 자기대국과 모방 학습예제 소스

예제 소스

오목으로 이해하는 자기대국과 모방 학습 · 파일 59개

daemon_omook_stack_watchdog.py

#!/usr/bin/env python3
"""Omook 서비스 스택 감시: Next(:3002) + VLM TCP(:9299).

죽으면 재기동. WS 브리지는 불필요(웹이 /api/vlm/move → :9299 직접 호출).

  .venv/bin/python scripts/daemon_omook_stack_watchdog.py
"""

from __future__ import annotations

import json
import os
import signal
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo

ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "omook-web"
PY = str(ROOT / ".venv" / "bin" / "python")
OUT = ROOT / "logs" / "omook_stack_watch"
HEART = OUT / "HEARTBEAT.md"
LOG = OUT / "watchdog.log"
NEXT_LOG = OUT / "next.watchdog.log"
VLM_LOG = OUT / "vlm.watchdog.log"
RESTART_FLAG = OUT / "RESTART_REQUESTED"
KST = ZoneInfo("Asia/Seoul")

NEXT_PORT = 3002
VLM_PORT = 9299
POLL_S = 20
HTTP_TIMEOUT_S = 3.0
NEXT_RESTART_WAIT_S = 90
VLM_RESTART_WAIT_S = 600  # 모델 로드

NODE_BIN = Path.home() / ".nvm" / "versions" / "node" / "v24.14.1" / "bin"
NPM = str(NODE_BIN / "npm")

VLM_DEVICE = os.environ.get("OMOOK_VLM_DEVICE", "mps")
VLM_CMD = [
    PY,
    "vlm_pva_server.py",
    "--host",
    "127.0.0.1",
    "--port",
    str(VLM_PORT),
    "--device",
    VLM_DEVICE,
]
# {"python": "../go/.venv/bin/python", "args": ["--backend", "mlx", ...]}  (경로는 ROOT 기준)
VLM_PROFILE = ROOT / "vlm_profile.json"


def vlm_cmd() -> list[str]:
    """재시작할 때마다 vlm_profile.json 을 다시 읽는다. 없으면 기본 VLM_CMD."""
    try:
        prof = json.loads(VLM_PROFILE.read_text(encoding="utf-8"))
    except FileNotFoundError:
        return list(VLM_CMD)
    except (OSError, ValueError) as e:
        log(f"vlm_profile.json 무시 (읽기 실패: {e})")
        return list(VLM_CMD)
    # resolve() 는 venv python 심볼릭 링크를 원본 인터프리터로 풀어 venv site-packages 를 잃는다.
    py = os.path.normpath(ROOT / prof["python"]) if prof.get("python") else PY
    return [py, *VLM_CMD[1:], *[str(a) for a in prof.get("args", [])]]


def now_kst() -> datetime:
    return datetime.now(KST)


def log(msg: str) -> None:
    line = f"[{now_kst():%Y-%m-%d %H:%M:%S}] {msg}"
    print(line, flush=True)
    OUT.mkdir(parents=True, exist_ok=True)
    with LOG.open("a", encoding="utf-8") as f:
        f.write(line + "\n")


def port_open(port: int, host: str = "127.0.0.1") -> bool:
    """레거시 TCP 프로브 — VLM 헬스에는 쓰지 말 것(eof 로그 유발)."""
    try:
        with socket.create_connection((host, port), timeout=1.5):
            return True
    except OSError:
        return False


def http_ok(url: str, expect: int = 200) -> bool:
    try:
        req = urllib.request.Request(url, method="GET")
        with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as resp:
            return int(resp.status) == expect
    except (urllib.error.URLError, TimeoutError, OSError, ValueError):
        return False


def next_healthy() -> bool:
    return http_ok(f"http://127.0.0.1:{NEXT_PORT}/", 200) or http_ok(
        f"http://127.0.0.1:{NEXT_PORT}/play", 200
    )


def vlm_healthy() -> bool:
    # TCP connect 프로브는 vlm_pva_server 에 eof(메시지 0건) 경고를 남기므로
    # 리스너 존재만 확인한다.
    return bool(listeners_on(VLM_PORT))


def pgrep(pattern: str) -> list[int]:
    try:
        out = subprocess.check_output(["pgrep", "-f", pattern], text=True)
    except (subprocess.CalledProcessError, FileNotFoundError, PermissionError, OSError):
        return []
    me = os.getpid()
    pids: list[int] = []
    for tok in out.split():
        try:
            p = int(tok)
        except ValueError:
            continue
        if p != me:
            pids.append(p)
    return pids


def kill_pids(pids: list[int], label: str) -> None:
    if not pids:
        return
    log(f"kill {label}: {pids}")
    for p in pids:
        try:
            os.kill(p, signal.SIGTERM)
        except (ProcessLookupError, PermissionError, OSError):
            pass
    time.sleep(2)
    for p in pids:
        try:
            os.kill(p, signal.SIGKILL)
        except (ProcessLookupError, PermissionError, OSError):
            pass


def listeners_on(port: int) -> list[int]:
    try:
        out = subprocess.check_output(
            ["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"],
            text=True,
        )
    except (subprocess.CalledProcessError, FileNotFoundError, PermissionError, OSError):
        return []
    pids: list[int] = []
    for tok in out.split():
        try:
            pids.append(int(tok))
        except ValueError:
            pass
    return pids


def free_port(port: int) -> None:
    kill_pids(listeners_on(port), f"port:{port}")


def spawn(cmd: list[str], cwd: Path, logfile: Path) -> subprocess.Popen:
    env = os.environ.copy()
    env["PYTHONUNBUFFERED"] = "1"
    env["TOKENIZERS_PARALLELISM"] = "********"
    path_parts = [str(NODE_BIN), "/opt/homebrew/bin", "/usr/local/bin"]
    if env.get("PATH"):
        path_parts.append(env["PATH"])
    env["PATH"] = ":".join(path_parts)
    env["PORT"] = str(NEXT_PORT)
    logfile.parent.mkdir(parents=True, exist_ok=True)
    fp = open(logfile, "a", encoding="utf-8")
    fp.write(f"\n===== spawn {now_kst():%Y-%m-%d %H:%M:%S} =====\n")
    fp.write(" ".join(cmd) + "\n")
    fp.flush()
    return subprocess.Popen(
        cmd,
        cwd=str(cwd),
        env=env,
        stdout=fp,
        stderr=subprocess.STDOUT,
        start_new_session=True,
    )


def ensure_caffeinate() -> None:
    me = str(os.getpid())
    try:
        if pgrep(f"caffeinate -dims -w {me}"):
            return
        spawn(["caffeinate", "-dims", "-w", me], ROOT, OUT / "caffeinate.log")
        log("caffeinate started (tied to this watchdog pid)")
    except (PermissionError, OSError) as e:
        log(f"caffeinate skip: {e}")


def proc_cmdline(pid: int) -> str:
    try:
        return subprocess.check_output(
            ["ps", "-p", str(pid), "-o", "command="], text=True
        )
    except (subprocess.CalledProcessError, FileNotFoundError, PermissionError, OSError):
        return ""


def next_is_dev() -> bool:
    for pid in listeners_on(NEXT_PORT):
        out = proc_cmdline(pid).lower()
        if "next dev" in out or "dev-with-ts.mjs dev" in out:
            return True
    return False


def ensure_next_build() -> bool:
    marker = WEB / ".next" / "BUILD_ID"
    if marker.exists():
        return True
    log("Next .next missing → npm run build")
    if not Path(NPM).exists():
        log(f"npm missing at {NPM}")
        return False
    try:
        subprocess.run(
            [NPM, "run", "build"],
            cwd=str(WEB),
            check=True,
            env={
                **os.environ,
                "PATH": f"{NODE_BIN}:{os.environ.get('PATH', '')}",
                "PORT": str(NEXT_PORT),
            },
        )
    except subprocess.CalledProcessError as e:
        log(f"build failed: {e}")
        return False
    return marker.exists()


def restart_next(n: int) -> bool:
    log(f"Next :{NEXT_PORT} DOWN/dev → service restart #{n}")
    kill_pids(
        pgrep(r"/omook/omook-web|/omook-web.*(next|npm|pnpm)"),
        "omook-next",
    )
    free_port(NEXT_PORT)
    if not Path(NPM).exists():
        log(f"npm missing at {NPM}")
        return False
    if not ensure_next_build():
        return False
    spawn([NPM, "run", "start"], WEB, NEXT_LOG)
    deadline = time.time() + NEXT_RESTART_WAIT_S
    while time.time() < deadline:
        if next_healthy() and not next_is_dev():
            log(f"Next service UP after restart #{n}")
            return True
        time.sleep(2)
    log(f"Next STILL DOWN after restart #{n}")
    return False


def restart_vlm(n: int) -> bool:
    log(f"VLM :{VLM_PORT} DOWN → restart #{n}")
    kill_pids(pgrep("vlm_pva_server\\.py"), "omook-vlm")
    free_port(VLM_PORT)
    cmd = vlm_cmd()
    if not Path(cmd[0]).exists():
        log(f"python missing at {cmd[0]}")
        return False
    spawn(cmd, ROOT, VLM_LOG)
    deadline = time.time() + VLM_RESTART_WAIT_S
    while time.time() < deadline:
        if vlm_healthy():
            log(f"VLM UP after restart #{n}")
            return True
        time.sleep(3)
    log(f"VLM STILL DOWN after restart #{n}")
    return False


def write_heartbeat(
    *,
    next_ok: bool,
    vlm_ok: bool,
    next_restarts: int,
    vlm_restarts: int,
    note: str,
) -> None:
    mode = "service" if next_ok and not next_is_dev() else ("dev" if next_ok else "down")
    lines = [
        f"# Omook stack watchdog — {now_kst():%Y-%m-%d %H:%M:%S KST}",
        "",
        f"- **Next :{NEXT_PORT}**: `{'up' if next_ok else 'DOWN'}` ({mode})",
        f"- **VLM :{VLM_PORT}**: `{'up' if vlm_ok else 'DOWN'}` (vlm_pva_server)",
        f"- **next_restarts**: `{next_restarts}`",
        f"- **vlm_restarts**: `{vlm_restarts}`",
        f"- **device**: `{VLM_DEVICE}`",
        f"- **note**: {note}",
        "",
        "로그: `logs/omook_stack_watch/watchdog.log`",
        "",
    ]
    HEART.write_text("\n".join(lines), encoding="utf-8")


def acquire_singleton() -> object:
    """중복 워치독이 VLM을 동시에 재시작/프로브하지 않도록 락."""
    OUT.mkdir(parents=True, exist_ok=True)
    lock_path = OUT / "watchdog.lock"
    fh = lock_path.open("a+", encoding="utf-8")
    try:
        import fcntl

        fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
    except OSError as e:
        fh.close()
        raise SystemExit(f"another omook watchdog holds {lock_path}: {e}") from e
    fh.seek(0)
    fh.truncate()
    fh.write(f"{os.getpid()}\n")
    fh.flush()
    return fh


def main() -> int:
    OUT.mkdir(parents=True, exist_ok=True)
    lock_fh = acquire_singleton()
    log(
        f"omook stack watchdog start "
        f"(Next:{NEXT_PORT} service + VLM:{VLM_PORT} device={VLM_DEVICE})"
    )
    ensure_caffeinate()
    next_restarts = 0
    vlm_restarts = 0
    migrated_to_service = False

    while True:
        if RESTART_FLAG.exists():
            reason = RESTART_FLAG.read_text(encoding="utf-8").strip()
            try:
                RESTART_FLAG.unlink()
            except OSError:
                pass
            log(f"ops requested restart: {reason}")
            low = reason.lower()
            if "vlm" in low:
                vlm_restarts += 1
                vlm = restart_vlm(vlm_restarts)
            else:
                next_restarts += 1
                nxt = restart_next(next_restarts)

        nxt = next_healthy()
        if nxt and next_is_dev() and not migrated_to_service:
            log("Next is dev mode → migrate to service (next start)")
            next_restarts += 1
            nxt = restart_next(next_restarts)
            migrated_to_service = True
        elif not nxt:
            next_restarts += 1
            nxt = restart_next(next_restarts)
            migrated_to_service = True

        vlm = vlm_healthy()
        if not vlm:
            vlm_restarts += 1
            vlm = restart_vlm(vlm_restarts)

        write_heartbeat(
            next_ok=nxt,
            vlm_ok=vlm,
            next_restarts=next_restarts,
            vlm_restarts=vlm_restarts,
            note="polling",
        )
        time.sleep(POLL_S)


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except KeyboardInterrupt:
        log("watchdog interrupted")
        raise SystemExit(0)
    except Exception:
        logging = __import__("logging")
        log("watchdog crashed")
        import traceback

        OUT.mkdir(parents=True, exist_ok=True)
        with LOG.open("a", encoding="utf-8") as f:
            f.write(traceback.format_exc() + "\n")
        raise