# Incident-2460 — Veles 사고 조사 보고서 (.merge-done silent + Gemini timeout 우회)

- **조사자**: 개발6팀 테스터 벨레스
- **조사 일시**: 2026-05-05 (KST)
- **모드**: read-only (코드 수정 없음, 보고서 단일 파일 작성)
- **대상 사고**: task-2454 PR #24, OPEN 5분 후 16:57:51에 회장 토큰으로 `gh pr merge 24 --squash` 수동 머지. Gemini 리뷰 0건임에도 `.merge-done` 마커가 16:52:53에 선행 생성되어 자동 머지 봇이 silent 종료된 사건.

---

## F. `.merge-done` silent 마커 결함 (finish-task.sh:447~451)

### F1. finish-task.sh 444~470 원문

**Evidence** — `scripts/finish-task.sh:444-470` (원문 그대로 인용):

```
        exit 1
    fi
    echo "[INFO] 머지 실행: worktree_manager.py finish $PROJECT_PATH $TASK_ID $TEAM_SHORT --action auto"
    python3 "$WORKSPACE/scripts/worktree_manager.py" finish "$PROJECT_PATH" "$TASK_ID" "$TEAM_SHORT" --action auto 2>&1 || {
        echo "[WARN] worktree_manager.py finish 실패 — 계속 진행."
    }
    echo '{"task_id":"'"$TASK_ID"'","project_path":"'"$PROJECT_PATH"'","timestamp":"'"$(date -Iseconds)"'"}' > "$MERGE_DONE_FILE"
    echo "[INFO] 머지 완료 — .merge-done 생성: $MERGE_DONE_FILE"
    # task-2367 P1: post-merge health probe (5분 후 background 실행)
    if [ -f "$WORKSPACE/scripts/post_merge_probe.py" ]; then
        MERGE_SHA="${MERGE_SHA:-$(cd "${PROJECT_PATH:-$WORKSPACE}" 2>/dev/null && git rev-parse HEAD 2>/dev/null || echo "")}"
        if [ -n "$MERGE_SHA" ]; then
            # 멱등 마크가 있으면 스킵
            PROBE_MARK="$EVENTS_DIR/${TASK_ID}.probe-done"
            if [ ! -f "$PROBE_MARK" ]; then
                echo "[POST-PROBE] 5분 후 health probe 예약: ${TASK_ID} sha=${MERGE_SHA:0:8}"
                (
                    python3 "$WORKSPACE/scripts/post_merge_probe.py" \
                        --task-id "$TASK_ID" \
                        --merge-sha "$MERGE_SHA" \
                        --project-path "${PROJECT_PATH:-$WORKSPACE}" \
                        --delay 300 \
                        >> "$WORKSPACE/logs/post-merge-probe.log" 2>&1 &
                ) </dev/null
                echo "[POST-PROBE] background pid 분리됨"
            fi
        fi
    fi
```

**결론**: `worktree_manager.py finish` 의 종료코드와 출력(JSON status)은 전혀 검사되지 않으며, 라인 450에서 `.merge-done` 이 무조건 기록된다. 줄 447의 `|| { echo "[WARN] ... 계속 진행."; }` 는 실패를 흡수하기만 하고 fail-fast 처리가 없다.

**gap 분석**: shell 단의 머지 결과 검증 로직 부재 — Python 머지 모듈이 `blocked_by_timeout`/`blocked_by_high_severity`/`merge_failed` 어떤 status를 리턴해도 shell은 동일하게 "성공" 마커를 작성한다.

---

### F2. `worktree_manager.py finish` status 종류 전수

**Evidence** — `scripts/worktree_manager.py:992-1022`:

```
        merge_status = "pending"
        if not gemini_found:
            gemini_verdict = "TIMEOUT"
        elif high_severity_count == 0:
            gemini_verdict = "PASS"
        else:
            gemini_verdict = "BLOCKED"

        if not gemini_found:
            # 타임아웃 시 머지 차단 — 수동 확인 필요
            merge_status = "blocked_by_timeout"
            logger.warning("Gemini review timed out for PR #%s. Merge blocked. Manual review required.", pr_number)
        elif high_severity_count == 0:
            merge_result = _run(
                ["gh", "pr", "merge", pr_number, "--merge", "--delete-branch"],
                cwd=wt_path,
                check=False,
            )
            if merge_result.returncode == 0:
                merge_status = "merged"
                ...
            else:
                merge_status = "merge_failed"
        else:
            merge_status = "blocked_by_high_severity"

        result: dict[str, Any] = {
            "status": merge_status,
            ...
```

