이 트랙의 규칙은 전부 대화가 언젠가 끝난다고 믿었어
대화 하나에 JSONL 파일 하나. GET 할 때마다 로그를 다시 읽는 healing. transcript 를 통째로 받아서 그리는 클라이언트. 다 시작하고, 한동안 이어지고, 멈추는 대화를 상대로 쓴 규칙이야. 그런데 2026년 10월에 집안에 안 멈추는 대화가 하나 생겼어. 모든 앱의 문에서 같이 쓰는 아빠의 기본 피파 대화. 왜 생겼는지는 Master Chief 퀘스트가 따로 다뤄. 이 대화는 이틀 만에 16.4 MB, JSONL 47,775줄이 됐어.
요란하게 깨진 건 없었어. 읽는 쪽이 하나같이 계속 자라기만 할 기록에 비례해서 느려졌고, 그중 몇은 조용히 뭔가를 흘리기 시작했지. 뒤이은 cold review(coop todo #351)가 문장 하나를 코드베이스의 규칙으로 박았어. 대화에 딸린 건 무엇도 끝없이 자라면 안 돼. 고친 방법은 범용이야. 그 대화 하나만이 아니라, 충분히 길어지는 모든 대화에 똑같이 걸려.
로그가 파일 여러 개로 이어지게 됐어
지금 쓰는 파일은 원래 경로 sessions/{conversation_id}.jsonl 그대로고, 쓰는 쪽은 전부 예전처럼 거기에 덧붙여. SessionLogger 가 이 프로세스에서 다른 logger 가 안 잡고 있는 대화를 열었는데 지금 파일이 PIPPA_JSONL_SEGMENT_BYTES(8 MiB)에 닿아 있으면, 먼저 봉인해. 파일을 통째로 sessions/{conversation_id}.segments/{seq:06d}.jsonl 로 옮기고, 새 파일은 pippa_segment_sealed 한 줄로 시작해. 턴 하나는 logger 하나가 쓰니까 턴이 파일 두 개에 걸칠 일이 없고, 봉인된 세그먼트는 다시는 안 바뀌어.
함정은 읽는 쪽에 있어. 기록 전체를 읽는다면서 sessions/*.jsonl 로 glob 하면 지금 파일만 보이고, 봉인된 건 소리 없이 다 빠져. 그래서 읽기는 store/session_logger.py 를 거쳐. 기록 전체는 conversation_jsonl_paths / read_conversation_events(내보내기, 복구 훈련, 아카이브 sweep, purge), 일찍 멈출 수 있는 읽기는 iter_conversation_files_newest_first(healing, tool-use 채우기), 저장소 전체를 도는 일은 all_session_jsonl_files. 집 안내문에 gotcha 로 적혀 있는 이유는, 틀린 glob 도 멀쩡히 컴파일되고 파일도 멀쩡히 돌려주기 때문이야.
압축은 replay 에서 빼지, 기록에서 지우지 않아
모든 두뇌는 턴마다 대화 기록을 다시 읽어(vessels 트랙 레슨 1). 끝나지 않는 대화라면 replay 도 끝없이 길어지면 곤란하니까, 오래된 행을 요약해서 replay 줄에서 빼. 첫 버전은 그 행들을 옆 테이블에 보관하고 지웠어. 키로 찾는 조회가 한꺼번에 다 그 행을 잃었지. 요청 id 중복 거르기, 보고서 멱등성, 턴 상태, 기억 엔진의 포인터까지.
실제로 나간 버전은 그 자리에 표시만 해. ConversationStore.compact_messages 가 compacted_at 이랑 compacted_by(요약 행의 id)를 적어. get_messages 와 healing 은 살아 있는 행만 읽고, 키로 찾는 조회는 압축된 행도 여전히 찾고, 대화를 지우면 그 행도 같이 가고, 벡터엔 compacted: true 가 붙어서 같은 대화 안에서는 검색으로 여전히 닿아. JSONL 엔 요약 글을 실은 pippa_compaction 표식이 남고. 기록은 온전하고, 짧아진 건 replay 뿐이야.
바이트 상한이 있는 페이지
GET /api/conversations/{id} 는 아직 있어. 다만 긴 대화를 그걸로 그리는 클라이언트는 이제 없어. GET /api/conversations/{id}/page 가 화면용 transcript 를 창 하나씩(limit, before, start) 내주고, 서버는 창마다 행 수뿐 아니라 바이트로도 상한을 걸어. 행이 몇 개든 JSON 512 KiB(PAGE_BYTES_MAX)까지야. 창은 최신 쪽에서 고르고, 맞을 때까지 가장 오래된 쪽 행부터 빼. 그래서 최신 행은 언제나 와. 페이지 자체는 읽는 순서, 그러니까 오래된 것부터 돌려주고, earlier 가 다음 페이지로 남은 수를 세. 상한보다 큰 행 하나는 혼자, 통째로 나가. 바이트 상한은 창을 나눌 수는 있어도 메시지를 자르진 않아. 행 개수로는 모자랐어. 압축 요약 행 하나 때문에 최신 200행이 2.47 MB가 됐고, 폰이 그걸 디코드하다가 아빠의 맥북 턴 한 덩어리를 떨어뜨렸거든(coop todo #374). 압축 요약은 replay 상태라서 페이지에는 아예 안 올라와. WebUI 와 공용 chat hook 을 거치는 모든 Sidekick 임베드, 피파 앱, Telegram 동기화가 이제 다 페이지로 읽어.
healing 은 턴이 건드렸을 만한 곳만 읽게 됐어
레슨 4는 어차피 JSONL 을 읽으니까 healing 이 싸다고 했지. 세그먼트가 생기면 'JSONL' 이 파일 여럿일 수 있어. tool-use 채우기는 찾을 도구가 없는 답이 GET 될 때마다 그 파일들을 서빙 루프 위에서 전부 읽고 있었어. 그래서 두 가지가 바뀌었어. GET 시점의 수리는 이제 스레드에서 돌고, 그 턴이 건드렸을 만한 파일까지 읽으면 멈춰. 그리고 turn_in_progress() 를 기다려. 스트림이든, cron 턴이든, lock 을 쥔 Chief 턴이든, 받아들여졌는데 아직 첫 이벤트가 안 나간 파이프라인이든. 준비 단계가 스트림 표시보다 먼저 돈 턴이 한 번은 끊긴 턴으로 치유됐고, 진짜 답이 형제 가지로 하나 더 붙었어.
아카이브는 조각을 써
append-only 대화 아카이브는 원래 돌 때마다 대화를 통째로 복사했어. 제일 긴 대화라면 매일 전체 기록을 또 한 벌. 이제 첫 아카이브 파일만 대화 전체고, 그다음부터는 아카이브에 아직 없는 메시지만 담은 조각이야. 그 내역은 이미 읽은 세그먼트와 함께 디렉터리의 .archive-manifest.json 에 적혀. 그리고 세션 로그를 못 읽는 곳에선 sweep 이 아예 안 돌아. 로그가 빠진 아카이브를 조용히 쓰는 것보다 그게 나아.