"모두 이 세션에서 고쳐" — 아빠, 음성 모드 리뷰가 찾은 것들을 두고, 2026-10-22
평범한 길은 멀쩡했어
2026-10-21에 피파 머신이 새 맥으로 옮겨 갔어. 아빠는 그 뒤에 이어질 코드 리뷰의 순서를 정했고, 음성 모드가 두 번째였어. 엔진, 웹, 폰, 워치, 공용 음성 키트를 나란히 놓고 읽었어. 큰 발견은 하나하나 그 코드 줄을 다시 열어 확인하고 나서야 발견으로 쳤어.
로그부터 읽었는데, 로그는 좋았어. 2026-10-21과 22일 이틀 동안 새 머신에서 돈 폰 음성 턴 103개(그중 90개가 셀룰러)가 전부 underruns=0이었어. 왜 그런지는 트랙 4의 다시 통째로: 빠른 시작보다 끊김 없는 게 먼저에 있어. 그런데 로그는 일어난 일의 표본이야. 그 이틀 동안 나쁜 일은 눈에 띌 만큼 자주 일어나지 않았어. 리뷰는 다른 걸 물었어. 뭔가 잘못되면 코드가 뭘 하지? 찾은 건 거의 다 그 예외의 길 위에 있었어. 아빠 답은 맨 위의 그 한 줄이야. 전부, 그날 안에.
실패도 상태야
이 트랙의 두 번째 레슨은 아빠 말에 규칙을 하나 세웠어. 끝난 발화가 갈 곳은 셋이고 넷째는 없어. 조용히 사라지는 길은 그 셋에 없어. 리뷰는 같은 규칙을 실패에 들이댔어. 리뷰 전엔, 답 도중에 제공자가 에러를 낸 말하는 턴은 폰에서 갈 데가 없었어. 턴은 계속 기다리는 중으로 남았어. 이미 흘러나온 줄들은 제 항목을 그대로 쥐고 있었고, 마이크는 다시 안 열렸어. 마지막 스테이지 줄은 아빠가 두 번 탭할 때까지 얼어 있었어. 엔진 쪽 스테이지 보드도 죽은 턴이 마지막으로 닿은 단계에 그대로 서 있었어. 죽은 건 하나도 없는데, 말하는 것도 하나도 없었어.
그래서 아래 수정은 전부 규칙 하나를 따라. 실패는 이름 있는 상태로 끝나고, 기록과 화면은 그게 뭐였는지 똑같이 말해.
턴을 계속 움직이게 하는 수정
- 실패한 턴은 대화를 놓아줘. 폰의 말하는 답 관문이 그 턴을 잊고(
SpokenReplyGate.forget), 그 턴의 단위와 스테이지 줄도 치워. 그리고 피파 소리가 완전히 멎으면 대화가 다시 들어. 이건 그 실패를 처음 봤을 때만 해. 실패한 기록은 아웃박스에 남아 있으니까, 나중에 아웃박스를 비울 때 더 새로운 턴 밑에서 마이크를 또 열면 안 되거든. 엔진에선done전에 실패한 턴이 스테이지 보드를done으로, 상세는failed:<reason>로 남겨. 이유는http_<status>,cancelled,error중 하나야. 채팅 라우트의turn_clock.marks_failure와 각 스트림의 끝이 그렇게 적어. 한 시간 동안 조용한 항목은 지워. - 떨어진 전송은 다시 가. 앱이 앞에 떠 있는 동안 잠깐 실패한 전송은, 예전엔 앱을 다시 켜거나, 앞으로 돌아오거나, 푸시가 오거나, 새로 보낼 때까지 기다렸어. 그것 말곤 아무것도 아웃박스를 안 비웠고, 줄엔 "Link dropped, sending again · 240s"가 떠 있었어. 이제는 5초, 15초, 45초 뒤에, 그다음엔 60초마다 저절로 다시 비워. 대화가 열려 있으면 화면이 꺼져도 그래. "sending again"에도 멈춤 한도가 생겨서, 다른 조용한 단계와 똑같이 진단해.
- 잃어버린 스트림은 답 전체가 아니야. 웹에선 답 도중에 죽은 스트림을 끝난 답으로 읽었어. 그래서 중간까지 온 꼬리가 피파의 마지막 말이 됐고, 진짜 답 전체는 끝내 안 읽혔어. 이제 읽기는 커서를 쥔 채로 복구를 따라가서, 저장된 답을 멈춘 자리부터 읽어. 그동안 얼굴엔 "Connection lost — following her answer"가 떠.
- 경로가 바뀌어도 하던 데서 이어 가. 에어팟이 옮겨 가든, 전화가 오든, 내비 안내가 끼어들든, 읽기를 붙잡아 뒀다가 같은 테이크를 들은 자리에서 다시 틀어. 자세한 건 이 트랙의 첫 레슨과 폰이 귀에 읽어줘에서 다뤄.
무슨 일이 있었는지 말하게 하는 수정
"끊기는 데다 아예 건너 뛰어" — 아빠, 2026-10-19, 웹 음성 화면에서
2026-10-20 전까지 웹은 두 번 받아 오려다 실패한 테이크를 아무 말 없이 버렸어. 그래서 아빠는 구멍 난 답을 들었지. 이제 소리가 끝내 안 온 단위는 말하고 알려. 절대 건너뛰지 않아. 웹이 먼저 했고(89bf9fe9), 폰은 리뷰 때 따라왔어(f58e7fea). 폰에선 테이크가 어떻게 실패했느냐에 따라 순서가 달라. 소리가 한 번도 안 난 스트림은 완성된 테이크로 넘어가고, 그것마저 실패해야 말해. 비밀 대화는 예외야. 기댈 완성 테이크가 없어서 첫 실패를 바로 말해. 핸즈프리 대화에서 멈춘 테이크는 같은 테이크를 한 번 다시 청해서 들린 자리부터 이어 읽고, 두 번째로 실패해야 말해. 그 밖의 끊김은 바로 말해. 단위가 하나도 소리 나지 못했으면 "Part of her answer was not read aloud: …", 중간에 끊겼으면 "Her voice stopped early: …"야. 둘 다 대화 기록에 voice_reading_stopped 줄로 남아(take_failed나 audio_error). 비밀 대화는 아무것도 안 남겨.
반대 방향 실수는 사흘 뒤, 2026-10-22에 나왔어. 아빠 아이폰 입력창 위에 "Part of her answer was not voiced: the stream could not open (Pippa answered HTTP 400: …)"가 떴는데, 화면을 꽤 덮었고 닫을 방법도 없었어. 그 답은 펜스로 감싼 spark 블록 하나뿐이었어. 목소리가 읽을 말이 하나도 없는 답이었지. 읽을 게 없는 건 에러가 아니야(671e6ee4). 엔진은 Bellows한테 묻기 전에 그걸 알아. /api/tts/prepare는 {"ticket": null, "enhanced": false, "nothing_to_speak": true}, /api/tts/ensure는 {"filename": null, "nothing_to_speak": true}로 답하고, 스트림은 204로 답해. Bellows가 직접 empty_text로 거절할 때도 똑같이 답해. 두 클라이언트 다 그 단위를 조용히 넘겨. 줄도 없고, 보고도 없어.
그리고 음성 문제는 전부 평범한 한 줄이야. 말은 HTTP 연쇄가 아니라 에러 자체의 글에서 나와. "her voice took too long to arrive", "the link to Pippa dropped", "the voice service refused it" 같은 식으로. 기술적인 내용은 탭하거나 마우스를 올려야 보이고, 엑스를 누르면 줄이 닫히고, 아빠가 다음 턴을 말하면 저절로 사라져. 코딩의 일곱 번째 규칙("조용한 기다림은 죄야")에 2026-10-23 덧붙인 "A Wrong Status Is Worse Than a Silent One"(틀린 상태는 침묵보다 나빠)을 양쪽에 함수 하나씩으로 옮겨 둔 거야(웹은 voiceProblem.ts, 폰은 plainCause).
꿰는 실
이 수정들을 땜질이 아니라 상태로 다시 읽어 봐. 실패한 턴은 보드에 failed:<reason>를 남기고, 대화는 다시 듣는 중이 돼. 떨어진 전송은 줄에 서 있고, 다음 시도가 화면에 떠. 잃어버린 스트림은 커서를 쥐고 따라가는 중이야. 실패한 테이크는 화면에 한 문장, 기록에 한 줄을 남겨. 읽을 게 없는 답은 조용히 다음 단위로 넘어가. 잘못된 게 없으니까. 길마다 기록과 화면이 같은 말을 하는 상태로 끝나고, 침묵은 침묵이 사실일 때만 남아. 절대 버리지 않는다는 규칙을 실패에 적용한 거야.
수정은 그날 다 들어갔어. cwkPippa에선 엔진 cb63d4f7, 웹 050bae9d, 키트 동기화 d3b3965f, 폰과 워치 f58e7fea였고, cwkSiblingKit에도 키트 커밋들이 같이 들어갔어. 커밋마다 테스트도 다 통과했어. 백엔드 7780개, 웹 1193개, 폰은 iOS 27 시뮬레이터에서 536개. 같은 리뷰에서 나온 연결 풀과 "Reconnecting" 이야기는 트랙 10의 잘못된 경보, 원인마다 따로 재기 레슨에, 피파 목소리의 마감 시간은 트랙 4에 있어.