**결론**: 가능한 status 전수 — `pending` (초기값) / `blocked_by_timeout` / `merged` / `merge_failed` / `blocked_by_high_severity`. `merged` 한 케이스 외에는 실제 머지가 일어나지 않은 상태이다.

**gap 분석**: Python 결과 dict의 `status` 키는 stdout JSON으로만 흘러가고, finish-task.sh 라인 447은 `2>&1 || { ... }` 로 "프로세스 exit code"만 본다. exit code는 `blocked_by_timeout` 케이스에서도 0이므로(차단을 status로 표현) shell이 검출하지 못한다.

---

### F3. status별 `.merge-done` 생성 권고

| status | `.merge-done` 생성 권고 | 근거 |
|---|---|---|
| `merged` | 생성 (현행 유지) | 실제 main 반영 완료 |
| `pending` | **생성 금지** | 진행 중 상태가 그대로 누설된 것 — 봇 추적 누락 |
| `merge_failed` | **생성 금지** | gh pr merge 실패, main 미반영 |
| `blocked_by_high_severity` | **생성 금지** | HIGH 미해결 — 수동 검토 필요 |
| `blocked_by_timeout` | **생성 금지** | Gemini 미실행 — 본 사건 핵심 누설 경로 |

**Evidence** — finish-task.sh 라인 450은 status 매개변수 미검증 무조건 write:

```
echo '{"task_id":"'"$TASK_ID"'","project_path":"'"$PROJECT_PATH"'","timestamp":"'"$(date -Iseconds)"'"}' > "$MERGE_DONE_FILE"
```

**gap 분석**: shell이 stdout JSON을 capture → `jq -r .status` → `merged` 일 때만 마커 생성하는 분기가 누락. 현행 구조는 "Python이 차단을 결정해도 shell이 성공 처리" 라는 의미론적 단절을 만든다.

---

### F4. probe-done `head_sha=a3238b8d` 가 실제 머지 SHA `e51cf833` 과 다른 이유

**Evidence** — `scripts/finish-task.sh:454`:

```
MERGE_SHA="${MERGE_SHA:-$(cd "${PROJECT_PATH:-$WORKSPACE}" 2>/dev/null && git rev-parse HEAD 2>/dev/null || echo "")}"
```

**Evidence** — `scripts/post_merge_probe.py:97-104`:

```
def _changed_paths(project_path: Path, merge_sha: str) -> list[str]:
    """git diff <merge_sha>~1..<merge_sha> --name-only — failures return []"""
    if not merge_sha:
        return []
    try:
        r = subprocess.run(
            ["git", "diff", f"{merge_sha}~1..{merge_sha}", "--name-only"],
```

**결론**: `MERGE_SHA` 는 `worktree_manager.py finish` 직후 `PROJECT_PATH` (= worktree HEAD) 의 `git rev-parse HEAD` 에서 가져온다. 이는 worktree 브랜치의 마지막 로컬 커밋 SHA(= `a3238b8d`) 이며, GitHub 측 squash-merge 후 main 에 새로 만들어진 커밋 SHA(`e51cf833`) 와는 별개이다.

**gap 분석**: (1) squash 머지의 경우 GitHub 가 새 SHA를 생성하므로 worktree HEAD 와 main HEAD 가 절대 일치하지 않는다. (2) 더구나 본 사건에서는 봇이 아닌 회장이 수동으로 `--squash` 머지 → worktree 자체가 main 머지를 인지하지도 못한 상태에서 finish-task.sh 가 실행되었을 가능성이 있다 (가설). (3) probe 가 잘못된 SHA로 `git diff` 를 실행하면 `_changed_paths` 가 빈 리스트를 반환 → smoke 모드로 fallback (`post_merge_probe.py:194`) → main 의 실제 변경분을 검증하지 않게 된다.

---

## G. Gemini review timeout 우회 (task-2454 PR #24)

### G1. `.github/workflows/` yml 파일 목록 + Gemini 트리거

**Evidence** — 디렉토리 구성: `ci.yml` (192 lines), `extension-release.yml` (93), `guard.yml` (101).

**Evidence** — `.github/workflows/ci.yml:119-154` 가 유일한 Gemini 관련 잡 (`gemini-review-gate`). `guard.yml` 은 주석에서 8 required checks 의 일부로 언급할 뿐 트리거하지 않는다.

**결론**: Gemini 관련 잡은 ci.yml 의 `gemini-review-gate` 단 1개. PR open 시 이를 통해 `scripts/gemini_review_gate.py --publish-check` 가 호출된다.

