#!/bin/bash
# 파이프라인 자동 운전 — 멈추면 스스로 회수하고 재개한다.
#
# 이 루프가 없어 같은 개입을 세 번 반복했다(재기동 스테일 락, 개별 스텝
# 회수 후 재개 누락, partial 정지). 규칙은 넷이다.
#   · 돌고 있으면 둔다.
#   · running 인데 스테일 만료를 넘겼으면 **resume 1회**로 회수한다.
#     (force 는 쓰지 않는다 — 하류를 지우고, 재시도가 started_at 을
#      갱신해 만료를 계속 미룬다.)
#   · failed/partial 이 있으면 그 스텝에 **표적 resume**(run-all 은
#     partial 을 못 잡는다 — 2026-08-12 S25sh1 실측). 스텝당 3회
#     넘으면 돈이 새는 무한 재시도이므로 소리치고 멈춘다.
#   · 아무도 안 돌고 남은 스텝이 있으면 run-all resume 으로 재개한다.
#
# Codex 합의(2026-08-11) 반영:
#   (a) failed/partial 을 남은 것으로 센다 — 이전엔 완주로 오판했다.
#   (b) 침묵 문턱은 이미지 호출 한 번의 최대 창(timeout×(1+retries))
#       보다 커야 한다 — config 에서 산출. 이전 300s 는 420s 단일
#       호출보다 짧아 살아 있는 run 을 오회수할 수 있었다.
#   (c) 생존 신호는 episode 스코프만 — llm_call_log(에피소드별 provider
#       호출)와 projects/$P/images/$E PNG. 전역 uvicorn.log JSON ts 는
#       다른 실행이 죽은 실행을 무기한 가리므로 쓰지 않는다.
set -u
P=${P:-e716bafb-24bb-42b7-aea0-fdb383844ee8}
E=${E:-d6a9aa85-b75e-400c-980c-4ee7e876a15b}
CAP=${CAP:-8000}
BASE=http://localhost:8000/api/v1/projects/$P/episodes/$E
STALE=3700          # 스테일 만료(3600) + 여유
COOL=0              # 재개 직후 과잉 재개 방지
DIR=$(cd "$(dirname "$0")" && pwd)

# (b) 침묵 문턱 — llm_timeout_image_gen×(1+llm_max_retries)+여유 120s.
SILENCE=$("$DIR/.venv/bin/python" -c "
from app.core.config import settings
print(settings.llm_timeout_image_gen*(1+settings.llm_max_retries)+120)" \
  2>/dev/null || echo 1800)
case "$SILENCE" in ''|*[!0-9]*) SILENCE=1800;; esac
echo "침묵 문턱 ${SILENCE}s (config 산출)"

# (a) 표적 resume 횟수 — 스텝당 3회 상한. ★파일 기반 카운터:
#   macOS /bin/bash 3.2 는 `declare -A` 를 모른다(Codex 재리뷰 BLOCK-1
#   실측 exit 127 — bash -n 으로도 안 잡힌다). 파일이면 bash 3 호환에
#   재기동을 거쳐도 상한이 유지된다.
# ★fail-closed(Codex 3차 BLOCK-1): 저장이 실패하면 상한이 사라져
#   유료 재개가 무한 반복된다 — 생성/읽기/쓰기 어느 실패도 즉시 멈춘다.
#   카운터는 스텝 성공(완주) 시 정리된다. 수동 해제:
#   rm "$CNT_DIR/<P>.<E>.<step>.cnt"
CNT_DIR="${CNT_DIR:-$DIR/autodrive_state}"
mkdir -p "$CNT_DIR" || { echo "카운터 디렉토리 생성 실패: $CNT_DIR — 상한을 보장할 수 없어 멈춘다"; exit 1; }
cnt_get() {
  # ★부재만 0 이다(Codex 4차): 존재하는 파일의 읽기 실패(EACCES·
  #   디렉터리·I/O)·빈값·비숫자는 전부 fail-closed — "" → 0 세탁은
  #   3회 든 상한을 읽기 불능 한 번으로 초기화해 유료 재개를 반복시킨다.
  local f v
  f="$CNT_DIR/$P.$E.$1.cnt"
  [ ! -e "$f" ] && { echo 0; return 0; }
  v=
  if ! IFS= read -r v < "$f" 2>/dev/null && [ -z "$v" ]; then
    echo "카운터 읽기 실패/빈 파일($f) — 상한을 보장할 수 없어 멈춘다" >&2
    return 1
  fi
  case "$v" in
    ''|*[!0-9]*)
      echo "카운터 파일 손상($f='$v') — 상한을 보장할 수 없어 멈춘다" >&2
      return 1;;
  esac
  echo "$v"
}
cnt_bump() {
  local n tmp
  n=$(cnt_get "$1") || exit 1
  tmp="$CNT_DIR/.$P.$E.$1.cnt.tmp"
  { echo $((n + 1)) > "$tmp" && mv "$tmp" "$CNT_DIR/$P.$E.$1.cnt"; } \
    || { echo "카운터 기록 실패($1) — 상한을 보장할 수 없어 멈춘다" >&2; exit 1; }
}

