"""
tests/test_watchdog_noise_elimination.py

task-2399 회귀 테스트 — false stalled 알람 0건 + 진짜 죽음만 검출
session-watchdog.sh 블랙박스 실행 기반 (옵션A: sed WORKSPACE 치환)

시나리오:
  1. test_relative_taskfile_resolved_to_absolute
  2. test_no_taskfile_and_stalled_alert_one_alarm_only
  3. test_escalate_marker_suppresses_alert
  4. test_escalate_acked_keeps_suppression_chairman_def  (task-2405 회장 정의 수정)
  5. test_design_heartbeat_30min_threshold
  6. test_code_heartbeat_10min_threshold
  7. test_progress_marker_codex_gate_keeps_alive
  8. test_progress_marker_pr_creating_keeps_alive
  9. test_alert_body_contains_debug_info
 10. test_recent_events_activity_keeps_alive
 11. test_pr_or_worktree_keeps_alive_skipped_if_unavailable
 12. test_no_double_push_for_same_task
 13. test_no_running_tasks_exits_clean  (보너스)
 14. test_grace_period_skips_recent_dispatch  (보너스)
"""

from __future__ import annotations

import json
import os
import subprocess
import time
from pathlib import Path

# ---------------------------------------------------------------------------
# 공통 픽스처 & 헬퍼
# ---------------------------------------------------------------------------

ORIG_SCRIPT = Path("/home/jay/workspace/scripts/session-watchdog.sh")


def _build_timers(tasks: dict) -> dict:
    """task-timers.json 포맷."""
    return {"tasks": tasks}


def _running_task(
    task_id: str,
    team_id: str = "dev1-team",
    task_file: str = "",
    start_offset: int = -3600,
) -> dict:
    """status=running 태스크 항목. start_time은 현재 - start_offset 초."""
    start_ts = time.strftime(
        "%Y-%m-%dT%H:%M:%S.000000",
        time.localtime(time.time() + start_offset),
    )
    return {
        "task_id": task_id,
        "team_id": team_id,
        "status": "running",
        "start_time": start_ts,
        "task_file": task_file,
        "retry_count": 0,
        "max_retry": 2,
    }


def setup_workspace(tmp_path: Path, timers: dict) -> Path:
    """
    tmp_path 아래 필수 디렉토리·파일을 생성하고
    WORKSPACE가 tmp_path를 가리키도록 패치된 스크립트를 반환.
    """
    for d in [
        "memory/events",
        "memory/heartbeats",
        "memory/tasks",
        "logs",
        "scripts",
    ]:
        (tmp_path / d).mkdir(parents=True, exist_ok=True)

    # task-timers.json
    (tmp_path / "memory" / "task-timers.json").write_text(
        json.dumps(timers), encoding="utf-8"
    )

    # .env.keys (더미 토큰)
    (tmp_path / ".env.keys").write_text("ANU_BOT_TOKEN=dummy\n", encoding="utf-8")

    # task-timer.py 더미 (스크립트 내부 python3 호출 방어)
    (tmp_path / "memory" / "task-timer.py").write_text(
        "#!/usr/bin/env python3\n", encoding="utf-8"
    )

    # 스크립트 복사 + WORKSPACE 치환
    orig = ORIG_SCRIPT.read_text(encoding="utf-8")
    patched = orig.replace(
        'WORKSPACE="/home/jay/workspace"',
        f'WORKSPACE="{tmp_path}"',
    )
    script_path = tmp_path / "scripts" / "session-watchdog.sh"
    script_path.write_text(patched, encoding="utf-8")
    script_path.chmod(0o755)

    return script_path


def run_watchdog(
    script_path: Path,
    tmp_path: Path,
    extra_env: dict | None = None,
    cwd: Path | None = None,
) -> tuple[int, str]:
    """
    WATCHDOG_DRY_RUN=1로 스크립트 실행 후 (returncode, log_contents) 반환.
    """
    env = os.environ.copy()
    env["WATCHDOG_DRY_RUN"] = "1"
    if extra_env:
        env.update(extra_env)

    result = subprocess.run(
        ["bash", str(script_path)],
        capture_output=True,
        text=True,
        cwd=str(cwd or tmp_path),
        env=env,
        timeout=15,
    )

    log_file = tmp_path / "logs" / "session-watchdog.log"
    log_content = log_file.read_text(encoding="utf-8") if log_file.exists() else ""
    return result.returncode, log_content


