폰은 끊기게 돼 있어
아빠의 턴은 주머니 속 폰에서, 손목의 워치에서, 이동통신망 위의 Telegram에서, 잠드는 노트북에서 와. 진짜 Chief 턴은 몇 분씩 돌기도 해. 도구 쓰고, 위임하고, 길게 답하고. 서버가 턴을 그걸 실어 온 요청 안에서 돌리면, 연결이 끊길 때마다 턴이 중간에 취소돼. 답은 날아가고, 상태는 반쯤 쓰이고, 모델이 한 일은 돈만 내고 버려져. 같이 쓰는 방이라 더 나빠. 다음에 Chief를 여는 기기는 답 없는 질문을 보게 되니까.
턴과 연결을 떼어낸다
Chief 라우트는 순진한 스트리밍 핸들러가 한데 붙여놓는 일 두 개를 갈라. produce() 코루틴은 턴을 쥐어. 턴 락을 기다리고, 브레인의 원래 채팅 핸들러를 돌리고, 그 스트림을 asyncio.Queue로 다 받아내. 답은 그 과정에서 저장돼. 시작은 spawn_detached로 해. create_task에, 태스크가 끝날 때까지 강한 참조를 쥐는 걸 더한 거야 (참조 없는 태스크는 도는 중에 가비지 컬렉션될 수 있다고 파이썬 문서가 경고하거든). 그리고 앱의 background_streams 집합에 등록돼서, 정상 종료가 이걸 기다려줘. consume() 제너레이터는 연결을 쥐어. 큐를 읽고 조각마다 클라이언트로 내보내. 10초 동안 조용하면 : chief waiting을 내보내. Server-Sent Events의 주석 줄이라 클라이언트는 무시하지만, 턴이 줄 서 있거나 모델이 생각하는 동안 프록시랑 소켓이 스트림을 죽은 걸로 판정하지 않게 해줘.
폰이 끊기면 죽는 건 consume()뿐이야. produce()는 끝까지 받아내고, 답은 대화에 내려앉아. 엔진 테스트가 정확히 이걸 해. 턴 도중에 클라이언트 요청을 취소하고, 모델이 마저 끝내게 두고, 마지막 행이 "finished offline"인지 확인해. 2026-10-08 콜드 리뷰도 이 모양을 점검했고 문제없다고 했어.
나중에 묻되, 두 번 시키지 않는다
스트림을 놓친 클라이언트는 턴을 다시 시작하지 않고 무슨 일이 있었는지 물어야 해. 그래서 클라이언트마다 보내기 전에 요청 id를 찍어. Telegram은 tg-<chat>-<message>, WebUI는 webui-<uuid>, 피파 앱은 pm-turn-<uuid>, 워치는 pw-msg-<uuid>. 엔진은 영문자·숫자·대시·밑줄 8자에서 80자까지 받고, 메시지 테이블의 유니크 인덱스 덕에 id 하나는 턴 하나만 가질 수 있어. 피파 앱의 아웃박스 드레인이 기준 클라이언트야. 보내기 전에 엔진한테 이 턴 이미 있냐고 물어. completed면 저장된 답을 다시 틀고, pending이면 계속 확인하고, 모르는 id일 때만 모델 호출을 시작해.
이걸 정직하게 만든 상태 두 개가 있어. 둘 다 콜드 리뷰에서 나왔어. 락을 기다리는 턴은 아직 행이 없었어. 그래서 턴 상태가 404로 답했고, 클라이언트는 다들 그걸 "도착 안 했네, 다시 보내"로 읽어. 첫 사본이 아직 기다리는 중에 다시 보낸 클라이언트는 턴을 두 번 돌렸지 (2번 지적). 이제 기다리는 턴의 id는 대기 맵에 들어 있고, pending, queued: true로 읽혀. 그리고 첫 전송이랑 경주하듯 겹친 재전송은, 받아들여지고 나서 행이 이미 있는 걸 보고 status 이벤트로 already_accepted를 답해. 에러가 아니라 상태야. 네이티브 아웃박스는 error 이벤트를 영구 실패로 보고 그 턴을 격리하거든. 이미 답이 달린 턴들한테 정확히 그 짓을 했었어 (8번 지적).
철회는 중단이 아냐
기다리던 턴이 안 돌고 끝나는 길은 두 가지고, 클라이언트는 어느 쪽인지 알아야 해. 아빠가 Stop을 누르면 cancel_waiting이 그 대화의 stop 리비전을 올리고, 아직 기다리는 턴을 전부 취소해 (워커 보고는 대기 목록에 아예 안 올라가서, Stop이 보고를 철회하는 일은 없어). 프로듀서는 취소를 받고, 리비전이 움직인 걸 보고, error를 withdrawn 상태로 보내. 문구는 Stopped before it began: this turn was withdrawn and will not run.이고, 뜻은 '다시 보내지 마'야. 반대로 서버가 내려가는 중이면, 종료 절차가 먼저 withdraw_waiting_for_shutdown을 불러. 리비전은 그대로고, 프로듀서는 status를 interrupted 상태로 보내. 문구는 Interrupted before it began; this turn was not run. Send it again.이야. 아웃박스 클라이언트는 서버가 돌아오면 딱 그렇게 해.
그 종료 경로는 2번 지적 때문에 생겼어. 재시작이 돌던 턴을, 백엔드가 내건 30초 유예 중 1초쯤에서 잘라 버렸어. 런처한테 따로 고정 3초 마감이 있었거든. 줄 서 있던 턴은 그냥 사라졌고. 고친 커밋(f3bd5968)은 런처가 서비스의 실제 종료 시간 제한을 따르게 했고, 기다리는 턴을 먼저 철회해서 유예가 잘라버릴 모델 호출을 아무도 시작하지 않게 했어. Telegram은 끊긴 뒤 턴 상태부터 묻고 엔진이 안 받았을 때만 같은 tg- id로 다시 보내게 바뀌었고, WebUI는 보낼 말을 큐로 되돌려 보류하게 바뀌었어. 13번 지적 밑으로 작은 항목 둘이 뒤따랐어. stop 리비전은 이제 시작할 때 읽어 들이는 chief_stop_revisions 테이블에 살아. 재시작으로 0이 된 카운터가 모든 클라이언트에서 원격 Stop으로 읽혀서 모든 큐를 붙잡았거든. 그리고 steer는 이제 돌고 있는 턴만 끝내. 예전엔 아빠의 다른 기기에서 줄 서 있던 턴까지 철회했어.
에러는 뭐라도 말해야 해
마지막 항목은 제일 작고 제일 민망해. Chief 라우트 자체의 에러 이벤트는 message 키를 썼는데, 클라이언트는 전부 text를 읽어. WebUI엔 "Error: undefined"가 떴어. 이제 라우트가 직접 내보내는 에러와 상태 이벤트엔 전부 text가 실리고, 와이어 문구는 모듈 맨 위에 상수로 모여 있어. 그 위엔 문구를 거기 두게 만든 지적 번호가 주석으로 달려 있고.