"""파이프라인 자동 운전 — 멈추면 스스로 회수하고 재개한다.

셸 판본이 조건 분기에서 조용히 아무것도 안 하는 일이 있어(정지 상태인데
재개 안 함) 판단을 한곳으로 모았다. **매 사이클 상태를 찍는다** — 아무것도
안 한 사이클도 기록에 남아야 "왜 안 했나"를 나중에 알 수 있다.

규칙 셋:
  · 돌고 있으면 둔다.
  · `running` 인데 스테일 만료를 넘겼으면 **resume 1회**로 회수한다.
    (force 금지 — 하류를 지우고, 재시도가 `started_at` 을 갱신해 만료를
     계속 미룬다.)
  · 아무도 안 돌고 남은 스텝이 있으면 run-all resume 으로 재개한다.
  · **재개했는데 완료 수가 안 늘면 다른 수를 쓴다.** `partial` 스텝을 찾아
    개별 resume 을 건다 — run-all resume 은 partial 을 다시 돌리지 않아서,
    하류가 그것 때문에 막혀 있으면 아무리 재개해도 안 풀린다.

사용: .venv/bin/python autodrive.py
"""
from __future__ import annotations

import subprocess
import time
from pathlib import Path
from collections import Counter
from datetime import datetime, timezone

from typing import Optional

import requests

PROJ = "e716bafb-24bb-42b7-aea0-fdb383844ee8"
EPI = "d6a9aa85-b75e-400c-980c-4ee7e876a15b"
BASE = f"http://localhost:8000/api/v1/projects/{PROJ}/episodes/{EPI}"
STALE_S = 3700          # 만료(3600) + 여유
PERIOD = 60
COOL_AFTER_RESUME = 3   # 재개 직후 이만큼은 다시 재개하지 않는다
# 재개를 이만큼 했는데 완료 수가 안 늘면 같은 수를 반복해도 소용없다고 보고
# partial 스텝 개별 resume 으로 넘어간다. 실측: 그것 없이 55회 헛돌았다.
STUCK_RESUMES = 3
# 이 시간 넘게 아무 활동 없으면 죽은 것으로 본다. 그림 한 장 타임아웃이
# 420초이고 실제로 8분 간격으로 도는 단계를 봤다 — 재시도까지 겹칠 여지를
# 두고 20분으로 잡는다. 짧으면 살아 있는 작업을 죽인다.
IDLE_DEAD_S = 1200
#: 아직 안 끝난 상태. ★`partial`·`failed` 가 빠져 있었다(Codex 지적, 확인함) —
#: 그 둘만 남으면 "남은 것 0"이 되어 **미완인데 완주라고 보고 종료**한다.
#: 끝났다고 말할 수 있는 것은 `completed` 와 `not_applicable` 뿐이다.
LEFT_STATES = ("stale", "pending", "blocked", "partial", "failed")
#: 개별 resume 으로 회수를 시도할 상태. run-all 이 이것들을 실제로 진행시키지
#: 못하는 경우가 있다 — 실측: `shot_conti_light`(partial)와
#: `scene_image_pipeline`(failed) 둘 다 run-all 재개로는 안 풀리고 개별 호출로
#: 풀렸다.
RECOVERABLE = ("partial", "failed")
#: 개별 resume 까지 이만큼 해도 상태가 안 바뀌면 사람이 봐야 한다.
GIVE_UP_ROUNDS = 4
#: 같은 스텝을 이만큼 개별 resume 했는데도 상태가 그대로면 더 부르지
#: 않는다. ★전에는 회수 뒤 무조건 stuck 을 0 으로 되돌려 **영원히**
#: 다시 불렀다 — 이미지 단계면 돈이 반복해서 나간다.
MAX_RECOVER_TRIES = 2

#: 자동으로 다시 부르지 않을 스텝. 실패가 **입력 탓**이라 다시 불러도 같은
#: 값이 나오는 것들이다.
#:
#: `scene_segmentation` — 저작한 규칙이 계약을 못 넘거나 후보끼리 경계가 갈린
#: 채 남으면 실패한다. 그건 그 대본의 형식이 특이하다는 뜻이라 사람이 봐야
#: 한다. 한 번 부를 때 대본 **전문을 최대 3번** 보내는데, 자동 회수 2회면
#: 같은 입력에 9번까지 나간다(Codex 지적). 다시 부른다고 달라질 근거가 없다.
NO_AUTO_RECOVER = frozenset({"scene_segmentation"})