**gap 분석**: gemini-code-assist GitHub App(별도 외부 봇) 자체를 트리거하는 워크플로우는 리포지토리 코드 어디에도 없다 — 즉 외부 GitHub App 설치/marketplace 설정에 의존한다 (가설). 따라서 외부 App이 silent fail 하면 코드만 봐서는 원인을 알 수 없다.

---

### G2. PR open 시 gemini-code-assist 자동 실행 메커니즘

**Evidence** — `.github/workflows/ci.yml:119-154` 는 `scripts/gemini_review_gate.py` 를 호출하지만, `gemini-code-assist` 라는 이름의 리뷰 봇 코멘트를 직접 생성하지는 않는다. `scripts/worktree_manager.py:919` 는 `"gemini-code-assist" in reviews_result.stdout.lower()` 문자열 매칭으로 외부 봇 코멘트를 탐지한다:

```
if reviews_result.returncode == 0 and "gemini-code-assist" in reviews_result.stdout.lower():
    gemini_found = True
```

**결론**: 자동 머지 봇은 PR reviews API에서 `gemini-code-assist` 사용자명을 찾는 방식. 외부 GitHub App이 PR open 이벤트에 hook 되어 리뷰 코멘트를 남겨야 매칭된다 — workflow 코드로는 강제하지 못한다.

**gap 분석**: 본 사건에서 PR #24 reviews API 응답이 0건 → 외부 App이 호출되지 않았거나(설치/권한 문제 가설) repo 레벨에서 비활성화된 상태에서 봇이 5분간 폴링만 반복.

---

### G3. CI check `gemini-review-gate: pass` 통과 이유

**Evidence** — `scripts/gemini_review_gate.py:144-146`:

```
api_key = os.environ.get("GEMINI_API_KEY")
if not api_key:
    return {"ok": False, "text": "", "tokens_in": 0, "tokens_out": 0, "latency_ms": 0, "error": "GEMINI_API_KEY missing"}
```

**Evidence** — `scripts/gemini_review_gate.py:271-280`:

```
if not gemini_result["ok"]:
    conclusion = "neutral"
    summary = f"gemini call failed: {gemini_result.get('error', 'unknown')}"
elif matches:
    conclusion = "failure"
    summary = f"blocking matches: {matches}"
else:
    conclusion = "success"
    summary = "no blocking issues"
```

**결론**: API 키가 secrets에 없거나 호출 실패 시 conclusion 은 `neutral` 로 처리되며, `gate()` 종료코드 또한 `failure` 일 때만 1을 리턴 (라인 308: `return 1 if conclusion == "failure" else 0`). 따라서 GitHub Ruleset 의 "required check pass" 기준은 `success` + `neutral` 모두 통과이다.

**gap 분석**: `neutral` 도 통과로 간주하는 정책 + Gemini API 키 미설정/호출 실패 → "리뷰 안 했지만 통과" 라는 false-pass 채널. 회장 토큰으로 수동 머지 시 이 neutral 결과는 ruleset 차원에서 차단되지 않는다.

---

### G4. `gemini_found=False` + `blocked_by_timeout` 처리 함수 위치

**Evidence** — `scripts/worktree_manager.py:992-1003`:

```
        merge_status = "pending"
        if not gemini_found:
            gemini_verdict = "TIMEOUT"
        ...
        if not gemini_found:
            # 타임아웃 시 머지 차단 — 수동 확인 필요
            merge_status = "blocked_by_timeout"
            logger.warning("Gemini review timed out for PR #%s. Merge blocked. Manual review required.", pr_number)
```

**결론**: `worktree_manager.py` 의 `finish` 핸들러 내부 6번째 단계 (라인 991 주석 `# 6. Merge if no unresolved High issues; block on timeout or HIGH`). 차단 의도는 `merge_status = "blocked_by_timeout"` 으로 명확히 표현되며 `logger.warning` 까지 남긴다.

**gap 분석**: 의도와 달리 차단이 "결과 dict 키 값" 으로만 표현되고 (a) raise/exit 비유발 (b) shell 측에서 미검증 — 결과적으로 Python 의 의도가 시스템 거동에 반영되지 않는다.

---

### G5. 회장 수동 머지가 task-2440 enforcement(Ruleset, merge_group) 우회한 경로

**Evidence** — `memory/reports/task-2440-enforce/01b-rulesets.txt`:

