# PR Watcher Terminal/Callback — Fix Diff Preview (task-2670)

- 작성: dev8 라 (★ 2026-05-25/26)
- chair_authorization_id: `CHAIR-AUTH-PR-WATCHER-TERMINAL-CALLBACK-ROOT-CAUSE-20260525-JJONGS-RCA-001`
- ★★★ **preview only · 실제 diff 0 · watcher.py 변경 0 · commit 0 · push 0 · PR 0 · merge 0 · 적용은 별도 회장 verbatim 강제** ★★★
- target file (★ read-only 박제 · 본 task 에서 수정 안 함): `/home/jay/.cokacdir/workspace/29C74592/watcher.py`

---

## 0. 적용 게이트 (회장 verbatim 강제)

본 fix 는 다음 모두 충족 시에만 적용:
1. 회장 verbatim chair_authorization_id 별도 발급 (task-2670 fix-apply 전용)
2. 적용 주체는 **dev7 또는 dev8 bot** (★ ANU 직접 0)
3. 새 watcher 작업은 신규 schedule 로 발사 — 본 task-2667 의 `29C74592` workspace 는 사고 박제 보존용 (★ 수정 0)
4. 적용 PR 은 expected_files 외부 0
5. regression suite (sibling spec) 통과 필수

---

## 1. Fix #1 — `classify()` 5-enum decision tree 보강 (P0)

### 1.1 결함 요약 (analysis sibling §2 참조)
- `CHAIR_REQUIRED` enum 자동 분류 분기 부재
- `BLOCKED + fresh-gemini + new-unresolved` 신호 무시
- spec 명명 `CHAIR_REQUIRED` 와 watcher.py 표기 `HOLD_FOR_CHAIR` 불일치

### 1.2 preview unified diff (★ 실제 diff 0 · 텍스트 박제 only)

```diff
*** /home/jay/.cokacdir/workspace/29C74592/watcher.py  (READ-ONLY ORIGINAL)
--- watcher.py.preview                                  (★ preview · 적용 0)
@@ L75-99 latest_gemini + classify head
 def classify(pr_data, th_data, elapsed_watcher):
     head = pr_data.get("headRefOid", "")
     mss = pr_data.get("mergeStateStatus", "")
     rd = pr_data.get("reviewDecision", "")
     checks = pr_data.get("statusCheckRollup", []) or []
     reviews = pr_data.get("reviews", []) or []
     nodes = (...)
     unresolved = sum(1 for n in nodes if not n.get("isResolved"))
     lg = latest_gemini(reviews)
     gemini_fresh = bool(lg) and lg.get("commit", {}).get("oid", "") == EXPECTED_HEAD
     check_map = {c.get("name", ""): c for c in checks}

@@ L103-109 CI_FAILED_NON_REMEDIABLE — 유지
     critical_failures = [...]
     if critical_failures:
         return "CI_FAILED_NON_REMEDIABLE", f"critical7 fail: {critical_failures}"

@@ L111-113 head drift — 명명 통합 + spec enum 정명
-    if head and head != EXPECTED_HEAD:
-        return "HOLD_FOR_CHAIR", f"head drift to {head}"
+    if head and head != EXPECTED_HEAD:
+        return "CHAIR_REQUIRED", f"head_drift to {head} (spec §5 admin override)"

@@ L116-126 MERGE_READY — 유지

@@ L127-128 (NEW) ★ Fix #1 핵심: CHAIR_REQUIRED 자동 escalate
+    # ★ CHAIR_REQUIRED — fresh Gemini head match + unresolved>0 + mss=BLOCKED
+    # (spec §5 'expected_files 밖 수정 / admin override 필요' 의 PR-state 등가물)
+    if (
+        gemini_fresh
+        and unresolved > 0
+        and mss == "BLOCKED"
+    ):
+        return (
+            "CHAIR_REQUIRED",
+            f"fresh_gemini_head_match + unresolved={unresolved} + mss=BLOCKED "
+            f"(자동수렴 불가 · 회장 판정 필요)"
+        )

@@ L129-135 GEMINI_EXTERNAL_TRIGGER_STALE — 유지

@@ L138-139 LOOP_BOUNDARY — 유지 (정책상 elapsed-only 적합)
     if elapsed_watcher >= 60 * 60:
         return "LOOP_BOUNDARY", f"watcher elapsed {elapsed_watcher}s"

     return None, "continue"   # L141 — fall-through 줄어듦
```

