#!/usr/bin/env bash
# session-watchdog.sh — 팀장 봇 세션 자동 이어가기 Watchdog
# systemd timer에 의해 2분마다 실행
#
# task-2422 (2026-05-03): watchdog alert 전용화 — 모든 변경 행위 제거
#   - timer.json status 박제 (jq write) 3개소 제거 (line 295/365/512)
#   - backoff 파일 쓰기 제거
#   - task-timer.py end 호출 제거 (mutation)
#   - watchdog는 이제 read-only + Telegram alert만 수행
#   - 회장 명시: "와치독은 스스로 아무것도 변경하지 못해야 한다"
#
# task-2399 (2026-05-03): false stalled 알람 제거 + 죽음 판정 정밀화
#   - cwd 정규화 (bug #1: no-taskfile false positive)
#   - add_stalled 헬퍼로 한 task = 한 알람 (bug #2)
#   - .escalate 마커 시 알람 억제 (bug #3)
#   - design/code task 종류별 heartbeat 차등 (bug #4)
#   - AND 조건 죽음 판정: PID + 진행 마커 + events mtime + PR/worktree (bug #5/#6)
#   - long-running 마커 화이트리스트 5종 (bug #7)
#   - 알람 본문 디버깅 정보 (bug #8 부분, taskfile/escalate/hb_age/markers)
#   - PR/worktree 교차 검증 (bug #9)

set -euo pipefail

WORKSPACE="/home/jay/workspace"
TIMERS_FILE="${WORKSPACE}/memory/task-timers.json"
HEARTBEAT_DIR="${WORKSPACE}/memory/heartbeats"
EVENTS_DIR="${WORKSPACE}/memory/events"
LOG_FILE="${WORKSPACE}/logs/session-watchdog.log"
STALE_THRESHOLD_CODE=600       # code 작업: 10분 (초)
STALE_THRESHOLD_DESIGN=1800    # design 작업: 30분 (초)
STALE_THRESHOLD_DEFAULT=1800   # 명시 부재 시 보수적 30분 (false alert 방지 우선)
EVENTS_STALE_THRESHOLD=900     # 마지막 events 파일 mtime: 15분 정지 시 정지로 간주
GRACE_PERIOD=600               # dispatch 직후 유예 기간 (초)
WATCHDOG_CHAT_ID="6937032012"
MAX_RETRY_DEFAULT=2
RAPID_REFAIL_WINDOW=180        # 3분 이내 재stall → 빠른 재실패 (alert 본문 분류용)
MAX_REDELEGATIONS_PER_CYCLE=3  # 사이클당 최대 재위임 건수 (read-only counter)

# 진행 마커 화이트리스트 (이 마커 중 하나라도 있으면 alive 판정)
# task-2399: long-running 단계 보호
# task-2405 fix#D: 회장 정의 — long-running 단계 100% 보호
PROGRESS_MARKERS=(
    "codex-gate"        # Codex G1/G2 검증 중
    "qc-done"           # QC 진행 중
    "done.merging"      # G3 머지 진행 중
    "pr-creating"       # PR 생성 중 (신규 마커)
    "external-running"  # 외부 CLI 호출 중 (신규 마커)
)

# task-2405 fix#C: 후속 task에 의해 superseded된 원본 task 검사
# - <task>.superseded_by 마커 존재 → skip (1순위)
# - 다른 task md 파일 본문에 task_id 언급 → skip (2순위, 보조)
should_skip_for_superseded() {
    local task_id="$1"
    # 1순위: 명시적 마커
    if [[ -f "${EVENTS_DIR}/${task_id}.superseded_by" ]]; then
        return 0
    fi
    # 2순위: 다른 task md에서 언급 (memory/tasks/*.md만 검사, 자기 자신 제외)
    local own_file="${WORKSPACE}/memory/tasks/${task_id}.md"
    if grep -rl "${task_id}" "${WORKSPACE}/memory/tasks/" --include='*.md' 2>/dev/null \
        | grep -v "^${own_file}$" \
        | head -1 | grep -q .; then
        return 0
    fi
    return 1
}

# 환경변수에서 Telegram 토큰 로드 (ps aux 노출 방지)
source "${WORKSPACE}/.env.keys"