```
===== B1: Try rulesets API GET (list) =====
{"message":"Upgrade to GitHub Pro or make this repository public to enable this feature.", ...,"status":"403"}
exit_code=1
===== B2: Try POST a ruleset =====
{"message":"Upgrade to GitHub Pro or make this repository public to enable this feature.", ..."status":"403"}
exit_code=1
```

**Evidence** — `memory/reports/task-2440-enforce/05-physical-3-admin-bypass.txt`:

```
===== D physical-3: gh pr merge --admin (free repo bypass attempt) =====
! Pull request JonghyukJeon/dev_workspace#14 was already merged
exit_code=0
```

**Evidence** — `.github/workflows/ci.yml:5`:

```
  merge_group:
```

**Evidence** — `.github/workflows/guard.yml:8-10`:

```
#     / lock-in-check / merge-safety-check / gemini-review-gate / ci/guard / guard)
#     은 절대 수정하지 않습니다 (task-2440/2445 ruleset 보호).
#   - ruleset 등록은 회장이 직접 처리합니다.
```

**결론**: (1) 본 리포지토리는 free 플랜으로 Rulesets API 자체가 403 리턴 → branch protection / required reviewers 정책을 코드로 강제할 수 없는 상태. (2) `gh pr merge --admin` 는 이미 머지 완료 상태에서도 exit 0 을 반환해 admin 토큰의 강제 권한이 확인됨. (3) ci.yml 은 `merge_group` 트리거를 등록해 두었으나 merge queue 자체가 Pro/Enterprise 기능이라 free 플랜에서 동작하지 않는다 (가설).

**gap 분석**: enforcement 가 "주석 + 회장 수동 등록" 에 의존하는 honor-system. 회장 토큰의 admin 권한 + free 플랜의 ruleset 부재 → `gh pr merge --squash` 가 어떠한 required check 대기도 거치지 않고 통과한다.

---

### G6. 자동 머지 시스템 단절 지점 정리

**단절 체인 (시간순)**:

1. **PR #24 생성** — `worktree_manager.py:880` 부근에서 gh pr create 성공.
2. **Gemini 봇 미트리거** — 외부 gemini-code-assist GitHub App이 PR open 에 반응하지 않음. workflow 코드로 강제 불가 (G1, G2).
3. **봇 폴링 timeout** — `worktree_manager.py:913-923` 의 30s × N 루프 + 라인 935-944 의 60s 재확인 모두 0건. `gemini_found = False` 확정.
4. **Python 차단 결정** — 라인 1002 `merge_status = "blocked_by_timeout"`. 의도는 차단.
5. **Shell 결과 미검증** — `finish-task.sh:447` 에서 stdout JSON status 무시, 라인 450 에서 `.merge-done` 무조건 작성 (16:52:53 기록). 봇 후속 단계는 마커 존재만 보고 silent 종료.
6. **수동 머지 (16:57:51)** — 회장이 `gh pr merge 24 --squash` 실행. free 플랜 ruleset 부재(G5) + neutral 통과 정책(G3) 으로 차단 없음.
7. **probe-done 잘못된 SHA** — finish-task.sh 라인 454 의 worktree HEAD(`a3238b8d`) 가 main squash 결과(`e51cf833`) 와 불일치 → probe 가 실제 main 변경분을 보지 못함 (F4).

**핵심 단절 지점 3개**:

- **단절 A — shell/python 결과 의미론 불일치** (F1, F3): `merge_status` 가 차단을 의미해도 shell은 마커를 만든다.
- **단절 B — 외부 봇 의존 + neutral 통과** (G2, G3): gemini-code-assist 자체는 코드로 강제되지 않고, gemini-review-gate 는 키 부재 시 `neutral` → CI pass.
- **단절 C — enforcement honor-system** (G5): free 플랜 + admin 토큰 결합으로 ruleset/merge_group 모두 무력화.

---

## 결론 요약

세 단절(A/B/C)이 동시에 작동해야 본 사건이 재현된다. 단 하나만 막혀도 silent merge 는 차단된다:

- A 차단: `.merge-done` 을 status 검증 후 생성 → 봇 후속 단계가 차단 인지.
- B 차단: gemini-code-assist 외부 App 부재를 ci.yml 에서 `failure` 로 처리 → required check 차단.
- C 차단: 유료 플랜 ruleset + admin bypass 금지 정책 → 수동 머지 불가.

본 보고서는 read-only 조사 산출물로, 상기 결함의 코드 수정은 본 task 범위 외이다. 별도 후속 task 에서 fix-forward 가 필요하다.
