# PR Watcher Terminal/Callback — Regression Suite (task-2670)

- 작성: dev8 라 (★ 2026-05-25/26)
- chair_authorization_id: `CHAIR-AUTH-PR-WATCHER-TERMINAL-CALLBACK-ROOT-CAUSE-20260525-JJONGS-RCA-001`
- scope: **test case 박제 only · 실제 fixture/test 파일 작성 0 · python 실행 0**
- 본 spec 은 fix 적용 task (별도 회장 verbatim 강제) 가 발행될 때 인용할 acceptance criteria 묶음.

---

## 0. 본 사고 재발 방지 핵심 3 케이스 (회장 verbatim 보고 필수 #6)

| ID | 케이스명 | spec doctrine 라인 | 적중 fix |
|---|---|---|---|
| `RS-2670-A` | LOOP_BOUNDARY-with-unresolved-residual | spec §5 `CHAIR_REQUIRED` + §6 step 4 callback | Fix #3 |
| `RS-2670-B` | fresh-evidence-with-new-unresolved (★ poll #12 박제) | spec §5 `CHAIR_REQUIRED` 자동 escalate | Fix #1 |
| `RS-2670-C` | callback-registrar-failure | spec §6 step 4 callback 발사 | Fix #2 |

---

## 1. RS-2670-A — LOOP_BOUNDARY-with-unresolved-residual

### 1.1 시나리오 (★ 본 task-2667 poll #30 재현)
- 입력: 30 polls 완료 · elapsed=3524s · mss=BLOCKED · unresolved=3 · fresh_gemini=True · critical7 SUCCESS · gemini-review-gate FAILURE · phase3-merge-gate FAILURE
- 기존 (현재 코드): `LOOP_BOUNDARY` 로 분류 (★ 사고 박제 상태)
- 기대 (Fix #3 적용 후): **`CHAIR_REQUIRED`** 로 분류 (reason: `loop_boundary_with_residual: unresolved=3 mss=BLOCKED`)

### 1.2 assertion
- `terminal_state == "CHAIR_REQUIRED"`
- `reason` 에 `loop_boundary_with_residual` 포함
- callback envelope body 에 `terminal_state: CHAIR_REQUIRED` 박제

### 1.3 fixture (텍스트 박제 · 파일 생성 0)
```json
{
  "elapsed_watcher": 3600,
  "pr_data": {"headRefOid": "4bb627fe9252acacc1c32007211807fe9905809f", "mergeStateStatus": "BLOCKED", "reviewDecision": "", "statusCheckRollup": [
    {"name": "cancel-kill-switch", "conclusion": "SUCCESS"},
    {"name": "qc-check", "conclusion": "SUCCESS"},
    {"name": "hidden-path-audit", "conclusion": "SUCCESS"},
    {"name": "lock-in-check", "conclusion": "SUCCESS"},
    {"name": "merge-safety-check", "conclusion": "SUCCESS"},
    {"name": "ci/guard", "conclusion": "SUCCESS"},
    {"name": "guard", "conclusion": "SUCCESS"},
    {"name": "gemini-review-gate", "conclusion": "FAILURE"},
    {"name": "phase3-merge-gate", "conclusion": "FAILURE"}
  ], "reviews": [{"author": {"login": "gemini-code-assist"}, "submittedAt": "2026-05-25T13:39:36Z", "commit": {"oid": "4bb627fe9252acacc1c32007211807fe9905809f"}}]},
  "th_data_unresolved_count": 3
}
```

---

## 2. RS-2670-B — fresh-evidence-with-new-unresolved (★ poll #12 박제 — 결정적 case)

### 2.1 시나리오 (★ 본 사고의 결정적 line)
- 입력: poll #12 (t+1336s · ≈22.27 min · elapsed_watcher=1336) — `mss=BLOCKED · unresolved=3 · fresh_gemini_head_match=True · critical7 SUCCESS · head 일치`
- 기존 (현재 코드): classify() L141 `None / continue` → 18 polls 헛돌이
- 기대 (Fix #1 적용 후): **즉시 `CHAIR_REQUIRED`** 반환 (reason: `fresh_gemini_head_match + unresolved=3 + mss=BLOCKED`)

### 2.2 assertion
- `terminal_state == "CHAIR_REQUIRED"`
- `reason` 에 `fresh_gemini_head_match` + `unresolved=3` + `mss=BLOCKED` 모두 포함
- main() while 루프 즉시 break (poll #13 진입 0)
- callback envelope body 에 `terminal_state: CHAIR_REQUIRED` 박제

### 2.3 부수 case (Fix #1 false-positive 방지)
- **B-pos**: unresolved=0 + fresh_gemini=True + mss=CLEAN + 11/11 SUCCESS → **MERGE_READY** (★ Fix #1 분기 발화 안 함)
- **B-neg**: unresolved=3 + fresh_gemini=False (stale) → **None / continue** (★ Fix #1 분기는 `gemini_fresh=True` 강제이므로 발화 안 함)
- **B-edge**: unresolved=3 + fresh_gemini=True + mss=BEHIND (★ mss != BLOCKED) → **None / continue** 또는 별도 분기 (★ 본 fix 외 정책 결정)

---

## 3. RS-2670-C — callback-registrar-failure

### 3.1 시나리오 (★ spec §6 step 4 위반 재발 방지)
- 입력: terminal_state 도달 (`MERGE_READY` / `CHAIR_REQUIRED` / `LOOP_BOUNDARY` 어느 것이든)
- 기존 (현재 코드): callback 발사 0 · `_terminal.json` 만 저장
- 기대 (Fix #2 적용 후): **`subprocess.run(['cokacdir', '--cron', ...])`** 호출 1회 · envelope UTF-8 ≤3900 bytes · absolute timestamp now+30s · ANU_KEY env-driven · self-key 0

### 3.2 assertion
- `subprocess.run` 호출 횟수 == 1 (★ mock 으로 capture)
- 인자 0번 == `/usr/local/bin/cokacdir`
- 인자 1번 == `--cron`
- envelope (인자 2번) UTF-8 byte 길이 ≤ 3900
- `--key` 인자 값 == ANU_KEY (★ env)
- `--chat` 인자 값 == ANU_CHAT_ID (★ env)
- `--once` 포함 (one-shot)
- `--at` 값이 ISO-like absolute timestamp (★ relative `30s` 형식 금지 — task-2661 Phase 2b 박제)
- log 에 `anu_callback_fired terminal=<state> bytes=<N>` 박제

### 3.3 부수 case
- **C-fail**: subprocess 호출 timeout → log `anu_callback_fire_fail: ...` · watcher process 자체는 정상 종료 (★ retry 0)
- **C-skip**: ANU_KEY env 부재 → log `anu_callback_skip: ANU_KEY/CHAT_ID env 부재` · subprocess 호출 0

---

## 4. acceptance criteria 통합 (Fix 적용 task 발행 시 인용)

1. RS-2670-A · B · C 모두 PASS
2. RS-2670-B 부수 case (B-pos / B-neg / B-edge) 모두 PASS
3. RS-2670-C 부수 case (C-fail / C-skip) 모두 PASS
4. existing classify() 분기 4종 (CI_FAILED_NON_REMEDIABLE / MERGE_READY / GEMINI_EXTERNAL_TRIGGER_STALE / LOOP_BOUNDARY-no-residual) regression PASS
5. envelope UTF-8 ≤3900 bytes invariant 통과
6. forbidden 8 종 (회장 verbatim) 위반 0
7. spec `system_ci_watch_handoff_policy_spec_260523.md` §5 5-enum 모두 자동 분류 가능

---

## 5. 적용 task 발행 시 권장 expected_files

- `utils/ci_watch_handoff_runner.py` (★ watcher classify + callback fire 통합 모듈)
- `utils/ci_watch_handoff_schema.py` (★ envelope schema · 5-enum doctrine)
- `tests/test_ci_watch_handoff_classify.py` (★ RS-2670-A · B 본체)
- `tests/test_ci_watch_handoff_callback.py` (★ RS-2670-C 본체 · subprocess mock)
- `tests/fixtures/poll_history/*.json` (★ RS-2670-B poll #12 박제 + RS-2670-A poll #30 박제)

★ 본 task-2670 에서는 위 파일 생성 0 · 적용은 별도 회장 verbatim 강제.

---

## 6. ANCHOR

- ANCHOR-1: "3 케이스 (A LOOP_BOUNDARY 잔재 · B poll #12 결정적 · C callback registrar) — 본 사고 재발 방지 필수"
- ANCHOR-2: "B-pos / B-neg / B-edge 부수 case 로 Fix #1 false-positive 0 검증"
- ANCHOR-3: "RS-2670-C envelope invariant = UTF-8 ≤3900 bytes + absolute timestamp + ANU key 단일출처"
- ANCHOR-4: "본 spec 은 acceptance criteria · 실제 fixture/test 파일은 fix 적용 task 에서 생성"

끝
