빈 프롬프트가 검증자 36명을 침묵시킨 원인 사슬

26년 08월 31일

세션 아카이브, 규약을 역으로 맞추는 설계

pi 에이전트의 handoff 확장에 세션 스냅샷 기능을 붙였다.

지금 세션을 통째로 저장하는 /handoff-save, 아카이브에서 불러오는 /handoff 옵션, 멀티셀렉트 TUI로 지우는 /handoff-delete까지 세 커맨드다.

핵심은 저장 포맷 결정이었다.

아카이브 엔트리 안에 세션 jsonl과 같은 이름의 run 디렉토리를 나란히 두면 기존의 세션 파일에서 run을 조회하는 규약을 그대로 만족한다.

덕분에 clone과 summarize 코드를 거의 손대지 않고 재사용했다.

새 기능이 기존 코드의 조회 규약을 역으로 맞추는 게 침입 최소 경로라는 걸 몸으로 배웠다.

배포 뒤에 버그가 두 개 따라왔다.

하나는 빈 세션에서 저장을 누르면 ENOENT로 터지는 것.

pi는 세션 파일을 첫 대화 엔트리가 생길 때야 디스크에 쓰는데 조회 API는 존재 여부와 무관하게 경로를 미리 돌려준다.

경로를 받았다는 게 파일 실존을 보장하지 않으니 read 전에 existsSync 가드가 필요하다.

다른 하나는 복제 직후 안내문이 사용자 첫 요청 뒤에야 도착하는 UX 결함.

큐잉 방식을 다음 턴이 아니라 별도 턴 생성으로 바꿔서 순서를 잡았다.

스톨 재프롬프트가 28분째 큐에 갇힌 이유

검증 루프에서 재프롬프트가 TUI 입력 큐에 유저 요청 모양으로 쌓이는 사고를 해부했다.

전달 경로를 조사해보니 스톨 복구 지점만 구버전 전송 API를 쓰고 있었다.

이 API는 전송 시점 스냅샷이라 레이스에 구조적으로 취약하고, 중복 제거도 없다.

실측 로그를 보니 28분 동안 대기하던 시점에 3밀리초 차이로 낡은 피드백이 새 판정을 이기고 들어가 있었다.

시스템 발화와 사용자 입력이 같은 큐를 공유하는 게 설계의 핵심 약점이었다.

타임스탬프를 전송 시각과 생성 시각으로 분리해 비교하니 28분 큐잉이 증명됐다.

수정은 해당 호출을 삭제하고 본문 전체를 단일 시스템 notice로 병합하는 것.

검증자가 구현 전에 회귀 하나를 잡아줬다.

루프를 재개하면 재프롬프트 억제 게이트가 초기화되지 않아서 병합 후에는 재프롬프트가 유일한 깨우기 채널이라 루프가 영구 정지할 뻔했다.

검증자 무한루프의 진짜 원인

2.5시간째 12라운드를 도는 검증 루프의 원인 사슬을 완전히 규명했다.

검증자들은 매 라운드 ended without calling submit_verdict로 실패하고 있었다.

디스크 아티팩트를 열어보니 충격적인 게 있었다.

검증자 프롬프트 파일의 내용이 빈 문자열이었다.

루프로 시작한 세션은 목표를 확장이 별도 관리하므로 메인 세션에 user 메시지가 0개다.

프롬프트 생성 마지막 단계가 메시지가 없으면 빈 문자열을 반환하고, 그대로 36개 검증 브리프 전원이 조용히 비어서 나갔다.

더 문제는 fail-closed 가드의 사각지대다.

쓰기 실패는 디스패치를 차단하지만, 쓰기 성공에 내용이 빈 경우는 통과시킨다.

fail-closed 검증은 성공 여부가 아니라 내용이 비었는지를 검사해야 한다.

검증자 입장에서는 지시문이 없으니 도구를 안 부르는 게 정직한 행동이었다.

간헐적으로 성공처럼 보였던 건 도구 설명 문구만 보고 추측한 도박이 우연히 맞은 것.

여기에 판정 전원일치 리셋이 겹쳤다.

한 명이라도 판정을 놓치면 전원 초기화라 라운드 성공 확률이 관측치 기준 약 0.5퍼센트.