relogin() {
  curl -s -c /tmp/th.cookie -X POST http://localhost:8000/api/v1/auth/login \
    -H 'Content-Type: application/json' \
    -d '{"username":"admin","password":"admin123"}' -o /dev/null
}

# (c) episode 스코프 생존 나이(초) — provider 호출·이미지 산출 중 최신.
# ★find 시간 비교는 -mmin 만 쓴다 — -newermt 상대 형식은 macOS 에서
#   조용히/명시적으로 실패한다(2026-08-07 실측 + 이번 bfs 거부).
MMIN=$(( (SILENCE + 59) / 60 ))
alive_age() {
  local db_age img_age img_new
  db_age=$(PGPASSWORD=theroad_dev_2026 psql -h localhost -U theroad -d theroad \
    -t -A -c "SELECT COALESCE(EXTRACT(EPOCH FROM \
      (now()-max(created_at::timestamptz)))::int, 999999) \
      FROM llm_call_log WHERE episode_id='$E';" 2>/dev/null || echo 999999)
  case "$db_age" in ''|*[!0-9]*) db_age=999999;; esac
  img_new=$(/usr/bin/find "$DIR/../projects/$P/images/$E" -name "*.png" \
    -mmin "-$MMIN" 2>/dev/null | head -1)
  img_age=999999
  [ -n "$img_new" ] && img_age=0
  if [ "$db_age" -lt "$img_age" ]; then echo "$db_age"; else echo "$img_age"; fi
}

relogin   # 만료 쿠키로 시작하면 401 오류 JSON 이 "스텝 0개"로 읽혀 가짜 완주가 된다

