마지막 업데이트 2026-09-17
PR #1108. 지금까지 job 종료는 아동 퇴장으로 세션이 close될 때만 걸려 있어서, 참가자가 남은 채 방치된 방은 job이 계속 살아 같은 ECS 태스크의 CPU를 잠식했다(9/15 AI 미발화의 근본원인). 이번 변경은 job마다 벽시계 20분 데드라인을 걸고, 만료를 data channel로 알린 뒤 종료한다.
리뷰에서 가장 먼저 볼 지점은 두 가지다. ① 이 상한은 무활동 기준이 아니라 절대 시간이라 20분을 넘긴 활성 대화도 끊기고 자동 복구가 없다 — 정상 스텝 길이의 실측 분포가 아직 확인되지 않았다. ② 적용 범위가 실수업뿐 아니라 mode=test와 prompt-test까지다 — prompt-test도 아동의 conversationMode가 livekit이면 같은 ppi_agent job을 만든다.
기존 attach_session_close_job_shutdown은 세션 close 이벤트(참가자 이탈·오류)에만 반응한다. 게스트 페이지가 며칠씩 열려 있는 패턴에서는 참가자가 계속 붙어 있어 close가 오지 않고, job은 워커 슬롯과 CPU를 쥔 채 남는다. 그래서 이벤트가 아니라 시간으로 끊는 장치를 추가했다.
PPI_JOB_MAX_DURATION_SECONDS(초). 파싱 실패·0 이하·비유한 값은 경고 로그 후 기본 1200초로 되돌린다. 재배포 없이 운영에서 올릴 수 있다.request_shutdown을 통과한다. 중복 ctx.shutdown이 나가지 않는다.agent-event-log data channel과 게스트 미디어 상태 이벤트를 그대로 쓴다.job 데드라인 만료 · WARNING 로그
apps/livekit-agent/job_lifetime.py
loop 단조시계 기준 20분. 경과·상한을 payload에 담는다.
data channel 알림 후 종료(1초 grace)
_publish_agent_event · agent-event-log
전송이 멈춰도 별도 타이머가 종료를 강제한다.
신뢰된 Agent 데이터만 1회 수용 · 종결 처리
lib/voice-agent/livekit-job-expiry.ts
readiness fail + disconnect. 재시도 경로로 새지 않는다.
배너 · 진행자 모니터 · 세션 로그
shared/ui/ai-session-expired-notice.tsx
아동에겐 배너, 진행자에겐 사유 문구, 로그엔 agent_job_expiry.
만료 payload의 max_duration_seconds는 실제 적용된 값이라, 상한을 10분으로 내리면 배너와 진행자 문구도 "10분"으로 따라간다.
request_shutdown은 ctx.shutdown이 성공한 뒤에야 플래그를 세운다. 조기 종료 요청이 예외로 실패해도 데드라인이 마지막 회수 경로로 남는다.
핸들러는 pin된 Agent가 agent-event-log 토픽으로 보낸 것만 수용하고, type과 reason을 모두 확인한 뒤 한 번만 처리한다. onExpired가 던져도 finally로 종결 정리는 반드시 수행된다.
전환 effect의 의존성에는 매 렌더 새 객체가 될 수 있는 foundActivity·foundStep이 들어 있다. 가드가 없으면 "세션 없음 + AI 스텝" 분기가 곧바로 새 job을 만들어 상한이 무력화된다. 반대로 스텝이 실제로 바뀌면 만료 배너를 내린다.
| 레이어 | 파일 | 핵심 변경 |
|---|---|---|
| Agent | apps/livekit-agent/job_lifetime.py (신규) | job 데드라인 무장·env 오버라이드·만료 알림·멱등 shutdown 요청 |
| Agent | apps/livekit-agent/agent.py | 진입 직후 데드라인 부착, 세션 close·start 실패 경로를 같은 요청으로 통일 |
| Web · lib | lib/voice-agent/livekit-job-expiry.ts (신규) | 만료 이벤트 계약, 필드 검증(jobId 패턴·음수 차단), 1회 수용 |
| Web · lib | lib/voice-agent/job-expiry-notice.ts (신규) | 적용 상한 → 분 환산, 진행자 모니터 문구 생성 |
| Web · lib | lib/voice-agent/livekit-client-session.ts | onJobExpired 옵션, 데이터 핸들러에서 만료 우선 소비 후 종결 |
| Web · entities | guest-session/model/use-ai-session.ts | jobExpiry 상태·clearJobExpired·onSessionJobExpired, 자동 재시작 없이 정리 |
| Web · entities | guest-page-session/model/use-guest-page-session.ts | 만료 사유를 진행자 모니터로 전송, 만료 후에도 수동 재시작 허용 |
| Web · entities | guest-page-session/model/use-step-transition.ts | 재렌더 재생성 차단 + 스텝 이동 시 배너 해제 |
| Web · UI | shared/ui/ai-session-expired-notice.tsx (신규) | guest/prompt-test 문구 분기, 적용 상한 분 표기, Storybook 3종 |
| Web · page | components/pages/prompt-test.tsx | 만료 시 lifecycle을 idle로 맞추고 대화 내보내기 스냅샷 생성 |
| 진단 | lib/voice-agent/livekit-input-diagnostic.ts · apps/socket/.../livekit-input-diagnostics.ts | agent_job_expiry stage 신설 — reason·jobId·경과/상한 초만 통과(방 이름·전사 제외) |
20분은 무활동이 아니라 절대 시간이다. 한 AI 스텝이 20분을 넘기면 대화 중이어도 끊기고, 자동 재시도가 없어 진행자가 같은 스텝을 다시 선택해야 복구된다. 실제 job 지속시간 분포(p99)가 아직 확인되지 않았으니, 배포 전에 CloudWatch /ppi/livekit/{env}/agent에서 한 번 확인하거나 env로 상한을 넉넉히 두고 시작하는 편이 안전하다.
적용 대상은 ppi_agent 단일 엔트리라 실수업 · mode=test · prompt-test가 모두 동일하다. prompt-test도 useAiSession을 쓰고 아동의 conversationMode 기본값이 livekit이라 같은 job이 생성된다. 예외는 realtime/tts 모드뿐(브라우저↔OpenAI 직결이라 job 없음).
데드라인은 loop.call_later이라 이벤트 루프가 살아 있어야 발화한다. 좀비의 원인이 job 프로세스 SIGSEGV거나 CPU 고갈로 루프가 굶는 상황이면 만료도 같이 늦어진다. 이번 변경은 "방치로 인한 점유"를 막는 상한이지, 죽은 프로세스를 회수하는 장치가 아니다.
만료 알림 grace는 1초다. 회선이 나쁘면 알림이 잘리고 배너 없이 Agent만 사라질 수 있다 — 그때도 agent WARNING 로그와 진행자 모니터 상태는 남는다. 값은 상수라 조정하려면 배포가 필요하다.
python 24 / node 32 / vitest(web) 5 / vitest(socket) 21 통과, tsc 신규 오류 없음, Storybook 정적 빌드 성공. 실기기 미검증 — 실제 20분 만료 시 배너 노출, 진행자 모니터 문구, 만료 후 수동 재시작은 확인이 남았다.
원인 쪽: 좀비 job 슬롯 점유로 같은 컨테이너 수업 job이 지연된 AI 미발화 원인분석 — 이번 상한이 막으려는 바로 그 상황이다.
규모 파악: 좀비 job 판별 오해 3가지 · 20분 로그 712건 CPU 독식 — 좀비를 어떻게 판별하고 무엇을 오해했는지.
선행 계획: 좀비 job 자동 정리 적용 계획 — 에이전트 수명 상한 · web 리퍼 — 이 PR은 그 계획 중 에이전트 수명 상한 부분의 구현이다(web 리퍼는 미착수).
배경 구조: LiveKit 도입 · voice agent 추상화 전체 플로우 — job과 방이 어떻게 만들어지는지.