def touch_file(path: Path, age_seconds: int = 0) -> None:
    """파일 생성 후 mtime을 (현재 - age_seconds)로 설정."""
    path.touch()
    if age_seconds > 0:
        t = time.time() - age_seconds
        os.utime(path, (t, t))


# ---------------------------------------------------------------------------
# 1. 상대 경로 task_file → 절대 경로 정규화
# ---------------------------------------------------------------------------


def test_relative_taskfile_resolved_to_absolute(tmp_path):
    """
    fix#1: task_file이 'memory/tasks/task-X.md' 상대 경로여도
    WORKSPACE 기준으로 정규화되어 파일을 찾는다.
    cwd를 scripts/ 서브디렉토리로 바꿔도 동일하게 alive 처리.
    """
    tid = "task-9001"
    task_file_rel = f"memory/tasks/{tid}.md"

    timers = _build_timers({tid: _running_task(tid, "dev1-team", task_file=task_file_rel)})

    # setup_workspace 먼저 호출하여 디렉토리 구성
    script_path = setup_workspace(tmp_path, timers)

    # 디렉토리 생성 후 task 파일 작성
    task_file_abs = tmp_path / task_file_rel
    task_file_abs.write_text(f"# {tid}\n---\nteam: dev1-team\n---\n", encoding="utf-8")

    # heartbeat 신선하게 (alive 유지 목적)
    hb = tmp_path / "memory" / "heartbeats" / f"{tid}.heartbeat"
    touch_file(hb, age_seconds=10)

    # cwd를 scripts 서브디렉토리로 변경 — 상대 경로 버그 재현 환경
    rc, log = run_watchdog(script_path, tmp_path, cwd=tmp_path / "scripts")

    assert rc == 0, f"스크립트 비정상 종료: rc={rc}"
    # task_file 없음 false positive 없어야 함 (taskfile=no 로그 부재)
    assert f"taskfile=no" not in log or tid not in log.split("taskfile=no")[0].split("\n")[-1], \
        "상대 경로 task_file이 정규화되지 않아 'taskfile=no' 오탐 발생"
    # alive 처리 확인 (heartbeat 신선 → stalled 없음)
    assert f"{tid}(team=" not in log, f"alive 태스크가 stalled로 잘못 판정됨: {log}"


# ---------------------------------------------------------------------------
# 2. TASK_FILE 없음 + stalled → 알람 1건만 (no-taskfile 별도 알람 없음)
# ---------------------------------------------------------------------------


def test_no_taskfile_and_stalled_alert_one_alarm_only(tmp_path):
    """
    fix#1+#2: TASK_FILE 없어도 별도 'no-taskfile' 알람이 아닌
    stalled 알람 1건만 발생해야 한다 (taskfile=no 표시 포함).
    """
    tid = "task-9002"
    timers = _build_timers({tid: _running_task(tid, "dev1-team", task_file="")})

    script_path = setup_workspace(tmp_path, timers)
    rc, log = run_watchdog(script_path, tmp_path)

    assert rc == 0

    # stalled 알람이 있다면 1건만
    stalled_hits = log.count(f"{tid}(team=")
    assert stalled_hits <= 1, f"동일 태스크 알람 {stalled_hits}건 (중복 발생)"

    # 알람이 있다면 taskfile=no 포함
    if stalled_hits == 1:
        idx = log.index(f"{tid}(team=")
        snippet = log[idx : idx + 300]
        assert "taskfile=no" in snippet, f"taskfile=no 표시 없음: {snippet}"

    # 별도 no-taskfile 키워드 알람 없음 (fix#1 핵심)
    # 스크립트에서 no-taskfile reason이 stalled details 안에만 존재해야 함
    # "no-taskfile" 가 alarms에 별도 라인으로 나오면 안 됨
    lines_with_notaskfile = [l for l in log.splitlines() if "no-taskfile" in l and "stalled-no-taskfile" not in l and "(team=" not in l]
    assert len(lines_with_notaskfile) == 0, f"별도 no-taskfile 알람 라인 존재: {lines_with_notaskfile}"


# ---------------------------------------------------------------------------
# 3. .escalate 마커 → 알람 억제
# ---------------------------------------------------------------------------