def log(msg: str) -> None:
    print(f"{datetime.now().strftime('%H:%M:%S')} {msg}", flush=True)


def login(sess: requests.Session) -> None:
    sess.post("http://localhost:8000/api/v1/auth/login",
              json={"username": "admin", "password": "admin123"}, timeout=30)


def stale_locks() -> list[tuple[str, int]]:
    """만료를 넘긴 running 스텝 — DB 가 유일한 진실이다."""
    sql = (
        "SELECT step_id, EXTRACT(EPOCH FROM (now()-started_at::timestamptz))::int "
        f"FROM step_run WHERE episode_id='{EPI}' AND status='running';"
    )
    out = subprocess.run(
        ["psql", "-h", "localhost", "-U", "theroad", "-d", "theroad",
         "-t", "-A", "-F", "|", "-c", sql],
        capture_output=True, text=True,
        env={"PGPASSWORD": "theroad_dev_2026", "PATH": "/usr/bin:/bin:"
             "/opt/homebrew/bin"},
    )
    rows = []
    for ln in (out.stdout or "").strip().splitlines():
        if "|" not in ln:
            continue
        sid, el = ln.split("|", 1)
        try:
            rows.append((sid.strip(), int(el)))
        except ValueError:
            pass
    return [(s, e) for s, e in rows if e > STALE_S]


def elapsed_of(step_id: str) -> int | None:
    """그 스텝이 얼마나 돌고 있나 — 정체를 눈에 보이게 한다."""
    sql = ("SELECT EXTRACT(EPOCH FROM (now()-started_at::timestamptz))::int "
           f"FROM step_run WHERE episode_id='{EPI}' AND step_id='{step_id}' "
           "AND status='running';")
    out = subprocess.run(
        ["psql", "-h", "localhost", "-U", "theroad", "-d", "theroad",
         "-t", "-A", "-c", sql],
        capture_output=True, text=True,
        env={"PGPASSWORD": "theroad_dev_2026",
             "PATH": "/usr/bin:/bin:/opt/homebrew/bin"})
    try:
        return int((out.stdout or "").strip().splitlines()[0])
    except Exception:  # noqa: BLE001
        return None


def _log_idle() -> int | None:
    """uvicorn.log 의 마지막 LiteLLM 줄이 몇 초 전인가."""
    import re
    try:
        lines = Path("uvicorn.log").read_text(
            "utf-8", errors="ignore").splitlines()[-3000:]
    except Exception:  # noqa: BLE001
        return None
    last = None
    for ln in lines:
        m = re.search(r'"timestamp": "([0-9T:\-.+]+)".*LiteLLM', ln)
        if m:
            try:
                last = datetime.fromisoformat(m.group(1))
            except ValueError:
                pass
    if last is None:
        return None
    return int((datetime.now(timezone.utc) - last).total_seconds())


def _db_idle() -> int | None:
    """llm_call_log 의 마지막 호출이 몇 초 전인가 — 이미지 호출도 남는다."""
    sql = ("SELECT EXTRACT(EPOCH FROM (now()-max(created_at::timestamptz)))::int "
           f"FROM llm_call_log WHERE episode_id='{EPI}';")
    out = subprocess.run(
        ["psql", "-h", "localhost", "-U", "theroad", "-d", "theroad",
         "-t", "-A", "-c", sql],
        capture_output=True, text=True,
        env={"PGPASSWORD": "theroad_dev_2026",
             "PATH": "/usr/bin:/bin:/opt/homebrew/bin"})
    try:
        return int((out.stdout or "").strip().splitlines()[0])
    except Exception:  # noqa: BLE001
        return None


def _file_evidence() -> tuple[int, Optional[int]]:
    """산출 파일 수와 최신 파일이 몇 초 전인가 — 상태 셈과 별개의 물증."""
    roots = [Path(f"../projects/{PROJ}/images/{EPI}"),
             Path(f"../projects/{PROJ}/checkpoints/episodes/{EPI}")]
    newest, n = 0.0, 0
    for root in roots:
        if not root.exists():
            continue
        for p in root.rglob("*"):
            try:
                if p.is_file():
                    n += 1
                    newest = max(newest, p.stat().st_mtime)
            except OSError:
                pass
    if newest <= 0:
        return n, None
    return n, int(time.time() - newest)


