Verified Implementation · PPI-1312 · 2026-09-17

PPI-1312 · LiveKit job 20분 제한과 좀비 job 관리

마지막 업데이트 2026-09-17

공통 Agent 진입 직후 1,200초 타이머 등록. 만료·퇴장·오류 종료 요청 중복 방지와 타이머 해제. Python 테스트 21개 및 웹 회귀 테스트 통과. 브라우저 만료 안내·진단·자동 재시도 차단 추가. 운영 배포 및 실제 슬롯 반환 확인은 미수행.

로컬 구현·검증미배포브랜치 PPI-1312 · 2026-09-17원인: 원인 분석 문서

모니터 대시보드 · 좀비 job 수동 정리

진입: /monitor-dashboard?zombieJobs=1. 관리자·개발자에게만 테스트 세션 영역 하단에 표시한다. 쿼리가 없거나 권한이 없으면 조회 요청도 보내지 않는다. 서버는 쿠키 세션 권한을 별도로 검사하며 삭제에는 동일 출처 검증을 적용한다.

문제와 변경: 기존 모니터 테스트 세션 목록은 LiveKit job 상태와 별개라 장기 잔존 job을 조회하거나 룸을 정리할 수 없었다. 현재 환경의 PPI 룸에 배정된 Agent job 중 실제 시작 시각부터 20분 초과·실행 중·종료 시각 없음인 job을 조회한다. 테스트와 실수업을 모두 포함하며 오래된 순으로 표시한다. 경과 시간은 좀비 확정 근거가 아니므로 ‘좀비 의심’으로 표시한다.

책임구현 (PPI 저장소 기준)
화면 진입apps/web/app/monitor-dashboard/page.tsxwidgets/monitor-dashboard/test-session-manager/ui/test-session-manager.tsxwidgets/monitor-dashboard/livekit-zombie-jobs/livekit-zombie-jobs.tsx
APIapps/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) 구현.

확정된 정책: 공통 PPI Agent job의 진입 시점부터 20분 후 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 가상환경으로 실행했다. 테스트 범위는 종료 요청 경계까지이며, 운영 워커의 실제 슬롯 반환을 검증한 것은 아니다.

배포 후 확인과 한계

이전 검토안 보관 — 아래 90분·재접속 감지·dry-run 제안은 현재 구현이 아님
권고. 1차는 에이전트 job 자체에 수명 상한 + Realtime 재접속 반복 감지를 넣어 스스로 ctx.shutdown() 하게 한다(안 B). 폴링·자격·다중 인스턴스 문제가 없고, 9/15 좀비의 실제 시그니처(20분 주기 재접속 ×9)를 직접 본다. web 리퍼(안 A)는 dry-run 로그만 남기는 안전망으로 뒤에 붙인다. 두 안 모두 처음 1주는 관측만 하고 삭제하지 않는다.

01현재 상황과 지금 있는 것

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/errorctx.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 호출 코드 없음

02적용 범위

  • 포함: 좀비 job 판정 기준 정의, 에이전트 자가 종료(안 B), web 리퍼 관측 모드(안 A dry-run), 종료·판정 이벤트 로그, Slack 알림 훅 자리
  • 제외: 17:32 임계 초과 요인 규명(ECS 메트릭 확보 후 별건), 워커 배정 알고리즘 변경(_ppi_agent_load), TTS 미도달 job의 클라이언트 자동 재배정(원인 분석 처방 2·3 — 별도 계획), 진행 중 수업의 품질 개선

03두 안 비교

안 A · web 리퍼 (밖에서 끊는다) PPI web 서버 주기 타이머 / cron LiveKit ListRooms creationTime · 참가자 DeleteRoom(room) 방 나이 ≥ 상한 job 프로세스 종료 "room disconnected" 안 B · 에이전트 수명 상한 (안에서 끝낸다) — 권고 job 프로세스 자기 감시 ① 나이 ≥ 상한 ② 재접속 ≥ N회 session_reconnected 카운트 ctx.shutdown() reason=job_lifetime_exceeded 슬롯 반납 · 방은 departure_timeout으로 닫힘 브라우저는 새 방으로 재시작 가능
그림 1. A는 자격·폴링·다중 인스턴스 중복 실행을 다뤄야 하고, B는 job 하나가 자기 일만 본다.
관점안 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를 안전망으로 남긴다

04판정 기준 (제안값 — 결정 필요)

#기준제안값근거
B-1job 나이 상한90분 (PPI_LIVEKIT_AGENT_MAX_JOB_MINUTES)LiveKit 방은 수업이 아니라 AI 스텝(voiceSessionId) 단위(call/route.ts:81 createRoomName). Valkey 설정 TTL 7200초. 9/15 좀비는 60분 시점(16:08)이면 잡힘
B-2Realtime 재접속 반복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 기록"누구를 끊었나"를 로그에 남겨 오탐 검증 가능하게
presence 기반 판정은 쓰지 않는다. "아동 접속 없음 = 좀비"는 9/15에 맞지 않았다. 방치 페이지는 실제로 방에 붙어 있었고 socket presence heartbeat도 살아 있었을 가능성이 높다. 좀비의 본질은 붙어 있는데 아무 일도 안 하는 것이다.

05코드 지점

무엇위치·방법
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)

06적용 순서

  1. Phase 0 · 관측 (1주) — 안 B 훅을 shutdown 없이 로그만 발행하도록 배포. 정상 세션의 job 나이 분포·재접속 횟수 분포를 CloudWatch에서 집계해 B-1·B-2 값을 확정. ppicw logs --search job_lifetime_guard로 조회
  2. Phase 1 · 안 B 활성화 — 환경변수로 상한 켬. staging에서 강제 재현(테스트 계정 방치 + PPI_LIVEKIT_AGENT_MAX_JOB_MINUTES=3)으로 종료·슬롯 반납 확인 후 prod
  3. Phase 2 · 안 A dry-run — web 리퍼를 로그만 남기게 배포. B가 놓친 방이 실제로 있는지 2주 관측. 0건이면 A는 dry-run 상태로 유지(삭제 활성화 안 함)
  4. Phase 3 · 알림job_lifetime_guard_triggered 발생 시 Slack(기존 ISSUE_NOTIFICATION_URL) 1줄. 하루 N건 초과면 판정값 재검토
각 Phase는 별도 PR. Phase 0는 코드상 무해(로그만)이므로 리뷰 경량, Phase 1부터 pr-impact-check 필수.

07수용 기준과 검증

구분기준검증 방법
자동훅 단위 테스트: 나이 도달·재접속 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 대조

08사용자 결정이 필요한 것

  1. 1차를 안 B로 가는가 — 아니면 A 단독, 혹은 병행
  2. B-1 상한값 — 90분 제안. 한 AI 스텝이 90분을 넘는 활동이 실제로 있는지 활동 템플릿 기준 확인 필요
  3. 안 A 실행 위치 — web 타이머 + Valkey 락 vs EventBridge → API 라우트. socket 서버에 두려면 LiveKit 자격을 socket에도 넣어야 함
하지 않을 것. Phase 0 데이터 없이 상한값을 켜지 않는다. 안 A의 deleteRoom을 dry-run 없이 켜지 않는다. 좀비를 "가해자"로 전제한 CPU 제한·워커 재시작 자동화는 이 계획에 넣지 않는다(17:32 요인 미확정).

관련 문서