---
id: incident-2026-05-05-timer-merge-gemini
date: 2026-05-05
investigator: dev6-team (페룬, 스바로그, 벨레스)
mode: read-only
sources:
  - memory/specs/incident-2460-svarog-timer.md
  - memory/specs/incident-2460-veles-merge-gemini.md
  - memory/logs/cleanup-stale.log
  - memory/logs/app.log
  - memory/events/task-2453.*
  - memory/events/task-2454.*
  - memory/events/task-2422.*
  - memory/task-timers.json (offset 37580~)
  - memory/reports/task-2440-enforce/*
status: completed
---

# 사고 보고서 — 2026-05-05 timer 결함 + .merge-done silent + Gemini timeout

회장이 요청한 3사고 통합 조사 보고서. 코드 1줄도 수정하지 않았으며, 본 문서는 별도 후속 task 의 fix-forward 명세 입력으로 사용한다.

---

## 1. 사고 요약

| ID | 사고 | 실제 (Truth) | 시스템 기록 (Recorded) |
|----|------|-------------|------------------------|
| S1 | timer 미스매칭 | task-2453 dispatch + 작업 + cancel(15:12), task-2422 좀비 | task-2453 `task-timers.json` 0건, task-2422 end_time 17:46:19로 박제 |
| S2 | .merge-done silent | worktree_manager.py가 `blocked_by_timeout` 차단 | `.merge-done` 16:52:53 무조건 생성, 봇 silent 종료 |
| S3 | Gemini timeout 우회 | Gemini 리뷰 0건 (외부 봇 미트리거) | `gemini-review-gate: pass`, 회장 admin 토큰으로 16:57:51 수동 머지 |

3 사고 모두 **"내부 결과는 실패/차단을 표현했지만 외부 마커/기록은 성공으로 누설"** 되는 동일 패턴 (silent corruption — *Truth ≠ Record*).

---

## 2. 타임라인 (2026-05-05 KST)

```
13:00:01  cron cleanup-stale-tasks.sh
            → cleanup_stale() : task-2421/2422/2423 trio 모두 status=stale
              evidence: memory/logs/cleanup-stale.log:818-820
14:43:29  task-2453 dispatch (dev2-team, bot-c) — codex-gate 통과
            → tasks["task-2453"] 생성 (running)
15:00:31  task-2453.retry_count 마커
15:07:03  task-2453.qc-done / qc-result 생성
15:09:06  task-2453.scope-guard-done 생성
15:12:04  봇 odin-dev2 자가 STOP 마커 감지
            → scripts/finish-task.sh:36-48 / :43-44 가 task-timer.py end --qc-result CANCELLED
            → task-2453 end_time 박제, .cancelled 마커 생성, finish-task.sh 차단
            → reason: "task 파일 상단 STOP 마커 감지 ... finish-task.sh 호출 차단"
              detected_by: "odin-dev2"  (= dispatch.py --cancel 미사용)
15:19:07  task-2422.done.acked / task-2421.done.acked / task-2423.done.acked 생성
            (좀비 정리 흔적, 1차 mass-end 호출. 호출자 미식별)
15:42:16  task-2453.done.escalated
15:49:28  task-2422.done.escalated
?         tasks["task-2453"] 키 자체 소멸 (가설: dispatch._cleanup_task 의 del tasks[task_id])
16:02:16  task-2454.codex-gate
16:49:45  task-2454.qc-done / qc-result
16:51:13  task-2454.scope-guard-done
16:52:53  task-2454.merge-done   ← .merge-done 무조건 생성 시점
16:53:11  task-2454.completion.txt / done.acked / done.notified / anu-notified
16:57:51  회장 토큰 `gh pr merge 24 --squash` 수동 머지
            → main HEAD = e51cf833 (squash 결과)
16:57:54  task-2454.probe-done (head_sha = a3238b8d, 실제 main = e51cf833 불일치)
17:46:19  unknown caller가 task-timer.py end 호출 (trio 동시)
            → end_task() : stale → completed 덮어쓰기
            → memory/logs/app.log:2441-2443 :
              "태스크 완료: task-2421 (47시간 18분)"
              "태스크 완료: task-2422 (46시간 56분)"
              "태스크 완료: task-2423 (46시간 32분)"
            → _write_event_file() 가 .done 본문 갱신 (mtime 17:46)
17:51     .done.escalated 마커 trio 일괄 생성
```

---

## 3. 사고별 근본 원인

### 3.1 S1 — timer 미스매칭 (task-2453 0건 + task-2422 stale 덮어쓰기)

**(a) task-2453 시한이 timers.json 에서 사라진 메커니즘**

- `cancel_task()` ( `dispatch/__init__.py:3815-3963` ) 자체는 timer 항목을 보존한다 (6단계에서 `task-timer.py end --qc-result CANCELLED` 만 호출). 따라서 정상 cancel 경로면 `status=completed / qc_result=CANCELLED` 로 남아야 한다.
- 그러나 `dispatch/__init__.py:1972` `_cleanup_task()` 의 `del tasks[task_id]` 는 dispatch 후속 단계에서 실패 시 **항목 통째 삭제** 한다. 본 사건에서 cancel 후 후속 dispatch 시도가 있었거나 외부 정리 코드가 이 경로를 통한 것으로 추정 (코드 evidence 미확정 — 가설).
- 결과: task-2453 의 모든 작업 흔적이 timers.json 에서 소실. `.cancelled` 마커, `.qc-done`, capabilities 파일은 별도 디렉토리이므로 살아남음.

**(b) task-2422 end_time 의 출처 — `end_task()` 의 stale 미거부**

`memory/task-timer.py:221-266` `end_task()` 는 `completed` 상태만 멱등 처리하고 (242-252), `stale` / `reserved` / `running` 상태는 모두 통과시켜 `task_data["status"] = "completed"` 로 덮어쓴다 (266). 즉 **임의 caller 가 stale task 에 대해 end 를 호출하면 무조건 completed 박제** 가 발생한다.

```
memory/task-timer.py:242   if task_data.get("status") == "completed":
memory/task-timer.py:252       return { ... "already_completed": True }
memory/task-timer.py:262   task_data["end_time"] = end_time.isoformat()
memory/task-timer.py:266   task_data["status"] = "completed"
```

13:00 cron 이 trio 를 stale 로 마킹한 뒤 17:46 unknown caller 가 trio 모두에 대해 `task-timer.py end` 를 호출 → stale → completed 박제 + .done 본문 mtime 갱신. 회장 보고의 17:39:41 시각은 production end_time `17:46:19.570403` 와 7분 차이 — 시각 인용 오차이거나 별도의 중간 갱신 가능성.

**(c) dev2-team active task 가 task-2422 로 자동 회귀한 메커니즘**

`memory/task-timer.py:394-408` `_update_bot_activity()` 는 동일 team 의 다른 running task 가 있으면 idle 전환 자체를 차단한다:

```
bot_key = team_id.replace("-team", "")
for tid, tdata in self.timers["tasks"].items():
    if tdata.get("status") == "running" and tdata.get("team_id") == team_id:
        return  # 아직 running task 있으므로 갱신 안 함
```

15:12 task-2453 cancel 시점에 task-2422 가 still running (실제로는 stale 인데 다른 의미로) → dev2 active 가 idle 로 전환되지 않고 task-2422 로 자동 회귀.

### 3.2 S2 — .merge-done silent (finish-task.sh:447~450)

`scripts/finish-task.sh:447-450` 가 `worktree_manager.py finish` 의 stdout JSON 의 `status` 키를 검증하지 않은 채 `.merge-done` 마커를 무조건 작성:

```
scripts/finish-task.sh:447   python3 ".../scripts/worktree_manager.py" finish ... --action auto 2>&1 || {
scripts/finish-task.sh:448       echo "[WARN] worktree_manager.py finish 실패 — 계속 진행."
scripts/finish-task.sh:449   }
scripts/finish-task.sh:450   echo '{"task_id":"'"$TASK_ID"'", ...}' > "$MERGE_DONE_FILE"
```

`worktree_manager.py finish` 의 가능한 status 5종 ( `scripts/worktree_manager.py:992-1022` ):

| status | 의미 | exit code |
|--------|------|-----------|
| `merged` | gh pr merge 성공 | 0 |
| `pending` | 초기값 (이상 상태) | 0 |
| `merge_failed` | gh pr merge 실패 | 0 |
| `blocked_by_high_severity` | Gemini HIGH 미해결 차단 | 0 |
| `blocked_by_timeout` | Gemini 리뷰 미실행 차단 | 0 |

**핵심**: 차단 의도는 `merge_status` dict 키 *값* 으로만 표현되고 raise/exit 비유발. shell 측은 exit code (모든 케이스 0) 만 보고 동일하게 진행.

또 다른 결함: `scripts/finish-task.sh:454` `MERGE_SHA="${MERGE_SHA:-$(... git rev-parse HEAD ...)}"` 는 worktree HEAD (= a3238b8d, 본 브랜치 마지막 로컬 커밋) 를 가져온다. GitHub squash-merge 는 main 에 새 SHA (= e51cf833) 를 만들기 때문에 본질적으로 일치할 수 없으며, `scripts/post_merge_probe.py:97-104` 의 `git diff <merge_sha>~1..<merge_sha> --name-only` 가 빈 리스트를 반환 → smoke fallback (`post_merge_probe.py:194`) 로 실제 main 변경분 미검증.

### 3.3 S3 — Gemini review timeout 우회 (PR #24)

3개의 단절이 동시에 작동:

**(a) 외부 GitHub App 의존 + workflow 강제 부재** — `.github/workflows/ci.yml:119-154` 의 `gemini-review-gate` 는 `scripts/gemini_review_gate.py` 만 호출하며 `gemini-code-assist` GitHub App (별도 외부 봇) 자체를 트리거하지 않는다. `scripts/worktree_manager.py:919` 가 PR reviews API 응답에서 `"gemini-code-assist" in stdout.lower()` 문자열 매칭으로 외부 봇 코멘트 존재 여부만 확인. PR #24 응답이 0건 → 외부 App 이 호출되지 않음 (설치/권한/marketplace 설정 문제 — 가설).

**(b) `neutral` 통과 정책** — `scripts/gemini_review_gate.py:144-146`:

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

라인 271-280 에서 Gemini 호출 실패 시 `conclusion = "neutral"` 로 처리되며, `gate()` 종료코드는 `failure` 일 때만 1 (라인 308). 따라서 GitHub Ruleset 의 "required check pass" 기준에서 `success` + `neutral` 모두 통과 → "리뷰 안 했지만 통과" false-pass 채널.

**(c) Ruleset honor-system** — `memory/reports/task-2440-enforce/01b-rulesets.txt`: free 플랜 → Rulesets API 403 (`"Upgrade to GitHub Pro or make this repository public"`). `05-physical-3-admin-bypass.txt`: `gh pr merge --admin` 통과 확인. `.github/workflows/guard.yml:8-10` 주석:

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

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

---

## 4. 공통 패턴 검증 (1.5 항목)

회장 가설: 3 사고 모두 "**실제 결과 ≠ 시스템 기록**" silent corruption 패턴.

**검증 결과: PASS — 동일 패턴 확인.**

| 사고 | 결정 지점 (Python/내부) | 누설 채널 (외부 마커/기록) |
|------|------------------------|---------------------------|
| S1 (timer) | `end_task()` 가 stale 거부 안 함 → 임의 caller 의 end 호출이 stale → completed 덮어쓰기 | `task-timers.json`, `.done` 본문, app.log "태스크 완료" |
| S2 (.merge-done) | `worktree_manager.py:1002` `merge_status="blocked_by_timeout"` (의도: 차단) | `finish-task.sh:450` `.merge-done` 무조건 생성 |
| S3 (Gemini) | `gemini_review_gate.py:271-280` `conclusion="neutral"` (의도: 미실행 표시) | GitHub check `gemini-review-gate: pass` |

**공통 구조**: Python/내부 결과는 `dict 키`/`함수 반환값`/`status string` 으로 차단/실패/stale 을 표현하지만, **shell 또는 GitHub check API 같은 외부 구속 매체** 가 그 값을 검증 없이 success-equivalent 로 누설한다.

### 4.1 추가 silent 누설 후보 grep

`scripts/finish-task.sh` 에서 `|| true` 패턴 12+ 건 (라인 20, 31, 39, 42, 43, 317, 319, 403, 530, 531, 603, 604, 607). 이 중 mutation 호출에 붙은 `|| true` 는 동일 risk:

- `scripts/finish-task.sh:20` `task-timer.py end "$TASK_ID" 2>&1 || true` — 멱등 호출이지만 실패 흡수
- `scripts/finish-task.sh:31` `task-timer.py end "$TASK_ID" --qc-result CANCELLED 2>&1 || true`
- `scripts/finish-task.sh:43-44` STOP 마커 감지 시 timer end + `.cancelled` 생성 — 모두 `|| true` 흡수

→ 본 사건의 호출자 미식별 (15:19, 17:46 mass-end) 의 후보 중 하나. 다른 task 의 finish-task.sh 가 fuzzy match 로 trio 를 건드릴 가능성 검토 필요.

---

## 5. 재발 방지 권고 (코드 수정 명세 — 후속 task 입력)

### R1. `finish-task.sh` — `worktree_manager.py finish` 결과 검증

**대상**: `scripts/finish-task.sh:447-450`

**권고**:
1. `worktree_manager.py finish` 의 stdout 을 임시 파일로 capture.
2. `jq -r .status` 로 `merge_status` 추출.
3. `merged` 일 때만 `.merge-done` 작성. 그 외 (`pending`/`merge_failed`/`blocked_by_high_severity`/`blocked_by_timeout`) 는 `.merge-failed` 마커 + 아누 알림.
4. exit code 0 + non-merged status 조합도 차단 처리 (현행 `|| { echo WARN; }` 제거).

### R2. `dispatch.py` — timer 등록 atomic + cancel 시 명시적 정리

**대상**: `dispatch/__init__.py:3275-3285` (start), `:1938-1988` (`_cleanup_task`), `:3815-3963` (`cancel_task`)

**권고**:
1. dispatch 의 timer start subprocess 가 returncode != 0 인 경우 dispatch 자체를 abort + cancel — 현행은 `logger.warning` 만 하고 진행.
2. `_cleanup_task()` 의 `del tasks[task_id]` 직전에 archived/escalated 박제 가드 추가 — completed/cancelled 흔적은 보존.
3. `cancel_task()` 후 timer 항목에 명시적 `cancel_reason` 필드 기록 (현행은 qc_result=CANCELLED 만).

### R3. `task-timer.py end_task()` — stale 거부 + 호출자 추적

**대상**: `memory/task-timer.py:221-293`

**권고**:
1. `task_data.get("status") == "stale"` 입력에 대해 멱등 거부 (현행 stale → completed 덮어쓰기).
2. 거부 응답: `{"status":"error","reason":"stale task — manual cleanup required"}`.
3. caller 추적을 위해 `--source <caller_name>` 인자 필수화 + timers.json 의 `end_caller` 필드 박제.
4. `cleanup_stale()` 과 별개로 stale → completed 정식 전환 함수 추가 (`finalize_stale()`).

### R4. `.done` / 머지 성공 검증 게이트

**대상**: `scripts/finish-task.sh` `.done` 생성 직전, `scripts/post_merge_probe.py:97-104`

**권고**:
1. `.done` 생성 전 `gh pr view <pr_number> --json mergedAt,mergeCommit` 으로 main 머지 SHA 확인 → MERGE_SHA 변수 갱신.
2. probe 의 `git diff <merge_sha>~1..<merge_sha>` 가 빈 리스트면 smoke fallback 대신 main 머지 SHA 기반 재시도 1회.
3. `.merge-done` + 머지 SHA 일치 → `.done` 생성 허용. 불일치 → 차단 + 아누 알림.

### R5. Gemini review 미실행 시 자동 차단 + escalation

**대상**: `scripts/gemini_review_gate.py:271-280`, `.github/workflows/ci.yml:119-154`

**권고**:
1. `gemini call failed` 또는 `GEMINI_API_KEY missing` 에서 `conclusion="neutral"` 대신 `failure` 로 변경. **단** GitHub Pro 미사용 환경에서는 ruleset 대신 `worktree_manager.py:991-1003` 의 `blocked_by_timeout` 경로를 강제하는 방식으로 보완.
2. `worktree_manager.py:919` 의 외부 봇 폴링 외에 **`scripts/gemini_review_gate.py` 자체가 Gemini 호출을 직접 수행하여 PR 코멘트를 작성** 하는 fallback 추가 — 외부 GitHub App 의존도 제거.
3. `gemini-code-assist` 미발견 시 `worktree_manager.py finish` 의 exit code 를 1 로 변경 → finish-task.sh 가 검출 가능하게 함.

### R6. 한정승인 admin 토큰 사용 정책

**대상**: 운영 정책 (코드 외)

**권고**:
1. `gh pr merge --admin` (또는 회장 토큰 squash) 사용 시 `memory/events/<task_id>.admin-bypass-used` 마커 강제 생성.
2. 마커 존재 시 `.done` 생성 차단 + 아누 명시 승인 후에만 진행.
3. free 플랜 → Pro 플랜 업그레이드 후 Ruleset 으로 admin bypass 차단 (별도 task).

---

## 6. 본 보고서가 후속 task 에서 직접 참조할 수 있는 코드 수정 가이드

후속 task 의 fix-forward 입력으로 본 문서의 §5 (R1~R6) 를 사용한다. 각 권고는 다음 정보를 포함한다:

- 대상 파일 경로 + 라인 번호 (현행 코드)
- 결함 메커니즘 evidence (§3 참조)
- 권고 변경 행동 동사 ("검증 추가", "거부 분기 추가", "마커 강제 생성" 등)
- 검증 기준 (변경 후 통과해야 할 시나리오)

**우선순위**:
1. **P0** — R1 (.merge-done 검증), R3 (end_task stale 거부): 본 사건 재발의 1차 차단선.
2. **P1** — R4 (probe SHA), R5 (Gemini fallback): 누설 후 검출 가능성 확보.
3. **P2** — R2 (dispatch timer atomic), R6 (admin 토큰 정책): 시스템 무결성 강화.

---

## 7. 미해결 항목 (추가 조사 필요)

- **U1**: 15:19 / 17:46 mass-end 호출자 — 코드 evidence 미확보. `scripts/finish-task.sh:20`/`:31`/`:43-44` 의 `task-timer.py end ... || true` 가 다른 task 의 finish 에서 trio 를 fuzzy match 한 가설 검증 필요.
- **U2**: tasks["task-2453"] 키 소멸 trigger — `_cleanup_task()` 의 호출 흐름 추적 필요.
- **U3**: `gemini-code-assist` 외부 GitHub App 의 marketplace/권한 상태 — 리포지토리 외부에서 확인 필요 (회장 권한 영역).
- **U4**: 회장 보고의 17:39:41 시각 ↔ production end_time 17:46:19 의 7분 차이 — 중간 갱신/되돌림 흔적 추가 grep 필요.

---

## 8. 셀프 QC 8항목

- [x] 사실/가설 분리: 가설은 모두 "(가설)" 로 명시
- [x] 코드 라인 evidence: 각 결론에 파일:라인 번호 포함
- [x] read-only 준수: 코드 1줄도 수정 안 함 (보고서 단일 파일만)
- [x] 5개 조사 항목 (1.1~1.5) 전부 응답
- [x] 공통 패턴 검증 PASS/FAIL 명확 (PASS)
- [x] 재발 방지 권고 4건 이상 (R1~R6 6건)
- [x] 미해결 항목 별도 섹션 (§7 U1~U4)
- [x] 후속 task 입력으로 사용 가능한 형태 (§6)

---

## 9. 참조

- 스바로그 보고서: `memory/specs/incident-2460-svarog-timer.md`
- 벨레스 보고서: `memory/specs/incident-2460-veles-merge-gemini.md`
- task 보고서: `memory/reports/task-2460.md`