def _file_idle() -> int | None:
    """가장 최근에 만들어진 산출 파일이 몇 초 전인가."""
    return _file_evidence()[1]


def idle_seconds() -> int | None:
    """마지막 활동이 몇 초 전인가 — 살아 있는지의 진짜 근거.

    ★uvicorn.log 하나만 보면 안 된다 (2026-08-07 실측). 그 파일에는 LiteLLM
    을 거치는 호출만 남고 **이미지 생성은 한 줄도 안 남는다.** 실제로 같은
    순간에 DB 기록은 37초 전인데 로그는 21분 전이었다 — 그 차이만 믿으면
    살아서 그림을 만들고 있는 작업을 죽었다고 보고 잠금을 잘못 푼다.

    근거 셋 중 **가장 최근 것**을 쓴다: DB 호출 기록 · 산출 파일 · 로그.
    셋 다 모르겠으면 None 을 돌려 "판단 불가"로 두고, 부르는 쪽은 그때
    잠금을 건드리지 않는다.
    """
    cands = [v for v in (_db_idle(), _file_idle(), _log_idle()) if v is not None]
    return min(cands) if cands else None


def runnable_ids() -> set[str]:
    """run-all 이 실제로 고르는 스텝만.

    ★이걸 안 걸러서 **완주를 영원히 인식하지 못했다**(2026-08-08 실측). 남은
    13개 중 12개가 `deprecated`/`removed` 이거나 `on_demand`/`disabled` 라
    run-all 이 애초에 후보로도 안 넣는데, 상태는 `pending`/`blocked` 로 남는다.
    그것들을 "남은 것"으로 세니 `left` 가 0 이 될 수 없고, 실제 진행이 막힌
    동안 이 루프는 끝없이 재개만 반복했다.

    기준은 `select_steps_for_category` 와 같다 — lifecycle 이 active 이고
    applicability 가 on_demand/disabled 가 아닌 것. `if_planning_doc` 은
    런타임이 not_applicable 로 마킹하므로 여기서 거르지 않는다.
    """
    from app.core.step_manifest import STEP_MANIFEST

    return {sid for sid, m in STEP_MANIFEST.items()
            if m.get("lifecycle") == "active"
            and m.get("applicability") not in ("on_demand", "disabled")}


def _psql(sql: str) -> Optional[list[str]]:
    """실패하면 **None** — 빈 목록으로 내려가면 안 된다.

    ★조회가 깨졌을 때 "회수할 것이 없다"와 구분이 안 되면, 실제로는 막혀 있는데
    완주로 읽거나 회수를 건너뛴다. 모르는 것은 모른다고 돌려준다.
    """
    out = subprocess.run(
        ["psql", "-h", "localhost", "-U", "theroad", "-d", "theroad",
         "-t", "-A", "-F", "|", "-c", sql],
        capture_output=True, text=True,
        env={"PGPASSWORD": "theroad_dev_2026",
             "PATH": "/usr/bin:/bin:/opt/homebrew/bin"})
    if out.returncode != 0:
        log(f"  psql 실패(rc={out.returncode}): {(out.stderr or '')[:120]}")
        return None
    return [ln.strip() for ln in (out.stdout or "").splitlines() if ln.strip()]


def state_snapshot(runnable: set[str]) -> Optional[tuple]:
    """돌 대상 스텝의 (id, 상태, 완료수, 실패수) — 진행의 근거.

    ★"완료 **스텝 수**"만 비교하면 안 된다. partial 안에서 95→96 으로 나아가는
    것, 실패 수가 줄어드는 것을 못 본다. 그러면 실제로는 일하고 있는데 "안
    듣는다"고 판단해 쓸데없이 다시 부른다.

    ★**돌 대상만** 본다(Codex 지적). 안 도는 스텝의 상태가 우연히 바뀌면
    "진행 중"으로 오인해 회수를 미룬다.
    """
    rows = _psql(
        "SELECT step_id, status, coalesce(completed_count,-1), "
        f"coalesce(failed_count,-1) FROM step_run WHERE episode_id='{EPI}' "
        "ORDER BY step_id;")
    if rows is None:
        return None
    return tuple(r for r in rows if r.split("|")[0] in runnable)