def test_escalate_marker_suppresses_alert(tmp_path):
    """
    fix#3: events/<tid>.escalate 있고 .escalate.acked 없으면
    알람 0건, 로그에 '알람 억제 (회장 승인 대기)' 포함.
    """
    tid = "task-9003"
    timers = _build_timers({tid: _running_task(tid, "dev1-team")})
    script_path = setup_workspace(tmp_path, timers)

    # escalate 마커 생성 (acked 없음)
    touch_file(tmp_path / "memory" / "events" / f"{tid}.escalate")

    rc, log = run_watchdog(script_path, tmp_path)

    assert rc == 0
    assert "회장 승인 대기" in log, f"escalate 억제 로그 없음: {log}"
    assert f"{tid}(team=" not in log, f"escalate 상태에서 stalled 알람 발생: {log}"


# ---------------------------------------------------------------------------
# 4. escalate + acked → skip 유지 (task-2405 회장 정의: acked = 알람 그만)
# ---------------------------------------------------------------------------


def test_escalate_acked_keeps_suppression_chairman_def(tmp_path):
    """
    task-2405 회장 정의: acked = 알람 그만.
    escalate + escalate.acked 둘 다 있으면 → 회장이 이미 인지 → skip (알람 0건).
    Fix A: should_skip_for_escalate()는 acked 유무와 무관하게 escalate/acked 어느 하나라도 있으면 skip.
    """
    tid = "task-9004"
    timers = _build_timers({tid: _running_task(tid, "dev1-team")})
    script_path = setup_workspace(tmp_path, timers)

    # escalate + acked 둘 다 생성
    events_dir = tmp_path / "memory" / "events"
    touch_file(events_dir / f"{tid}.escalate")
    touch_file(events_dir / f"{tid}.escalate.acked")

    # heartbeat 노후 (stalled 조건 충족)
    hb = tmp_path / "memory" / "heartbeats" / f"{tid}.heartbeat"
    touch_file(hb, age_seconds=2000)

    rc, log = run_watchdog(script_path, tmp_path)

    assert rc == 0
    # 회장 정의: acked = 알람 그만 → stalled 알람 없어야 함
    assert f"{tid}(team=" not in log, \
        f"escalate+acked 상태에서 stalled 알람 발생 (회장 정의 위반): {log}"
    # 억제 로그 또는 검사 스킵 확인 (회장 승인 대기 메시지 또는 스킵 처리)
    assert "회장 승인 대기" in log or f"검사 시작: {tid}" not in log, \
        f"acked 상태 skip 처리 미확인: {log}"


# ---------------------------------------------------------------------------
# 5. design team heartbeat 30분 임계
# ---------------------------------------------------------------------------


def test_design_heartbeat_30min_threshold(tmp_path):
    """
    fix#4: team_id="design", heartbeat 1500s ago → alive.
             heartbeat 1900s ago + 다른 진행 신호 없음 → stalled.
    """
    # 케이스 A: 1500s ago (30min=1800s 미만) → alive
    tid_a = "task-9005a"
    timers = _build_timers({tid_a: _running_task(tid_a, "design")})
    script_path = setup_workspace(tmp_path, timers)

    hb_a = tmp_path / "memory" / "heartbeats" / f"{tid_a}.heartbeat"
    touch_file(hb_a, age_seconds=1500)

    rc, log = run_watchdog(script_path, tmp_path)
    assert rc == 0
    assert f"{tid_a}(team=" not in log, f"1500s ago design task가 stalled 오탐: {log}"

    # 케이스 B: 별도 tmp_path2 사용, 1900s ago → stalled
    tmp2 = tmp_path / "ws2"
    tmp2.mkdir()
    # ws2 하위 디렉토리 구성
    for d in ["memory/events", "memory/heartbeats", "memory/tasks", "logs", "scripts"]:
        (tmp2 / d).mkdir(parents=True, exist_ok=True)

    tid_b = "task-9005b"
    timers_b = _build_timers({tid_b: _running_task(tid_b, "design")})
    (tmp2 / "memory" / "task-timers.json").write_text(json.dumps(timers_b), encoding="utf-8")
    (tmp2 / ".env.keys").write_text("ANU_BOT_TOKEN=dummy\n", encoding="utf-8")
    (tmp2 / "memory" / "task-timer.py").write_text("#!/usr/bin/env python3\n")

    orig = ORIG_SCRIPT.read_text(encoding="utf-8")
    patched = orig.replace('WORKSPACE="/home/jay/workspace"', f'WORKSPACE="{tmp2}"')
    sp2 = tmp2 / "scripts" / "session-watchdog.sh"
    sp2.write_text(patched)
    sp2.chmod(0o755)

    hb_b = tmp2 / "memory" / "heartbeats" / f"{tid_b}.heartbeat"
    touch_file(hb_b, age_seconds=1900)

    env = os.environ.copy()
    env["WATCHDOG_DRY_RUN"] = "1"
    subprocess.run(["bash", str(sp2)], capture_output=True, text=True, cwd=str(tmp2), env=env, timeout=15)
    log2 = (tmp2 / "logs" / "session-watchdog.log").read_text() if (tmp2 / "logs" / "session-watchdog.log").exists() else ""

    # stalled 판정 또는 STALLED 로그 존재
    assert "STALLED" in log2 or f"{tid_b}(team=" in log2, \
        f"1900s ago design task가 stalled로 판정 안 됨: {log2}"


