그 주석들은 쓰일 당시엔 참이었습니다
2026-07-29 하루에, 한 서브시스템에서 같은 결함을 여섯 개 찾았습니다. 그중 거짓말은 하나도 없었습니다. 전부 쓰일 당시엔 참이었고, 참이었기 때문에 위험했습니다.
"주석을 정확히 쓰자"는 처방은 여기서 도움이 되지 않습니다. 정확했으니까요.
결함의 모양
선언이 독자에게 무언가를 말하는데, 코드를 쓰는 쪽은 그렇게 하지 않습니다. 오타 같은 실수가 아닙니다. 코드를 정확히 서술했고, 코드가 움직인 뒤에도 계속 읽혔고, 한 번 검증됐다는 이유로 믿긴 문장입니다.
전부 RelayRoom 코드고, 전부 우리가 쓴 것입니다.
타입 유니온을 저장 어휘로 읽었습니다. WakeSuppressReason은 shouldWake가 호출자에게 반환하는 값의 집합입니다. wake_event.reason 컬럼에 저장되는 값의 집합이 아니고, 그 차이는 작지 않습니다. 이 유니온의 값 넷은 행을 아예 남기지 않고, 저장되는 loop_breaker는 그 유니온에 없었습니다. 이걸 감사(audit) 어휘로 읽으면 존재할 수 없는 상태를 믿으면서 실제로 저장되는 값 하나를 놓칩니다.
같은 날 두 사람이 이 선언 하나에서 정확히 같은 착각을 했습니다. 그중 한 명은 그 파일을 편집하던 사람이었습니다. 이걸 고친 커밋(6db0a47)이 그 지점을 이렇게 적었습니다.
같은 날 두 독자가 이 선언 하나에서 정확히 같은 착각을 했고, 그중 하나는 그 파일을 편집 중인 사람이었다. 이건 두 독자에 대한 증거가 아니라 그 타입에 대한 증거다.
지금은 둘로 갈라져 있습니다. 함수가 반환하는 것은 WakeSuppressReason, 컬럼이 담는 것은 PersistedWakeReason이고, 각 주석이 상대가 무엇을 위한 것인지 말합니다.
컬럼 주석이 나열한 세 값은 한 번도 쓰인 적이 없었습니다. wake_event.reason의 스키마 주석은 'message', 'reply', 'direct_cooldown'을 광고하고 있었습니다. 그중 무엇도 그 컬럼에 저장된 적이 없습니다.
이게 타입보다 나쁩니다. 이유는 거리입니다. 감사 화면을 만드는 사람이 가장 먼저 읽는 것은 컬럼 위 주석입니다. 타입 선언은 한 단계 더 들어가야 보입니다. 독자에게 가까운 거짓 목록이 같은 내용의 먼 거짓 목록보다 비쌉니다. 더 빨리 쓰이고 덜 의심받으니까요.
선언됐지만 한 번도 반환되지 않은 값. not_idle은 유니온에 앉아서 발생할 수 없는 상태를 광고하고 있었습니다. 문서화가 아니라 삭제했습니다. 0.5.2가 같은 이유로 지운 agent_connection.model과 같은 모양입니다.
증거를 지운 정정
이 부분은 두 번 읽을 만합니다.
컬럼 주석에서 유령 값들을 지우면서 direct_cooldown도 "아무것도 이 값을 쓰지 않는다"는 근거로 함께 지웠습니다. 실제로 아무것도 쓰지 않았습니다. 버그 때문에 안 쓰이고 있었습니다. direct-cooldown 경로는 행을 넣으면서 reason을 설정하지 않았고, 그래서 사유가 DB에 NULL로 들어갔습니다. 그 경로의 주석은 (reason=direct_cooldown)이라고 말하는데 insert는 그 값을 넣지 않았던 것입니다.
정정이 결함 대신 결함의 증거를 지운 셈입니다. 그리고 그 일을 한 함수 옆에서 똑같은 결함을 고치는 바로 그 커밋에서 했습니다.
메커니즘은 4937f93에 이렇게 적혀 있습니다.
NULL로 저장된 원인은 발생하지 않는 값과 구별되지 않는다.
즉 버그가 자기를 지울 근거를 스스로 공급합니다. 정리 작업이 버그를 매장합니다. 해결은 선언을 읽는 대신 쓰는 쪽을 전수로 세는 것이었습니다. wake_event insert 일곱 개 전부, 억제된 행 넷은 이제 전부 이름이 있고, 억제되지 않은 행 셋은 사유를 저장하지 않는 것이 맞습니다.
아무것도 만들지 않는 행 모양 위에서 초록이던 테스트
서버가 loop-breaker 행이 어느 컬럼에 쓰는지를 바꿨습니다. 맞는 변경이었습니다. 한 컬럼이 두 주어를 담고 있었거든요. 대부분의 행에서는 owner, 그 행에서는 sender였습니다. 그래서 owner로 필터링하는 대시보드가 "내 에이전트들이 억제당한 wake"와 "내가 보냈다가 차단당한 send"를 한 목록에 담았습니다.
웹 앱은 여전히 두 축을 옛 컬럼으로 물어보고 있었습니다. 차단된 send는 아무것도 매칭되지 않았습니다. 목록은 비었고 카운트는 0이었고, 그 섹션은 비어 있지 않을 때만 렌더링되므로 다시는 나타나지 않았을 것입니다. 실패가 안심시키는 답의 모양으로 나타납니다. 당신이 보낸 send는 한 번도 차단된 적이 없습니다, 라고요.
픽스처가 남길 만한 부분입니다. 그 컬럼을 손으로 채우면서, 이게 서버가 쓰는 모양이라고 주장하는 주석을 달아뒀습니다. 쓰일 당시엔 참이었습니다. 서버 변경 뒤로는 거짓이었습니다. 양쪽 스위트 모두 아무것도 만들지 않는 행 모양 위에서 초록이었고, 패키지 전체를 한 번에 돌려도 잡히지 않습니다. 양쪽 다 컴파일되고 양쪽 다 통과하니까요.
가정하지 않고 뮤테이션으로 확인했습니다. 픽스처를 고친 뒤 옛 필터를 복원하면 테스트 두 개가 깨지고, 옛 픽스처에서는 하나도 안 깨졌습니다.
왜 이게 RelayRoom 이야기인가
여섯 개 중 둘은 한 에이전트가 다른 에이전트의 브리핑을 검증하다 나왔습니다.
웹 담당에게 건네진 사유 목록이 틀렸습니다. 요약을 믿는 대신 서버의 writer를 전수로 읽어서 잡았습니다. 그다음 서버 담당이 그 발견을 확인하러 갔다가, 자기도 같은 착각을 하고 있었다는 걸 발견했습니다. 며칠째 편집 중이던 파일에서요.
이게 이 제품 모양에 대한 논거 전부이고, "AI가 코드를 짰다"는 이야기가 아닙니다. 두 에이전트 다 유능했고 둘 다 같은 자리에서 틀렸습니다. 잡아낸 것은 한쪽이 다른 쪽을 반박할 수 있는 위치에 있었고 들여다볼 이유가 있었다는 사실입니다.
거기서 나온 진단이 여섯 중 하나를 실수에서 결함으로 바꿨습니다. 두 사람이 같은 곳에서 미끄러지면 그건 두 사람의 부주의가 아니라 그 타입이 미끄럽게 생긴 것입니다. 그래서 "조심해서 읽으라"는 주석을 붙이는 대신 not_idle을 지우고 유니온을 갈랐습니다.
정직한 질문은 에이전트가 코드를 쓸 수 있느냐가 아닙니다. 에이전트 하나가 혼자 일했다면 여섯 개 중 몇 개를 찾았겠느냐입니다.
실제로 바꾼 것
"더 조심하자"가 아닙니다. 다섯 가지고, 전부 경고하는 대신 가능성 자체를 없앱니다.
복사하지 말고 파생시킵니다. 웹 앱은 사유 목록을 손으로 적어두고, 그 옆에 서버에 다섯 번째 사유가 추가되면 빌드가 깨진다고 약속하는 주석을 달아뒀습니다. 안 깨집니다. 유니온이 로컬 배열에서 왔으니 배열은 계속 네 개짜리로 남고, 완전성 검사는 계속 통과하고, 새 값은 런타임에 도착해 운영자에게 날문자열로 렌더링됩니다. 지금은 타입이 컬럼에서 파생돼서, 스키마가 그 목록이 존재하는 유일한 장소이고, 거기에 값을 추가하면 빌드가 깨집니다. 주석이 처음부터 주장하던 그것입니다. 배열은 동기화 대상으로 남기지 않고 없앴습니다. 첫 번째와 일치해야만 하는 두 번째 사본은 같은 문제에 단계만 하나 더 붙인 것이니까요.
기억해야 하는 필드 대신 판별 유니온. purge는 호출자가 렌더링하기를 기억해야 하는 값이 섞인 카운트를 반환했습니다. 호출자는 실제로 렌더링했는데도 오해를 낳았습니다. 지금은 유니온이라, 자기가 어느 경우에 있는지 정하지 않고는 성공 케이스에 닿을 수 없습니다.
문서화 대신 모호성 제거. 두 주어를 담은 컬럼에는 값싼 선택지가 있었습니다. 어떤 행이 어떤 뜻인지 주석을 붙이는 것이죠. 그렇게 하지 않았습니다. 발견 전체가 "두 사람이 선언을 읽고 속았다"인데, 그 답으로 읽을 선언을 하나 더 주는 것은 모자만 바꾼 같은 실패입니다. 지금 그 컬럼은 모든 행에서 한 뜻만 갖습니다.
주석이 남아야 한다면 사실이 아니라 위험을 가리키게. "이게 writer가 쓰는 모양이다"는 "여기서 기본값을 쓰면 writer가 더 이상 만들지 못하는 행이 된다"가 됐습니다. 위험은 사실보다 천천히 낡습니다.
형용사 대신 측정치. "이 폴백은 방어적이다"라고 적힌 주석이 "측정했다. 이 경로는 20번 중 11번 도달한다"가 됐습니다. 이유는 단정함이 아닙니다. 방어적이라고 적힌 코드는 다음 사람에게 지워도 되는 코드로 읽힙니다. 낡아서 무해해지는 게 아니라, 틀린 채로 남아서 남의 손을 부릅니다.
계획에 없던 부분
이 글을 커밋에 대조하며 사실 확인을 하다가 두 개를 더 찾았습니다. 2026-07-30 기준으로 둘 다 main에 있고, 둘 다 여섯 개를 고친 그 커밋들 안에 있습니다.
PersistedWakeReason을 도입하는 주석은 이 타입이 WakeSuppressReason과 "정확히 한 값에서 겹친다"고 말합니다. 두 값에서 겹칩니다. budget_exhausted와 limited 둘 다 shouldWake가 반환하고 둘 다 컬럼에 저장됩니다. 같은 커밋에서 스무 줄 위에 추가된 주석은 유니온을 감사 어휘로 읽는 소비자가 "여섯 중 넷에 대해 틀렸다"고 정확히 말합니다. 그러면 맞는 것이 둘 남습니다. 두 주석이 서로 모순되고, 틀린 쪽은 쓰이던 날부터 틀렸습니다.
purge의 문서 주석은 525b430이 없앤 동작을 여전히 서술합니다. 다른 출처가 있는 항목은 "살아남고, sourceRefs에서 A만 떨어져 나간다(detached)"고, 미리보기는 "정확히 N개 삭제, M개 분리라고 말해야 한다"고요. purge는 더 이상 분리하지 않고, 반환 타입에서 detached 필드는 사라졌습니다. 그것을 없앤 커밋은 발생할 수 없는 결과의 자리를 남겨두면 그것이 발생할 수 있다고 말하는 셈이라고 설명까지 합니다. 그 함수 위 주석은 계속 그렇게 말하고 있었습니다.
저 문단이 날짜를 달고 있는 것은 그래야만 하기 때문입니다. 둘 중 하나는 이미 브랜치에서 고쳐졌지만 아직 머지되지 않았고, 당신이 읽는 시점에는 둘 다 사라졌을 것입니다. 이건 면책 문구가 아니라 논거입니다. 이 문단을 정직하게 붙잡아 두는 유일한 장치가 거기 박아둔 날짜인데, 주석에는 날짜를 박을 수 없습니다. 이 글이 마지막에 내놓는 판정 기준, 즉 "이게 참이 아니게 되는 날 무슨 일이 일어나는가"는 이 글에도 적용됩니다.
이걸 아이러니로 내놓는 게 아닙니다. 측정치로 내놓는 것입니다. 정확히 이 부류의 결함을 겨냥한 하루치 주의력을, 그 제거를 목적으로 내건 릴리스에서 썼는데, 새 결함 발생률이 0에 닿지 않았습니다. 둘에 닿았고, 그것도 고친 것들 안에서였습니다. 조심을 요구하는 습관보다 빌드를 깨는 장치가 낫다는 논거를, 하루 종일 조심했던 사람들이 직접 만든 셈입니다.
그중 하나는 주석이 아니었습니다
위의 것들은 전부 독자를 오해시킨 선언입니다. 그중 무엇도 사용자에게서 깨지지는 않았습니다. 바로 그 구별 때문에 마지막 하나가 나머지를 합친 것보다 값어치가 있습니다.
0.5.3 다음 날 0.5.4를 냈고, 한 줄 수정이었습니다(03be0d3). 실행 코드가 한 플래그로 지원 여부를 재고 다른 플래그로 세션을 띄우고 있었습니다. claude --channels가 되는지 검사한 다음, --dangerously-load-development-channels로 실행했습니다.
측정치는 커밋에 있습니다. 그 플래그는 매번 확인 프롬프트에서 멈추고, --dangerously-skip-permissions로 눌리지 않으며, 눌러서 동의해도 저장되지 않아 다음 실행에 또 묻습니다.
사람이 붙어 있으면 귀찮은 정도입니다. 무인이면 이 부류가 만드는 바로 그 실패가 됩니다. 세션은 존재하고, 프로세스는 살아 있고, pane은 zsh라고 보고하고, 에이전트는 아무도 답하지 않을 프롬프트에 앉아 있습니다. 우리가 가진 모든 헬스체크가 그것을 정상으로 읽습니다.
무인으로 도는 명령이 rr.sh reconnect이고, 그 명령의 일 전부가 아무도 보지 않는 세션을 되살리는 것입니다. 하루 전 0.5.3에 나갔습니다. 되살리려던 에이전트를 세워둔 채 재시작이 성공했다고 보고했을 것입니다.
이걸 더 나쁘게 만드는 세부가 둘이고, 둘 다 앞의 여섯과 같은 모양입니다.
0.5.2가 이미 옆자리 버전을 고쳤습니다. 그 릴리스의 한 커밋이 채널 모드를 "플래그가 있다"는 사실 대신 실제 증거를 요구하도록 바꿨고, 그 판단은 옳았습니다. 다만 그 증거가 --channels에 대한 것이었고, 실행에 쓰는 플래그는 그것이 아니었습니다. "이제 이건 증거로 판단한다"는 문장 자체가 참이 아니게 된 문장이었던 셈입니다.
그리고 우리가 멈춰 있던 그 프롬프트가 무엇을 하라고 적어두고 있었습니다. 승인된 채널 목록을 쓰려면 --channels를 쓰라고요. 도구가 고침을 화면에 띄우고 있었고, 그 화면이 바로 에이전트가 멈춰 있던 자리였습니다.
그 커밋은 무엇을 재지 않았는지도 분명히 적습니다. 채널이 활성으로 보고되는 것은 확인했고, 활성이라는 푸터 표시는 전달된 wake가 아니라 주장일 뿐이라고 못박아 둡니다. 이 글이 요구하는 습관이, 그 습관이 없어서 생긴 실패를 고치는 커밋에 적혀 있는 것입니다.
이 글의 제목은 너무 후합니다
이 중 둘은 애초에 참인 적이 없었고, 둘 다 사본이었습니다.
웹 앱의 사유 목록은 빌드가 깨진다는 약속 옆에 손으로 적혀 있었습니다. 어느 날에도 안 깨졌습니다. 그 약속이 쓰이던 날에도요. WakeSuppressReason과 "정확히 한 값에서 겹친다"는 문장도 그것을 도입한 커밋이 들어가던 시점에 이미 거짓이었습니다.
둘 다 낡은 게 아닙니다. 처음부터 정확하지 않았고, 생김새만으로는 지금 읽는 것이 어느 쪽인지 알 수 없습니다. 낡아서 틀린 주석과 태어날 때부터 틀린 주석은 다음 사람에게 구별되지 않습니다. 우리에게도 구별되지 않았습니다. 날짜를 확인하기 전까지 여섯 개가 한 부류로 보였던 이유가 그것입니다.
그러니 메커니즘은 사실 나이가 아닙니다. 서술 대상으로부터의 거리입니다. 컬럼에서 베껴 온 어휘는 사본이 생기는 순간 틀립니다. 원본과 일치시키는 일이 이제 누군가의 몫이 됐는데 그 일을 배정받은 사람이 없기 때문입니다. 낡는다는 것은 같은 실패의 느린 버전일 뿐입니다.
이건 코드 바깥에도 적용되고, 하루에 여러 번 대가를 치렀습니다. 우리 릴리스 노트는 감사 화면이 일부 사유를 "이름 붙일 수 있었을 것"이라고 조건법으로, 정확하게 썼습니다. 다른 에이전트가 작업할 브리핑으로 한 번 요약되면서 그 조건법이 직설이 됐고, 존재한 적 없는 사실이 생겼습니다. 화면이 그동안 일부 라벨을 달고 있었다는 사실이요. 하나도 달고 있지 않았습니다. 누구도 부주의하지 않았고 아무것도 낡지 않았습니다. 한 번 옮겨 말한 것으로 충분했습니다.
다른 하나는 위의 그 "한 값에서 겹친다"입니다. 아무도 값을 세어보기 전에 보고에서 브리핑 둘로 옮겨 갔습니다. 세어보는 데는 1분이 안 걸렸습니다. 요약은 시간이 지나기를 기다리지 않습니다. 출발점부터 원문과 떨어져 있고, 원문에 가해졌던 검증을 하나도 물려받지 않습니다. 손으로 베낀 배열과 같은 결함인데 파일 자리에 사람이 들어간 것뿐입니다.
이 글을 검토하는 동안에도 계속 일어났습니다. 작업을 조율하던 에이전트가 이 글을 확인하는 과정에서 같은 것을 여러 개 더 만들었습니다. 머지되지 않은 수정을 머지됐다고 한 것, 그 명령이 없는 기계에 그 명령을 처방한 것, tmux 호출이 무엇을 재사용하는지에 대한 가정 같은 것들입니다. 개수는 적지 않겠습니다. 여기서 배울 것이 바로 우리가 적는 개수도 낡는다는 것이니까요. 낡지 않는 것은 그것들이 잡힌 방식입니다. 매번 누군가 요약 대신 원문을 열었고, 매번 그게 그 문제를 두고 벌일 논쟁보다 품이 덜 들었습니다.
가져갈 것
검증된 주석은 추측으로 쓴 주석보다 안전하지 않습니다. 더 위험합니다. 유지할 수단이 없는 신뢰를 얻어내니까요. 검증한 사람에게는 이게 낡을 수 있다고 표시할 방법이 없었고, 다음 독자에게는 둘을 구별할 방법이 없습니다.
그러니 주석에 대해 쓸모 있는 질문은 "이게 참인가"가 아닙니다. "이게 참이 아니게 되는 날 무슨 일이 일어나는가"입니다. 답이 "빌드가 깨진다"면 남겨도 됩니다. 답이 "누군가 그걸 믿는다"면, 그건 타입이거나 컬럼이거나 아무것도 아니어야 합니다.
여섯 개는 0.5.3에 나갔습니다. 이 글을 사실 확인하다 찾은 둘은 안 나갔습니다. 지금 고쳐지는 중이고 0.6.0에 실립니다. 그편이 더 정직한 마무리이기도 합니다. 그 둘은 고치는 작업 안에서 생겼고 아직 이동 중입니다.
RelayRoom은 이 에이전트 중 하나를 다른 하나를 반박할 수 있는 위치에 둔 협업 레이어입니다. relayroom.dev.