def recoverable_steps(runnable: set[str]) -> Optional[list[tuple[str, str]]]:
    """개별 resume 으로 회수를 시도할 스텝 — `partial` 과 `failed`.

    ★2026-08-08 실측으로 생겼다. `shot_conti_light` 가 96개 중 1개 실패로
    partial 이 되자 하류 `scene_image_pipeline` 이 "필수 상류 스텝 미완료"로
    막혔는데, 이 루프는 **7시간 동안 run-all 재개만 55회** 반복했다.

    ★한 번에 전부 부르지 않는다. "성공분만 재사용"은 StepRunner 공통 계약이
    **아니다** — 스텝에 따라 resume 이 전량 다시 호출할 수 있어 돈이 샌다.
    맨 앞 하나만 부른다.

    ★**돌 대상만** 본다(Codex 지적). 안 도는 스텝의 오래된 partial/failed 가
    맨 앞으로 뽑히면 엉뚱한 것을 계속 부른다.

    ★`NO_AUTO_RECOVER` 는 아예 뺀다. 다시 불러도 같은 값이 나오는데 그때마다
    대본 전문이 여러 번 나가는 스텝이다.
    """
    rows = _psql(
        "SELECT step_id, status FROM step_run WHERE "
        f"episode_id='{EPI}' AND status IN "
        f"({','.join(chr(39) + s + chr(39) for s in RECOVERABLE)}) "
        "ORDER BY started_at;")
    if rows is None:
        return None
    out = []
    for r in rows:
        if "|" not in r:
            continue
        sid, status = r.split("|")[0], r.split("|")[1]
        if sid not in runnable:
            continue
        if sid in NO_AUTO_RECOVER:
            log(f"  {sid} 는 자동으로 다시 부르지 않는다({status}) — 사람이 볼 것")
            continue
        out.append((sid, status))
    return out


def resume_step(sess: requests.Session, step_id: str) -> str:
    """개별 스텝 resume — 성공분을 재사용하고 실패분만 다시 부른다.

    force 를 쓰지 않는다. force 는 하류 체크포인트를 지우고, 재시도가
    `started_at` 을 갱신해 만료를 계속 미룬다.
    """
    try:
        r = sess.post(f"{BASE}/steps/{step_id}", params={"mode": "resume"},
                      timeout=120)
        return f"{r.status_code} {r.text[:100]}"
    except Exception as exc:  # noqa: BLE001
        return f"실패 {exc!r}"


def clear_lock(step_id: str) -> None:
    """멈춘 스텝의 running 표시를 내린다 — resume 은 409 로 거부되므로."""
    sql = (f"UPDATE step_run SET status='failed' WHERE episode_id='{EPI}' "
           f"AND step_id='{step_id}' AND status='running';")
    subprocess.run(
        ["psql", "-h", "localhost", "-U", "theroad", "-d", "theroad", "-c", sql],
        capture_output=True, text=True,
        env={"PGPASSWORD": "theroad_dev_2026",
             "PATH": "/usr/bin:/bin:/opt/homebrew/bin"})


