readiness 타임아웃 자동 재시도 후 "듣기 ON" 미인식
— 의도 재예약 누락 원인 분석 및 수정 구현 완료 · 미커밋

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

작성: 2026-09-16입력: 재현 경로 신고 (세션 특정 없음)브랜치: PPI-1300 (worktree, 미커밋)

① 코드 경로

이 문서가 다루는 파일·함수. 이번에 수정된 파일은 수정로 표시. 줄번호는 2026-09-16 PPI-1300 워크트리 기준.

파일대상
apps/web/lib/voice-agent/livekit-readiness-retry.tsonBeforeRetryAttempt 옵션·호출 지점, resolveRetryListeningIntent 순수 판정 함수수정
apps/web/entities/guest-session/model/use-ai-session.tshandleStartSession — 의도 스냅샷 + listeningCommandSinceStartRef 초기화 + 재시도 직전 재예약, setAgentListeningEnabled — 명시적 명령 기록, stopSessionstopGenerationRef 증가수정
apps/web/lib/voice-agent/livekit-readiness-retry.test.ts콜백 호출 계약 3건 + 의도 판정 4건 추가수정
apps/web/lib/voice-agent/livekit-client-session.tsLIVEKIT_AGENT_READY_TIMEOUT_MS=10000 (73행), LiveKitAgentReadinessTimeoutError (827행), createLiveKitAgentReadinessGate (845행), agentReadiness.wait() (3791행)미수정 (타임아웃 발생 지점)
apps/web/entities/guest-session/model/use-ai-session.tsstopSession ref 리셋 (2641·2647·2648행), 실패 정리 await stopSession() (3877행), applyDeferredListening (2047행), scheduleMicOnAfterFirstResponse (2086행), initialListeningIntent (2984행)미수정 (분석 대상)
apps/web/entities/guest-page-session/model/use-step-transition.tsAI 스텝 진입 시 예약 호출 (111·144행)미수정 (예약 주체)
apps/web/entities/guest-page-session/model/use-guest-page-session.tsrestartCurrentSessionwasMicrophoneEnabled 스냅샷 (1411행)미수정 (대조군 · 동일 패턴)

② 목적 요약

진행자가 '듣기 ON'인 상태에서 AI 준비(agent readiness) 10초 타임아웃이 한 번 발생하고 자동 재시도로 세션이 살아난 경우, 시작 멘트와 진행자 타이핑 응답은 정상인데 아동 음성만 인식되지 않는 증상을 분석한다.

근본 원인은 실패한 첫 attempt의 정리 루틴이 '듣기 의도'를 비우는데, 재시도 경로에는 그 의도를 다시 세우는 주체가 없다는 것이다. 재시도 attempt 직전에 의도를 재예약하는 콜백을 추가해 수정했다.

사용자가 인지하는 증상 — "AI가 말은 하는데 아이 말을 못 알아들어요". 진행자가 듣기 OFF→ON을 한 번 하면 그 자리에서 복구되므로 현장에서는 일시적 버그로 흘려보내기 쉽다.

③ 입출력 흐름

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행).

클라이언트는 "신호가 안 왔다"만 알 뿐 사유를 구분하지 못한다. 같은 타임아웃으로 떨어지는 경우가 최소 셋이다 — ① job 미배정(워커 없음·포화), ② 배정은 됐는데 기동·초기화가 10초 초과(좀비 job의 CPU 고갈, 콜드 스타트), ③ 기동까지 됐는데 ready 신호가 클라에 도달하지 못함. 재시도 코드 주석은 ①을 상정해 쓰여 있지만, 새 방·새 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 (이번 변경)

runAgentReadinessRetryonBeforeRetryAttempt 옵션을 추가하고(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없음예약 복원 → 시작 멘트 종료 후 인식
ONOFF예약 안 함 → 계속 차단
OFF없음계속 차단
OFFON예약 복원 → 시작 멘트 종료 후 인식

취소된 재시도가 뒤늦게 세션을 살리지 않게 FACT (2차 라운드)

재시도는 대기 후 getGeneration()이 바뀌었는지 보고 폐기를 결정한다. 그런데 sessionGenerationRefstartSession에서만 올라간다. 활동 전환은 stop 뒤에 새 start가 따르므로 걸리지만, 수업 종료처럼 stop만 하는 경우는 generation이 그대로였다. 게다가 재시도 대기 중에는 세션 자원이 이미 정리돼 있어 그때의 stopSession은 중복 stop 조기 반환에 걸린다. 그 결과 종료 후에도 재시도가 새 방을 만들 수 있었다(이번 변경으로 드러난 기존 설계 공백).

stopGenerationRefstopSession 진입 직후(조기 반환보다 앞)에서 올리고, getGenerationsessionGeneration + 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를 거치지 않는 새 듣기 변경 경로가 생기면 기록이 조용히 누락될 수 있다.
명령 기록은 비-AI 스텝 mute(toggleMicrophone(false))에도 남는다. 그 경로는 곧 새 세션 시작으로 generation이 바뀌어 재시도가 폐기되므로 실질 영향은 없고, 남더라도 "재예약하지 않는" 안전한 방향이다.

확진하지 못한 부분 UNKNOWN

이 문서는 재현 경로 신고 + 코드 트레이스로 확정한 것이고, 해당 증상이 찍힌 실제 세션 로그를 대조하지는 않았다. 위 4단계 로그 시퀀스가 남은 세션을 찾으면 확진이 완성된다.

검증 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). tschost-disconnected.png 모듈 오류도 사전 존재 항목.

이슈 히스토리

관련 문서