합성 통화 재생기가 마지막 턴을 잃는다 — 연결 닫는 시점
정성윤이 09-22 오늘 진행 기록·미결 항목을 보고 만든 티켓이다. 담당은 «제안»이다 — 본인이 확인하고 맞으면 착수, 아니면 옮긴다.
무엇을
09-22 운영 SYN-010(류준)에서 마지막 턴(5, 상담원 인사)이 저장되지 않았다(4/5). 같은 날 정성윤 재투입은 5/5, SYN-008 은 16/16. 재생기가 마지막 확정 직후 연결을 닫는 경쟁으로 보인다(가설 — 류준 05번 기록).
완료 조건
- 원인을 찾아 기록한다 — 재생기 쪽이면 마지막 확정의 서버 저장 응답을 기다린 뒤 닫는다
- 24건 대본을 다시 흘려 저장 건수 = 턴 수 ·
e2e_check.py가 마지막 턴 누락을 ❌ 로 잡는다
2026-09-22 — 코드로 본 원인과 재생기 수정 (류준, 운영 재확인 전)
원인 — 증명된 것과 가설을 가른다.
- 증명(코드 읽기): 재생기가 일찍 닫아서 «저장»이 빠질 수는 없다. 콜 미디에이터
/dev/text는 메시지를 받은 순서대로 동기 처리한다(ws_server.tsacceptText→pushText→ 채널 줄).{"type":"end"}나 소켓 닫힘은channel.close()→drain()만 부르고,drain의 10초 상한(call_registry.ts)은 기다림을 끊을 뿐 줄에 든 전사·진행 중인 서버 호출을 취소하지 않는다. 재생기 프로세스가 끝나도 콜 미디에이터 → 서버 요청은 계속 간다. 그래서 티켓의 첫 가설(«마지막 확정 직후 닫는 경쟁»)은 DB 저장 누락의 원인으로는 틀렸다. 닫는 시점이 영향을 주는 곳은 재생기 자신의/ws관찰(자막 수·--close본문)뿐이다. - 남은 가설 둘(미증명). 운영에서 DB 도
/ws도 4개였다 — 콜 미디에이터는 서버/hub/transcripts가 성공한 것만 방송하므로 마지막 확정은 ① 콜 미디에이터에 닿지 않았거나(재생기send()가 소켓이 열려 있지 않으면 조용히 건너뛰었다 — 흔적이 없다) ② 콜 미디에이터 → 서버 전사 요청이 실패했다(5초 시간 초과 또는 5xx — 콜 미디에이터 로그에전사 전달 실패 call=syn-prod-syn-010-20260922가 남는다). ②가 정황에 더 맞다 — SYN-010 은 모델 탑재 뒤 운영이 처음 받은 통화였고 추천이 3,300ms 걸렸다. 가르는 것은 그 시각의 콜 미디에이터 파드 로그다(정성윤 님 — 파드가 교체됐으면 못 가른다).
재생기 수정(원인이 어느 쪽이든 조용히 잃지 않게) — replay_persona_call.ts · persona_replay/final_echo.ts
- 보낸 확정이 화자별로 전부
/ws로 돌아온 뒤에야end를 보낸다(마스킹본이라 글자 대신 개수로 센다).--final-timeout(기본 20초) 안에 안 돌아오면 경고 + 종료 코드 1.--watch가 없어도 뷰 토큰이 있거나 루프백이면/ws를 열어 확인한다. - 소켓이 닫혀 확정을 못 보내면 경고 + 종료 코드 1(가설 ①이 이제 보인다).
end뒤 15초 안에 콜 미디에이터가 안 닫으면 경고하고 이쪽에서 닫는다. - 가짜 콜 미디에이터로 확인(스택 없음): 정상 5/5 →
✓ 확정 자막 5건이 전부 /ws 로 돌아왔다, 마지막 상담원 확정을 버리게 하면⚠ 확정 자막 상담원 1건이 2초 안에 /ws 로 돌아오지 않았다· 종료 코드 1. e2e_check.py의왕복·API 확정 자막 수는 원래도 ❌ 였고, 이제 빠진 턴 번호를 적는다.
남은 것: 로컬 24건 재생(다음 E2E 묶음) · 운영 SYN-010 재투입으로 5/5 확인 · 콜 미디에이터 로그로 ①/② 가르기.
2026-09-22 — 운영 재투입 5/5 (정성윤) → done
- 새 재생기(09-22 R 수정)로 운영에 SYN-010 을 흘렸다(
syn-prod-syn-010-20260922-162552, call-mediator0.2.5). 재생기가 「✓ 확정 자막 5건이 전부 /ws 로 돌아왔다(마지막 확인까지 185ms)」를 찍고 종료 코드 0.GET /hub/calls/{id}/transcript저장 5건 = 턴 수. 로컬 24건은 류준 07번 기록에서 마지막 턴 394/394. - ①/② 가르기는 못 한다 — 오전 SYN-010(
syn-prod-syn-010-20260922) 시각의 콜 미디에이터 파드가 그 뒤 배포로 두 번 교체돼 로그가 없다. 재발하면 이제 재생기가 종료 코드 1 로 알리고, 그때 파드 로그에서전사 전달 실패를 찾는다. 원인 미확정으로 닫는다(절대 원칙 8).
2026-09-22 (저녁) — 운영 확인, 닫는다 (류준)
운영 SYN-010 재투입(syn-prod-syn-010-20260922-v2)에서 저장 5/5, 재생기가 「확정 자막 5건이 전부 /ws 로 돌아왔다(마지막 확인까지 207ms)」를 확인한 뒤 닫았다. 로컬 E2E 도 394/394.
원인은 확정하지 못했다 — 코드로 「재생기가 일찍 닫아서」는 기각됐고, 남은 가설(① 마지막 확정이 미디에이터에 안 닿음 ② 미디에이터→서버 전사 실패)은 오전 운영 미디에이터 로그로 가른다(정성윤, 미결). 재생기는 이제 같은 일이 나면 조용히 넘어가지 않고 경고 + 종료 코드 1 을 낸다.
2026-09-22 (밤) — 원인 판정: ② 콜 미디에이터 → 서버 전사 요청 실패 · 확정 재시도 (류준, 운영 로그 없이)
판정 — ② (증명: 코드 + 운영 산출물 소거법). ①(마지막 확정이 콜 미디에이터에 닿지 않음)은 기각된다. 실패 종류(시간 초과 · 5xx · 연결 끊김)는 여전히 미결이다.
- ① 기각 근거. 운영 명령은
2>&1 | tee syn-010-watch.txt였다(세션 기록) — 옛 재생기(a866ff4)는 화자 소켓이 1000 이 아닌 코드로 닫히면 stderr 에[agent] 콜 미디에이터가 닫았다를 찍는데 그 줄이 없다. 옛send()가 확정을 건너뛰려면 소켓이 OPEN 이 아니어야 하고, 콜 미디에이터가 1000 으로 닫는 길은{"type":"end"}(확정 뒤에 같은 소켓으로 보낸 것) 하나뿐이다(ws_server.tsacceptText). 조용히 끊긴 반쪽 연결이었다면end도 못 닿아 재생기가 10초 상한 + 3초를 다 기다려 약 35초에 끝났어야 하는데, 실제는 12:19:59 → 12:20:31(32초) — 확정 + 10초 타이머 모양이다(로컬 재현도 같은 모양). 즉 콜 미디에이터가end를 받아 정상으로 닫았고, 같은 소켓에서 그 앞에 온 확정도 받았다. - 그러면 남는 길은 하나. 받은 확정은
pushText→ 채널 줄 →forward()로 가고, 자막·「검색 중」·추천·필요서류 판정이 전부hub.ingestTranscript성공 뒤에 있다. 운영 5번은 자막 없음 · pending 없음 · DB 없음 · 5번 뒤 필요서류 판정 행도 없음(옛 규칙이면TERM-2.9재판정 행이 하나 더 생겼어야 한다) — 이 서명을 내는 분기는 전사 요청 실패뿐이다. DB 에도 없으므로 서버가 커밋하지 못했다 (느리기만 했다면 서버가 늦게라도 UPSERT 했을 것) → 서버가 오류로 답했거나 요청이 서버 앱에 닿지 못했다. 어느 쪽인지는 그 시각 콜 미디에이터 로그의전사 전달 실패 call=syn-prod-syn-010-20260922 segment=5 status=…로만 가른다(미결). - 확정이 저장 없이 사라지는 분기 전부(
services/call-mediator/src/): ⓐ 전사 요청 실패 — 시간 초과 5초(hub_http.ts:105)·연결 실패· 5xx·4xx(401 토큰·409 통화 없음·422) → 자막·pending 없음(call_registry.tsforward) ⓑ 채널이 이미 닫힘(pushText,end/소켓 닫힘 뒤) ⓒ 메시지 형식 위반 → 1008 로 닫힘(뒤 것 전부) ⓓ 채널 열기 실패(통화 시작 실패 등) → 모아 둔 것 전부 ⓔ 빈 글자 ⓕ 연결 유지 ping 실패로terminate(1006) ⓖ auto 채널의 화자 라벨 없는 확정. 줄의 interim 갈아 끼우기는 확정을 버리지 않고,drain10초 상한은 기다림만 끊는다. ⓑ~ⓕ 는 재생기 쪽에 1000 이 아닌 닫힘이 찍히거나 운영 서명과 맞지 않는다. - 로컬 재현 (
:8002서버 ·:8082콜 미디에이터 · 검사 DBcallguard_e2e_l, 끝나고 지움 · 옛 재생기 사본 · 서버 앞 작은 프록시로 5번 확정 전사만 방해): (a) 콜드 스타트(서버 뜨자마자 재생) → 5/5, 재현 안 됨. (b) 5번 확정 1회 503 → 4/5,/ws자막·pending 5번 없음, 재생기 stderr 조용 — 운영과 같은 서명. (b′) 5번 확정 1회 6초 멈춤 후 서버에 안 넘김 → 콜 미디에이터status=연결 실패(5초 시간 초과) · 4/5 · 같은 서명. 수정 뒤 같은 두 주입 → 5/5(전사 전달 실패 … 300ms 뒤 다시 보낸다 (1/3)한 줄). 계속 503 이면 3회 뒤전사 전달 포기 … (3회 시도)+ 새 재생기⚠ 확정 자막 상담원 1건이 … 돌아오지 않았다· 종료 코드 1.
수정 — 콜 미디에이터가 확정 전사를 일시 실패에 한 번에 버리지 않는다 (call_registry.ts ingest()·INGEST_RETRY_DELAYS_MS).
확정만, 연결 실패·시간 초과·5xx·429 일 때 300ms · 1,000ms 뒤 다시 보낸다(최대 3회). 4xx·interim 은 다시 보내지 않는다. 다시 보내도 되는 근거는
서버 저장이 (call_id, segment_id) UPSERT + 마스킹 구간 지우고 다시 넣기라는 것(decisions/205) — 첫 요청이 늦게 서버에서 끝났어도 행은 하나다.
재시도는 채널 줄 안에서 해서 순서가 뒤집히지 않는다(그동안 뒤 자막이 늦는다). 경고 로그에는 통화·발화 번호·상태·시도 횟수만(SEC-1).
단위 테스트 6건 추가 — 옛 코드에서 4건 실패, 새 코드 153/153.
남은 것: 실패 종류(시간 초과인지 5xx 인지 연결 끊김인지)와 그 서버 쪽 원인 — 운영 콜 미디에이터·서버 로그(정성윤, 파드가 교체됐으면 못 가른다). 로컬 콜드 스타트로는 재현되지 않았다. 확인이 안 되는 가설 하나: undici 연결 재사용 ↔ uvicorn keep-alive 5초 경계(연결 끊김이면 서버가 요청을 못 받아 DB 에 없는 것과 맞는다).