[HAN-559] Pantheon startup-audit 이중 dispatch 인시던트 보고
Summary
bridge가 watchdog force-exit으로 재시작되는 동안 사용자가 보낸 메시지가 startup-audit + Slack 지연 재전송 두 경로로 중복 처리되어, 이미 완료된 작업이 재실행되고 진행 중인 신규 요청이 interrupt되는 사고가 2026-06-08 발생했다.
Incident Timeline
| 시각 | 이벤트 |
|---|---|
| 19:15:02 | jini BrokenPipeError 연속 발생 시작 |
| 19:16:27 | watchdog DEGRADED 감지 (5/6 active, jini inactive) |
| 19:16:40 | audit-fallback이 이전 in-flight("네 이번엔 넘어가고...") retrigger → Jarvis kill/재처리 |
| 19:17:28 | Jarvis LLM 완료 (HAN-559 이슈 생성 완료, 46.8s) |
| 19:17:30~57 | 사용자가 "채널에도 리포트해주세요" 전송 (추정 구간) |
| 19:17:57 | watchdog WEDGED (partial-conns 60s) → Force-exit |
| 19:18:09 | bridge 재시작 완료 |
| 19:18:39 | startup-audit 30s 대기 완료 후 스캔 시작 |
| 19:19:06 | startup-audit이 "채널에도 리포트해주세요"를 미응답 스레드로 탐지 → Jarvis dispatch (1차) |
| 19:19:08 | retrigger dispatched: jarvis → 채널 리포트 포스팅 완료 |
| 19:24:14 | 사용자가 "그러면 재배포해볼까요?" 전송 → Jarvis in-flight 시작 |
| 19:24:54 | Slack이 "채널에도 리포트해주세요" 이벤트를 Socket Mode로 지연 재전송 (5분 38초 후) |
| 19:24:54 | handle_user_message가 in-flight kill → 채널 리포트 재실행 (2차) |
| 19:25:10 | (별도) jini 스레드 interrupt |
Root Cause Analysis
핵심: startup-audit + Slack Socket Mode 재전송의 race condition
사용자 메시지 전송 (19:17:30~57)
↓
bridge offline (19:17:57 force-exit)
┌──────────────────────────────────────────┐
│ Slack Socket Mode: │
│ ACK 미수신 → 메시지 재전송 큐에 적재 │
└────────────────────────┬─────────────────┘
↓ ↓
bridge 재시작 (Slack 재전송 큐 대기 중)
startup-audit 스캔
conversation.history에서 동일 메시지 발견
fake_event로 handle_user_message 호출 (1차)
→ 채널 리포트 완료 ✅
↓
사용자: "그러면 재배포해볼까요?" → in-flight 시작
↓
Slack 재전송 큐에서 지연 전송 도착 (19:24:54, 5분 38초 후)
→ handle_user_message 정상 호출 (2차) ← 이게 버그
→ in-flight KILL → 재배포 요청 interrupt
→ 채널 리포트 재실행 ❌
왜 5분 38초 지연?
bridge 재시작 후에도 jini BrokenPipeError가 계속되어 Socket Mode 연결이 불안정했다. Slack은 ACK 미수신 이벤트를 백오프 재시도하며, 안정적인 연결이 확보된 19:24:54에 최종 전달한 것으로 추정된다.
취소 메커니즘의 한계
handle_user_message 내 interrupt 로직(_in_flight.kill())은 같은 thread_ts의 더 최신 메시지가 오면 이전 처리를 중단하는 설계다. 그러나 startup-audit의 fake_event는 conversation.history 기반이고 Slack 재전송 이벤트는 원본 ts를 가지므로, 두 이벤트는 별개의 메시지로 동일하게 유효하다. 중복 여부를 감지하는 장치가 없다.
Evidence
로그 근거 (bridge.log, 2026-06-08)
# 1차 처리 — startup-audit
19:19:06.625 [startup-audit] jarvis: msg text="[재시작 복구] ... '채널에도 리포트해주세요...'
19:19:08.108 [startup-audit] jarvis: [audit] retrigger dispatched: jarvis thread=1780912675.785339
# 2차 처리 — Slack 지연 재전송 (5분 38초 후)
19:24:14.953 [ThreadPoolExecutor-0_3] jarvis: msg text='그러면 재배포해볼까요?'
19:24:54.624 [ThreadPoolExecutor-0_0] jarvis: msg text='채널에도 리포트해주세요...' ← 동일 메시지 재도착
19:24:54.982 [ThreadPoolExecutor-0_0] jarvis: interrupt: killed in-flight for thread_ts=1780912675.785339
19:24:55.001 [claude-jarvis] jarvis: in-flight process killed by interrupt — discarding
# watchdog force-exit (재시작 원인)
19:17:57.323 [CRITICAL][watchdog] WEDGED (partial-conns) - active=5/6 for 60s. Force-exit.
코드 근거
bridge.py:1971— startup-audit/audit-fallback은fake_event로handle_user_message()직접 호출bridge.py:1947-1964—_in_flight기반 interrupt는 동일 thread_ts의 더 최신 요청 도착 시 이전 kill- 중복 dispatch 방어 코드 없음:
fake_event.ts기록 후 정상 이벤트 skip하는 로직 부재
Impact
| 항목 | 내용 |
|---|---|
| 사용자 피해 | "그러면 재배포해볼까요?" 요청 interrupt, 중복 채널 리포트 게시 |
| 발생 조건 | watchdog force-exit → 재시작 → startup-audit 처리 완료 후 Slack 지연 재전송 도착 |
| 빈도 | 재시작 빈번 기간(jini BrokenPipeError 다발) + 사용자 메시지 타이밍 일치 시 |
Fix Proposals
Option A (권장): 처리된 ts 집합 dedup — _audit_dispatched_ts
startup-audit / audit-fallback이 fake_event를 dispatch할 때, 실제 메시지 ts를 _audit_dispatched_ts: set[str] (global)에 기록.
handle_user_message 진입부에서 event["ts"]가 이 집합에 있으면 skip + log.
# bridge.py — 전역
_audit_dispatched_ts: set[str] = set()
# _audit_retrigger / _audit_jarvis_fallback — fake_event dispatch 직후
_audit_dispatched_ts.add(last_user["ts"]) # 원본 메시지 ts
# handle_user_message — 진입부
if event.get("ts") in _audit_dispatched_ts:
log.info("[%s] skipping duplicate (already audit-dispatched ts=%s)", persona, event["ts"])
return
- 메모리 누수 방지:
_audit_dispatched_ts에 TTL(예: 1시간) 또는 최대 사이즈(1000건) 제한 추가
Option B: Slack event dedup (envelope_id 기반)
Slack이 보내는 envelope_id로 이미 처리한 이벤트 skip. 현재 bridge가 envelope_id를 tracking하지 않음 → 구현 비용 더 높음.
Option C: startup-audit 비활성화 (STARTUP_AUDIT_ENABLED=false)
근본 원인 제거. 단, 재시작 중 누락된 메시지 미복구 → UX 저하. 권장 안 아님.
Decisions
| 결정 | 근거 |
|---|---|
| Option A 채택 | 구현 최소, fake_event 발원지에서 기록 → 정상 경로에서 skip. 가장 직접적 |
| TTL 기반 자동 만료 | set 무한 증가 방지, 재시작 후 장시간 경과한 ts는 충돌 위험 없음 |
Related
- Linear: https://linear.app/hangman-lab/issue/HAN-559
- 재시작 원인 이슈: jini BrokenPipeError 반복 (19:15:02 ~ 19:17:58) — wedged log
logs/wedged-20260608T101757Z.txt - 취소 메커니즘 코드:
bridge.py:1868(handle_user_message),bridge.py:1947-1971(interrupt 블록) - startup-audit 코드:
bridge.py:2720(startup_audit_loop),bridge.py:2480(_audit_retrigger)