# ---------------------------------------------------------------------------
# 6. code team heartbeat 10분 임계
# ---------------------------------------------------------------------------


def test_code_heartbeat_10min_threshold(tmp_path):
    """
    fix#4: dev1-team, heartbeat 700s ago → stalled.
             heartbeat 500s ago → alive.
    """
    # 케이스 A: 700s ago (10min=600s 초과) → stalled
    tid_stale = "task-9006s"
    timers_a = _build_timers({tid_stale: _running_task(tid_stale, "dev1-team")})
    script_path_a = setup_workspace(tmp_path, timers_a)

    hb_stale = tmp_path / "memory" / "heartbeats" / f"{tid_stale}.heartbeat"
    touch_file(hb_stale, age_seconds=700)

    rc, log_a = run_watchdog(script_path_a, tmp_path)
    assert rc == 0
    assert "STALLED" in log_a or f"{tid_stale}(team=" in log_a, \
        f"700s ago dev team task가 stalled 미감지: {log_a}"

    # 케이스 B: 별도 workspace, 500s ago → alive
    tmp2 = tmp_path / "ws_code_alive"
    tmp2.mkdir()
    for d in ["memory/events", "memory/heartbeats", "memory/tasks", "logs", "scripts"]:
        (tmp2 / d).mkdir(parents=True, exist_ok=True)

    tid_alive = "task-9006a"
    timers_b = _build_timers({tid_alive: _running_task(tid_alive, "dev1-team")})
    (tmp2 / "memory" / "task-timers.json").write_text(json.dumps(timers_b))
    (tmp2 / ".env.keys").write_text("ANU_BOT_TOKEN=dummy\n")
    (tmp2 / "memory" / "task-timer.py").write_text("#!/usr/bin/env python3\n")

    orig = ORIG_SCRIPT.read_text(encoding="utf-8")
    sp2 = tmp2 / "scripts" / "session-watchdog.sh"
    sp2.write_text(orig.replace('WORKSPACE="/home/jay/workspace"', f'WORKSPACE="{tmp2}"'))
    sp2.chmod(0o755)

    hb_alive = tmp2 / "memory" / "heartbeats" / f"{tid_alive}.heartbeat"
    touch_file(hb_alive, age_seconds=500)

    env = os.environ.copy()
    env["WATCHDOG_DRY_RUN"] = "1"
    subprocess.run(["bash", str(sp2)], capture_output=True, text=True, cwd=str(tmp2), env=env, timeout=15)
    log_b = (tmp2 / "logs" / "session-watchdog.log").read_text() if (tmp2 / "logs" / "session-watchdog.log").exists() else ""

    assert f"{tid_alive}(team=" not in log_b, f"500s ago dev task 오탐: {log_b}"


# ---------------------------------------------------------------------------
# 7. codex-gate 진행 마커 → alive
# ---------------------------------------------------------------------------


def test_progress_marker_codex_gate_keeps_alive(tmp_path):
    """
    fix#7: events/<tid>.codex-gate 존재 + heartbeat 노후 → alive.
    로그에 '진행 마커 존재' + 'alive (long-running)' 포함.
    """
    tid = "task-9007"
    timers = _build_timers({tid: _running_task(tid, "dev2-team")})
    script_path = setup_workspace(tmp_path, timers)

    # heartbeat 노후 (2000s)
    hb = tmp_path / "memory" / "heartbeats" / f"{tid}.heartbeat"
    touch_file(hb, age_seconds=2000)

    # codex-gate 마커
    touch_file(tmp_path / "memory" / "events" / f"{tid}.codex-gate")

    rc, log = run_watchdog(script_path, tmp_path)

    assert rc == 0
    assert "진행 마커 존재" in log, f"진행 마커 존재 로그 없음: {log}"
    assert "alive (long-running)" in log, f"'alive (long-running)' 로그 없음: {log}"
    assert f"{tid}(team=" not in log, f"codex-gate 마커인데 stalled 오탐: {log}"


