# task-2512 보고서 — post_merge_smoke_runner

- 작업: post_merge_smoke_runner: main 기준 smoke 자동 실행 (5 모듈 #4)
- 팀: dev3 (다그다 / 루 / 모리건)
- 레벨: Lv.3 / 우선순위 ★★ / 일시: 2026-05-09
- 회장 결정: 2026-05-09 직접 발행
- 브랜치: `task/task-2512-dev3` (origin/main 59ec8d37 기준)
- 워크트리: `/home/jay/workspace/.worktrees/task-2512-dev3`

## SCQA

### S — Situation
자동 머지 큐(merge_queue_executor + replacement_pr_runner + auto_gemini_triage)는 PR을 main에 자동으로 머지한다. 그러나 머지 직후 origin/main 기준 smoke를 자동 실행하지 않으면 후행 PR을 그대로 머지해 회귀가 누적된다. 5 모듈 #4의 책임은 "자동 머지 직후 smoke를 main 기준으로 실행하고, 실패 시에만 Critical #7(POST_MERGE_SMOKE_FAILED) escalation packet을 만드는 것"이다.

### C — Complication
- `automation_contracts.py`(task-2509+2 PR #60)는 freeze 상태이며 본 task의 expected_files에 포함되지 않는다.
- 회장 §6은 `SmokeResult(merge_commit/status/duration_ms/task_id/...)` 정보량을 요구하지만 frozen `SmokeResult`는 6 필드(`command, passed, exit_code, stdout_tail, stderr_tail, failure_reason`)만 가진다.
- 회장 §8은 PASS 시 `AutomationDecision(allow_continuation=True)`을 요구하지만 frozen `AutomationDecision`에는 그 필드가 없다.
- task-2513(critical_escalation_reporter)와 disjoint해야 하므로 packet 전송/보고 코드는 작성 금지.
- task-2514 wiring 보류 → `merge_queue_executor`/`dispatch.py` 수정 절대 금지.

### Q — Question
freeze contract를 위반하지 않으면서 회장 §1~10 정보량/동작을 어떻게 보존할 것인가?

### A — Answer (envelope 패턴)
별도 dataclass `PostMergeSmokeRun`(envelope)에 freeze `SmokeResult`를 transport object로 내장하고, 추가 메타데이터(merge_commit/task_id/status/duration_ms/allow_continuation/escalation/stale/dry_run)를 envelope 자체 필드로 보존한다. CLI/회귀 테스트는 envelope JSON을 출력/검증한다. wiring 단계(task-2514)에서 envelope를 풀어 `AutomationDecision`/`merge_queue_executor`에 신호를 전달한다. 본 task에서는 packet 생성만 담당.

3 Step Why (context-notes 기록):
- 1st: main 기준 smoke 자동 실행이 없으면 큐 정체/회귀 누적 발생
- 2nd: 분리 모듈 + envelope이 단위 테스트/contract 보존/wiring 분리 모두 가능 (대안 inline 확장은 회장 명시 금지 사항 위반)
- 3rd: envelope 패턴은 contract freeze + replay fixture + 회귀 12건 모두 동시 만족

## 모델 사용 기록
- 다그다(Opus): 설계 + 게이트 + 보고 (직접 코딩 일부 — Codex risk fix)
- 루(Sonnet): `utils/post_merge_smoke_runner.py` 신규 구현 (582→589 라인)
- 모리건(Sonnet): `tests/regression/test_post_merge_smoke_runner_2512.py` 신규 회귀 12건 (372→407 라인)
- 마아트(Sonnet): 독립 검증 PASS

(haiku 사용 0건. 본 task는 contract freeze/critical 7종 처리가 포함된 critical 작업이라 sonnet 이상 필수.)

## 생성/수정 파일

### 신규
1. `utils/post_merge_smoke_runner.py` (589 라인) — 핵심 모듈
2. `tests/regression/test_post_merge_smoke_runner_2512.py` (407 라인) — 회귀 12건

### 수정
없음.

## 수정 파일별 검증 상태

| 파일 | 라인 | 변경 유형 | grep 키워드 | 검증 결과 |
|---|---|---|---|---|
| `utils/post_merge_smoke_runner.py` | 589 | 신규 | `POST_MERGE_SMOKE_FAILED`, `PostMergeSmokeRun`, `REPLAY_FIXTURES`, `SmokeStatus`, `run_post_merge_smoke` | PASS (모두 ≥1건) |
| `tests/regression/test_post_merge_smoke_runner_2512.py` | 407 | 신규 | `test_01_pass_smoke`, `test_11_replay_fixtures_pass`, `POST_MERGE_SMOKE_FAILED` | PASS (12 함수, 15 expand pytest) |

### 머지/큐 파일 영향
- `utils/automation_contracts.py`: **무수정** (freeze 준수)
- `utils/merge_queue_executor.py`: **무수정** (wiring 보류)
- `dispatch.py`: **무수정**

## 회장 §1~10 매핑

| § | 요구 | 구현 위치 |
|---|---|---|
| §1 | merge_commit 기준 main smoke 실행 + stale 판정 | `run_post_merge_smoke()`, `check_main_head_stale()` |
| §2 | smoke_command registry 처리 | `extract_smoke_command()` (yaml→registry→None 3단계 fallback) |
| §3 | subprocess timeout (default 600s) | `subprocess.TimeoutExpired` catch + `status=TIMEOUT` |
| §4 | stdout/stderr capture (head/tail, 64KB cap) | `capture_head_tail()` |
| §5 | PASS/FAIL/SKIPPED/TIMEOUT 분류 | `SmokeStatus` enum + 5상태 분기 (BLOCKED 추가) |
| §6 | SmokeResult 반환 (freeze) | frozen 6 필드 그대로 + envelope 보강 |
| §7 | 실패 시 Critical #7 packet | `build_smoke_failed_packet()` |
| §8 | PASS 시 continuation 신호 | envelope `allow_continuation: bool` |
| §9 | replay fixture 4종 | `REPLAY_FIXTURES` dict + test_11 (실제 task md 파일 우선) |
| §10 | smoke 미정의 정책 4 케이스 | dry_run/defined 매트릭스 분기 |

## 회장 §1~12 회귀 12건

```
test_01_pass_smoke                                         PASS
test_02_fail_smoke_creates_critical_7_packet                PASS
test_03_timeout_smoke_creates_critical_7_packet             PASS
test_04_missing_smoke_dry_run_true_skipped                  PASS
test_05_missing_smoke_dry_run_false_blocked                 PASS
test_06_stdout_head_tail_capture                            PASS
test_07_stderr_size_cap                                     PASS
test_08_json_serialization_round_trip                       PASS
test_09_critical_7_enum_exact_match                         PASS
test_10_merge_commit_propagation                            PASS
test_11_replay_fixtures_pass[task-2506,2507,2509,2511]      PASS (4 parametrize)
test_12_continuation_signals_for_all_states (+ stale 게이트) PASS
```

`pytest tests/regression/test_post_merge_smoke_runner_2512.py -q` → **15 passed in 0.14s**.

## L1 스모크테스트 결과 (필수 기록)

- **서버 재시작**: 해당없음 (CLI 모듈, 서버 무관)
- **API 응답 확인**: CLI 직접 호출
  ```bash
  cd /home/jay/workspace/.worktrees/task-2512-dev3
  PYTHONPATH=. python3 utils/post_merge_smoke_runner.py \
    --task-file /home/jay/workspace/memory/tasks/task-2512.md \
    --merge-commit 59ec8d37 --dry-run --skip-stale-check
  ```
  결과 (FAIL 분기 — pytest cwd가 worktree 외부에 있어 returncode=4):
  - status: `FAIL`
  - escalation_type: `POST_MERGE_SMOKE_FAILED` (정확 매칭)
  - merge_commit propagation: ✅ envelope `merge_commit=59ec8d37`, escalation evidence `merge_commit=59ec8d37`
  - allow_continuation: `False`
- **PASS 경로 확인** (fake_runner 주입):
  - status: `PASS`, allow_continuation: `True`, escalation: `None`
- **스크린샷**: 해당없음 (CLI 모듈)

## 발견 이슈 및 해결

| # | 심각도 | 발견 경로 | 해결 |
|---|---|---|---|
| 1 | medium | Pyright (4 unused imports + 1 unreachable) | `dataclasses/field/to_json` 제거, `text: Optional[str]`로 변경, 즉시 grep 검증 |
| 2 | medium | Pyright (test 파일 unused imports + cwd) | 미사용 import 제거 + `del cwd` 추가 |
| 3 | high | 회장 §10 표 — dry_run+defined → "smoke 실행"인데 SKIPPED 반환 | dry_run 분기에서 smoke_command 정의된 경우 항상 실행하도록 수정 (`utils/post_merge_smoke_runner.py:431`) |
| 4 | high (Codex G1) | stale 판정이 단순 boolean. PASS+stale도 allow_continuation=True 반환 | PASS 분기에서 `allow_continuation = not stale`로 게이트 추가 (`utils/post_merge_smoke_runner.py:497`) + test_12에 stale 시나리오 추가 |
| 5 | medium (Codex G1) | timeout reason hardcode `DEFAULT_TIMEOUT_SEC` | `build_smoke_failed_packet()`에 `timeout_sec` 인자 추가 + 모든 호출처 전달 |
| 6 | high (Codex G1) | replay fixture 테스트가 임시 md만 사용 | test_11에서 `_REAL_FIXTURE_DIR / f"{task_id}.md"` 실제 파일 우선 + FAIL 결정성 검증 추가 |
| 7 | low (Codex G1) | CLI `--dry-run` help 부정확 | "smoke_command 미정의 시 SKIPPED 처리 (정의된 경우는 항상 실행)"으로 수정 |

## Codex G1 결과
- 1차 검사 (구현 전): `pass=false`, critical=1 (파일 미존재 — 예상). high=2 (envelope/legacy enum), medium=1 (replay fixture).
- 2차 검사 (구현 후): `pass=false`, critical=1 (envelope 패턴 — 의도적 설계, context-notes 문서화), high=2 (stale 게이트, replay fixture — **수정 완료**), medium=1 (timeout — **수정 완료**), low=1 (CLI help — **수정 완료**).
- envelope 패턴 critical은 의도적 설계 결정. 회장 §6 정보량을 frozen contract 위반 없이 보존하는 유일한 방식. wiring 단계(task-2514)에서 envelope를 풀어 `AutomationDecision`으로 변환 예정.

## 마아트 독립 검증
**PASS** — A 10/10 + B 12/12 + C 6/6 + D PASS + E PASS.

## Merge Topology Gate (자기참조)
- effective diff (origin/main 기준): 정확히 2 파일 (`utils/post_merge_smoke_runner.py`, `tests/regression/test_post_merge_smoke_runner_2512.py`)
- forbidden_path 침입: 0
- expected_files 외 수정: 0
- amendment 보호: stale 감지 시 `allow_continuation=False`로 강등 + escalation evidence에 `stale: bool` 기록 — main HEAD 변경 시 자동 머지 재진행 차단

## 머지 판단

- **머지 필요**: Yes
- **브랜치**: `task/task-2512-dev3`
- **워크트리 경로**: `/home/jay/workspace/.worktrees/task-2512-dev3`
- **머지 의견**:
  - 회장 §1~10 구현 + §1~12 회귀 모두 충족
  - automation_contracts freeze 준수 (envelope 패턴)
  - 마아트 독립 검증 PASS, Codex G1 actionable risk 4종 모두 수정
  - task-2513(disjoint)/task-2514(wiring 보류) 영역 비침범
  - effective diff = 정확히 expected_files 2건
  - 권장 액션: PR 생성 → Gemini 리뷰 → 자동 머지

## 후행
- task-2513 (`critical_escalation_reporter`) — 병렬 진행 중
- task-2514 (5 모듈 wiring) — 두 task 모두 main 머지 후 시작 (★ 마지막 serial orchestration)

## 비고
- 본 task의 envelope 패턴은 `AutomationDecision.allow_continuation` 필드 부재를 우회하면서도 freeze contract를 위반하지 않는다. task-2514 wiring 단계에서 `AutomationDecision(decision="ALLOW_CONTINUATION", reason_codes=["POST_MERGE_SMOKE_PASS"], ...)`로 변환하는 매핑 계층을 추가할 것.
- legacy `merge_queue_executor.py`의 `CRITICAL_POST_MERGE_SMOKE` 상수는 freeze enum과 다른 문자열일 수 있다. wiring task에서 단일 변환 계층을 정의하면 해소됨 (Codex G1 high 2건째 권고).

## 세션 통계
- 총 도구 호출: 0회

