# task-2808 — WATCHDOG_FALSE_ALERT_FIX 보고서

- 작성: 이참나(개발7팀장) / 2026-07-20
- 브랜치: `task/task-2808-dev7` (커밋 1개, PR 0, main merge 0)
- 워크트리: `/home/jay/workspace/.worktrees/task-2808-dev7`
- 커밋: `a8118654`

---

## S (Situation)

`scripts/session-watchdog.sh` 는 user systemd timer(`session-watchdog.timer`, 2분 주기)로 실행되어 running task 의 생존을 판정하고 STALLED 를 텔레그램 알림한다. 오탐이 반복돼 실제 좀비와 구분이 되지 않는 상태였다.

## C (Complication)

| 결함 | 내용 | 실측 근거 |
|---|---|---|
| A | 유예(grace)를 **dispatch 등록 시각**(`start_time`) 기준 600s 로만 부여. 봇 실제 spawn 이 등록보다 늦으면 "아직 안 태어남"을 "죽음"으로 오판 | task-2806: 등록 12:04:30 → grace 만료 12:14:30 → 실제 기동 12:19. 그 사이 hb 없음·PID 없음·마커 없음 = 전부 죽음 신호로 읽힘 |
| B | 알림 cooldown/dedup 부재 → 동일 task 무한 반복 알림 | task-2802: 7회 연속, hb_age 734→1458s |
| C | `heartbeat 파일 없음`(신생)과 `heartbeat 오래됨`(정지)이 동일 취급 | `:401` 에서 둘 다 `HEARTBEAT_ALIVE=false` 로 합류 |

## Q (Question)

**오탐을 줄이되 미탐(false negative)을 만들지 않으면서** 세 결함을 해소할 수 있는가?

## A (Answer)

3개 결함 + 작업 중 발견한 기존 결함 1건을 수정. 오탐 케이스는 알림 0건, 유령·좀비는 알림 유지(반복 없이)를 **실증 재현으로 확인**했다.

---

## 1. 변경 내역

**변경 파일 2개 (신규 파일 0, `new_file_limit: 1` 준수)**

- `scripts/session-watchdog.sh` : 555줄 → 754줄 (+225 / −14)
- `tests/test_watchdog_noise_elimination.py` : 606줄 → 999줄 (+395 / −0)

`memory/**`, `dispatch/**` 는 일절 수정하지 않았다.

### 결함 A 조치 — spawn 기준 유예

`is_spawned()` 신설. 아래 중 하나라도 참이면 spawn 판정(비용 낮은 순):

1. `memory/heartbeats/<tid>.heartbeat` 존재
2. `memory/events/<tid>.*` 존재
3. `${BOT_SESSION_ROOT}/<ID>/.task_id` 내용이 tid 와 일치 (`timeout 8` 로 감싸 graceful)

→ escalate/superseded 스킵 직후, PID 추적 직전에 분기 삽입:

- 미spawn + 등록경과 ≤ `SPAWN_PENDING_MAX` → **`spawn-pending`, 알림 0건**
- 미spawn + 등록경과 > `SPAWN_PENDING_MAX` → **`spawn-timeout`, 유령 알림 1건**

기존 `GRACE_PERIOD=600` 은 **삭제하지 않고 하한선으로 유지**했다(기존 test 14 및 저비용 조기 스킵 보존).

### ★ 임계값 결정 근거 (실측 분포)

지시서의 관측 사례를 그대로 쓰지 않고, **직접 전수 측정**했다.
방법: `memory/task-timers.json` 의 `start_time` 과 `/home/jay/.cokacdir/workspace/*/.task_id` 의 mtime(=세션 디렉터리 최초 생성) 차이.

```
n = 495
  p50 :    37s
  p75 :    53s
  p90 :   109s
  p95 :   167s
  p99 :  1409s
  min :    11s   mean : 511s

상위 지연 (정상 범위):
  task-2279  837s (14.0분)   task-2028  848s (14.1분)
  task-2014  875s (14.6분)   task-2806  883s (14.7분)  ← 지시서 실증 케이스
  task-1925  983s (16.4분)   task-1936 1004s (16.7분)
  task-1989 1065s (17.7분)   task-1992 1409s (23.5분)  ← 정상 최대

이상치(디렉터리 재사용 추정, 정상 spawn 분포에서 분리):
  task-2705 42316s / task-1861_2.1~2.3 57448~61095s (11~17시간)

임계 후보별 초과 건수:
  >  600s : 14건 (2.83%)
  >  900s :  8건 (1.62%)
  > 1200s :  5건 (1.01%)
  > 1800s :  4건 (0.81%)  ← 전부 위 이상치 4건
```

