마지막 업데이트 2026-09-17
공통 Agent 진입 직후 1,200초 타이머 등록. 만료·퇴장·오류 종료 요청 중복 방지와 타이머 해제. Python 테스트 21개 및 웹 회귀 테스트 통과. 브라우저 만료 안내·진단·자동 재시도 차단 추가. 운영 배포 및 실제 슬롯 반환 확인은 미수행.
진입: /monitor-dashboard?zombieJobs=1. 관리자·개발자에게만 테스트 세션 영역 하단에 표시한다. 쿼리가 없거나 권한이 없으면 조회 요청도 보내지 않는다. 서버는 쿠키 세션 권한을 별도로 검사하며 삭제에는 동일 출처 검증을 적용한다.
문제와 변경: 기존 모니터 테스트 세션 목록은 LiveKit job 상태와 별개라 장기 잔존 job을 조회하거나 룸을 정리할 수 없었다. 현재 환경의 PPI 룸에 배정된 Agent job 중 실제 시작 시각부터 20분 초과·실행 중·종료 시각 없음인 job을 조회한다. 테스트와 실수업을 모두 포함하며 오래된 순으로 표시한다. 경과 시간은 좀비 확정 근거가 아니므로 ‘좀비 의심’으로 표시한다.
| 책임 | 구현 (PPI 저장소 기준) |
|---|---|
| 화면 진입 | apps/web/app/monitor-dashboard/page.tsx → widgets/monitor-dashboard/test-session-manager/ui/test-session-manager.tsx → widgets/monitor-dashboard/livekit-zombie-jobs/livekit-zombie-jobs.tsx |
| API | apps/web/app/api/monitor-dashboard/livekit-zombie-jobs/route.ts GET·DELETE. 관리자/개발자만 허용, no-store 응답, 내부 오류 정보 차단, 삭제 성공 감사 로그. |
| LiveKit 조회·삭제 | apps/web/lib/voice-agent/livekit-zombie-jobs.ts. 공식 SDK listRooms/listDispatch/listParticipants/deleteRoom 사용. JobState의 Unix 나노초를 변환하며 룸 생성 시각을 job 시작 시각으로 대체하지 않는다. |
삭제: 각 행의 룸 삭제 → 공통 useOverlay 확인창에서 전체 참가자 연결 종료 안내 → 서버에서 룸 SID와 job의 만료 조건 재검증 → 룸 삭제 → 목록 갱신. 변경되었거나 이미 종료된 대상은 409로 거부한다. 삭제 중 중복 요청을 막고 실패 후 현재 상태를 다시 조회한다. 성공 메시지는 룸 삭제만 확인하며 워커 슬롯 반환을 보장하지 않는다.
조회 보호: 수동 새로고침, 1회 최대 100개 PPI 룸, 동시 조회 5개, SDK 요청별 제한 5초. 한도 초과·조회 실패 건수를 표시하므로 일부 실패를 ‘좀비 없음’으로 오인하지 않는다. 화면 이탈 시 브라우저 조회 요청을 취소한다.
관련 Vitest 29개 통과 (서비스 12, API 권한·HTTP 7, UI 7, 기존 overlay 3). 변경 파일 10개 범위의 TypeScript 검사 오류 0건; 마지막 테스트 보완 후 해당 2개 파일도 재검증했다. 목록·빈 상태·로딩·오류·부분 실패·삭제 확인·삭제 중·삭제 실패·모바일 fixture의 Storybook 정적 빌드 통과. git diff --check 통과.
pnpm --dir apps/web exec vitest run lib/voice-agent/livekit-zombie-jobs.test.ts app/api/monitor-dashboard/livekit-zombie-jobs/route.test.ts widgets/monitor-dashboard/livekit-zombie-jobs/livekit-zombie-jobs.test.tsx shared/ui/overlay/use-overlay.test.tsx
한계: ESLint는 기존 설정의 순환 참조 오류로 실행 실패. 브라우저 자동화는 연결 오류로 실제 브라우저 검증을 수행하지 못했다. 운영 LiveKit API 호출·실제 룸 삭제·실제 슬롯 반환·배포는 미수행이다. 룸이 없는 고아 job은 조회되지 않는다. LiveKit 삭제 API는 이름 기반이므로 재검증과 삭제 사이의 상태 변경을 원자적으로 차단하지 못한다. 오래된 룸 100개를 넘는 경우 미조회 건수가 남을 수 있다. 전체 웹 프로덕션 빌드 성공을 주장하지 않는다.
근거: 공식 AgentDispatch API, 공식 RoomService API, job 시작 시각(UnixNano) 구현.
ctx.shutdown(reason="job_max_duration_exceeded")를 요청한다. 수업 누적 시간 제한과는 별개다.현재 워커는 CPU 대신 실행 중 job 수로 부하를 계산한다(기본 한도 2). 기존 퇴장·오류 종료 훅에는 시간 상한이 없어, 연결이 유지된 job의 장시간 슬롯 점유를 제한하지 못했다. 9/17 제공된 장기 job 8건 각각의 실제 생존·종료 로그 누락 여부는 운영 대조가 필요하다.
?mode=test, prompt-test, /guest에서 LiveKit을 선택한 연결은 공통 Agent 진입점을 거친다. URL 판별 없이 이 진입점의 모든 job에 적용하므로 파이프라인 종류에 따른 예외가 없다.
| 파일 (PPI 저장소 기준) | 구현 |
|---|---|
apps/livekit-agent/job_lifetime.py | 단조 시계 기반 1,200초 타이머. job별 상태 소유. 종료 요청 중복 방지, 정상 정리 시 타이머 해제, 만료·정리 로그. 만료 이벤트 전송 실패·지연과 무관하게 종료 요청. 조기 종료 요청 실패 시 기존 데드라인 유지. |
apps/livekit-agent/agent.py | 설정 조회 이전에 타이머 등록. 참가자 퇴장·오류·시작 실패를 같은 종료 함수에 연결. |
apps/livekit-agent/tests/test_job_lifetime.py | 초기화 대기 중 만료, 1,199/1,200초 경계, 조기 정리, 종료 경합 양방향, 시작 실패, 독립 job, 진단 필드, 전송 실패·취소 저항·유예 시간, 조기 종료 실패 검증. asyncio 사설 API 없이 가짜 시계 사용. |
변경 전 기존 종료 테스트 11개 통과. 신규 만료·정리 회귀 테스트 2개가 기존 코드에서 실패함을 확인한 뒤 구현했다. 최종 Python 관련 테스트는 21개 통과. 웹 만료 처리·진단·기존 LiveKit 세션 테스트 7개 및 UI·스텝 전환 테스트 2개 통과. 만료 후 같은 스텝 재렌더가 job을 재생성하는 회귀를 재현 후 차단했다. 안내 UI Storybook 정적 빌드 통과. ESLint는 기존 FlatCompat 설정의 순환 참조 오류로 실행하지 못했다. 기존 의존성 경고 3건. 실제 AI·운영 DB·미디어 장치는 호출하지 않았다. guardian-gate 코드 리뷰에서 P0/P1 발견 없음.
python -m pytest -q apps/livekit-agent/tests/test_job_lifetime.py apps/livekit-agent/tests/test_session_close_job_shutdown.py
git diff --check
기존 로컬 LiveKit Agent 가상환경으로 실행했다. 테스트 범위는 종료 요청 경계까지이며, 운영 워커의 실제 슬롯 반환을 검증한 것은 아니다.
job maximum duration exceeded의 job_id·room·elapsed_seconds·reason을 SDK 프로세스 종료 로그 및 active_jobs 감소와 대조하고 신규 job 수락을 확인한다. job lifetime cleanup 로그만으로 프로세스 종료를 확정하지 않는다.ctx.shutdown() 하게 한다(안 B). 폴링·자격·다중 인스턴스 문제가 없고, 9/15 좀비의 실제 시그니처(20분 주기 재접속 ×9)를 직접 본다. web 리퍼(안 A)는 dry-run 로그만 남기는 안전망으로 뒤에 붙인다. 두 안 모두 처음 1주는 관측만 하고 삭제하지 않는다.
9/15 17:33~18:12 워커 AW_eg5RnPhNXC2r에 배정된 수업 job 24개가 전부 TTS 미도달. 같은 ECS 태스크에서 테스트 계정 '테스트아리'의 방치 세션 job AJ_CLYSMmWdT876이 15:08~18:12:48(3시간 5분) 살아 슬롯 2개 중 1개를 점유하고 있었다. 방치 페이지가 닫히자 재시작 없이 즉시 회복했다. 17:32 임계 초과 요인은 미확정이지만, 슬롯 점유가 없었으면 노출도 없었다는 점은 확정이다.
| 이미 있는 것 | 위치 | 부족한 점 |
|---|---|---|
| 아동 퇴장 시 job 즉시 종료 | apps/livekit-agent/agent.py:2465 attach_session_close_job_shutdown — 세션 close 사유가 participant_disconnected/error면 ctx.shutdown() | 퇴장이 없으면 아무것도 안 함. 9/15는 브라우저가 붙어 있었음 |
| 수동 라이브 모니터 | ppicw watch — CloudWatch `received job request`/`process exiting` 차집합, 60분 초과 붉은 표시 | 사람이 보고 있어야 하고 종료 수단 없음. lookback 8h 밖 job은 안 보임 |
| 좀비 정리 선례 | apps/socket/src/recording/recordingManager.ts:258-266 — 녹음 세션 startedAt 2시간 초과 시 강제 종료 (PPI-1267) | 녹음에만 적용. 같은 패턴을 job에 옮기는 것이 이 계획 |
| LiveKit 관리 자격 | apps/web/lib/voice-agent/livekit-config.ts:37 getLiveKitServerConfig() — URL·API key·secret. livekit-server-sdk 2.15.4 의존성 보유 | AccessToken만 사용 중. RoomServiceClient 호출 코드 없음 |
_ppi_agent_load), TTS 미도달 job의 클라이언트 자동 재배정(원인 분석 처방 2·3 — 별도 계획), 진행 중 수업의 품질 개선| 관점 | 안 A · web 리퍼 | 안 B · 에이전트 수명 상한 |
|---|---|---|
| 볼 수 있는 신호 | 방 나이, 참가자 수·identity. 세션 내부 상태는 못 봄 | job 나이, session_reconnected 횟수, 마지막 응답 시각 — 9/15 시그니처를 직접 봄 |
| 오탐 위험 | 긴 정상 스텝이 캡에 걸림. 삭제하면 아동 세션이 끊김 | 같은 캡 위험 + 재접속 기준은 정상 세션에서 거의 0회라 분리 쉬움 |
| 필요한 것 | RoomServiceClient 신규 사용, 실행 위치·중복 방지(인스턴스 여러 대), 타이머 | agent.py 훅 1개 + 환경변수 2개. 인프라 변경 없음 |
| 배포 | web prod 배포 | livekit-agent prod 배포 (독립 파이프라인) |
| 안 B가 못 잡는 것 | job 프로세스 자체가 멎은 경우(SIGSEGV·행). 이때는 워커가 job을 정리하거나 A가 필요 — 워커 열화 쉬운설명의 SIGSEGV 오탐 항목 참고. 그래서 A를 안전망으로 남긴다 | |
| # | 기준 | 제안값 | 근거 |
|---|---|---|---|
| B-1 | job 나이 상한 | 90분 (PPI_LIVEKIT_AGENT_MAX_JOB_MINUTES) | LiveKit 방은 수업이 아니라 AI 스텝(voiceSessionId) 단위(call/route.ts:81 createRoomName). Valkey 설정 TTL 7200초. 9/15 좀비는 60분 시점(16:08)이면 잡힘 |
| B-2 | Realtime 재접속 반복 | 60분 내 3회 (PPI_LIVEKIT_AGENT_MAX_RECONNECTS) | 플러그인이 재접속마다 session_reconnected 발행(livekit/plugins/openai/realtime/realtime_model.py:1162). 9/15 좀비는 15:28부터 20분 주기 ×9. 정상 세션 기준치는 Phase 0에서 측정 |
| A-1 | 방 나이 상한 (리퍼) | 120분 — B-1보다 길게 | B가 먼저 끝내야 하고 A는 B가 못 끝낸 것만 잡는 안전망. 처음엔 dry-run(로그만) |
| A-2 | 삭제 전 확인 | listParticipants로 agent 외 참가자 identity 기록 | "누구를 끊었나"를 로그에 남겨 오탐 검증 가능하게 |
| 안 | 무엇 | 위치·방법 |
|---|---|---|
| B | 수명 상한 훅 | agent.py:4644 @server.rtc_session 진입 후, :4685 attach_session_close_job_shutdown(ctx, session) 옆에 attach_job_lifetime_guard(ctx, session) 추가. asyncio 타이머가 B-1 도달 시 ctx.shutdown(reason="job_lifetime_exceeded") |
| B | 재접속 카운트 | 같은 훅 안에서 realtime 모델의 session_reconnected 구독 → 슬라이딩 창 카운트, B-2 도달 시 ctx.shutdown(reason="realtime_reconnect_loop") |
| B | 환경변수 | agent.py:348 _parse_positive_int_env 패턴 재사용. 두 값 모두 0이면 비활성(관측만) |
| B | 이벤트 로그 | 기존 agent event payload 규약(boundary/outcome)으로 job_lifetime_guard_triggered 발행 → 게스트 lesson-log에도 남음 |
| A | 리퍼 모듈 | apps/web/lib/voice-agent/livekit-room-reaper.ts (신규) — RoomServiceClient(livekitUrl, apiKey, apiSecret), listRooms() → 나이 계산 → dry-run 로그 / deleteRoom() |
| A | 실행 위치 | 결정 필요. 후보 ① apps/web/server.js:49 memoryTimer 옆 타이머 + Valkey 락으로 인스턴스 중복 방지 ② EventBridge 스케줄 → /api/livekit/reap 라우트(내부 토큰) |
# 안 B 골격 (agent.py, 의사코드)
def attach_job_lifetime_guard(ctx, session, *, max_minutes, max_reconnects):
started = time.monotonic(); reconnects: deque[float] = deque()
async def _age_watch():
await asyncio.sleep(max_minutes * 60)
_emit("job_lifetime_guard_triggered", rule="age", age_s=time.monotonic() - started)
ctx.shutdown(reason="job_lifetime_exceeded")
def _on_reconnected(_ev):
now = time.monotonic(); reconnects.append(now)
while reconnects and now - reconnects[0] > 3600: reconnects.popleft()
if len(reconnects) >= max_reconnects:
_emit("job_lifetime_guard_triggered", rule="reconnect", count=len(reconnects))
ctx.shutdown(reason="realtime_reconnect_loop")
if max_minutes: asyncio.create_task(_age_watch())
if max_reconnects: realtime_model.on("session_reconnected", _on_reconnected)
ppicw logs --search job_lifetime_guard로 조회PPI_LIVEKIT_AGENT_MAX_JOB_MINUTES=3)으로 종료·슬롯 반납 확인 후 prodjob_lifetime_guard_triggered 발생 시 Slack(기존 ISSUE_NOTIFICATION_URL) 1줄. 하루 N건 초과면 판정값 재검토pr-impact-check 필수.
| 구분 | 기준 | 검증 방법 |
|---|---|---|
| 자동 | 훅 단위 테스트: 나이 도달·재접속 N회·비활성(0) 세 경로에서 shutdown 호출 여부 | apps/livekit-agent/tests/ 기존 패턴(test_agent_response_terminal_trace.py 등)으로 fake ctx/session |
| 자동 | 정상 세션 종료 경로에 영향 없음 | 기존 테스트 전부 통과, attach_session_close_job_shutdown 테스트 회귀 없음 |
| staging | 방치 세션 job이 상한에 종료되고 워커 load가 내려감 | CloudWatch process exiting reason=job_lifetime_exceeded + ppicw watch에서 job 소멸 |
| 실기기 | 상한에 끊긴 아동 화면이 다음 AI 스텝에서 새 방으로 정상 재시작 | iPad Safari에서 MAX_JOB_MINUTES=3으로 재현 — 사용자 확인 필요 |
| 운영 | 2주간 오탐(정상 수업 중 종료) 0건 | Slack 알림 건수 × lesson-log 대조 |
deleteRoom을 dry-run 없이 켜지 않는다. 좀비를 "가해자"로 전제한 CPU 제한·워커 재시작 자동화는 이 계획에 넣지 않는다(17:32 요인 미확정).