# task-2399 fix#1: cwd 정규화 (상대 경로 task_file 검사 안전)
cd "$WORKSPACE"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"
}

# team_id → cokacdir key 매핑
get_cokacdir_key() {
    local team_id="$1"
    case "$team_id" in
        dev*-team)
            local role="${team_id%-team}"  # dev1-team → dev1
            local var="COKACDIR_KEY_${role^^}"  # COKACDIR_KEY_DEV1
            echo "${!var}"
            ;;
        anu-direct)
            echo "${COKACDIR_KEY_ANU:-}"
            ;;
        *)
            echo ""
            ;;
    esac
}

# task-2399 fix#1: TASK_FILE을 절대 경로로 정규화
# - 빈 값/null → ${WORKSPACE}/memory/tasks/${task_id}.md fallback
# - 상대 경로 → ${WORKSPACE}/${path}로 prepend
# - 절대 경로 → 그대로 사용
resolve_task_file() {
    local task_id="$1"
    local raw="$2"
    if [[ -z "$raw" || "$raw" == "null" ]]; then
        echo "${WORKSPACE}/memory/tasks/${task_id}.md"
        return
    fi
    if [[ "$raw" == /* ]]; then
        echo "$raw"
    else
        echo "${WORKSPACE}/${raw}"
    fi
}

# task-2399 fix#4: task 종류별 heartbeat 임계 결정
# - team_id == "design" 또는 task_file path가 design-md/ 포함 → 1800초
# - dev*-team 또는 그 외 → 600초
# - 명시 부재 시 보수적 1800초
get_stale_threshold() {
    local team_id="$1"
    local task_file_abs="$2"
    if [[ "$team_id" == "design" ]]; then
        echo "$STALE_THRESHOLD_DESIGN"
        return
    fi
    if [[ "$team_id" =~ ^dev[0-9]+-team$ ]]; then
        echo "$STALE_THRESHOLD_CODE"
        return
    fi
    # frontmatter fallback
    if [[ -f "$task_file_abs" ]]; then
        local fm_team
        fm_team=$(awk '/^---$/{c++; next} c==1 && /^team:/{sub(/^team:[[:space:]]*/,""); print; exit}' "$task_file_abs" 2>/dev/null || echo "")
        if [[ "$fm_team" == "design" ]]; then
            echo "$STALE_THRESHOLD_DESIGN"
            return
        fi
    fi
    echo "$STALE_THRESHOLD_DEFAULT"
}

# task-2399 fix#7: 장기실행 마커 존재 검사
# events/<tid>.{codex-gate,qc-done,done.merging,pr-creating,external-running} 중 하나라도 있으면 0(true)
has_progress_marker() {
    local task_id="$1"
    local m
    for m in "${PROGRESS_MARKERS[@]}"; do
        if [[ -f "${EVENTS_DIR}/${task_id}.${m}" ]]; then
            return 0
        fi
    done
    return 1
}

