# PR Watcher Terminal/Callback — Root Cause Packet (task-2670 통합)

- 작성: dev8 라 (★ 2026-05-25/26)
- chair_authorization_id: `CHAIR-AUTH-PR-WATCHER-TERMINAL-CALLBACK-ROOT-CAUSE-20260525-JJONGS-RCA-001`
- scope: **RCA + fix preview + regression suite + report** 통합 packet · 즉시 코드 수정 0 · runtime 변경 0
- 단일소스: `memory/tasks/task-2670.md` (sha256 `f2de5dd3df6c3d0cb9c8906da346907cdeb267c859ef553fe810da3f9a692d4f`)
- 완료 상태 (회장 verbatim): **`PR_WATCHER_TERMINAL_CALLBACK_ROOT_CAUSE_PACKET_READY`**

---

## 0. TL;DR (회장 보고 형식)

- **사고**: task-2667 dev7 PR #149 watcher (schedule `29C74592`) 가 poll #12 (t+22.27 min) 시점 fresh Gemini head match + 3 new unresolved threads 신호를 감지하고도 `None/continue` 로 처리, 18 polls (36 min) 헛돌이 후 max_watch_minutes=60 도과로 LOOP_BOUNDARY 종결.
- **root cause (복합)**: classify() 5-enum decision tree 의 `CHAIR_REQUIRED` 분기 부재 + BLOCKED/fresh-gemini/new-unresolved 조합 평가 분기 부재 + ANU normal callback registrar 호출 부재.
- **회장 verbatim 4 후보 모두 적중** (#1 #2 #3 P0 · #4 P1).
- **fix preview**: 4 + 1 (명명 통합) — 별도 회장 verbatim 강제 후 신규 task 로 적용.
- **regression suite**: 3 핵심 + 5 부수 케이스 박제.

---

## 1. sibling spec 4 종 (★ Track G 전용)

| 파일 | 내용 |
|---|---|
| [analysis] `pr_watcher_terminal_callback_root_cause_analysis_260525.md` | 사실 박제 + classify() line 인용 + enum 매핑 표 + callback path 추적 + 4 후보 적중 매트릭스 |
| [fix preview] `pr_watcher_terminal_callback_fix_diff_preview_260525.md` | Fix #1 classify 보강 / #2 callback registrar / #3 LOOP_BOUNDARY escalate / #4 non-Critical7 정책 / #5 enum 명명 통합 · ★ 실제 diff 0 |
| [regression] `pr_watcher_terminal_callback_regression_suite_260525.md` | RS-2670-A·B·C 핵심 3 + B-pos/B-neg/B-edge + C-fail/C-skip 부수 5 · acceptance criteria 통합 |
| [packet] `pr_watcher_terminal_callback_root_cause_packet_260525.md` | ★ 본 파일 — 통합 색인 + 회장 보고 형식 + 보고 필수 8 필드 합본 |

---

## 2. 보고 필수 8 (회장 verbatim 박제)

### 2.1 root cause classification (★ #1)
**복합 root cause** — 회장 verbatim 4 후보 모두 적중:

| # | 후보 | 우선 | 적중 근거 (analysis §2.3) |
|---|---|---|---|
| 1 | fresh unresolved 시 HOLD_FOR_CHAIR 조기 전환 조건 미흡 | **P0** | classify() L82–141 — unresolved 가 MERGE_READY 분기(L124) 외에서 evaluate 0 |
| 2 | terminal_state 5 enum evaluation policy 결함 | **P0** | spec §5 `CHAIR_REQUIRED` 분기 부재 + watcher.py 표기 `HOLD_FOR_CHAIR` (L113) 명명 불일치 |
| 3 | unresolved callback timing policy 결함 | **P0** | watcher.py main() L226–243 — `_terminal.json` 저장만, callback 발사 0 |
| 4 | max_watch 도달 전 HOLD 조건 누락 | **P1** | L138 LOOP_BOUNDARY elapsed-only, escalate 분기 1개 (head drift L112) 만 |

**한 줄 요약**: classify() decision tree 가 spec §5 `CHAIR_REQUIRED` enum 자체를 모르고, callback fire 코드가 watcher.py 안에 0줄이라, 본 사고 같은 PR 상태 (BLOCKED + fresh Gemini + new unresolved) 가 silent 한 채 max_watch 까지 흘러갔다.

### 2.2 watcher.py code path 인용 (★ #2)

watcher.py L82–141 classify() 본체 verbatim:

```python
# L82-99 (preamble)
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 = (
        th_data.get("data", {})
        .get("repository", {})
        .get("pullRequest", {})
        .get("reviewThreads", {})
        .get("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 (Critical7 FAILURE)
    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)
    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", "mss=CLEAN + 11/11 SUCCESS + gemini fresh + 0 unresolved"

# 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",
            f"head committed {elapsed_since_head}s ago, fresh gemini review missing",
        )

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

# L141 (fall-through)
    return None, "continue"
```

핵심: spec §5 enum `CHAIR_REQUIRED` 가 위 본문에 0회 등장. 대신 L113 에 `HOLD_FOR_CHAIR` (head drift 전용) 만 1회. 따라서 5-enum 중 `CHAIR_REQUIRED` 자동 분류가 작동할 길이 없다.

### 2.3 terminal_state 분류 표 — poll #12 시점 (★ #3)

poll #12 (t+1336s) 입력:
- `head` = `4bb627fe9252acacc1c32007211807fe9905809f` (= EXPECTED_HEAD)
- `mergeStateStatus` = `BLOCKED`
- `reviewDecision` = `""` (빈 문자열)
- Critical7 checks 모두 SUCCESS (★ 7개 모두)
- non-Critical7 FAILURE: `gemini-review-gate` · `phase3-merge-gate`
- `unresolved_thread_count` = 3
- `latest_gemini_review.head_match` = True (`2026-05-25T13:39:36Z`, head 일치)

5-enum 매핑:

| enum | 판정 | 사유 |
|---|---|---|
| `CI_FAILED_NON_REMEDIABLE` (L107) | NO | Critical7 FAILURE 0 |
| `HOLD_FOR_CHAIR` watcher.py 표기 (L112) | NO | head 일치 |
| `MERGE_READY` (L119) | NO | mss≠CLEAN, unresolved>0, 2 gates FAILURE |
| `GEMINI_EXTERNAL_TRIGGER_STALE` (L131) | NO | gemini_fresh=True |
| `LOOP_BOUNDARY` (L138) | NO | elapsed=1336 < 3600 |
| **결과** | **`None / continue`** (L141) | ★ silent fall-through → 18 polls 헛돌이 |

★ **정상 분류 결정 (spec doctrine)**: spec §5 `CHAIR_REQUIRED` 는 "Critical7 / credential expansion / expected_files 밖 수정 / **admin override 필요** / post-merge smoke fail" 을 포괄. BLOCKED + fresh Gemini review with new unresolved threads 는 "**자동수렴 불가능 + 회장(또는 PR author) 판정 필요**" 의 PR-state 등가물이므로, **`CHAIR_REQUIRED`** 로 분류되어야 한다.

### 2.4 callback registrar code path 인용 (★ #4)

watcher.py main() L226–244 verbatim:
```python
# L226-228 (terminal fallback)
    if not terminal:
        terminal = "LOOP_BOUNDARY"
        reason = f"max polls reached, elapsed {elapsed}s"

# L229-241 (_terminal.json 저장)
    with (POLL_DIR / "_terminal.json").open("w") as f:
        json.dump(
            {
                "terminal_state": terminal,
                "reason": reason,
                "polls_completed": poll,
                "elapsed_sec": elapsed,
                "t0_utc": t0,
                "history_len": len(history),
            },
            f,
            indent=2,
        )

# L242-243 (_history.json 저장)
    with (POLL_DIR / "_history.json").open("w") as f:
        json.dump(history, f, indent=2, default=str)

# L244 (log only)
    log(f"watcher_end terminal={terminal} reason={reason}")
```

★ 본 구간 어디에도 `subprocess.run(['cokacdir', '--cron', ...])`, `--key`, `--chat`, envelope, callback 키워드 0회. spec §6 step 4 ("watcher 는 terminal state 에서 ANU normal callback 을 발사한다") 위반.

★ callback 도착 (`schedule_history/29C74592.log` status=ok, duration 4142s) 은 watcher 가 발사한 게 아니라, 별개 schedule 사이클 (이참나 ANU 본체 prompt) 자연 종료 record. 즉 doctrine 충족이 코드 기여 0% · 운 좋은 부수효과.

### 2.5 fix packet diff preview (★ #5)
- 본 packet sibling: `pr_watcher_terminal_callback_fix_diff_preview_260525.md` (★ 실제 diff 0)
- 핵심 fix 5종:
  - **Fix #1 (P0)** classify() L127-128 에 `CHAIR_REQUIRED` 자동 escalate 분기 신설 (gemini_fresh + unresolved>0 + mss=BLOCKED)
  - **Fix #2 (P0)** main() L244 직후 `cokacdir --cron` callback 발사 (env-driven, UTF-8 ≤3900 bytes, absolute timestamp now+30s, ANU_KEY 단일출처)
  - **Fix #3 (P1)** L138 LOOP_BOUNDARY 도달 시 unresolved>0 또는 mss=BLOCKED 잔재 → CHAIR_REQUIRED 격상
  - **Fix #4 (P2 · 정책 토론)** non-Critical7 gates (`gemini-review-gate · phase3-merge-gate`) FAILURE 처리 정책 A/B/C 옵션
  - **Fix #5 (P1)** L113 `HOLD_FOR_CHAIR` → `CHAIR_REQUIRED` 명명 통합

### 2.6 regression suite 제안 (★ #6)
- sibling: `pr_watcher_terminal_callback_regression_suite_260525.md`
- 핵심 3 케이스: **RS-2670-A** (LOOP_BOUNDARY 잔재) · **RS-2670-B** (poll #12 결정적 · fresh-evidence-with-new-unresolved) · **RS-2670-C** (callback registrar)
- 부수 5 케이스: B-pos / B-neg / B-edge + C-fail / C-skip
- acceptance criteria 7 항목

### 2.7 forbidden_action_count (★ #7) — **target 0**

| 금지 (회장 verbatim 8) | 본 task 위반 |
|---|---|
| 1. 즉시 코드 수정 (utils/ hooks/ watcher.py) | **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** |
| **합계** | **0** |

allowed_resources 외 파일 작성 0 · forbidden_paths 위반 0 · BOT_GITHUB_TOKEN(ghs_) 호출 0 · live cokacdir 실호출 (callback cron 1회 finalize) ANU key 만, self-key 0.

### 2.8 recommended next action (★ #8)

1. ★ 본 packet 박제 통보 (회장 verbatim 보고: `PR_WATCHER_TERMINAL_CALLBACK_ROOT_CAUSE_PACKET_READY`)
2. ★ fix 적용은 **별도 회장 verbatim chair_authorization_id** 발급 후 신규 task 발행 (★ 본 task 에서 적용 0)
3. ★ 적용 task 권장 expected_files (regression sibling §5):
   - `utils/ci_watch_handoff_runner.py`
   - `utils/ci_watch_handoff_schema.py`
   - `tests/test_ci_watch_handoff_classify.py`
   - `tests/test_ci_watch_handoff_callback.py`
   - `tests/fixtures/poll_history/*.json`
4. ★ 적용 시 `29C74592` workspace 는 사고 박제 보존용 — 수정 0 · 신규 schedule 로 watcher 발사
5. ★ Fix #4 (non-Critical7 gates 정책) 는 별도 1:1 회장 verbatim 결정 필요 (A/B/C 옵션 enumerate)

---

## 3. anchor 5 (회장 verbatim 4 + 1)

- ANCHOR-1: "root cause + fix packet only · 즉시 코드 수정 0 · watcher.py 변경 0"
- ANCHOR-2: "dev7 watcher 자기 분석 모순 회피 · executor dev8 라 (task-2666 COMPLETED 후 슬롯 자유)"
- ANCHOR-3: "watcher.py read-only 분석 + poll_history 30 + _history.json 검토"
- ANCHOR-4: "fix 적용은 본 packet 박제 후 별도 회장 verbatim 강제 · 본 task 는 RCA + fix preview only"
- ANCHOR-5: "task-2662~2669 파일 충돌 0 · 본 task prefix `pr_watcher_terminal_callback_*` 차별화"

---

## 4. 끝

- 완료 상태: **`PR_WATCHER_TERMINAL_CALLBACK_ROOT_CAUSE_PACKET_READY`**
- chair_authorization_id: `CHAIR-AUTH-PR-WATCHER-TERMINAL-CALLBACK-ROOT-CAUSE-20260525-JJONGS-RCA-001`

끝
