# PR Watcher Terminal/Callback — Root Cause Analysis (task-2670)

- 작성: dev8 라 (★ 2026-05-25/26)
- chair_authorization_id: `CHAIR-AUTH-PR-WATCHER-TERMINAL-CALLBACK-ROOT-CAUSE-20260525-JJONGS-RCA-001`
- scope: **RCA only · 즉시 코드 수정 0 · watcher.py 변경 0**
- 단일소스: `memory/tasks/task-2670.md` (sha256 `f2de5dd3df6c3d0cb9c8906da346907cdeb267c859ef553fe810da3f9a692d4f`)
- read-only target: `/home/jay/.cokacdir/workspace/29C74592/watcher.py` (181 LOC) + `poll_history/poll_01..30.json` + `_history.json` + `_terminal.json`

---

## 1. 사실 박제 (회장 verbatim 정정 반영)

### 1.1 watcher 종료 상태
| key | value |
|---|---|
| schedule_id | `29C74592` |
| terminal_state | `LOOP_BOUNDARY` |
| reason | `max polls reached, elapsed 3524s` |
| polls_completed | 30/30 |
| elapsed | 3524s (58.7 min · max_watch_minutes=60 도과) |
| t0_utc | 1779715125 (2026-05-25T13:18:45Z) |
| callback_arrived | **Yes** (`schedule_history/29C74592.log` status=ok, duration 4142s) |

### 1.2 poll-별 상태 전환 (★ 결정적 사실)
- poll #01~11 (t+0~1215s ≈ 0~20.25 min): `mss=BLOCKED · unresolved=0 · fresh_gemini_head_match=False` (stale Gemini at `2026-05-25T12:40:36Z`, prior head)
- **poll #12 (t+1336s ≈ 22.27 min): 전환 이벤트** — `mss=BLOCKED · unresolved=3 · fresh_gemini_head_match=True` (Gemini at `2026-05-25T13:39:36Z`, head `4bb627fe...`)
- poll #13~30 (t+1457~3524s ≈ 24.28~58.7 min): poll #12와 동일 상태 18회 유지 → max_watch 도과 → LOOP_BOUNDARY

### 1.3 poll #12 시점 11 check 분포 (verbatim)
- SUCCESS 9: `cancel-kill-switch · taskctl-state-guard ×2 · qc-check · hidden-path-audit · lock-in-check · merge-safety-check · ci/guard · guard`
- FAILURE 2: `gemini-review-gate · phase3-merge-gate` (★ **둘 다 Critical7 enum 밖**)

---

## 2. classify() 5-enum decision tree 분석 (watcher.py L82–141)

### 2.1 watcher.py classify() verbatim 분기 (line 인용)
```python
# L103-109  Critical7 FAILURE → CI_FAILED_NON_REMEDIABLE
critical_failures = [
    n for n in CRITICAL7
    if check_map.get(n, {}).get("conclusion", "") == "FAILURE"
]
if critical_failures:
    return "CI_FAILED_NON_REMEDIABLE", f"critical7 fail: {critical_failures}"

# L111-113  head drift → HOLD_FOR_CHAIR
if head and head != EXPECTED_HEAD:
    return "HOLD_FOR_CHAIR", f"head drift to {head}"

# L116-126  MERGE_READY
all_11_ok = bool(checks) and all(
    c.get("conclusion", "") == "SUCCESS" for c in checks
)
if (
    mss == "CLEAN"
    and (rd in ("APPROVED", ""))
    and all_11_ok
    and gemini_fresh
    and unresolved == 0
):
    return "MERGE_READY", "..."

# L129-135  GEMINI_EXTERNAL_TRIGGER_STALE
now_utc = int(time.time())
elapsed_since_head = now_utc - HEAD_COMMITTED_UTC
if elapsed_since_head > GEMINI_STALE_THRESHOLD and not gemini_fresh:
    return ("GEMINI_EXTERNAL_TRIGGER_STALE", "...")

# L138-139  LOOP_BOUNDARY
if elapsed_watcher >= 60 * 60:
    return "LOOP_BOUNDARY", f"watcher elapsed {elapsed_watcher}s"

return None, "continue"   # L141
```

### 2.2 poll #12 시점 enum 매핑 표 (★ 보고 필수 #3)