# task-2399 fix#7: 활성 마커 이름들 (디버그 출력용)
list_active_markers() {
    local task_id="$1"
    local out=()
    local m
    for m in "${PROGRESS_MARKERS[@]}"; do
        if [[ -f "${EVENTS_DIR}/${task_id}.${m}" ]]; then
            out+=("$m")
        fi
    done
    if [[ ${#out[@]} -eq 0 ]]; then
        echo "none"
    else
        local IFS=','
        echo "${out[*]}"
    fi
}

# task-2399 fix#5: 마지막 events/<tid>.* 파일 mtime 노후도(초). 파일 없으면 -1
get_last_event_age() {
    local task_id="$1"
    local now="$2"
    local newest=0
    local f
    shopt -s nullglob
    for f in "${EVENTS_DIR}/${task_id}".* "${EVENTS_DIR}/${task_id}"; do
        if [[ -f "$f" ]]; then
            local m
            m=$(stat -c %Y "$f" 2>/dev/null || echo 0)
            if [[ "$m" -gt "$newest" ]]; then
                newest="$m"
            fi
        fi
    done
    shopt -u nullglob
    if [[ "$newest" -eq 0 ]]; then
        echo "-1"
    else
        echo "$((now - newest))"
    fi
}

# task-2399 fix#9: PR/worktree 교차 검증
# - gh pr list로 task_id 매칭 PR 존재 → alive (0)
# - git worktree list로 task_id 매칭 worktree 존재 → alive (0)
# - 둘 다 없거나 명령 실패 → 1
has_active_pr_or_worktree() {
    local task_id="$1"
    # gh PR 검사 (graceful: gh 미설치/실패 시 0건 처리)
    if command -v gh &>/dev/null; then
        local pr_count
        pr_count=$(timeout 8 gh pr list --search "$task_id" --state open --limit 1 2>/dev/null | wc -l || echo 0)
        if [[ "$pr_count" -gt 0 ]]; then
            return 0
        fi
    fi
    # git worktree 검사
    if command -v git &>/dev/null; then
        if git -C "$WORKSPACE" worktree list 2>/dev/null | grep -q "$task_id"; then
            return 0
        fi
    fi
    return 1
}

# task-2405 fix#A: 회장 정의 — escalate 또는 acked 어느 하나라도 있으면 skip (acked = 회장 인지 = 알람 그만)
# 0 = skip 해야 함, 1 = 정상 처리
should_skip_for_escalate() {
    local task_id="$1"
    local esc="${EVENTS_DIR}/${task_id}.escalate"
    local acked="${EVENTS_DIR}/${task_id}.escalate.acked"
    if [[ -f "$esc" || -f "$acked" ]]; then
        return 0
    fi
    return 1
}

# task-timers.json이 없으면 종료
if [[ ! -f "$TIMERS_FILE" ]]; then
    log "task-timers.json 없음, 종료"
    exit 0
fi

# jq 필수
if ! command -v jq &>/dev/null; then
    log "ERROR: jq가 설치되어 있지 않습니다"
    exit 1
fi

# running 상태 tasks 추출 (anu-direct 제외)
RUNNING_TASKS=$(jq -r '.tasks | to_entries[] | select(.value.status == "running") | select(.value.status != "archived" and .value.status != "escalated") | select(.value.team_id != "anu-direct") | .key' "$TIMERS_FILE" 2>/dev/null)

if [[ -z "$RUNNING_TASKS" ]]; then
    log "running 태스크 없음"
    exit 0
fi

NOW=$(date +%s)
REDELEGATION_COUNT=0

# task-2399 fix#2: 한 task = 한 알람 (중복 push 차단)
# STALLED_TIDS: 추가된 task_id 추적용 (associative array)
# STALLED_DETAILS: tid → 디버그 본문 (정렬 안정 위해 STALLED_ORDER 별도 유지)
declare -A STALLED_TIDS=()
declare -A STALLED_DETAILS=()
STALLED_ORDER=()
REDELEGATED_LIST=()
ESCALATED_LIST=()

# task-2399 fix#2: stalled 등록 헬퍼 (중복 push 차단)
# 인자: task_id, team_id, reason, debug_info(선택)
add_stalled() {
    local tid="$1"
    local team="$2"
    local reason="$3"
    local debug="${4:-}"
    if [[ -n "${STALLED_TIDS[$tid]:-}" ]]; then
        # 이미 등록된 task — 추가 reason 병합 (sub-cause)
        local prev="${STALLED_DETAILS[$tid]}"
        STALLED_DETAILS[$tid]="${prev}+${reason}"
        return
    fi
    STALLED_TIDS[$tid]=1
    STALLED_ORDER+=("$tid")
    local entry="${tid}(team=${team}, reason=${reason}"
    if [[ -n "$debug" ]]; then
        entry="${entry}, ${debug}"
    fi
    entry="${entry})"
    STALLED_DETAILS[$tid]="$entry"
}

while IFS= read -r TASK_ID; do
    [[ -z "$TASK_ID" ]] && continue

    log "검사 시작: ${TASK_ID}"

    # 1. .done 계열 파일 존재 확인 — task-2405 fix#B: 회장 정의 자동 박제
    DONE_FILE="${EVENTS_DIR}/${TASK_ID}.done"
    if [[ -f "$DONE_FILE" || -f "${DONE_FILE}.acked" || -f "${DONE_FILE}.clear" || -f "${DONE_FILE}.notified" ]]; then
        log "${TASK_ID}: .done 계열 파일 존재 → 재위임 스킵 (read-only)"
        continue
    fi

    # 1.1 재시도 계열 완료 체크 (base task의 형제 +N 중 하나라도 .done이면 스킵)
    BASE_TASK_ID=$(echo "$TASK_ID" | sed 's/+[0-9]*$//')
    if [[ "$BASE_TASK_ID" != "$TASK_ID" ]]; then
        SIBLING_DONE=false
        for DONE_CHECK in "${EVENTS_DIR}/${BASE_TASK_ID}"*.done "${EVENTS_DIR}/${BASE_TASK_ID}"*.done.acked "${EVENTS_DIR}/${BASE_TASK_ID}"*.done.clear; do
            if [[ -f "$DONE_CHECK" ]]; then
                SIBLING_DONE=true
                log "${TASK_ID}: 형제 작업 완료 감지 ($(basename "$DONE_CHECK")) → 재위임 스킵"
                break
            fi
        done
        if [[ "$SIBLING_DONE" == "true" ]]; then
            continue
        fi
    fi

    # 1.5. 초기 유예 기간 확인 (dispatch 직후 10분 이내 → 스킵)
    START_TIME=$(jq -r --arg tid "$TASK_ID" '.tasks[$tid].start_time // ""' "$TIMERS_FILE" 2>/dev/null)
    if [[ -n "$START_TIME" && "$START_TIME" != "null" ]]; then
        START_EPOCH=$(date -d "$START_TIME" +%s 2>/dev/null || echo 0)
        if [[ "$START_EPOCH" -gt 0 ]]; then
            GRACE_ELAPSED=$((NOW - START_EPOCH))
            if [[ "$GRACE_ELAPSED" -lt "$GRACE_PERIOD" ]]; then
                log "${TASK_ID}: 시작 ${GRACE_ELAPSED}초 전 (유예 기간 ${GRACE_PERIOD}초) → 스킵"
                continue
            fi
        fi
    fi

    # task 메타데이터 읽기
    SCHEDULE_ID=$(jq -r --arg tid "$TASK_ID" '.tasks[$tid].schedule_id // ""' "$TIMERS_FILE" 2>/dev/null)
    RETRY_COUNT=$(jq -r --arg tid "$TASK_ID" '.tasks[$tid].retry_count // 0' "$TIMERS_FILE" 2>/dev/null)
    MAX_RETRY=$(jq -r --arg tid "$TASK_ID" '.tasks[$tid].max_retry // '"$MAX_RETRY_DEFAULT"'' "$TIMERS_FILE" 2>/dev/null)
    TASK_FILE_RAW=$(jq -r --arg tid "$TASK_ID" '.tasks[$tid].task_file // ""' "$TIMERS_FILE" 2>/dev/null)
    TEAM_ID=$(jq -r --arg tid "$TASK_ID" '.tasks[$tid].team_id // ""' "$TIMERS_FILE" 2>/dev/null)

    # task-2399 fix#1: TASK_FILE 절대 경로 정규화 (cwd 의존 제거)
    TASK_FILE=$(resolve_task_file "$TASK_ID" "$TASK_FILE_RAW")
    TASK_FILE_EXISTS="no"
    if [[ -f "$TASK_FILE" ]]; then
        TASK_FILE_EXISTS="yes"
    fi

    # retry_count가 숫자가 아닐 경우 0으로 초기화
    if ! [[ "$RETRY_COUNT" =~ ^[0-9]+$ ]]; then
        RETRY_COUNT=0
    fi
    if ! [[ "$MAX_RETRY" =~ ^[0-9]+$ ]]; then
        MAX_RETRY=$MAX_RETRY_DEFAULT
    fi

    # task-2399 fix#3: .escalate 마커 시 알람 억제 (acked 없으면 skip)
    if should_skip_for_escalate "$TASK_ID"; then
        log "${TASK_ID}: .escalate 발급됨, .escalate.acked 부재 → 알람 억제 (회장 승인 대기)"
        continue
    fi

    # task-2405 fix#C: 후속 task에 의해 superseded된 원본 → skip (read-only)
    if should_skip_for_superseded "$TASK_ID"; then
        log "${TASK_ID}: 후속 task에 의해 superseded → 알람 억제 (read-only)"
        continue
    fi

    # 2. PID 추적 (Primary)
    PID_ALIVE=false
    if [[ -n "$SCHEDULE_ID" && "$SCHEDULE_ID" != "null" ]]; then
        PROMPT_FILE=$(grep -rl "$SCHEDULE_ID" /home/jay/.cokacdir/system_prompt_* 2>/dev/null | head -1)
        if [[ -n "$PROMPT_FILE" ]]; then
            HASH=$(basename "$PROMPT_FILE" | sed 's/system_prompt_\(.*\)_.*/\1/')
            PID=$(ps aux | grep "system_prompt_${HASH}" | grep -v grep | awk '{print $2}' | head -1)
            if [[ -n "$PID" ]]; then
                PID_ALIVE=true
                log "${TASK_ID}: PID ${PID} 존재 → alive"
            fi
        fi
    fi

    if [[ "$PID_ALIVE" == "true" ]]; then
        continue
    fi

    # task-2399 fix#7: 진행 마커 존재 시 alive 판정 (heartbeat 무관)
    if has_progress_marker "$TASK_ID"; then
        log "${TASK_ID}: 진행 마커 존재 ($(list_active_markers "$TASK_ID")) → alive (long-running)"
        continue
    fi

    # 3. heartbeat mtime 확인 (Secondary, task 종류별 차등)
    STALE_THRESHOLD=$(get_stale_threshold "$TEAM_ID" "$TASK_FILE")
    HEARTBEAT_FILE="${HEARTBEAT_DIR}/${TASK_ID}.heartbeat"
    HEARTBEAT_ALIVE=false
    HB_AGE="-1"
    if [[ -f "$HEARTBEAT_FILE" ]]; then
        HEARTBEAT_MTIME=$(stat -c %Y "$HEARTBEAT_FILE" 2>/dev/null || echo 0)
        HB_AGE=$((NOW - HEARTBEAT_MTIME))
        if [[ "$HB_AGE" -lt "$STALE_THRESHOLD" ]]; then
            HEARTBEAT_ALIVE=true
            log "${TASK_ID}: heartbeat ${HB_AGE}s ago (< ${STALE_THRESHOLD}s, team=${TEAM_ID}) → alive"
        else
            log "${TASK_ID}: heartbeat ${HB_AGE}s ago (>= ${STALE_THRESHOLD}s, team=${TEAM_ID}) → stale"
        fi
    else
        log "${TASK_ID}: heartbeat 파일 없음"
    fi

    if [[ "$HEARTBEAT_ALIVE" == "true" ]]; then
        continue
    fi

    # 4. stalled 판정
    # race condition 방지: status 재확인
    CURRENT_STATUS=$(jq -r --arg tid "$TASK_ID" '.tasks[$tid].status // "unknown"' "$TIMERS_FILE" 2>/dev/null)
    if [[ "$CURRENT_STATUS" != "running" ]]; then
        log "${TASK_ID}: status=${CURRENT_STATUS} (not running) → 스킵"
        continue
    fi

    # race condition 방지: end_time 존재 확인
    END_TIME=$(jq -r --arg tid "$TASK_ID" '.tasks[$tid].end_time // ""' "$TIMERS_FILE" 2>/dev/null)
    if [[ -n "$END_TIME" && "$END_TIME" != "null" ]]; then
        log "${TASK_ID}: end_time 존재 → 완료됨, 재위임 스킵"
        continue
    fi

    # 재시도 계열: base task의 end_time 존재 시에도 완료 처리
    BASE_TASK_ID_END=$(echo "$TASK_ID" | sed 's/+[0-9]*$//')
    if [[ "$BASE_TASK_ID_END" != "$TASK_ID" ]]; then
        BASE_END_TIME=$(jq -r --arg tid "$BASE_TASK_ID_END" '.tasks[$tid].end_time // ""' "$TIMERS_FILE" 2>/dev/null)
        if [[ -n "$BASE_END_TIME" && "$BASE_END_TIME" != "null" ]]; then
            log "${TASK_ID}: base task(${BASE_TASK_ID_END}) end_time 존재 → 완료됨, 재위임 스킵"
            continue
        fi
    fi

    # task-2399 fix#5/#6: AND 조건 죽음 판정
    # PID 없음 + 진행 마커 없음 + heartbeat 노후 (이미 충족) AND
    # (c) 마지막 events 파일 mtime N분 이상 정지 AND
    # (d) PR/worktree 정지
    EVENT_AGE=$(get_last_event_age "$TASK_ID" "$NOW")
    EVENTS_STALE=true
    if [[ "$EVENT_AGE" -ge 0 && "$EVENT_AGE" -lt "$EVENTS_STALE_THRESHOLD" ]]; then
        EVENTS_STALE=false
        log "${TASK_ID}: events mtime ${EVENT_AGE}s ago (< ${EVENTS_STALE_THRESHOLD}s) → alive (recent activity)"
    fi
    if [[ "$EVENTS_STALE" == "false" ]]; then
        continue
    fi

    if has_active_pr_or_worktree "$TASK_ID"; then
        log "${TASK_ID}: 활성 PR 또는 worktree 존재 → alive (long-running review/merge)"
        continue
    fi

    # 모든 진행 신호 부재 → 진짜 STALLED 판정
    ACTIVE_MARKERS=$(list_active_markers "$TASK_ID")
    DEBUG_INFO="taskfile=${TASK_FILE_EXISTS}, escalate=$([[ -f "${EVENTS_DIR}/${TASK_ID}.escalate" ]] && echo yes || echo no), hb_age=${HB_AGE}s, ev_age=${EVENT_AGE}s, markers=${ACTIVE_MARKERS}"
    log "${TASK_ID}: STALLED 판정 (PID 없음, 마커 없음, heartbeat ${HB_AGE}s, events ${EVENT_AGE}s, PR/worktree 없음)"

    # --- Circuit Breaker: 백오프 체크 ---
    BACKOFF_FILE="${HEARTBEAT_DIR}/${TASK_ID}.backoff"
    if [[ -f "$BACKOFF_FILE" ]]; then
        BACKOFF_EXPIRY=$(sed -n '2p' "$BACKOFF_FILE" 2>/dev/null || echo 0)
        if ! [[ "$BACKOFF_EXPIRY" =~ ^[0-9]+$ ]]; then BACKOFF_EXPIRY=0; fi
        if [[ "$BACKOFF_EXPIRY" -gt 0 ]] && [[ "$NOW" -lt "$BACKOFF_EXPIRY" ]]; then
            REMAINING=$(( BACKOFF_EXPIRY - NOW ))
            log "${TASK_ID}: 백오프 중 (잔여 ${REMAINING}s), 재위임 스킵"
            add_stalled "$TASK_ID" "$TEAM_ID" "backoff" "$DEBUG_INFO"
            continue
        fi
        # 백오프 만료 또는 미적용 — 빠른 재실패 감지
        LAST_REDELEG=$(sed -n '1p' "$BACKOFF_FILE" 2>/dev/null || echo 0)
        if ! [[ "$LAST_REDELEG" =~ ^[0-9]+$ ]]; then LAST_REDELEG=0; fi
        if [[ "$LAST_REDELEG" -gt 0 ]] && [[ $(( NOW - LAST_REDELEG )) -lt "$RAPID_REFAIL_WINDOW" ]]; then
            log "${TASK_ID}: 빠른 재실패 감지 → 알람만 (read-only)"
            add_stalled "$TASK_ID" "$TEAM_ID" "rapid-refail" "$DEBUG_INFO"
            continue
        fi
    fi

    # 글로벌 재시도 상한: base task 기준으로 전체 +N 합산
    BASE_TASK_ID_FOR_RETRY=$(echo "$TASK_ID" | sed 's/+[0-9]*$//')
    GLOBAL_RETRY_COUNT=0
    while IFS= read -r SIBLING_TID; do
        [[ -z "$SIBLING_TID" ]] && continue
        S_RC=$(jq -r --arg tid "$SIBLING_TID" '.tasks[$tid].retry_count // 0' "$TIMERS_FILE" 2>/dev/null)
        if [[ "$S_RC" =~ ^[0-9]+$ ]]; then
            GLOBAL_RETRY_COUNT=$((GLOBAL_RETRY_COUNT + S_RC + 1))
        fi
    done <<< "$(jq -r '.tasks | keys[]' "$TIMERS_FILE" 2>/dev/null | grep "^${BASE_TASK_ID_FOR_RETRY}")"

    GLOBAL_MAX_RETRY=3
    if [[ "$GLOBAL_RETRY_COUNT" -ge "$GLOBAL_MAX_RETRY" ]]; then
        log "${TASK_ID}: 글로벌 재시도 상한 (${GLOBAL_RETRY_COUNT}/${GLOBAL_MAX_RETRY}) 도달 → 재위임 거부"
        add_stalled "$TASK_ID" "$TEAM_ID" "global-retry-limit" "$DEBUG_INFO"
        continue
    fi

    if [[ "$RETRY_COUNT" -ge "$MAX_RETRY" ]]; then
        log "${TASK_ID}: retry_count=${RETRY_COUNT} >= max_retry=${MAX_RETRY} → 에스컬레이션 알람 (read-only)"
        ESCALATED_LIST+=("${TASK_ID}(${TEAM_ID})")
        continue
    fi

    # 동시 재위임 상한 체크
    if [[ "$REDELEGATION_COUNT" -ge "$MAX_REDELEGATIONS_PER_CYCLE" ]]; then
        log "${TASK_ID}: 이번 사이클 재위임 상한(${MAX_REDELEGATIONS_PER_CYCLE}) 도달, 다음 사이클로 연기"
        add_stalled "$TASK_ID" "$TEAM_ID" "deferred" "$DEBUG_INFO"
        continue
    fi

    # 재위임 비활성화 — 경고만 전송 (2026-04-16 제이회장님 지시)
    log "${TASK_ID}: STALLED 감지 — 재위임 비활성화, 경고만 전송"
    # task-2399 fix#1+#2: TASK_FILE 검사는 add_stalled의 reason으로 통합 (no-taskfile 별도 push 금지)
    if [[ "$TASK_FILE_EXISTS" == "no" ]]; then
        add_stalled "$TASK_ID" "$TEAM_ID" "stalled-no-taskfile" "$DEBUG_INFO"
    else
        add_stalled "$TASK_ID" "$TEAM_ID" "stalled-alert-only" "$DEBUG_INFO"
    fi

    # dispatch.py 재위임 호출 주석 처리 (2026-04-16 제이회장님 지시, 향후 재활성화 가능)
    # [비활성화] cokacdir 스케줄 제거, 잔존 프로세스 kill, task-timer end, dispatch.py 재위임

done <<< "$RUNNING_TASKS"

# 집계 알림 전송
ALERT_PARTS=()
if [[ ${#REDELEGATED_LIST[@]} -gt 0 ]]; then
    ALERT_PARTS+=("🔄 재위임 ${#REDELEGATED_LIST[@]}건: $(IFS=', '; echo "${REDELEGATED_LIST[*]}")")
fi
if [[ ${#ESCALATED_LIST[@]} -gt 0 ]]; then
    ALERT_PARTS+=("🚨 에스컬레이션 ${#ESCALATED_LIST[@]}건: $(IFS=', '; echo "${ESCALATED_LIST[*]}")")
fi
if [[ ${#STALLED_ORDER[@]} -gt 0 ]]; then
    # task-2399 fix#2/#8: 한 task = 한 알람 + 디버깅 본문
    STALLED_ENTRIES=()
    for tid in "${STALLED_ORDER[@]}"; do
        STALLED_ENTRIES+=("${STALLED_DETAILS[$tid]}")
    done
    ALERT_PARTS+=("⚠️ stalled ${#STALLED_ORDER[@]}건: $(IFS=$'\n'; echo "${STALLED_ENTRIES[*]}")")
fi

if [[ ${#ALERT_PARTS[@]} -gt 0 ]]; then
    ALERT_MSG=$(printf '[Watchdog] %s\n' "${ALERT_PARTS[@]}")
    if [[ "${WATCHDOG_DRY_RUN:-0}" == "1" ]]; then
        log "DRY_RUN: 알람 전송 건너뜀. 본문:"
        log "${ALERT_MSG}"
    else
        curl -s -X POST "https://api.telegram.org/bot${ANU_BOT_TOKEN}/sendMessage" \
            --data-urlencode "chat_id=6937032012" \
            --data-urlencode "text=${ALERT_MSG}" \
            > /dev/null 2>&1
    fi
else
    log "알람 없음 (false alert 0건 확인)"
fi

log "워치독 사이클 완료"
