# CI_WATCH_HANDOFF 정책 spec — v2 보강 (task-2673 fix-implementation 정합)

- 작성: dev8 라 (★ 2026-05-26)
- 단일소스: `memory/tasks/task-2673.md` + 본 spec
- chair_authorization_id: `CHAIR-AUTH-PR-WATCHER-TERMINAL-CALLBACK-FIX-20260526-JJONGS-IMPLEMENT-001`
- 선행 spec: `system_ci_watch_handoff_policy_spec_260523.md` (★ §1~§12 read-only · 본 v2 는 §5 / §6 step 4 / §11 보강)
- 적용 모듈: `utils/pr_watcher_terminal_state_classifier.py`
- regression: `tests/pr_watcher_terminal_state_classifier/`

본 v2 는 task-2670 RCA packet (★ poll #12 silent fall-through 사고 박제) 의 회장 verbatim
4 후보 적중 결과를 doctrine 으로 박제한다.

---

## 1. 5-enum doctrine (회장 verbatim · v1 §5 정합 + v2 명명 통일)

| enum | 의미 | classifier 발화 조건 (우선순위 순) |
|---|---|---|
| `CI_FAILED_NON_REMEDIABLE` | 자동수렴 불가 CI failure | Critical7 FAILURE 1건 이상 |
| `HOLD_FOR_CHAIR` (=spec §5 `CHAIR_REQUIRED` 의 watcher.py 표기 · 본 v2 에서 단일 표기 채택) | 회장 판정 필요 | (a) head_drift / (b) fresh-evidence + new unresolved + BLOCKED / (c) LOOP_BOUNDARY-with-residual |
| `MERGE_READY` | merge 가능 | mss=CLEAN + 0 unresolved + checks all SUCCESS + gemini fresh + RD ∈ {APPROVED, ""} |
| `GEMINI_EXTERNAL_TRIGGER_STALE` | fresh review 미도착 | head_committed 후 GEMINI_STALE_THRESHOLD 도과 + not fresh |
| `LOOP_BOUNDARY` | max_watch 도과 + 잔재 없음 | elapsed >= max_watch · (unresolved==0 AND mss != BLOCKED) |

★ task-2673 verbatim 5-enum 은 v1 spec §5 의 `CHAIR_REQUIRED` 와 `HOLD_FOR_CHAIR` 를
**단일 표기 `HOLD_FOR_CHAIR`** 로 통합한다. 이는 dev7 watcher.py L113 의 기존 표기와
정합하면서, classifier 구현/regression 명명의 일관성을 확보한다.

---

## 2. classify() decision tree (★ v2 신설)

```
classify(snap, elapsed_watcher_sec, expected_head, max_watch_seconds, ...):
    if Critical7 FAILURE:
        return CI_FAILED_NON_REMEDIABLE
    if head_drift:
        return HOLD_FOR_CHAIR  # spec §5 admin override
    if gemini_fresh and unresolved > 0 and mss == "BLOCKED":
        return HOLD_FOR_CHAIR  # ★ 수정 목표 1·4 — fresh-evidence 조기 전환
    if MERGE_READY 조건 (CLEAN + 0 unresolved + checks ok + gemini fresh):
        return MERGE_READY
    if gemini_stale_threshold 도과 and not gemini_fresh:
        return GEMINI_EXTERNAL_TRIGGER_STALE
    if elapsed_watcher_sec >= max_watch_seconds:
        if unresolved > 0 or mss == "BLOCKED":
            return HOLD_FOR_CHAIR  # ★ 수정 목표 5 — LOOP_BOUNDARY 잔재 escalate
        return LOOP_BOUNDARY
    return "", "continue"
```

★ 본 decision tree 는 dev7 watcher.py L82-141 의 결함 (5-enum 분기 부재 +
`HOLD_FOR_CHAIR` 단일 head-drift 발화 + LOOP_BOUNDARY elapsed-only) 을
모두 수정한다.

---

## 3. ANU normal callback 발사 (★ §6 step 4 보강)

v1 spec §6 step 4 "watcher 는 terminal state 에서 ANU normal callback 을 발사한다" 의
구현 invariant:

| invariant | 강제 |
|---|---|
| envelope encoding | UTF-8 |
| envelope 길이 | ≤ 3900 bytes (truncate, not reject) |
| fire-at timestamp | absolute `YYYY-MM-DD HH:MM:SS` (★ relative `30s` 금지 · task-2661 Phase 2b 정합) |
| delay | now + 30s (default `CALLBACK_DELAY_SEC`) |
| ANU key | env `ANU_KEY` → fallback `ANU_KEY_DEFAULT=c119085addb0f8b7` (single source, fail-closed) |
| chat id | env `ANU_CHAT_ID` → fallback `ANU_CHAT_ID_DEFAULT=6937032012` |
| subprocess timeout | 30s, retry 0 (★ exception silent · watcher 본체 정상 종료 보장) |
| `--once` | 강제 (one-shot schedule) |
| envelope body | `terminal_state`, `reason`, `polls`, `elapsed`, `head_match`, `mss`, `unresolved`, `latest_gemini`, `action` 라인 필수 |

★ classifier 모듈은 `register_terminal_callback(envelope, ...)` 순수함수로 위 invariant
을 강제하며, runner argument 로 subprocess 를 외부 주입 가능 (test mock 친화).

---

## 4. 사고 박제 재발 방지 매트릭스 (★ task-2670 RCA 후속)

| 사고 박제 (poll #12) | dev7 watcher 거동 (현행) | classifier v2 거동 (수정 후) |
|---|---|---|
| head_match=True · mss=BLOCKED · gemini_fresh=True · unresolved=3 · critical7 SUCCESS · elapsed=1336s | `None / continue` (silent fall-through) → 18 polls 헛돌이 → LOOP_BOUNDARY | **HOLD_FOR_CHAIR 즉시 반환** (reason="fresh_gemini_head_match + unresolved=3 + mss=BLOCKED") |
| 동일 시나리오 + 30 polls 도달 (LOOP_BOUNDARY 진입) | LOOP_BOUNDARY 분류 + callback 발사 0 | **HOLD_FOR_CHAIR (loop_boundary_with_residual)** + callback 발사 1회 |
| terminal state 도달 시 callback | 발사 0 (별개 schedule 부수효과) | `register_terminal_callback` 1회 호출 |

---

## 5. regression invariant (★ tests/pr_watcher_terminal_state_classifier/)

acceptance criteria 통합:

1. RS-2670-A: LOOP_BOUNDARY-with-residual → HOLD_FOR_CHAIR (`test_classify_loop_boundary.py::test_loop_boundary_with_unresolved_escalates_to_hold`)
2. RS-2670-B: poll #12 fresh-evidence → HOLD_FOR_CHAIR (`test_classify_hold_for_chair.py::test_poll_12_returns_hold_for_chair_immediately`)
3. RS-2670-B 부수: B-pos / B-neg / B-edge (`test_classify_hold_for_chair.py::test_b_pos_*`, `test_b_neg_*`, `test_b_edge_*`)
4. RS-2670-C: subprocess 인자 검증 + absolute timestamp + ANU key 단일출처 (`test_callback_registrar.py::*`)
5. existing branches regression: Critical7 / MERGE_READY / Gemini stale (`test_classify_existing_branches.py::*`)
6. envelope invariant: UTF-8 ≤ 3900 bytes (5 enum 모두 PASS)
7. fail-closed default: env / explicit 모두 부재 → ANU_KEY_DEFAULT fallback

---

## 6. 적용 범위 (★ task-2673 verbatim 허용/금지 정합)

### 허용 (코드 수정 9 항목)
- `utils/pr_watcher_terminal_state_classifier.py` (신설 · 본 v2 의 reference impl)
- `tests/pr_watcher_terminal_state_classifier/**` (신설 · regression)
- `memory/specs/system_ci_watch_handoff_policy_spec_260523_v2.md` (본 문서)
- `memory/events/task-2673.*`, `memory/reports/task-2673.md`
- 별도 PR (★ PR #149 코드와 혼합 0)

### 금지 (8 항목 · 회장 verbatim)
- PR #149 코드 (`utils/anu_codex_micro_refinement_loop.py`, `utils/codex_cc_decision_loop.py`, `tests/anu_codex_micro_refinement_loop/**`) 변경 0
- PR #149 merge / auto-merge 0
- Axis runtime / live settings.json / hooks live / dispatch.py / HARNESS_ENFORCED 전체 변경 0

---

## 7. dev7 watcher.py 와의 관계

- `/home/jay/.cokacdir/workspace/29C74592/watcher.py` 는 **사고 박제 보존용** read-only.
- 본 v2 classifier 는 dev7 watcher.py 의 `classify()` decision tree 결함을 doctrine 화한
  reference impl 로, 후속 watcher 발사 시 import 해서 사용한다.
- 신규 watcher cron 발사 시 권장 사용 패턴:

```python
from utils.pr_watcher_terminal_state_classifier import (
    PRSnapshot, classify, build_callback_envelope,
    register_terminal_callback, TERMINAL_STATES,
)

snap = PRSnapshot.from_gh(pr_data, th_data)
state, reason = classify(
    snap,
    elapsed_watcher_sec=elapsed,
    expected_head=EXPECTED_HEAD,
    max_watch_seconds=60 * 60,
    head_committed_utc=HEAD_COMMITTED_UTC,
)
if state:
    env = build_callback_envelope(
        task_id="...", pr_number=..., terminal_state=state,
        reason=reason, polls_completed=poll, elapsed_sec=elapsed,
        last_snapshot=snapshot_dict,
    )
    register_terminal_callback(envelope=env)
```

---

## 8. ANCHOR

- ANCHOR-1: "task-2673 단일 표기 = HOLD_FOR_CHAIR (★ spec v1 §5 CHAIR_REQUIRED 와 watcher.py L113 HOLD_FOR_CHAIR 통합)"
- ANCHOR-2: "classify() decision tree 우선순위 = Critical7 → head_drift HOLD → fresh-evidence HOLD → MERGE_READY → Gemini stale → LOOP_BOUNDARY (residual escalate)"
- ANCHOR-3: "fresh-evidence HOLD 분기 = gemini_fresh AND unresolved>0 AND mss==BLOCKED (사고 박제 poll #12 결정적 line)"
- ANCHOR-4: "callback invariant 7 = UTF-8 ≤3900 / absolute timestamp / ANU key 단일출처 / `--once` / subprocess timeout 30s / retry 0 / exception silent"
- ANCHOR-5: "PR #149 코드 변경 0 · task-2662~2672 파일 충돌 0 · 별도 PR 권장 · merge 회장 별도 chair signature"

끝