| enum | spec doctrine (system_ci_watch_handoff_policy_spec_260523.md §5) | watcher.py 분기 (line) | poll #12 적용 결과 |
|---|---|---|---|
| `MERGE_READY` | CI 11/11 + Gemini fresh + 0 unresolved + CLEAN + mergeable | L119–126 | **NO** — `mss=BLOCKED · unresolved=3 · 2 gates FAILURE` |
| `CHAIR_REQUIRED` (spec enum 정명) | Critical7 / credential expansion / expected_files 밖 수정 / admin override 필요 / post-merge smoke fail | **분기 부재** (★ watcher.py 내 `CHAIR_REQUIRED` 문자열 0회) | **자동 분류 0** (★ 결함) |
| `GEMINI_EXTERNAL_TRIGGER_STALE` | OWNER nudge 1회 hard limit 후에도 fresh review 미도착 | L131–135 | **NO** — poll #12 시점 fresh Gemini 도착 |
| `CI_FAILED_NON_REMEDIABLE` | 자동수렴 불가능한 CI failure (Critical7) | L103–109 | **NO** — Critical7 모두 SUCCESS (실패한 2 gates는 enum 밖) |
| `LOOP_BOUNDARY` | 동일 함수 HIGH 반복 + 자동수렴 무한 루프 방지 | L138–139 | poll #12 시점 NO (elapsed<60min) · **poll #30 시점 YES** (3524s) |
| (watcher.py 비-spec enum) `HOLD_FOR_CHAIR` | (spec 정의 없음) head drift 전용 | L112–113 | NO — head 일치 |
| **→ 결과** | | | **`None / continue`** (L141) — fall-through → 18 polls × 120s = 36min 헛돌이 |

### 2.3 결정적 결함 (★ 보고 필수 #1 root cause classification)

**복합 결함 — 4 후보 중 #1·#2·#4 모두 적중. #3 부분 적중.**

핵심 결함 = **#1 + #2** (한 줄로 압축):

> *watcher.py classify() 의 5-enum decision tree 가 spec `system_ci_watch_handoff_policy_spec_260523.md §5` 의 `CHAIR_REQUIRED` enum 분기를 누락했고, BLOCKED + fresh Gemini head match + new unresolved 조합을 평가하는 어떠한 분기도 존재하지 않아, poll #12 의 ANU 자동수렴 trigger 시점 신호를 `None/continue` 로 잠재웠다.*

부수 결함:
- **#4 (max_watch 도달 전 HOLD 조건 누락)**: LOOP_BOUNDARY 우선순위가 watcher.py L138 에 elapsed-only 로 박제되어 있어, elapsed<60min 영역에서 `CHAIR_REQUIRED` (또는 spec 외 `HOLD_FOR_CHAIR`) 로 escalate 할 유일한 분기가 `head drift` (L112) 하나뿐.
- **#3 (callback timing policy 결함)**: spec §6 step 4 ("watcher 는 terminal state 에서 ANU normal callback 을 발사한다") 가 watcher.py main() 내에 코드로 0회 구현됨. `_terminal.json` 저장은 있으나 `cokacdir --cron` 호출 0. 즉 callback 도착(`schedule_history/29C74592.log` status=ok)은 watcher 가 발사한 것이 아니라 외부 ANU(이참나) 세션의 별도 schedule 발화임 (※ watcher process 자체는 silent terminal).

---

## 3. callback registrar 호출 path 추적 (★ 보고 필수 #4)

### 3.1 spec 요구 (system_ci_watch_handoff_policy_spec_260523.md §6 step 4)
> "watcher 는 terminal state 에서 ANU normal callback 을 발사한다."

CI_WATCH_HANDOFF 12-필수 필드 중 `callback_on_terminal_state: bool` (default=true) 가 박제하는 행동.

### 3.2 watcher.py 실제 구현 (L184–244 main())
```python
# L226-228  terminal=None → LOOP_BOUNDARY 강제 부여 (★ 폴백)
if not terminal:
    terminal = "LOOP_BOUNDARY"
    reason = f"max polls reached, elapsed {elapsed}s"

# L229-243  _terminal.json + _history.json 저장 후 log 남김
with (POLL_DIR / "_terminal.json").open("w") as f:
    json.dump({...}, f, indent=2)
with (POLL_DIR / "_history.json").open("w") as f:
    json.dump(history, f, indent=2, default=str)
log(f"watcher_end terminal={terminal} reason={reason}")
```