**결정: `SPAWN_PENDING_MAX = 1800s (30분)`**

- 정상 spawn 최대 관측치 1409s 대비 **+27.7% 여유** → 실측된 정상 케이스 **100% 를 오탐 없이 흡수**한다.
- 1800s 초과 4건은 전부 11시간+ 이상치로, 정상 spawn 이 아니다 → 상한을 1800s 로 둬도 정상 케이스 미탐이 없다.
- 유령(task-2798, 산출물 0으로 1시간 38분) 은 **30분 시점에 검출** → 종전 대비 68분 앞당겨 잡는다.
- 지시서 "근거 없이 크게 올려 미탐 유발 금지" 준수: p99(1409s) 기준 최소 여유만 부여했고, 2400s·3600s 등 더 큰 값을 택하지 않았다.

### 결함 B 조치 — 알림 cooldown / dedup

- 상태 파일 `logs/watchdog-notify/<tid>.notify` (1행=마지막 알림 epoch, 2행=사유 시그니처). **재시작해도 스팸이 재발하지 않는다.**
- 규칙:
  - 상태 없음 → 알림
  - **사유 시그니처 변경 → 즉시 재알림** (상태 전환을 숨기지 않기 위함)
  - 사유 동일 + 경과 < `NOTIFY_COOLDOWN` → 억제 (`알림 cooldown 억제` 로그)
  - 사유 동일 + 경과 ≥ COOLDOWN → 재알림
- **해소 처리**: 사이클 종료 시 이번 사이클에 stalled/escalated 로 등록되지 않은 task 의 상태 파일 제거 → 다음에 진짜로 죽으면 cooldown 에 걸리지 않고 즉시 알린다.
- escalation 알림에도 동일 cooldown 적용(시그니처 `escalated`).

**`NOTIFY_COOLDOWN = 1800s` 근거**: 실측 스팸은 2분 간격 7회(task-2802). watchdog 주기가 2분이므로 30분 cooldown = 15사이클 억제. 좀비는 사람 개입 전까지 해소되지 않는데 개입 소요가 수분~수십분이므로, 30분이면 정보 손실 없이 **알림량을 720회/일 → 48회/일 (93.3% 감소)** 로 줄인다. 알림 자체는 끄지 않았고, 만료 후 반드시 재알림된다(테스트로 고정).

### 결함 C 조치 — heartbeat 부재 vs 노후

- `HB_STATE` 도입: `missing` / `stale` / `fresh`
- 로그 분리: 부재 시 `heartbeat 파일 없음 (hb_state=missing, 신생 가능성)`
- 알림 본문에 `hb_state=` 키 추가 (기존 `hb_age=` 키는 test 9 호환 위해 유지, 부재 시 `hb_age=none`)
- **판정 경로 분리**: reason 이 `stalled-no-heartbeat`(부재) / `stalled-alert-only`(노후) 로 갈린다

### 신규 설정 (전부 환경변수 override 가능 → 테스트 격리에 사용)

```bash
BOT_SESSION_ROOT="${WATCHDOG_SESSION_ROOT:-/home/jay/.cokacdir/workspace}"
SPAWN_PENDING_MAX="${WATCHDOG_SPAWN_PENDING_MAX:-1800}"
NOTIFY_COOLDOWN="${WATCHDOG_NOTIFY_COOLDOWN:-1800}"
NOTIFY_STATE_DIR="${WATCHDOG_STATE_DIR:-${WORKSPACE}/logs/watchdog-notify}"
```

---

## 2. 발견 이슈 및 해결

### 이슈 1 — `pr_count` 파싱 결함 (**기존 결함**, 해결 완료)

실증 재현 중 stderr 에서 발견:

```
scripts/session-watchdog.sh: line 221: [[: 0
0: syntax error in expression (error token is "0")
```

원인: `pr_count=$(... | wc -l || echo 0)` 에서 `gh` 실패 시 `wc -l` 이 이미 `0` 을 출력한 뒤 `|| echo 0` 이 **추가로** `0` 을 출력 → `"0\n0"` → `[[ ]]` 산술 비교 붕괴.

**신규 유입 여부 판별(교차 검증)**: main 원본(`git show main:scripts/session-watchdog.sh`)으로 동일 케이스를 재현한 결과 `line 207` 에서 **동일 에러 발생** → task-2808 이 유입시킨 것이 아닌 **기존 결함**으로 확정.