사실상 수렴이 불가능한 구조였다.

이터레이션 제한과 스톨 감지 안전망도 각각 다른 실패 모드만 가정해서 이 간극을 통과했다.

무한 방지 카운터는 성공 실패와 무관하게 라운드 수 기준으로 하나 두는 게 안전하다.

갤러리 모달, 얕은 병합의 세 얼굴

개인 프로젝트에서 갤러리 크게 보기 모달을 완결했다.

Tailwind v4에서 w-[min(96vw,1280px)]를 지정했는데 CSS가 아예 생성되지 않는 버그부터 만났다.

임의값 안의 콤마 파싱 문제라, 폭을 분해해서 w-[92vw] max-w-[1800px]로 바꿨다.

캐러셀 스크롤바는 3중 버그를 고치고 나서도 혼란 요소라고 판단해 완전히 제거했다.

네비 버튼과 썸네일 클릭, 선택 아웃라인으로 충분하다.

트리거 컴포넌트를 래퍼로 전환하면서 cloneElement 얕은 병합 버그를 세 개 연달아 만났다.

자식의 className이 슬롯 클래스로 통째 교체되는 것, children: undefined 키가 자식 내용을 덮어써서 아이콘이 사라지는 것, absolute 배치가 inline display를 blockify해서 중앙정렬이 무효화되는 것까지.

전부 같은 원인의 다른 얼굴이었다.

안 보이는 UI를 디버깅할 때는 스크린샷보다 getComputedStyle와 getBoundingClientRect 실측이 원인을 바로 특정해준다.

린트 래칫 첫 실전 집행과 정책 전환

준수 라인 스캐너를 문서에서만 살아있는 유령인 채로 두지 않고 부활시켰다.

재작성 전에 git 로그의 삭제 이력을 먼저 뒤져서 왜 죽었는지부터 확인했다.

죽은 이유를 모르면 같은 이유로 다시 죽는다.

라인 수 상한 래칫도 첫 실전 집행을 봤다.

배포 커밋이 신규 파일 상한 초과로 차단됐고 우회 없이 등록 절차를 밟으면서 322줄이던 갤러리 파일을 287줄로 분할해 항목을 소진했다.

등록부터 성장 차단, 분할 소진, 항목 삭제까지 래칫 라이프사이클을 전부 완주한 셈이다.

그런데 래칫에도 맹점이 있었다.

총량만 보니까 부채가 다른 위치로 이동해도 통과한다.

그래서 정책을 바꿨다.

커밋에 포함된 파일에 치환 가능한 값이 있으면 그 파일의 것만 전량 나열하고 차단한다.

손댄 파일만 클린하면 된다는 규칙이라 장부 관리도 필요 없어졌다.

데드코드 5,689줄 삭제

개인 프로젝트에서 100파일에 걸친 전수 정리 PR을 끝냈다.

일회성 배치 산출물과 재실행할 주체가 없는 수동 마이그레이션 SQL 13종, 쓰이지 않는 익스포트까지 지웠다.

러너 없는 수동 SQL은 아카이브하지 않고 삭제한다는 정책을 문서로 남겼다.

핵심은 검증 방식이었다.

삭제한 스크립트가 정말 아무 데서도 호출되지 않았다는 걸 로컬 정적 분석과 인프라 잡 목록 실측 양쪽에서 교차 검증했다.

한쪽만 믿었다면 놓쳤을 것이다.

남는 생각

하루를 관통한 키워드는 조용한 실패다.

빈 프롬프트는 에러 없이 조용히 잘못된 결과를 만들고, 얕은 병합은 타입 에러 없이 조용히 UI를 지운다.

쓰기 성공을 검사하는 가드는 내용이 비었는지는 못 본다.

그래서 오늘 추가한 검증은 전부 실측이다.

타임스탬프 분리, 디스크 아티팩트 직독, 스타일 대신 렌더링 좌표, 정적 분석과 인프라 교차 검증.

다음에는 빈 프롬프트 방어막과 폴백 브리프 주입을 구현해야 한다.

원인을 아는 것과 막는 것 사이에는 아직 커밋 하나만큼의 거리가 있다.