# ---------------------------------------------------------------------------
# 8. pr-creating 진행 마커 → alive
# ---------------------------------------------------------------------------


def test_progress_marker_pr_creating_keeps_alive(tmp_path):
    """
    fix#7: events/<tid>.pr-creating (신규 마커) + heartbeat 노후 → alive.
    """
    tid = "task-9008"
    timers = _build_timers({tid: _running_task(tid, "dev3-team")})
    script_path = setup_workspace(tmp_path, timers)

    hb = tmp_path / "memory" / "heartbeats" / f"{tid}.heartbeat"
    touch_file(hb, age_seconds=2000)

    touch_file(tmp_path / "memory" / "events" / f"{tid}.pr-creating")

    rc, log = run_watchdog(script_path, tmp_path)

    assert rc == 0
    assert "alive (long-running)" in log, f"pr-creating 마커 alive 미감지: {log}"
    assert f"{tid}(team=" not in log, f"pr-creating 마커인데 stalled 오탐: {log}"


# ---------------------------------------------------------------------------
# 9. stalled 알람 본문 디버그 정보 포함
# ---------------------------------------------------------------------------


def test_alert_body_contains_debug_info(tmp_path):
    """
    fix#8: stalled 알람 발생 시 로그 본문에
    taskfile=, escalate=, hb_age=, markers= 키 모두 포함.
    """
    tid = "task-9009"
    timers = _build_timers({tid: _running_task(tid, "dev1-team")})
    script_path = setup_workspace(tmp_path, timers)

    # heartbeat 노후 → stalled 유발
    hb = tmp_path / "memory" / "heartbeats" / f"{tid}.heartbeat"
    touch_file(hb, age_seconds=2000)

    rc, log = run_watchdog(script_path, tmp_path)

    assert rc == 0
    # DRY_RUN 모드에서 알람 본문이 로그에 기록됨
    assert "DRY_RUN" in log, f"DRY_RUN 로그 없음 (알람 미발생): {log}"
    assert "taskfile=" in log, f"taskfile= 키 없음: {log}"
    assert "escalate=" in log, f"escalate= 키 없음: {log}"
    assert "hb_age=" in log, f"hb_age= 키 없음: {log}"
    assert "markers=" in log, f"markers= 키 없음: {log}"


# ---------------------------------------------------------------------------
# 10. 최근 events mtime 활동 → alive
# ---------------------------------------------------------------------------


def test_recent_events_activity_keeps_alive(tmp_path):
    """
    fix#5/#6: heartbeat 노후지만 events/<tid>.qc-result mtime 100s ago → alive.
    """
    tid = "task-9010"
    timers = _build_timers({tid: _running_task(tid, "dev1-team")})
    script_path = setup_workspace(tmp_path, timers)

    # heartbeat 노후 (2000s)
    hb = tmp_path / "memory" / "heartbeats" / f"{tid}.heartbeat"
    touch_file(hb, age_seconds=2000)

    # events mtime 최근 (100s ago) — EVENTS_STALE_THRESHOLD=900s 미만
    touch_file(tmp_path / "memory" / "events" / f"{tid}.qc-result", age_seconds=100)

    rc, log = run_watchdog(script_path, tmp_path)

    assert rc == 0
    assert "recent activity" in log, f"최근 events 활동 alive 로그 없음: {log}"
    assert f"{tid}(team=" not in log, f"최근 events activity 태스크 stalled 오탐: {log}"


# ---------------------------------------------------------------------------
# 11. gh/git 미설치 환경 → graceful fallback
# ---------------------------------------------------------------------------