while true; do
  # ★변수는 **python3 쪽**에 붙인다 — 파이프라인 앞 명령(curl)에 붙이면
  # 뒤 명령은 못 본다(실측). 그러면 수용 partial 이 조용히 무시돼 루프가
  # 그 스텝을 영원히 회수하려 든다.
  S=$(curl -s -b /tmp/th.cookie "$BASE/steps" 2>/dev/null \
      | ACCEPT="${ACCEPT_PARTIAL:-}" python3 -c "
import json,os,sys
try: d=json.load(sys.stdin)
except Exception: print('ERR'); sys.exit(0)
items=d if isinstance(d,list) else (d.get('steps') or [])
if not items: print('ERR'); sys.exit(0)
# 2026-08-20: run-all 이 **무조건** 거르는 두 값은 재개를 몇 번 보내도
# 실행되지 않는다(analysis_dispatch_service.py:260-261).
# 그것을 '남은 것'으로 세면 완주 조건이 원리적으로 충족될 수 없어 루프가
# 영원히 돈다 — 2026-08-08 에 일곱 시간 헛돈 것과 같은 부류다.
# (실측: last2 는 이것 때문에 pending 11 + blocked 2 가 끝까지 남았다.)
# ★`if_planning_doc` 은 여기서 빼지 않는다 — 그것은 **기획서가 없을 때만**
# 건너뛰는 조건부라(:262-263), 무조건 빼면 기획서가 있는 판에서 그 스텝이
# 실패로 남아도 완주로 선언하고 끝난다(미완을 완주로 읽는다).
STATIC_SKIP={'on_demand','disabled'}
items=[x for x in items if x.get('applicability') not in STATIC_SKIP]
# `if_planning_doc` 은 **기획서가 있을 때만** 도는 조건부다. 유무 판정은
# 네 곳을 보는 규칙(planning_doc_analysis_service.project_has_planning_doc)
# 이라 여기에 베껴 쓰면 두 판정이 갈린다. 대신 **상태로 가른다**:
#   · 아직 안 돈 것(pending/blocked) = 기획서가 없어 영영 안 불릴 수 있다
#     → 잔여로 세지 않는다. 세면 완주가 원리적으로 불가능해져 재개를
#     영원히 보낸다(기획서 없는 판이 전부 해당 — last1·last2 실측).
#   · 한 번이라도 돈 것(failed/partial/error/stale/running) = 기획서가
#     있다는 뜻이므로 그대로 센다. 실패를 완주로 읽지 않는다.
items=[x for x in items
       if x.get('applicability') != 'if_planning_doc'
       or x.get('status') not in ('pending', 'blocked')]
# 수용한 부분 완료(사람이 이미 판단한 결함)는 fp 와 left **양쪽**에서 뺀다.
# 한쪽만 빼면 표적 회수 3회 상한을 태우거나(fp 만) 완주가 영영 안 온다(left 만).
acc={s.strip() for s in os.environ.get('ACCEPT','').split(',') if s.strip()}
from collections import Counter
c=Counter(x.get('status') for x in items)
run=[x.get('step_id') for x in items if x.get('status')=='running']
# BLOCK-2: FP 는 파이프라인 순서 — 첫 하나만 회수해야 upstream 복구
# 전에 downstream 이 stale 입력으로 병행 실행되는 것을 막는다.
fp=[x.get('step_id') for x in sorted(items, key=lambda x: x.get('order') or 0)
    if x.get('status') in ('failed','partial','error')
    and x.get('step_id') not in acc]
left=c.get('stale',0)+c.get('pending',0)+c.get('blocked',0)+len(fp)
print(f\"{c.get('completed',0)} {left} {','.join(run) or '-'} {','.join(fp) or '-'}\")" 2>/dev/null)

  if [ "$S" = "ERR" ] || [ -z "$S" ]; then relogin; /bin/sleep "${SLEEP:-60}"; continue; fi
  set -- $S; DONE=$1; LEFT=$2; RUN=$3; FP=$4

  # 2026-08-20: `RUN = '-'` 를 함께 본다. running 은 잔여에 안 세어지므로,
  # 마지막 남은 스텝 하나가 **돌고 있는 중**에도 잔여가 0이 되어 그 자리에서
  # 완주로 끊고 나갔다(미완을 완주로 읽는다). 이 변경 이전부터 있던 모양이고,
  # 다른 스텝이 하나라도 남아 있으면 안 드러난다.
  if [ "$LEFT" = "0" ] && [ "$RUN" = "-" ]; then
    echo "완주 — 완료 $DONE (이 에피소드 표적 회수 카운터 정리)"
    rm -f "$CNT_DIR/$P.$E."*.cnt 2>/dev/null
    break
  fi

  if [ "$RUN" != "-" ]; then
    # 스테일 락 확인
    ST=$(PGPASSWORD=theroad_dev_2026 psql -h localhost -U theroad -d theroad -t -A -c \
      "SELECT step_id||':'||EXTRACT(EPOCH FROM (now()-started_at::timestamptz))::int \
       FROM step_run WHERE episode_id='$E' AND status='running' \
       AND EXTRACT(EPOCH FROM (now()-started_at::timestamptz))>$STALE;" 2>/dev/null)
    if [ -n "$ST" ]; then
      AGE=$(alive_age)
      if [ "$AGE" -lt "$SILENCE" ]; then
        echo "만료 초과지만 활동 ${AGE}s 전 — 살아 있음, 회수 보류: $ST"
      else
        SID=${ST%%:*}
        echo "스테일 락 회수: $ST (활동 ${AGE}s 침묵 ≥ ${SILENCE}s, resume 1회)"
        curl -s -b /tmp/th.cookie -X POST "$BASE/steps/$SID?mode=resume" -o /dev/null
        COOL=5
      fi
    fi
    /bin/sleep "${SLEEP:-60}"; continue
  fi

  if [ "$COOL" -gt 0 ]; then COOL=$((COOL-1)); /bin/sleep "${SLEEP:-60}"; continue; fi

  # (a) failed/partial 표적 회수 — run-all 은 이 상태를 못 잡는다.
  # BLOCK-2: 파이프라인 순서의 **첫 FP 하나만** 회수한다 — upstream 이
  # terminal 이 되면 다음 루프가 다음 FP 를 잡는다. 카운트는 서버가
  # 실제로 실행을 받은 경우(HTTP 2xx + status=started)만 올린다:
  # 401=재로그인만, 409/blocked/skipped=유료 시도 아님.
  if [ "$FP" != "-" ]; then
    SID=${FP%%,*}
    CNT=$(cnt_get "$SID") || exit 1   # 카운터 손상 = 상한 보장 불가 → 정지
    if [ "$CNT" -ge 3 ]; then
      echo "회수 불가 — $SID 표적 resume 3회에도 failed/partial 유지. 수동 개입 필요. 멈춘다."
      exit 1
    fi
    RESP=$(curl -s -w '\n%{http_code}' -b /tmp/th.cookie -X POST \
      "$BASE/steps/$SID?mode=resume" 2>/dev/null)
    CODE=${RESP##*$'\n'}
    BODY=${RESP%$'\n'*}
    STARTED=$(printf '%s' "$BODY" | python3 -c "
import json,sys
try: print(1 if json.load(sys.stdin).get('status')=='started' else 0)
except Exception: print(0)" 2>/dev/null || echo 0)
    if [ "$CODE" = "401" ]; then
      echo "표적 회수 $SID: 401 — 재로그인 (카운트 제외)"
      relogin
    elif [ "${CODE#2}" != "$CODE" ] && [ "$STARTED" = "1" ]; then
      cnt_bump "$SID"
      echo "failed/partial 표적 회수: $SID ($(cnt_get "$SID")회째, started)"
      COOL=3
    else
      echo "표적 회수 $SID: 실행 안 됨 (HTTP $CODE) — 카운트 제외: $(printf '%s' "$BODY" | head -c 160)"
    fi
    /bin/sleep "${SLEEP:-60}"; continue
  fi

  echo "정지 감지 — run-all 재개 (완료 $DONE / 남은 $LEFT)"
  curl -s -b /tmp/th.cookie -X POST \
    "$BASE/steps/run-all?category=all&mode=resume&image_call_cap=$CAP&approve_image_generation=true" \
    -o /dev/null
  COOL=3
  /bin/sleep "${SLEEP:-60}"
done