### 3.3 결함 (★ verbatim)
- watcher.py 내 `cokacdir`, `--cron`, `--chat`, `callback`, `envelope` 키워드 출현 **0회**
- `subprocess.run(...cokacdir...)` 또는 동등 호출 **0회**
- 결과: terminal_state 도달 후 ANU 에게 normal callback 을 **watcher 자체가 발사하지 않음**

### 3.4 callback 도착 출처 (사실 박제)
- `schedule_history/29C74592.log` status=ok 인 callback 은 watcher 가 발사한 게 아니라, 별개의 cokacdir schedule (ANU 이참나 세션 `t-2667-dev7-watcher` 본체) 가 prompt 한 사이클 끝에 자연 종료된 record.
- ★ 즉 doctrine "watcher 가 terminal 시 callback 발사" 는 **현재 코드로 0% 충족** 이며, callback 도착은 부수효과 (운 좋게 schedule 종료 시점이 max_watch 와 거의 일치) 였다.

---

## 4. terminal_state evaluation policy 결함 요약 (★ 보고 필수 #2 + #4)

| 결함 | 위치 | 영향 | 우선순위 |
|---|---|---|---|
| `CHAIR_REQUIRED` 분기 부재 | classify() L82–141 전체 | spec §5 정의 enum 1개가 코드로 evaluate 0 | **P0** |
| BLOCKED + fresh-gemini + new-unresolved 조합 평가 부재 | classify() L116–141 | poll #12 핵심 신호 silent fall-through | **P0** |
| ANU normal callback 발사 부재 | main() L226–243 | spec §6 step 4 위반 | **P0** |
| LOOP_BOUNDARY elapsed-only 조건 | L138 | unresolved=3 인 채로 max_watch 도달이 정상 종결로 박제됨 | **P1** |
| spec enum 이름 불일치 (`HOLD_FOR_CHAIR` vs `CHAIR_REQUIRED`) | L113 | 후속 reporting/통계 schema 분기 표준화 어려움 | **P1** |
| Critical7 enum 에 `gemini-review-gate · phase3-merge-gate` 미포함 | CRITICAL7 (L28–36) | non-Critical7 FAILURE 가 무한히 acceptable | **P2** (정책 결정 필요) |

---

## 5. 회장 verbatim 검토 후보 4 대응 매트릭스

| 후보 (회장 verbatim 2026-05-26) | 적중 여부 | 근거 line |
|---|---|---|
| 1. fresh unresolved 시 `HOLD_FOR_CHAIR` 조기 전환 조건 미흡 | ★ **적중 (P0)** | classify() L82–141 — unresolved 변수가 MERGE_READY 분기 외 어디서도 평가되지 않음 |
| 2. terminal_state 5 enum evaluation policy 결함 | ★ **적중 (P0)** | `CHAIR_REQUIRED` 분기 0회, watcher.py 가 spec 5-enum 중 4 개만 evaluate |
| 3. unresolved callback timing policy 결함 | ★ **적중 (P0)** | main() L226–243 — `_terminal.json` 저장만, callback 발사 0 |
| 4. max_watch 도달 전 HOLD 조건 누락 | ★ **적중 (P1)** | L138 LOOP_BOUNDARY elapsed-only, escalate 분기는 head drift (L112) 1개뿐 |

★ 결론: **단일 root cause 가 아닌 4중 복합 결함**. 회장 verbatim 4 후보 모두 적중. 그러나 P0 비중은 #1+#2+#3 합산, #4 는 부수 (P0 해결 시 부분적으로 해소).

---

## 6. ANCHOR

- ANCHOR-1: "root cause = classify() 5-enum 결함 + callback registrar 호출 부재 (복합)"
- ANCHOR-2: "poll #12 (t+22.27min) silent fall-through 19 polls (38min) · 본 사고의 결정적 line"
- ANCHOR-3: "watcher.py read-only · 본 RCA 는 fix preview only · 적용은 별도 회장 verbatim 강제"
- ANCHOR-4: "spec doctrine enum 이름 = `CHAIR_REQUIRED` · watcher.py 표기 = `HOLD_FOR_CHAIR` · 명명 통합 필요"

끝