### 1.3 적중 분석 (poll #12 재평가 시뮬레이션)
- 입력: `head_match=True · mss=BLOCKED · gemini_fresh=True · unresolved=3 · critical7 SUCCESS · elapsed=1336s`
- 기존 (현재 코드): L141 `None / continue` → 18 polls 헛돌이 → max_watch → LOOP_BOUNDARY
- preview 적용 후: 신규 분기 (L127-128 이후) → **`CHAIR_REQUIRED`** 즉시 반환 → main() while 루프 break → callback 발사 (Fix #2)

---

## 2. Fix #2 — `main()` 에 ANU normal callback registrar 추가 (P0)

### 2.1 결함 요약 (analysis sibling §3)
- spec §6 step 4 "watcher 는 terminal state 에서 ANU normal callback 을 발사한다" 가 watcher.py main() 에 0회 구현됨
- callback 도착 (`schedule_history/29C74592.log` ok) 은 별개 schedule 의 부수효과

### 2.2 preview unified diff (★ 실제 diff 0 · 텍스트 박제 only)

```diff
@@ L1-14 imports (헬퍼 추가)
 import json
 import os
 import subprocess
 import sys
 import time
 from datetime import datetime, timezone
 from pathlib import Path
+
+# ★ Fix #2 — ANU callback envelope helper
+ANU_KEY_FALLBACK = os.environ.get("ANU_KEY", "")
+ANU_CHAT_ID = int(os.environ.get("ANU_CHAT_ID", "0") or "0")
+CALLBACK_DELAY_SEC = 30
+ENVELOPE_MAX_BYTES = 3900

@@ L184-244 main() — terminal 박제 직후 callback 발사
     log(f"watcher_end terminal={terminal} reason={reason}")
+    if ANU_KEY_FALLBACK and ANU_CHAT_ID:
+        try:
+            envelope = build_envelope(terminal, reason, poll, elapsed, history)
+            assert len(envelope.encode("utf-8")) <= ENVELOPE_MAX_BYTES
+            fire_at = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")  # absolute now+30s 권장
+            subprocess.run([
+                "/usr/local/bin/cokacdir", "--cron", envelope,
+                "--at", fire_at,
+                "--chat", str(ANU_CHAT_ID),
+                "--key", ANU_KEY_FALLBACK,
+                "--once",
+            ], check=False, timeout=30)
+            log(f"anu_callback_fired terminal={terminal} bytes={len(envelope.encode('utf-8'))}")
+        except Exception as e:
+            log(f"anu_callback_fire_fail: {e}")
+    else:
+        log("anu_callback_skip: ANU_KEY/CHAT_ID env 부재")
```

### 2.3 envelope helper (preview — 별도 모듈로 분리 권장)
```python
def build_envelope(terminal, reason, polls, elapsed, history):
    last = history[-1] if history else {}
    body = (
        f"[task-2667 watcher terminal]\n"
        f"terminal_state: {terminal}\n"
        f"reason: {reason}\n"
        f"polls: {polls}  elapsed: {elapsed}s\n"
        f"head_match: {last.get('head_match_expected', False)}\n"
        f"mss: {last.get('mergeStateStatus', '')}\n"
        f"unresolved: {last.get('unresolved_thread_count', 0)}\n"
        f"latest_gemini: {(last.get('latest_gemini_review') or {}).get('submittedAt', '-')}\n"
        f"action: ANU consolidated report (회장 verbatim 4 doctrine)"
    )
    return body[:ENVELOPE_MAX_BYTES]
```

### 2.4 안전 안내
- **회장 세션 적용 0** (★ live settings.json 변경 0)
- envelope UTF-8 ≤3900 bytes (system_ci_watch_handoff_policy_spec_260523.md §11 강제)
- absolute timestamp now+30s (★ task-2661 Phase 2b CHAIR-AUTH-CALLBACK-DELAY-P2B 박제 정합)
- self-key 0 (ANU key 단일출처)
- env-driven (★ hard-code 0)

---

## 3. Fix #3 — `LOOP_BOUNDARY` 전 escalate 우선순위 정비 (P1)

### 3.1 결함 (analysis §2.3 #4)
- L138 `if elapsed_watcher >= 60*60` 분기가 elapsed-only 라, unresolved=3 인 채로 max_watch 도달이 정상 종결로 분류됨.

### 3.2 preview diff (★ 0)
```diff
@@ L138-139
-    if elapsed_watcher >= 60 * 60:
-        return "LOOP_BOUNDARY", f"watcher elapsed {elapsed_watcher}s"
+    if elapsed_watcher >= 60 * 60:
+        # ★ Fix #3 — escalate 우선순위: LOOP_BOUNDARY 도달 시 unresolved/blocked 잔재가 있으면 CHAIR_REQUIRED 로 격상
+        if unresolved > 0 or mss == "BLOCKED":
+            return (
+                "CHAIR_REQUIRED",
+                f"loop_boundary_with_residual: unresolved={unresolved} mss={mss}"
+            )
+        return "LOOP_BOUNDARY", f"watcher elapsed {elapsed_watcher}s"
```

### 3.3 적중 효과
- poll #30 (현행 LOOP_BOUNDARY) 시점 unresolved=3 + BLOCKED 잔재 → **CHAIR_REQUIRED** 로 보고
- 무 결함 시나리오 (mss=CLEAN + unresolved=0 + Gemini stale 등) 에서는 기존 LOOP_BOUNDARY 유지

---

## 4. Fix #4 — non-Critical7 gates FAILURE 정책 결정 (P2 · 정책 토론)

### 4.1 현황
- `gemini-review-gate · phase3-merge-gate` FAILURE 가 30 polls 동안 acceptable 로 처리됨.
- spec doctrine 상 자동수렴 대상 (medium/style/quality/non-critical HIGH) 인지 vs Critical7 격상인지 모호.

### 4.2 옵션 (★ verbatim 결정 필요)
- A안: Critical7 enum 에 `gemini-review-gate` 추가 → CI_FAILED_NON_REMEDIABLE 즉시 발화
- B안: `phase3-merge-gate` FAILURE 만 escalate → CHAIR_REQUIRED 즉시 발화
- C안: 두 gate 모두 자동수렴 candidate 로 OWNER_GEMINI_TRIGGER_ROUTER 1회 nudge 후 결정

★ 본 packet 은 옵션 enumerate 까지만. 회장 verbatim 결정 후 실제 적용 별도 task.

---

## 5. Fix #5 — schema 명명 통합 (P1)

- spec 정명: `CHAIR_REQUIRED`
- watcher.py 표기: `HOLD_FOR_CHAIR` (L113 단 1회)
- 후속 reporting/통계/dashboard 가 enum 으로 분기 시 표준화 필수

```diff
@@ L112-113 (Fix #1 에 포함되어 처리됨 — 본 fix #5 는 reminder)
-        return "HOLD_FOR_CHAIR", f"head drift to {head}"
+        return "CHAIR_REQUIRED", f"head_drift to {head} (spec §5 admin override)"
```

---

## 6. 적용 시 forbidden 검증 (회장 verbatim 8 금지 정합)

| 금지 | 본 fix preview 위반 여부 |
|---|---|
| 1. 즉시 코드 수정 | ★ 본 packet 은 preview only · 적용 0 |
| 2. PR 생성 | 0 |
| 3. merge | 0 |
| 4. dev bot 재dispatch | 0 |
| 5. live settings.json | 0 |
| 6. dispatch.py | 0 |
| 7. hooks live | 0 |
| 8. Axis runtime/HARNESS_ENFORCED/RUNNING 자동 | 0 |

---

## 7. 적용 시점 (★ 회장 verbatim 강제)

1. 본 packet 4 sub-spec + result + report + .done **박제 완료**
2. 회장 verbatim chair_authorization_id 별도 발급 (예: `CHAIR-AUTH-PR-WATCHER-TERMINAL-CALLBACK-FIX-APPLY-...`)
3. 신규 task (예: task-26XX) 발행 — dev7 또는 dev8 bot 본체
4. 신규 schedule 로 watcher 발사 · 본 사고 박제 workspace `29C74592` 는 보존
5. regression suite (sibling spec) 3 케이스 + Track G expected_files 강제

끝