조치: `|| echo 0` 제거 + 기존 파일의 숫자 정규화 스타일과 동일하게 `if ! [[ "$pr_count" =~ ^[0-9]+$ ]]; then pr_count=0; fi` 추가. 동작 의미 불변(PR 1건+ → alive, 실패/0건 → 폴백). 수정 후 stderr 노이즈 소멸 + 좀비 알림 1건 유지 확인.

### 이슈 2 — 테스트의 스크립트 경로 하드코딩 (해결 완료)

`ORIG_SCRIPT = Path("/home/jay/workspace/scripts/...")` 절대경로 탓에 worktree 안에서 pytest 를 돌리면 **메인 워크트리의 구버전 스크립트**를 검사해 6건이 실패했다(6 failed / 18 passed). `Path(__file__).resolve().parents[1] / "scripts" / ...` 상대 해석으로 수정.

> 참고: 실패한 6건이 정확히 신규 테스트였다는 점은 **테스트가 구현에 실제로 결합되어 있다**는 반증이기도 하다(구현이 없으면 반드시 빨간불).

### 이슈 3 — 미승인 배포 상태 (해결 완료)

`session-watchdog.service` 의 `ExecStart` 가 **작업 트리 파일을 직접 실행**한다(`/bin/bash /home/jay/workspace/scripts/session-watchdog.sh`). 따라서 메인 워크스페이스에서 파일을 수정한 동안에는 수정본이 **사실상 운영에 반영된 상태**였다(해당 구간 운영 사이클은 exit 0 / `워치독 사이클 완료` 정상).

조치: 작업을 전용 worktree 로 이관하면서 메인 트리의 두 파일을 **main 원본으로 복원**했다. 현재 운영 스크립트는 555줄 main 원본이며, 변경분은 브랜치 커밋에만 존재한다 → **"배포는 별도 승인" 제약 충족**.

---

## 3. 지시서 대비 편차 (2건, 사유 명시)

