어제는 코딩 에이전트 환경의 확장 업데이트를 돌다가 기묘한 상태를 만났다. 업데이트 명령은 EXIT=0으로 끝났고 로그에는 에러가 한 줄도 없었다. 그런데 설치된 버전을 확인하면 전부 그대로였다. 성공했다고 보고한 프로세스가 아무 일도 하지 않은 것이다. 하루 종일 이 조용한 실패를 추적하고 내 검증 도구들이 얼마나 쉽게 거짓말을 하는지도 같이 확인했다.
pnpm minimumReleaseAge의 무음 변형
사건의 전말은 이렇다. 코딩 에이전트에서 쓰는 확장 5종의 새 버전이 나와서 업데이트 명령을 실행했다. pi-web-access, pi-subagents, pi-mcp-adapter 같은 확장들인데 최신 버전이 존재하는 것까지는 확인한 상태였다. 명령은 성공 종료됐다. 그리고 아무것도 설치되지 않았다.
원인을 찾아 소스를 따라 내려갔다. 에이전트 코어의 dist 코드를 열어 최신 버전 조회와 설치 판정 로직을 확인했다. 조회는 pnpm view 기준으로 정상 동작하고 있었다. 즉 레지스트리는 최신을 말하고 있는데 설치만 일어나지 않는다. 조회와 설치가 어긋난 상태였다.
범인은 pnpm v11의 공급망 보호 기능이었다. minimumReleaseAge라는 설정인데 발행된 지 24시간이 안 지난 패키지는 위험 패키지로 간주해서 설치를 미룬다. 문제의 확장 5종은 전부 발행 2.6시간에서 5.9시간 전의 신선한 패키지였다. 게이트에 걸린 것이다.
이유가 웃긴 건 여기부터다. 같은 기능을 예전에 만난 적이 있는데 그때는 에러 메시지와 함께 설치가 거부됐다. ERR 코드가 뜨니 원인 파악이 어렵지 않았고 정리도 해뒀다. 그런데 이번엔 완전히 무음이었다. 에러도 경고도 없이 EXIT=0. 에이전트가 내부적으로 —ignore-workspace 플래그와 @latest 스펙으로 설치를 돌리는데 이 조합이 프로젝트 설정을 무시하고 기본값 24시간을 다시 적용한다. 결국 신선한 패키지가 에러 없이 조용히 미뤄지는 경로로 빠진 것이다.
같은 root cause가 환경에 따라 두 얼굴을 한다는 게 핵심이다. 에러로 시끄럽게 외치는 변형을 해결해도 무음 변형에는 면역이 생기지 않는다. 진단 시그니처를 하나 남겨둔다. pnpm view는 최신을 말하는데 설치가 안 된다. 그리고 최신 발행이 24시간 이내다. 이 조합이 보이면 이 함정이다.
해법은 CLI 플래그 폴백이다. 설치 명령에 —config.minimumReleaseAge=0을 직접 주면 —ignore-workspace가 무시하지 못한다. 프로젝트 설정 파일과 달리 명령줄에 직접 주는 값이라 통과한다. 이걸로 5종 전부 최신으로 반영했다.
검증 파이프라인의 오탐 4종
설치를 반영했으면 끝이 아니라 검증이 필요하다. 그런데 이 검증 과정에서도 함정이 네 개나 터졌다. 전부 검증했다는 결론 자체를 무너뜨리는 종류의 문제였다.
첫 번째는 파이프라인의 exit code다. 타입체크 명령을 파이프로 tail에 물리고 종료 코드를 봤더니 0이었다. 통과한 줄 알았는데 $?가 캡처하는 건 파이프 마지막 커맨드인 tail의 종료 코드였다. 타입체크는 실패하고 있었는데 tail이 성공해서 전체가 성공으로 변환된 것이다. 처방은 set -o pipefail이다. 파이프 없이 출력을 파일로 받은 다음 종료 코드를 확인하는 방법도 있다.
두 번째는 PATH 문제였다. 에이전트의 bash 환경에는 pnpm 전역 bin 디렉터리가 PATH에 없었다. 전역 패키지 설치 명령이 계속 거부됐다. 해당 경로를 세션에서 export한 뒤에야 해결됐다.
세 번째는 워크스페이스의 -w 플래그다. 루트 워크스페이스에서 패키지 제거는 성공하는데 -w 없는 재추가는 거부된다. 제거만 성공한 상태로 방치되면 package.json에서 의존성이 빠진 채 남는다. 재추가 시 -w를 붙이고 의심스러우면 git diff로 즉시 확인해서 복구했다.
네 번째가 가장 인상적이었다. 의존성을 올렸더니 타입체크 에러가 42개 보였다. 이게 새로 생긴 건지 원래 있던 건지 판단이 안 됐다. 그래서 이전 버전으로 강제 고정해서 동일 명령을 다시 돌렸다. 에러가 정확히 42개로 동일했다. 전부 사전에 존재하던 에러였고 내 변경의 영향은 0으로 입증됐다. 뭘 바꿨더니 에러가 보이는 상황에서 이전 버전으로 되돌려 에러 수를 비교하는 게 가장 저렴한 인과 확인법이다.
검증 배터리도 이 과정에서 다듬었다. 타입체크 비교는 에러 개수가 아니라 출력 byte를 비교한다. 파이프로 마스킹된 출력이 개수 비교를 속일 수 있어서다. 정적 검증이 끝나면 서브에이전트를 실제로 띄우는 스모크 테스트를 별도로 돌린다. 로드된 것처럼 보이는 것과 실제 로드는 다르다. 마지막으로 의존성 버전이 분리되지 않았는지 단일성 체크를 넣었다.
검사 도구 자체가 오탐이었다
하루의 절반은 내 도구가 틀리고 있었다. 에이전트 설정 파일들의 상대 경로가 잘못됐다는 드리프트 경고가 떠 있었다. 경로가 dangling이라는 판정이었다. 그런데 실제로 디렉터리를 열어보면 다 존재했다.
원인은 검사 스크립트 쪽이었다. 에이전트 런타임은 설정 파일의 상대 경로를 루트가 아니라 그 파일이 있는 디렉터리 기준으로 해석한다. 검사 스크립트는 루트에 앵커를 두고 path.join으로 전개했다. ../로 시작하는 경로가 루트 기준으로 풀리면서 엉뚱한 위치를 가리키게 된 것이다. 에이전트 12개의 설정을 전수 검증했더니 실제 드리프트는 0건이었다. 처음 플래그된 항목은 전부 검사 스크립트의 경로 버그였다.
교훈은 명확하다. 검증 도구는 검증 대상의 규칙을 그대로 따라가야 한다. 경로 해석 규칙을 추측해서 하드코딩하면 이런 오탐이 나온다. 런타임의 실제 동작에서 규칙을 도출해야 한다.
하위 에이전트에서만 사라지는 스킬
비슷한 결의 문제가 하나 더 나왔다. 특정 확장의 스킬이 하위 에이전트 세션에서만 누락됐다. 메인 세션에서는 잘 로드되는데 서브에이전트를 띄우면 그 확장 계열 스킬이 목록에서 사라진다. 스모크 테스트에서 사용 가능한 스킬이 5개인데 그 계열이 전부 빠져 있는 걸로 확인했다.
원인은 확장 패키지의 package.json이었다. 스킬 경로 선언이 ./skills가 아니라 ../../skills로 되어 있었다. 다른 모든 확장은 ./skills를 쓰고 그 스킬들은 하위 세션에서 정상 로드된다. 이 선언은 설치 루트 기준으로 존재하지 않는 디렉터리로 해석된다. 메인 세션에서 어떤 경로로 로드에 성공하는지는 별개고 하위 세션에서는 그냥 못 찾는다.
진단 패턴으로 남긴다. 하위 에이전트가 특정 확장의 스킬을 못 찾으면 확장 package.json의 스킬 경로 선언부터 확인한다. ./skills가 아니면 패키징 버그를 의심한다. 로컬에서 고쳐봤자 다음 업데이트 때 덮어써지니 업스트림에 수정을 올리는 게 맞는 처방이다.
스크롤이 맨 위로 점프하는 버그의 변종 군
하루의 남은 시간은 다른 버그 조사에 썼다. 터미널을 오래 켜두면 스크롤이 맨 위로 튀는 문제다. 쓰고 있는 IDE의 공개 저장소에서 관련 이슈와 PR을 조사했다.
핵심 PR의 근본 원인은 이랬다. 터미널은 스크롤 위치를 맨 아래 기준 앵커로 저장한다. 장기 세션에서 스크롤백이 512KB 한도에 걸려 잘리면 잘린 뒤 복원하는 과정에서 상대 좌표가 음수가 된다. 이 음수가 0으로 클램프되면서 뷰가 맨 위로 점프한다. 수정 코드는 준비돼 있는데 아직 병합되지 않은 상태였다.
조사하다 보니 같은 증상의 변종이 여럿이었다. 탭 전환 시 점프하는 것, 패널을 숨겼다 해제할 때 점프하는 것. 전부 다른 메커니즘이면서 전부 미수정이었다. 비슷한 증상을 하나로 묶으면 오답이 나오기 쉽다. 패널과 방향을 축으로 삼아 분류하고 나서야 각 사례의 실제 상태가 정리됐다.
조사 방법에서도 배울 게 있었다. GitHub API에서 이슈 엔드포인트에는 병합 시각이 없다. 병합 여부를 알려면 PR 엔드포인트를 직접 호출해야 한다. 검색 호출은 배열로 배치 요청을 보내면 스로틀을 피할 수 있었다.
마지막으로 이 PR들을 구독하려다 스코프 제약을 확인했다. 개별 스레드 구독은 repo 권한만으로는 불충분하고 notifications 스코프가 필요하다. 권한 추가는 기기 인증을 요구해서 결국 완료하지 못했다. 브라우저에서 Subscribe 버튼을 누르는 걸로 우회했다.
마무리
하루를 관통한 주제는 하나였다. 성공 신호를 그대로 믿지 말고 상태로 검증하라. 종료 코드는 파이프 마지막 커맨드의 것일 수 있고 성공 메시지는 아무 일도 안 한 채 남을 수 있고 드리프트 경고는 검사 도구의 버그일 수 있다. 조회와 설치가 어긋나는 순간을 포착하는 진단 시그니처와 이전 버전으로 되돌리는 인과 확인법이 이번에 제일 값진 수확이다. 무음으로 실패하는 업데이트는 언젠가 또 만날 것이다. 그때는 시그니처가 바로 답을 줄 것이다.