def test_pr_or_worktree_keeps_alive_skipped_if_unavailable(tmp_path):
    """
    fix#9: gh/git PATH에서 제거 → 명령 실패해도 스크립트 정상 완료.
    false alert 방지: 오류 없이 stalled 판정으로 이어져야 한다.
    """
    tid = "task-9011"
    timers = _build_timers({tid: _running_task(tid, "dev1-team")})
    script_path = setup_workspace(tmp_path, timers)

    # heartbeat 노후 → stalled 조건 충족
    hb = tmp_path / "memory" / "heartbeats" / f"{tid}.heartbeat"
    touch_file(hb, age_seconds=2000)

    # PATH를 최소화하여 gh, git 제거 (graceful fallback 테스트)
    minimal_path = "/usr/bin:/bin"
    env = os.environ.copy()
    env["WATCHDOG_DRY_RUN"] = "1"
    env["PATH"] = minimal_path

    result = subprocess.run(
        ["bash", str(script_path)],
        capture_output=True,
        text=True,
        cwd=str(tmp_path),
        env=env,
        timeout=15,
    )
    log_file = tmp_path / "logs" / "session-watchdog.log"
    log = log_file.read_text(encoding="utf-8") if log_file.exists() else ""

    # 비정상 종료 없어야 함 (set -euo pipefail이 gh/git 없어도 무너지면 안 됨)
    assert result.returncode == 0, f"gh/git 없는 환경에서 비정상 종료: rc={result.returncode}, stderr={result.stderr}"
    # 로그 파일 존재 (정상 동작 확인)
    assert log != "", "로그 파일 비어있음 — 스크립트가 조기 종료됨"
    # 워치독 사이클 완료 로그
    assert "워치독 사이클 완료" in log, f"사이클 완료 로그 없음: {log}"


# ---------------------------------------------------------------------------
# 12. 동일 task 중복 알람 방지 (add_stalled 1건만)
# ---------------------------------------------------------------------------


def test_no_double_push_for_same_task(tmp_path):
    """
    fix#2: 단일 사이클에서 동일 task_id가 중복 stalled 등록되어도
    알람 본문에 task_id가 1번만 등장.
    """
    tid = "task-9012"
    timers = _build_timers({tid: _running_task(tid, "dev1-team", task_file="")})
    script_path = setup_workspace(tmp_path, timers)

    # heartbeat 노후 → stalled
    hb = tmp_path / "memory" / "heartbeats" / f"{tid}.heartbeat"
    touch_file(hb, age_seconds=2000)

    rc, log = run_watchdog(script_path, tmp_path)

    assert rc == 0
    # 알람 본문(DRY_RUN 로그)에 tid가 몇 번 등장하는지 확인
    # 정상이면 "(team=" 패턴이 1번만 있어야 함
    alert_occurrences = log.count(f"{tid}(team=")
    assert alert_occurrences <= 1, \
        f"동일 태스크 알람 중복 {alert_occurrences}건: {log}"


# ---------------------------------------------------------------------------
# 13. (보너스) running 태스크 없음 → exit 0 + 로그
# ---------------------------------------------------------------------------


def test_no_running_tasks_exits_clean(tmp_path):
    """
    running 태스크 없음 → exit 0, 로그에 'running 태스크 없음'.
    """
    timers = _build_timers({
        "task-done": {
            "task_id": "task-done",
            "team_id": "dev1-team",
            "status": "completed",
            "start_time": "2026-01-01T00:00:00.000000",
        }
    })
    script_path = setup_workspace(tmp_path, timers)

    rc, log = run_watchdog(script_path, tmp_path)

    assert rc == 0, f"비정상 종료: rc={rc}"
    assert "running 태스크 없음" in log, f"'running 태스크 없음' 로그 없음: {log}"


# ---------------------------------------------------------------------------
# 14. (보너스) grace period — dispatch 직후 스킵
# ---------------------------------------------------------------------------


def test_grace_period_skips_recent_dispatch(tmp_path):
    """
    start_time이 5분(300s) 전인 태스크 → grace_period(600s) 이내 → 스킵.
    stalled 알람 0건.
    """
    tid = "task-9014"
    # start_offset=-300 → 5분 전 시작 (grace_period=600s 이내)
    task = _running_task(tid, "dev1-team", start_offset=-300)
    timers = _build_timers({tid: task})
    script_path = setup_workspace(tmp_path, timers)

    # heartbeat 없음 (노후 조건 충족이지만 grace 기간이 우선)
    rc, log = run_watchdog(script_path, tmp_path)

    assert rc == 0
    assert "유예 기간" in log, f"유예 기간 로그 없음: {log}"
    assert f"{tid}(team=" not in log, f"grace period 내 태스크 stalled 오탐: {log}"