1. **브랜치명**: 지시서는 `task/task-2808-watchdog-dev7`. 그러나 저장소 pre-commit 가드(`start_task_guard.py` 검증 #3)가 `task/<task-id>-<bot>` 형식을 강제해 커밋이 차단됐다. 가드 우회(`--no-verify`)는 안전장치 무력화이므로 택하지 않고 **`task/task-2808-dev7`** 로 명명했다. PR·머지가 금지된 작업이라 브랜치명의 기능적 영향은 없다.
2. **워크트리 사용**: 지시서는 main 분기 브랜치만 언급했으나, 동일 가드(검증 #1)가 전용 worktree 를 강제해 `.worktrees/task-2808-dev7` 에서 작업했다. 결과적으로 이슈 3(미승인 배포)도 함께 해소됐다.

---

## 4. 테스트

### 테스트 카운트 전후

| | 전 | 후 |
|---|---|---|
| test 함수 | 14 | **24** (+10) |
| 결과 | 14 passed | **24 passed** |
| 기존 테스트 삭제 라인 | — | **0줄** (`git diff --numstat` = `395 0`, 삭제 0 검증) |

`skip` / `xfail` / assertion 약화 **0건** (grep 검증: 매치는 전부 docstring·주석 텍스트, `pytest.mark` 사용 0).

### 신규 테스트 10건

| 테스트 | 고정하는 것 |
|---|---|
| `test_spawn_pending_no_alert` | 등록 900s 경과·spawn 신호 전무 → 알림 0건 (task-2806 오탐 가드) |
| `test_spawn_pending_exceeds_cap_alerts_ghost` | 등록 3600s 경과 → 알림 1건 + 디버그 4키 (task-2798 유령 가드) |
| `test_session_dir_marks_task_as_spawned` | `<ID>/.task_id` 일치 시 spawn 판정 → spawn-timeout 미진입 |
| `test_real_zombie_still_alerts` | **진짜 좀비 알림 1건 유지 (미탐 방지 회귀 가드)** |
| `test_repeat_run_suppressed_by_cooldown` | 2회 연속 실행 시 2회차 추가 알림 0건 |
| `test_cooldown_expired_allows_realert` | cooldown 만료 후 재알림 발생 (좀비 영구 은폐 방지) |
| `test_reason_change_triggers_immediate_realert` | 사유 변경 시 cooldown 이내여도 즉시 재알림 |
| `test_heartbeat_missing_marks_hb_state_missing` | `hb_state=missing` + `stalled-no-heartbeat` |
| `test_heartbeat_stale_marks_hb_state_stale` | `hb_state=stale`, `stalled-no-heartbeat` 미포함 |
| `test_notify_state_cleared_when_resolved` | 해소 시 상태 파일 삭제 + `알림 상태 해소` 로그 |

### 검증 명령 결과 (원문)

```
$ python3 -m pytest tests/test_watchdog_noise_elimination.py -q
24 passed in 5.55s

$ bash -n scripts/session-watchdog.sh
syntax OK

$ shellcheck -S error scripts/session-watchdog.sh
(exit 0, 에러 0건)
```

---

## 5. ★ L1 스모크테스트 결과

- **서버 재시작**: 해당없음 (서버형 서비스 아님). 대신 **운영 systemd 서비스 실사이클 관측**으로 대체 — `session-watchdog.service` `code=exited, status=0/SUCCESS`, 로그 `워치독 사이클 완료` (12:53:46 / 12:55:36 / 12:59:47 3사이클 연속 정상).
- **API 응답 확인**: 해당없음 (HTTP API 없음). 대신 **실제 스크립트 실행 + 실증 2종 재현**으로 대체.
- **스크린샷**: 해당없음 (UI 없음, CLI/데몬 스크립트).

### L1-1. 실제 운영 데이터 대상 실행

```
$ WATCHDOG_DRY_RUN=1 WATCHDOG_STATE_DIR=/tmp/wd-l1-state bash scripts/session-watchdog.sh
exit code: 0
[12:55:36] task-2808: heartbeat 15s ago (< 600s, team=dev7-team) → alive
[12:55:36] 알람 없음 (false alert 0건 확인)
[12:55:36] 워치독 사이클 완료
```
→ 실 운영 `task-timers.json` (running task 4건) 대상 정상 완주, 오탐 0건.

### L1-2. ★ 실증 2종 + 유령 케이스 재현 (커밋된 스크립트 대상)

실제 `session-watchdog.sh` 를 격리 워크스페이스에서 구동해 실측 조건을 그대로 재현:

```
[false_2806]  tid=task-2806  등록경과=870s   hb=none  ev=none   실행 1회
  실행#1 → 알림 0건 | spawn-pending          ✅ 오탐이어야 하는 것 → 알림 없음

[ghost_2798]  tid=task-2798  등록경과=5880s  hb=none  ev=none   실행 1회
  실행#1 → 알림 1건 | spawn-timeout          ✅ 영원히 안 뜨는 유령 → 상한 초과로 검출

[zombie_2802] tid=task-2802  등록경과=7200s  hb=1458s ev=1458s  실행 3회
  실행#1 → 알림 1건 | STALLED 판정            ✅ 진짜 좀비 → 알림 있음
  실행#2 → 알림 0건 | cooldown 억제           ✅ 반복 스팸 없음
  실행#3 → 알림 0건 | cooldown 억제           ✅ 반복 스팸 없음
```

**지시서 요구 2종이 둘 다 올바르게 처리됨을 실행으로 확인.** stderr 에러 0건.

---

## 6. MATCH / GAP

### MATCH

| 요구 | 상태 |
|---|---|
| 1. spawn 기준 유예 (등록 시각 아님) | ✅ `is_spawned()` 3중 판정 |
| 1. spawn-pending 별도 상태 + 알림 안 함 | ✅ 알림 0건, 전용 로그 |
| 1. spawn-pending 상한 + 초과 시 알림 | ✅ 1800s, `spawn-timeout` 알림 1건 |
| 1. 상한값 실측 분포 근거 + 보고 기재 | ✅ n=495 전수 측정, §1 기재 |
| 2. 재알림 최소 간격 | ✅ 1800s cooldown |
| 2. 상태 변화 시 재알림 | ✅ 사유 시그니처 변경 시 즉시 |
| 2. 방식을 테스트로 고정 | ✅ 신규 3건 |
| 2. 억제 상태를 파일로 (재시작 내성) | ✅ `logs/watchdog-notify/<tid>.notify` |
| 3. heartbeat 부재 vs 노후 구분 | ✅ `HB_STATE`, 로그·본문·reason 분리 |
| 진짜 좀비 미탐 0 | ✅ L1-2 zombie 케이스 알림 1건 |
| 기존 테스트 전부 유지(감소 0) | ✅ 14 → 24, 삭제 0줄 |
| pytest PASS | ✅ 24 passed |
| `bash -n` PASS / shellcheck | ✅ 둘 다 PASS |
| 커밋 정확히 1개 | ✅ `a8118654`, `rev-list --count main..HEAD` = 1 |
| PR 0 · main merge 0 | ✅ |
| 테스트 skip·완화·알림 끄기 금지 | ✅ 0건 |
| `memory/**` · `dispatch/**` 미수정 | ✅ |
| 운영 watchdog 중단 없음 | ✅ 3사이클 연속 status=0/SUCCESS |
| allowed paths 준수 / 신규 파일 0 | ✅ 2개 파일만 |

### GAP / 잔여 리스크

1. **cooldown 억제 경로의 pytest 커버리지**: 억제 로직 자체는 `test_repeat_run_suppressed_by_cooldown` 으로 고정했으나, **장기(30분) 실사이클 억제**는 시간 의존이라 실측이 아닌 축약 검증(cooldown=1s)으로 대체했다.
2. **`is_spawned` 3순위 디렉터리 스캔 비용**: 현재 4,766개 디렉터리에서 약 0.1초. 워크스페이스가 더 커져 `timeout 8` 에 걸리면 graceful 하게 "미spawn" 으로 처리되어 **1800s 경과 task 에 대해 유령 오탐 가능성**이 있다. 다만 heartbeat·events 검사가 앞순위라 실제 살아있는 task 는 여기 도달하지 않는다. 모니터링 권장.
3. **`start_time` 파싱 실패(=0) task**: spawn 게이트를 건너뛰고 기존 PID/heartbeat 로직만 적용하도록 보수적으로 설계했다(새 기능이 데이터 이상 상황에서 새 오탐을 만들지 않도록). 이 경우 결함 A 개선 혜택은 못 받는다.
4. **배포 미실행**: 브랜치 커밋만 존재. 운영 반영은 별도 승인 필요(지시서 제약 준수).

---

## 7. 머지 판단

- **머지 필요**: Yes (단, **이번 작업 범위에서는 수행 금지** — 지시서 "PR 생성 0 · main merge 0")
- **브랜치**: `task/task-2808-dev7`
- **워크트리 경로**: `/home/jay/workspace/.worktrees/task-2808-dev7`
- **머지 의견**: 테스트 14→24 전부 PASS, 기존 테스트 삭제 0, shellcheck 에러 0, 실증 3종(오탐·유령·좀비) 실행 재현 확인. 변경은 `scripts/session-watchdog.sh` 1개 + 테스트 1개로 국한되어 충돌 위험이 낮다. **다만 배포 시 운영 watchdog 동작이 즉시 바뀌므로(ExecStart 가 작업 트리 파일 직접 실행) 아누/회장 승인 후 반영 권장.** 반영 직후 1시간은 알림량과 `spawn-timeout` 발생 건수를 관측할 것.

---

## 8. 모델 사용 기록

| 팀원 | 역할 | 모델 | 담당 |
|---|---|---|---|
| 쿠쿨칸 | 백엔드 | **sonnet** | `session-watchdog.sh` 결함 A·B·C 구현, fix#4 |
| 카마소츠 | 테스터 | **sonnet** | 신규 테스트 10건, 경로 해석 수정 |
| 이참나 | 팀장(opus) | opus | 실측 분포 측정, 임계값 결정, 설계·계약 정의, 독립 재검증, 결함 원인 판별, 커밋 |

- haiku 사용 **0건** (전 작업이 운영 알림 파이프라인의 정확성에 직결되어 haiku 부적합 판단).
- 이쉬첼(프론트)·아쿠인(UX/UI) 미소집 — UI 산출물이 없는 셸/테스트 작업이라 역할 해당 없음.
- 디자인 작업 없음 → 디자인팀 호출 불필요.

---

## 9. 셀프 QC

- [x] 지시서 요구 1·2·3 전부 구현 + 테스트로 고정
- [x] 기존 테스트 감소 0 (삭제 라인 0줄로 증명)
- [x] 임계값 실측 근거 확보 및 보고 기재 (n=495 전수)
- [x] 오탐·미탐 2종 케이스 실행 재현
- [x] Edit 후 grep 검증 (`is_spawned`, `spawn-pending`, `spawn-timeout`, `hb_state`, `NOTIFY_COOLDOWN`, `알림 상태 해소`, `task-2808 fix#4` 전부 매치 확인)
- [x] planned 항목 0건
- [x] 커밋 1개 / 파일 2개 / PR 0 / merge 0
- [x] `memory/**`·`dispatch/**` 미수정, 다른 팀 디렉터리 미접근
- [x] 보고서 git stage 안 함 (커밋 후 작성, `git show --stat` 에 미포함)
- [x] 취소 마커 재확인 (Edit 전·커밋 전 2회, 둘 다 없음)
- [x] L1 스모크테스트 실행 및 결과 기재

## 세션 통계
- 총 도구 호출: 0회

