readiness 타임아웃 자동 재시도 후 "듣기 ON" 미인식
— 의도 재예약 누락 원인 분석 및 수정 구현 완료 · 미커밋
마지막 업데이트 2026-09-16
① 코드 경로
이 문서가 다루는 파일·함수. 이번에 수정된 파일은 수정로 표시. 줄번호는 2026-09-16 PPI-1300 워크트리 기준.
| 파일 | 대상 | |
|---|---|---|
apps/web/lib/voice-agent/livekit-readiness-retry.ts | onBeforeRetryAttempt 옵션·호출 지점, resolveRetryListeningIntent 순수 판정 함수 | 수정 |
apps/web/entities/guest-session/model/use-ai-session.ts | handleStartSession — 의도 스냅샷 + listeningCommandSinceStartRef 초기화 + 재시도 직전 재예약, setAgentListeningEnabled — 명시적 명령 기록, stopSession — stopGenerationRef 증가 | 수정 |
apps/web/lib/voice-agent/livekit-readiness-retry.test.ts | 콜백 호출 계약 3건 + 의도 판정 4건 추가 | 수정 |
apps/web/lib/voice-agent/livekit-client-session.ts | LIVEKIT_AGENT_READY_TIMEOUT_MS=10000 (73행), LiveKitAgentReadinessTimeoutError (827행), createLiveKitAgentReadinessGate (845행), agentReadiness.wait() (3791행) | 미수정 (타임아웃 발생 지점) |
apps/web/entities/guest-session/model/use-ai-session.ts | stopSession ref 리셋 (2641·2647·2648행), 실패 정리 await stopSession() (3877행), applyDeferredListening (2047행), scheduleMicOnAfterFirstResponse (2086행), initialListeningIntent (2984행) | 미수정 (분석 대상) |
apps/web/entities/guest-page-session/model/use-step-transition.ts | AI 스텝 진입 시 예약 호출 (111·144행) | 미수정 (예약 주체) |
apps/web/entities/guest-page-session/model/use-guest-page-session.ts | restartCurrentSession의 wasMicrophoneEnabled 스냅샷 (1411행) | 미수정 (대조군 · 동일 패턴) |
② 목적 요약
진행자가 '듣기 ON'인 상태에서 AI 준비(agent readiness) 10초 타임아웃이 한 번 발생하고 자동 재시도로 세션이 살아난 경우, 시작 멘트와 진행자 타이핑 응답은 정상인데 아동 음성만 인식되지 않는 증상을 분석한다.
근본 원인은 실패한 첫 attempt의 정리 루틴이 '듣기 의도'를 비우는데, 재시도 경로에는 그 의도를 다시 세우는 주체가 없다는 것이다. 재시도 attempt 직전에 의도를 재예약하는 콜백을 추가해 수정했다.
③ 입출력 흐름
FACT는 코드 트레이스로 확인한 것.
[정상 흐름]
AI 스텝 진입 (듣기 ON)
→ use-step-transition.ts:111 scheduleMicOnAfterFirstResponse({ configuredIntent: true })
pendingConfiguredListeningRef = true // 첫 AI 응답까지 마이크를 잠시 막는 예약
→ handleStartSession → startSession → LiveKit room connect
→ agentReadiness.wait() 10초 안에 markReady (livekit-client-session.ts:3397 / 3431)
→ 첫 AI 응답 종료 → applyDeferredListening() (use-ai-session.ts:2047)
pendingConfiguredListeningRef || isMicrophoneEnabledRef = true → toggleMicrophone(true)
→ "Agent listening enabled" → 아동 음성 업링크 허용
[문제 흐름 — 수정 전]
AI 스텝 진입 (듣기 ON)
→ scheduleMicOnAfterFirstResponse({ configuredIntent: true }) // 예약됨
→ startSession 1차 attempt: agentReadiness 10초 타임아웃
LiveKitAgentReadinessTimeoutError (livekit-client-session.ts:827)
→ 실패 catch 에서 await stopSession() (use-ai-session.ts:3877)
isMicrophoneEnabledRef = false (2641행)
pendingMicOnAfterFirstResponseRef = null (2647행)
pendingConfiguredListeningRef = false (2648행) // 예약이 통째로 사라짐
→ throw → runAgentReadinessRetry 가 같은 인자로 startSession 만 재호출
→ 2차 attempt 성공. initialListeningIntent = resolveListeningIntent() = false (2984행)
→ 시작 멘트 재생 O / 진행자 타이핑 응답 O // 다운링크·텍스트 경로는 무관
→ 첫 응답 후 applyDeferredListening(): 두 ref 모두 false → no-op
→ 아동 음성 업링크 계속 차단 = "음성 미인식"
[복구 경로]
진행자 듣기 OFF → ON
→ setAgentListeningEnabled(true) → applyListeningIntent → "Agent listening enabled"
→ 그 자리에서 정상화 (= 이 증상이 세션·단말 문제가 아니라는 판별 근거)
④ 함수·모듈별 세부 설명
"AI 준비 시간초과"가 정확히 무엇인가 FACT
게스트가 LiveKit 방에 접속까지는 성공한 뒤, AI 에이전트(워커)가 "준비됨" 신호를 10초 안에 보내지 않은 상태다. 게이트는 createLiveKitAgentReadinessGate(845행)가 관리하고, 신호는 두 경로 중 하나로 들어온다 — ready 응답 ACK(markReady("ready_response_acknowledged"), 3397행) 또는 ppi.agent_ready 이벤트(markReady("agent_ready_event"), 3431행).
voiceSessionId로 다시 붙으므로 ②·③에도 유효하다(HYPOTHESIS). 서버 쪽 확진은 CloudWatch /ppi/livekit/{env}/agent에서 해당 roomName의 job 수락 기록 유무로 한다.
왜 readiness 타임아웃만 재시도하는가 FACT
shouldRetryAgentReadiness(livekit-readiness-retry.ts:13)는 LiveKitAgentReadinessTimeoutError일 때만, 최대 1회 재시도한다. 마이크 권한·토큰 같은 다른 실패는 재시도해도 결과가 같아 아동 대기 시간만 늘어나기 때문이다(코드 주석 명시).
근본 원인 — 의도가 "인자"가 아니라 "세션 범위 ref"에 있었다 FACT
재시도가 한 일은 startSession({activity, stepIndex, currentStep})을 같은 인자로 다시 호출한 것뿐이다. 그런데 '듣기 ON' 의도는 인자가 아니라 pendingConfiguredListeningRef·isMicrophoneEnabledRef에 들어 있고, 1차 실패 정리(3877행의 await stopSession())가 이 둘을 모두 비운다. 즉 재시도는 "방을 다시 만든다"까지만 책임지고, stopSession이 지운 시작 조건을 다시 세우는 주체가 아무도 없었다.
원래 예약을 걸어주는 주체는 한 층 위 use-step-transition.ts:111인데, 재시도는 그보다 아래(use-ai-session 내부)에서 일어나므로 다시 호출되지 않는다.
restartCurrentSession(use-guest-page-session.ts:1411)은 이 함정을 이미 알고 있어서 wasMicrophoneEnabled를 stop 전에 스냅샷했다가 재예약한다. 같은 방어가 readiness 재시도 경로에만 없었다.
수정 — 재시도 attempt 직전 재예약 FACT (이번 변경)
runAgentReadinessRetry에 onBeforeRetryAttempt 옵션을 추가하고(50행), generation 확인을 통과한 뒤 다음 attempt를 시작하기 직전에만 호출한다(81행). 폐기(abandoned)·비-readiness 실패 경로에서는 호출되지 않는다.
// use-ai-session.ts:5258 // 실패한 attempt 의 정리 루틴(stopSession)이 듣기 의도 ref 를 비우므로, // 재시도에서 복원할 수 있도록 시작 전에 스냅샷해 둔다. const configuredListeningIntent = pendingConfiguredListeningRef.current || isMicrophoneEnabledRef.current; await runAgentReadinessRetry({ startAttempt: () => startSession({ activity, stepIndex, currentStep }), getGeneration: () => sessionGenerationRef.current, onBeforeRetryAttempt: () => { const intent = resolveRetryListeningIntent({ configuredIntentAtStart: configuredListeningIntent, commandSinceStart: listeningCommandSinceStartRef.current, }); if (!intent) return; scheduleMicOnAfterFirstResponse({ configuredIntent: true }); }, … });
재예약 시점에는 세션이 이미 내려가 있어(isActiveRef=false) fallback 타이머가 즉시 걸리지 않고, 이어지는 startSession이 정상 경로와 동일하게 armDeferredMicFallback()을 건다. 즉 정상 스텝 전환과 같은 상태에서 재시도가 출발한다.
복구 중 진행자가 듣기를 바꾸면 최신 설정이 이긴다 FACT (2차 라운드)
시작 시점 스냅샷만으로 재예약하면, 재시도 공백(readiness 10초 + backoff 0.3~1.2초) 동안 진행자가 듣기를 바꿔도 낡은 값이 적용된다. OFF 방향이 특히 나쁜데, 단순한 stale 값이 아니라 명시적 OFF 처리를 되돌리기 때문이다 — OFF는 cancelPendingMicTimers()로 micDeferCancelledRef=true를 세워 예약을 취소하는데, scheduleMicOnAfterFirstResponse()가 그 플래그를 다시 false로 돌린다. 듣기 변경은 세션 generation을 바꾸지 않아 기존 폐기 검사로도 걸러지지 않는다.
그래서 실패 정리로 지워진 상태와 진행자가 명시적으로 바꾼 의도를 구분한다. setAgentListeningEnabled는 호출될 때마다 {issued:true, intent:newState}를 기록하고(stopSession은 이 함수를 거치지 않고 ref를 직접 비우므로 기록되지 않는다), handleStartSession은 시작 시점에 이를 비운다. 판정은 순수 함수로 분리했다.
// livekit-readiness-retry.ts export function resolveRetryListeningIntent({ configuredIntentAtStart, commandSinceStart }) { if (commandSinceStart.issued) return commandSinceStart.intent; // 최신 명령 우선 return configuredIntentAtStart; // 지워진 상태 복원 }
| 시작 시점 | 복구 중 진행자 조작 | 재시도 후 결과 |
|---|---|---|
| ON | 없음 | 예약 복원 → 시작 멘트 종료 후 인식 |
| ON | OFF | 예약 안 함 → 계속 차단 |
| OFF | 없음 | 계속 차단 |
| OFF | ON | 예약 복원 → 시작 멘트 종료 후 인식 |
취소된 재시도가 뒤늦게 세션을 살리지 않게 FACT (2차 라운드)
재시도는 대기 후 getGeneration()이 바뀌었는지 보고 폐기를 결정한다. 그런데 sessionGenerationRef는 새 startSession에서만 올라간다. 활동 전환은 stop 뒤에 새 start가 따르므로 걸리지만, 수업 종료처럼 stop만 하는 경우는 generation이 그대로였다. 게다가 재시도 대기 중에는 세션 자원이 이미 정리돼 있어 그때의 stopSession은 중복 stop 조기 반환에 걸린다. 그 결과 종료 후에도 재시도가 새 방을 만들 수 있었다(이번 변경으로 드러난 기존 설계 공백).
stopGenerationRef를 stopSession 진입 직후(조기 반환보다 앞)에서 올리고, getGeneration을 sessionGeneration + stopGeneration 합으로 바꿔 두 종류의 취소를 모두 감지하게 했다.
재현·검증 경로 FACT
게스트 콘솔/세션 로그를 이 순서로 본다.
1. readiness_retry_scheduled (attempt=1) 2. readiness_retry_succeeded 3. Defer mic: will apply after first AI response or fallback (configuredIntent: true) // ← 재시도 직후 한 번 더 찍히면 수정 반영됨. 수정 전에는 이 줄이 없다 4. Agent listening enabled // 첫 응답 후. 3이 없으면 4도 없다
readiness 타임아웃을 실제로 맞추기 어려우므로, dev에서 startAttempt가 첫 호출만 LiveKitAgentReadinessTimeoutError를 던지도록 임시 패치해 강제 유발하는 것이 가장 확실하다(검증 후 제거).
⑤ 주의사항과 이슈 히스토리
잔여 위험 HYPOTHESIS · P2
stopGenerationRef 증가 지점)은 유닛 테스트로 보호되지 않는다 — use-ai-session은 5,300줄 훅이라 현재 harness가 없다. setAgentListeningEnabled를 거치지 않는 새 듣기 변경 경로가 생기면 기록이 조용히 누락될 수 있다.
toggleMicrophone(false))에도 남는다. 그 경로는 곧 새 세션 시작으로 generation이 바뀌어 재시도가 폐기되므로 실질 영향은 없고, 남더라도 "재예약하지 않는" 안전한 방향이다.
확진하지 못한 부분 UNKNOWN
검증 FACT
pnpm install(신규 worktree) → packages/* 빌드 → tsc --noEmit 수정 파일 오류 없음 → tsx livekit-readiness-retry.test.ts 21/21 통과(신규 7건 — 콜백 계약 3건·의도 판정 4건, 수정 전 red 확인) → prettier --check 통과.
pnpm test:livekit-vp에서 lesson-session.service.disconnect-race 1건이 실패하지만 이번 변경과 무관한 사전 존재 실패다(sessionDao.createSession is not a function). tsc의 host-disconnected.png 모듈 오류도 사전 존재 항목.
이슈 히스토리
- 2026-07-29 — PPI-1167에서 같은 도메인의 사이드 이펙트. '듣기 의도'가 page-session 정본과 ai-session 사본으로 이중 존재하고, 사본이
stopSession등 3경로에서 리셋된다는 구조가 이때 정리됐다. - 2026-08-26 — readiness 타임아웃 자체(워커 디스패치 즉시 포기)의 원인 분석. 자동 재시도 도입 배경.
- 2026-09-16 — 재현 경로 신고 →
probe절차로 재시도 경로의 의도 유실 확정 → bounded 설계 승인 →PPI-1300워크트리에 구현·검증 → 이 문서 작성. - 2026-09-16 (2차) — 외부 리뷰에서 "스냅샷이 진행자의 최신 OFF를 덮어쓴다"는 P1 제기. 초판에서 P2 잔여 위험으로만 적었던 항목이며,
scheduleMicOnAfterFirstResponse가 취소 플래그까지 되돌린다는 점이 빠져 있었다. - 2026-09-16 (3차) — 요구사항 대조에서 두 항목 미충족 확인. ① "복구 중 변경은 최신 설정을 따름"은 OFF→ON 방향도 필요해 판정을
resolveRetryListeningIntent(최신 명령 우선)로 일반화, ② "수업 종료로 취소된 재시도가 뒤늦게 듣기를 켜지 않음"은stopSession이 generation을 올리지 않아 미충족이라stopGenerationRef추가.
관련 문서
- PPI-1167 듣기 자동 ON revert 후 재랜딩 복원 실패와 presence race 수정 — 같은 '듣기 의도 이중 소스' 구조. 이번 건은 그 구조가 재시도 경로에서 다시 드러난 사례
- LiveKit agent readiness 타임아웃 — 워커 디스패치 즉시 포기 원인 분석 — 타임아웃이 왜 발생하는지(서버 쪽 원인)
- PPI-1276 그룹수업 "듣기 확인 중" 고착 — join-room 거부 재시도 부재 — "재시도 경로에 후속 동기화가 빠져 있다"는 같은 계열의 결함