본문 바로가기
C.W.K.
Lesson 09 of 09 · published

예외의 길

~14 min · reliability, failure-paths, code-review, observability

Level 0음소거
0 XP0/64 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete
"모두 이 세션에서 고쳐" — 아빠, 음성 모드 리뷰가 찾은 것들을 두고, 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에 있어.

Code

실패한 턴은 보드를 done / failed:<reason>으로 남겨 (원문 발췌, backend/services/turn_clock.py, 2026-10-27 기준)·python
# backend/services/turn_clock.py:125-156 (cwkPippa)
def failure_reason(exc: BaseException) -> str:
    """What ``failed:<reason>`` names: an HTTP refusal by its status, a
    request that went away, or any other error."""
    status = getattr(exc, "status_code", None)
    if isinstance(status, int):
        return f"http_{status}"
    if isinstance(exc, asyncio.CancelledError):
        return "cancelled"
    return "error"


def marks_failure(endpoint: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
    """Wrap a chat route so a turn that fails before its stream starts leaves
    the board as ``done`` / ``failed:<reason>`` (todo #598): a 404 for a
    conversation the viewer may not write, a 500 for a row that would not
    save, or any error in the preparation. ``functools.wraps`` keeps the
    route's signature visible to FastAPI. The stream reports its own end."""

    @functools.wraps(endpoint)
    async def wrapper(*args: Any, **kwargs: Any) -> T:
        token = _route_turn.set(None)
        try:
            return await endpoint(*args, **kwargs)
        except BaseException as exc:
            clock = _route_turn.get()
            if clock is not None:
                clock.failed(failure_reason(exc))
            raise
        finally:
            _route_turn.reset(token)

    return wrapper
폰은 실패한 말하는 턴을 딱 한 번 놓아줘 (원문 발췌, clients/apple/PippaMobile/Sources/AppModel.swift, 2026-10-27 기준)·swift
// clients/apple/PippaMobile/Sources/AppModel.swift:4966-4979 (cwkPippa)
    /// A spoken turn failed (todo #598): after a provider error mid-reply the
    /// turn stayed awaited, the lines it had streamed kept a `spokenUnits`
    /// entry, the mic never opened again and the last stage line stood frozen
    /// until Dad tapped twice. It leaves the gate and the line now, and the
    /// talk listens again once nothing of hers is sounding. Only the first time
    /// the failure is seen: a failed record stays in the outbox, and every
    /// later drain must not reopen the mic under a newer turn.
    private func spokenTurnFailed(_ turnId: String) {
        let awaited = spokenReplies.forget(turnId)
        spokenUnits[turnId] = nil
        guard awaited else { return }
        if voiceStageTurnId == turnId || voiceStage?.turnId == turnId { clearVoiceStage() }
        if !voice.isReading { resumeHandsFree() }
    }
스케치: 실패는 전부 이름 있는 상태로 끝나 (npx tsx settle.ts)·typescript
// A sketch, not cwkPippa's code: the ways a spoken turn can fail, and the named
// state each one must end in. The rule it checks: a real failure is never
// silent, and "nothing to speak" is not a failure at all.

type Failure =
  | { kind: 'turnFailed'; reason: string; firstSeen: boolean }   // provider error mid-reply
  | { kind: 'sendDropped'; attempt: number }                     // a transient link drop
  | { kind: 'streamLost'; cursor: number }                       // web: the stream died mid-answer
  | { kind: 'routeChanged'; heardSeconds: number }               // AirPods moved, a call came
  | { kind: 'takeFailed'; how: 'neverSounded' | 'stalled' | 'brokeOff'; restarted: boolean;
      detail: string; privately: boolean }                      // a take in a hands-free talk
  | { kind: 'nothingToSpeak' };                                  // a lone ```spark block

interface Outcome {
  record: string | null;   // what the engine or the talk's record keeps
  screen: string | null;   // the one line Dad sees
  next: string;            // the state the turn moves on to
}

const RETRY_SECONDS = [5, 15, 45, 60];   // the last delay repeats

function retryAfter(attempt: number): number {
  return RETRY_SECONDS[Math.min(attempt, RETRY_SECONDS.length - 1)];
}

/** Words Dad can act on, from the error's own text; never the HTTP chain. */
function plainCause(detail: string): string {
  const text = detail.toLowerCase();
  if (/timed out|stalled|no audio in time/.test(text)) return 'her voice took too long to arrive';
  if (/offline|connection was lost|network/.test(text)) return 'the link to Pippa dropped';
  if (/\b5\d\d\b/.test(text)) return 'the voice service failed';
  if (/\b4\d\d\b/.test(text)) return 'the voice service refused it';
  return 'her voice did not come through';
}

export function settle(f: Failure): Outcome {
  switch (f.kind) {
    case 'turnFailed':
      // Only the first sighting frees the talk; a later drain must not reopen
      // the mic under a newer turn.
      return f.firstSeen
        ? { record: `stage board: done / failed:${f.reason}`, screen: null, next: 'gate forgets the turn; listening again' }
        : { record: null, screen: null, next: 'unchanged (already settled)' };
    case 'sendDropped':
      return { record: null, screen: `Link dropped, sending again in ${retryAfter(f.attempt)} s`, next: 'queued' };
    case 'streamLost':
      return { record: null, screen: 'Connection lost — following her answer',
               next: `read the stored reply from character ${f.cursor}` };
    case 'routeChanged':
      return { record: null, screen: null,
               next: `same take from ${Math.max(0, f.heardSeconds - 0.5).toFixed(1)} s` };
    case 'takeFailed': {
      // A stream that never sounded falls back to the finished take, except in a
      // private talk, which keeps no finished take: there the first failure is said.
      if (f.how === 'neverSounded' && !f.privately)
        return { record: null, screen: 'Her voice came from the finished take: the live stream did not open.',
                 next: 'play the finished take (take_failed if it fails too)' };
      // A stall (no audio for 20 s) is asked for again once, from where she was heard.
      if (f.how === 'stalled' && !f.restarted)
        return { record: null, screen: null, next: 'reopen the take at the heard position' };
      // A second stall, or any other break, is said at once.
      const voiced = f.how !== 'neverSounded';
      const cause = voiced ? 'audio_error' : 'take_failed';
      const line = voiced
        ? `Her voice stopped early: ${plainCause(f.detail)}.`
        : `Part of her answer was not read aloud: ${plainCause(f.detail)}.`;
      return { record: f.privately ? null : `voice_reading_stopped cause=${cause}`, screen: line, next: 'the next unit' };
    }
    case 'nothingToSpeak':
      return { record: null, screen: null, next: 'the next unit, silently' };
  }
}

const cases: Failure[] = [
  { kind: 'turnFailed', reason: 'http_500', firstSeen: true },
  { kind: 'turnFailed', reason: 'http_500', firstSeen: false },
  { kind: 'sendDropped', attempt: 0 },
  { kind: 'sendDropped', attempt: 7 },
  { kind: 'streamLost', cursor: 412 },
  { kind: 'routeChanged', heardSeconds: 37.2 },
  { kind: 'takeFailed', how: 'neverSounded', restarted: false, detail: 'HTTP 504', privately: false },
  { kind: 'takeFailed', how: 'neverSounded', restarted: false, detail: 'HTTP 504', privately: true },
  { kind: 'takeFailed', how: 'stalled', restarted: false, detail: 'stalled 20 s', privately: false },
  { kind: 'takeFailed', how: 'stalled', restarted: true, detail: 'stalled 20 s', privately: true },
  { kind: 'takeFailed', how: 'brokeOff', restarted: false, detail: 'the network connection was lost', privately: false },
  { kind: 'nothingToSpeak' },
];

for (const f of cases) {
  const o = settle(f);
  console.log(f.kind.padEnd(15), '| record:', o.record ?? '-', '| screen:', o.screen ?? '-', '| next:', o.next);
  // Only a first stall is asked for again in silence; every other failed take says so on screen.
  const retrying = f.kind === 'takeFailed' && f.how === 'stalled' && !f.restarted;
  if (f.kind === 'takeFailed' && !retrying && !o.screen) throw new Error('a failed take went silent');
}

External links

Exercise

스케치를 돌려 봐(npx tsx settle.ts). 그리고 줄마다 상태로 읽어. 그다음 스케치가 아직 모르는 실패 하나, 대화 도중에 듣기 허가가 거절된 경우를 더하고, 스케치에 이미 있는 비밀 대화 테이크 경우들도 따라가 봐. 각각 기록엔 뭐가 남고, 화면엔 뭐라고 뜨고, 다음 상태는 뭔지 정해. 마지막으로 2026-10-22의 실수를 잡았을 단언을 하나 넣어. 읽을 게 없는 답은 절대 화면에 줄을 띄우면 안 돼.
Hint
거절된 허가는 이 트랙 네 번째 레슨처럼 얼굴에 이유를 띄우고 멈추는 거야. 보낸 턴이 없으니 기록엔 남길 게 없어. 비밀 대화의 테이크는 어떻게 실패했느냐에 달렸어. 스트림이 아예 안 열리면 바로 말해. 비밀 답엔 기댈 완성 테이크가 없거든. 멈춘 테이크는 다른 테이크처럼 들은 자리에서 한 번 다시 청해. 어느 쪽이든 기록엔 아무것도 안 남아. 비밀 대화는 기록을 안 남기니까.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.