def main() -> None:
    sess = requests.Session()
    login(sess)
    cool = 0
    last = ""
    last_snap: tuple = ()   # 직전 상태 스냅샷 — 재개가 듣고 있는지의 근거
    stuck = 0               # 상태가 안 바뀐 재개 횟수
    tries: dict[str, int] = {}   # 스텝별 개별 resume 시도 횟수
    runnable = runnable_ids()
    log(f"run-all 대상 스텝 {len(runnable)}개 — 나머지는 남은 것으로 세지 않는다")
    log("자동 운전 시작")
    while True:
        try:
            r = sess.get(f"{BASE}/steps", timeout=60)
            if r.status_code in (401, 403):
                login(sess)
                time.sleep(PERIOD)
                continue
            items = r.json()
            items = items if isinstance(items, list) else (
                items.get("steps") or [])
        except Exception as exc:  # noqa: BLE001
            log(f"상태 조회 실패({exc!r}) — 재시도")
            time.sleep(PERIOD)
            continue

        # ★응답이 대상 전부를 담고 있는지 먼저 본다(Codex 지적). 200 인데 일부
        #  항목이 빠지면 그 스텝은 "없는 것"이 되어 **미완인데 완주로 읽힌다.**
        got = {x.get("step_id") for x in items}
        missing = runnable - got
        if missing:
            log(f"★응답에 대상 {len(missing)}개가 없다 {sorted(missing)[:5]}"
                " — 완주 판정을 보류한다")
            time.sleep(PERIOD)
            continue

        # ★run-all 이 고르는 스텝만 센다. deprecated/on_demand/disabled 는
        #  영원히 pending 으로 남아 있는 것이 정상이라 "남은 것"이 아니다.
        items = [x for x in items if x.get("step_id") in runnable]
        # 모르는 상태를 완료처럼 흘려보내지 않는다 — 끝난 것은 둘뿐이다.
        unknown = sorted({x.get("status") for x in items}
                         - {"completed", "not_applicable", "running"}
                         - set(LEFT_STATES))
        if unknown:
            log(f"★모르는 상태 {unknown} — 완주 판정을 보류한다")
            time.sleep(PERIOD)
            continue
        c = Counter(x.get("status") for x in items)
        run = sorted(x.get("step_id") for x in items
                     if x.get("status") == "running")
        left_ids = [x.get("step_id") for x in items
                    if x.get("status") in LEFT_STATES]
        left = len(left_ids)
        line = (f"완료 {c.get('completed', 0)} / 남은 {left} / 실행중 {run or '-'}"
                + (f" {left_ids}" if 0 < left <= 4 else ""))

        if left == 0 and not run:
            log(f"완주 — {line}")
            # ★활동 참고이지 신선함의 증명이 아니다(Codex HIGH-3): 이 수는
            #  과거 산출·중간 파일·체크포인트를 다 세므로, 최종 스틸이 옛
            #  파일 그대로여도 체크포인트 몇 개만 갱신되면 커 보인다.
            #  "258/258 완료"인데 253장이 옛 파일이던 실제 사례(2026-08-08).
            #  신선함은 산출 파일의 mtime 분포를 직접 봐야 한다.
            n_files, f_idle = _file_evidence()
            log(f"  완주 활동 참고(신선함 증명 아님) — 산출·CP 파일 "
                f"{n_files}개 · 최신 {f_idle if f_idle is not None else '?'}s"
                f" 전 · 마지막 호출 "
                f"{_db_idle() if _db_idle() is not None else '?'}s 전")
            return

        # ★자동으로 다시 부르지 않을 스텝이 막혀 있으면 **거기서 멈춘다**.
        #  회수 목록에서 빼기만 하면 아래 run-all 재개로 흘러가 결국 다시
        #  불린다 — 뺀 의미가 없어진다. 사람이 봐야 하는 일이므로 루프를 끝낸다.
        human = sorted(x.get("step_id") for x in items
                       if x.get("step_id") in NO_AUTO_RECOVER
                       and x.get("status") in RECOVERABLE)
        if human:
            log(f"★사람이 봐야 한다 — {human} 이(가) 막혔다. 다시 불러도 같은 값이"
                " 나오는 스텝이라 자동 재시도를 하지 않는다. 운전을 멈춘다.")
            return

        if run:
            # ★멈춤은 **활동 없음**으로만 판단한다 (2026-08-07 재수정).
            #
            # 전에는 `stale_locks()`(경과 3700초 초과)를 통과한 것만 활동을
            # 봤다. 그래서 시작한 지 8분 된 스텝이 한 줄도 일하지 않아도
            # 한 시간을 기다렸다 — 활동을 보는 코드를 만들어 놓고 경과 시간
            # 뒤에 가둬서 못 쓰게 한 꼴이다. 경과 시간은 멈춤의 근거가 아니다.
            # 오래 걸리는 정상 작업도 있고, 방금 시작해 죽는 작업도 있다.
            sid = run[0]
            el = elapsed_of(sid)
            idle = idle_seconds()
            if idle is not None and idle > IDLE_DEAD_S:
                log(f"멈춤 확정: {sid} 경과 {el}s · 활동 {idle}s 전 — 잠금 해제")
                clear_lock(sid)
                cool = 0
                last = line
                time.sleep(PERIOD)
                continue
            # ★상태가 안 바뀌어도 매 사이클 찍는다. 침묵은 "정상 진행"과
            #  "고장"을 구분하지 못한다.
            why = f"활동 {idle}s 전" if idle is not None else "활동 판단 불가"
            log(f"{line} (경과 {el}s · {why})")
            last = line
            time.sleep(PERIOD)
            continue

        if cool > 0:
            cool -= 1
            log(f"{line} — 재개 대기 {cool}")
            time.sleep(PERIOD)
            continue

        # ★재개가 듣고 있는지 본다 — **상태 스냅샷**으로. 완료 스텝 수만 보면
        #  partial 안에서 나아가는 진행을 못 본다.
        snap = state_snapshot(runnable)
        if snap is None:
            log(f"{line} — 상태 조회 실패, 판단 보류")
            time.sleep(PERIOD)
            continue
        if snap == last_snap:
            stuck += 1
        else:
            stuck, last_snap = 0, snap

        if stuck >= STUCK_RESUMES:
            rec = recoverable_steps(runnable)
            if rec is None:
                log(f"{line} — 회수 대상 조회 실패, 판단 보류")
                time.sleep(PERIOD)
                continue
            # ★같은 스텝을 무한히 다시 부르지 않는다(Codex BLOCKING). 전에는
            #  회수 호출 뒤 무조건 stuck 을 0 으로 되돌려, 그 스텝이 계속
            #  partial 이거나 POST 가 실패해도 3라운드마다 영원히 다시 불렀다.
            #  이미지 단계면 돈이 반복해서 나간다.
            fresh = [(s, st) for s, st in rec
                     if tries.get(s, 0) < MAX_RECOVER_TRIES]
            if fresh:
                sid, st = fresh[0]
                tries[sid] = tries.get(sid, 0) + 1
                log(f"재개 {stuck}회에도 상태 그대로 — {sid}({st}) 개별 resume"
                    f" [{tries[sid]}/{MAX_RECOVER_TRIES}]"
                    + (f" · 뒤에 {len(rec) - 1}개 더 있음" if len(rec) > 1 else ""))
                resp = resume_step(sess, sid)
                log(f"  응답 {resp}")
                if not resp.startswith("200"):
                    log(f"  ★회수 호출이 실패했다 — {sid} 는 이 시도로 안 풀린다")
                # ★stuck 을 여기서 0 으로 되돌리지 않는다. 호출이 먹혔는지는
                #  **다음 사이클의 상태 스냅샷**이 말한다. 상태가 실제로 바뀌면
                #  자동으로 0 이 되고, 안 바뀌면 시도 한도가 소진된다.
                cool = COOL_AFTER_RESUME
                time.sleep(PERIOD)
                continue
            if rec:
                log(f"★사람이 봐야 한다 — {[s for s, _ in rec]} 를 각각"
                    f" {MAX_RECOVER_TRIES}회 회수했는데 그대로다. 더 부르지 않는다"
                    " (다시 부르면 돈만 나간다).")
                time.sleep(PERIOD)
                continue
            if stuck >= GIVE_UP_ROUNDS:
                log(f"★사람이 봐야 한다 — 재개 {stuck}회에도 상태가 그대로인데"
                    f" 회수할 partial·failed 도 없다. 남은 것: {left_ids}")
                time.sleep(PERIOD)
                continue

        log(f"정지 감지 — run-all 재개 ({line})")
        try:
            resp = sess.post(
                f"{BASE}/steps/run-all",
                params={"category": "all", "mode": "resume",
                        "image_call_cap": 8000,
                        "approve_image_generation": "true"},
                timeout=120)
            log(f"  재개 응답 {resp.status_code} {resp.text[:120]}")
        except Exception as exc:  # noqa: BLE001
            log(f"  재개 실패: {exc!r}")
        cool = COOL_AFTER_RESUME
        last = line
        time.sleep(PERIOD)


def _install_exit_logging() -> None:
    """죽을 때 반드시 한 줄 남긴다.

    ★2026-08-08 15:11 재기동분이 1분 만에 로그 한 줄 없이 사라져, 죽은
    시각도 원인도 알 수 없었다. 신호(kill·^C)든 예외든 마지막 줄이 남아야
    다음 사람이 원인을 좇는다. SIGKILL(-9)만은 어떤 코드로도 못 잡는다 —
    로그가 침묵으로 끝났다면 그것부터 의심할 것.
    """
    import atexit
    import signal
    import sys

    atexit.register(lambda: log("운전 종료(프로세스 exit)"))

    def _on_signal(signum, frame):  # noqa: ARG001
        log(f"신호 {signal.Signals(signum).name} 수신 — 운전 종료")
        sys.exit(0)

    for s in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
        try:
            signal.signal(s, _on_signal)
        except (ValueError, OSError):
            pass


if __name__ == "__main__":
    _install_exit_logging()
    try:
        main()
    except SystemExit:
        raise
    except Exception:  # noqa: BLE001 — 마지막 줄을 남기는 것이 목적
        import traceback

        log("★운전 루프가 예외로 죽었다:\n" + traceback.format_exc())
        raise
