AI 에이전트와 TDD를 병렬로 돌릴 때, 의도된 RED가 다른 PR을 막는 교착을 어떻게 풀었나
의도된 RED를 안은 시험 PR이 논리 PR의 병합을 막는 교착을, 실패 집합과 등재 매니페스트를 비교하는 게이트로 푼 과정을 기록한다. 리뷰에서 걸린 우회와 라이브 확인, 남은 한계도 함께 적는다.

AI 에이전트 여러 개에게 TDD를 나눠 맡기면, 테스트를 먼저 병합하는 변경과 그 테스트를 통과시키는 구현 변경이 같은 저장소에서 동시에 열린다. 테스트를 먼저 병합하려면 아직 실패하는 테스트를 일부러 남겨야 하는데, 그 실패는 저장소 전체 검사에 그대로 잡힌다. 그러면 구현 변경은 자기 작업을 끝내고도 남의 실패 때문에 병합되지 않는다.
이 글은 내가 개인 하네스 저장소 두 곳에서 이 교착을 겪고 푼 과정을 기록한다. 풀이는 "실패 집합이 등재 매니페스트와 정확히 같을 때만 통과하는 게이트"였다. 아래에서 게이트 규칙, 리뷰에서 걸린 우회, 임시 PR로 한 라이브 확인, 남은 한계를 순서대로 적는다. 여기 나오는 수치는 모두 그 개인 저장소의 기록값이며 이 블로그 저장소의 상태가 아니다.
누가 무엇을 했나
이 실험의 계획 초안, 코드, 시험, 문서 문단은 AI 에이전트가 작성했다. 리뷰는 작성 벤더와 다른 벤더의 에이전트가 맡았다. 나는 목표와 완료 기준을 정했고, 실행이 막히거나 리뷰가 실패했을 때 어느 쪽으로 갈지 판단했다. 아래에서 "나"가 한 일은 지시와 판단이며, 구현과 실행 결과는 에이전트의 기록이다.
교착이 생긴 자리
문제는 검사 단계의 설정에 있었다. 시험 PR이 아닌 모든 PR에서 테스트 스크립트 전부를 실행했고, 첫 실패에서 멈추는 방식이었다. 그래서 의도된 RED를 안은 시험 PR이 병합되면, 그 뒤에 열린 논리 PR은 자기 변경과 무관한 실패를 그대로 물려받았다.
실제로 같은 저장소에서 병합 시도가 'the base branch policy prohibits the merge'라는 메시지로 거부된 적이 있었다. 다른 실행에서도 한 논리 PR이 같은 이유로 막혔다. 그 실행은 그 PR을 혼자 병합하지 않고 통합 PR에 얹어 넘겼다. 같은 교착을 두 실행이 겪은 셈이다.
병합 전 기준선은 이 개인 저장소 기록으로 셸 시험 RED가 apply-gate 3건, minimal-harness 7건, stale-wording 1건이었고, Bun 테스트 RED가 17건이었다. 타입 검사 오류는 0건이었다. 이 RED들은 다른 실행이 남긴 의도된 RED였다. 기준선 수치는 병합 전 저장소에 이미 여러 실패가 있었다는 점을 보여 준다.
게이트 규칙: 실패를 식별자로 맞춘다
게이트가 맞춰야 할 기준을 먼저 적었다. 핵심은 "실패가 있느냐"가 아니라 "실패 목록이 등재 목록과 같으냐"였다.
- 실패 집합이 매니페스트 항목과 정확히 같을 때만 통과한다. 식별자는 셸 시험이면 파일과 FAIL 줄 본문, Bun 시험이면 파일과 classname > name이다. 줄 번호는 식별자에서 뺐다. 주변 편집만으로 줄 번호가 바뀌기 때문이다.
- 목록에 없는 실패는 UNLISTED로 실패한다. 목록에는 있는데 이제 통과하는 항목은 STALE로 실패한다.
- FAIL 줄 없이 비정상 종료하는 SETUP 실패는 어떤 항목으로도 덮지 못한다.
- 항목은 file, test, run, slice, branch_slice, test_pr 필드를 가진다. 병합된 test-* PR만 출처로 인용할 수 있고, 시험 PR은 자기 번호를 인용할 수 있다. 논리 PR은 항목을 지우기만 할 수 있다.
- branch_slice가 X인 항목이 남은 logic-X PR은 OWN-SLICE로 실패한다. 내가 읽은 이 규칙의 뜻은, 자기 슬라이스의 실패를 등재해 두고 병합하는 길을 막는 것이다.
- develop에 push되는 실행과 main을 겨냥한 PR은 엄격 모드로 평가한다. 실패가 0건이고 매니페스트가 비어 있어야 통과한다.
- 작업 이름은 그대로 두고, 관리자 우회나 보호 규칙 변경은 하지 않는다.
계획 검증과 첫 시험의 실패
계획은 검증자에게 한 번 보냈다. 검증 결과는 차단 지적 세 개였다. 첫째, 시험 PR에서 추가 항목이 자기 PR 번호만 인용하라는 규칙이 기반 항목의 옛 PR 인용과 충돌했다. 둘째, Set up Bun 단계의 조건이 test-*를 계속 건너뛰게 만들고 있었다. 셋째, 같은 시험 파일을 두 저장소에서 바이트 단위로 같게 두면서 미러마다 다르게 두라는 요구가 모순이었다. 보정은 한 번만 했고, 정책에 따라 재검증은 하지 않았다.
시험 PR의 첫 시도는 기대와 달랐다. 검사기 스크립트가 아직 없어서 31건이 실패했다. 이것은 의도된 RED가 아니라 파일이 없어서 난 실패였다. 리뷰가 이를 지적했고, 정정에서는 모든 호출에 같은 메시지를 내고 종료 코드 3을 내는 스텁을 넣었다. 그 뒤 결과는 6건 통과, 40건 실패였다. 실패는 스텁의 종료 코드와 토큰 단언, 워크플로 배선, 문단 위치 단언이었다. 행동이 아니라 단언 수준에서 실패하는 의도된 RED라고 판단했다.
시험 리뷰는 스텁 문제 말고도 네 가지를 더 차단했다. 배선 단언이 파일명만 있어도 통과하는 문제, 엄격 모드 사례가 두 조건을 합쳐 따로 검증하지 못하는 문제, 파일은 다르고 본문은 같은 식별자 사례가 없는 문제, 문단 위치 단언이 약한 문제였다. 정정은 한 번이었고 재리뷰는 하지 않았다.
논리 PR과 리뷰에서 걸린 우회
논리 PR의 GREEN은 46건 통과, 실패 0건이었다. 검사기의 PR 모드 출력은 'expected-red: ok (29 listed failures)'였다. 남은 29건은 다른 실행의 RED였고 등재되어 있었으므로, 이 결과만 보면 통과가 맞았다. 그래도 이것만으로 병합하지 않았다.
논리 리뷰는 정확성과 안전성을 각각 5점으로 매겼고 차단 지적 두 개를 올렸다. 첫째는 유지 항목의 branch_slice만 바꾸면 OWN-SLICE를 피해 통과하는 우회였다. 둘째는 식별자 필드가 배열이면 중복 검사에서 TypeError로 죽어 스키마 오류 출력과 종료 코드 1을 잃는 문제였다. 문구 리뷰도 설명 문장이 main을 겨냥한 PR의 엄격 규칙을 빠뜨렸다고 차단했다.
정정은 한 번이었다. 기반과 다르게 바뀐 유지 항목은 ADDED로 보고하게 했다. 식별자 필드가 비어 있지 않은 문자열이 아니면 중복 검사를 건너뛰고 스키마 오류만 남기게 했다. 문구는 엄격 규칙이 main을 겨냥한 PR, develop push, 승격에 걸린다고 고쳤다.
정정 뒤 재리뷰는 하지 않는 정책이었다. 그래서 두 정정은 고정 시험으로 묶지 않고, 우회 사례를 넣은 임시 결정론 점검으로만 확인했다. 옛 검사기는 우회 사례를 종료 코드 0으로 통과시켰고, 식별자 배열 사례에서는 TypeError로 죽었다. 새 검사기는 ADDED와 SCHEMA를 냈고, 점검 출력은 모두 통과였다. 두 경우를 고정하는 시험은 저장소에 남기지 않았다.
리뷰는 위 두 건 말고도 두 우회를 더 잡았다. 테스트 PR이 기반 항목을 지워도 통과하는 문제, 번호만 바꾼 유지 항목이 같은 슬라이스 재고정 예외로 계산되는 문제였다. 재고정 예외는 기반에 없는 식별자의 자기 항목으로만 한정했고, 기반 항목을 지우는 변경은 REMOVED로 거절하게 했다. 이 두 규칙은 두 저장소에서 같은 검사기 바이트가 되도록 맞췄다.
임시 PR 세 개로 확인하기
병합 전에 실제 워크플로에서 세 반례를 확인했다. 확인용 PR을 세 개 열었다.
- 등재 목록만 있는 경우: 기반 매니페스트를 그대로 둔 빈 커밋 논리 PR은 'expected-red: ok (29 listed failures)'로 통과했다. 항목을 더하기 전에 올린 빈 커밋 실행도 성공했다.
- 목록 밖 실패: 실패하는 시험 파일을 추가한 논리 PR은 실패했고, 출력은 'UNLISTED tests/scratch-unlisted.test.sh :: scratch unlisted failure'였다.
- 낡은 항목: 통과하는 시험에 등재 항목 한 건을 덧붙인 테스트 PR은 'STALE tests/eol.test.sh :: scratch stale entry'로 실패했다.
세 반례는 게이트의 판정 경로 세 가지에 대응한다. 등재 목록만 있는 경우는 통과하고, 목록 밖 실패는 UNLISTED로, 낡은 항목은 STALE로 실패한다. 임시 PR은 실제 워크플로가 이 세 경로를 같은 식으로 판정하는지 확인하는 용도였다.
두 실패 실행에서 비밀 정보 검사 단계는 게이트 뒤에 있어 건너뛰어졌다. 세 PR은 병합하지 않고 닫았으며, 원격 브랜치와 워크트리를 지웠다. 확인용 커밋에는 검증 상태를 게시하지 않았다.
활성화, 승격, 막힌 지점
라이브 적용은 한 저장소에서만 했다. 파일 열한 개를 원자적으로 바꾸고 해시를 병합된 블롭과 맞췄다. 사용자가 고친 설정 파일은 건드리지 않았다. 다른 저장소는 이 실행에서 활성화하지 않았다. 그 로컬 체크아웃이 많이 뒤처져 있었고, 해당 파일은 CI와 시험 전용이라 라이브 효과가 없었기 때문이다.
지시 문서의 한 문장도 바꿨다. "논리 PR은 스위트가 전부 초록일 때만 병합된다"는 문장이 실제 CI와 어긋났기 때문이다. 바뀐 문장은 논리 PR이 자기 테스트를 통과하고 나머지 실패가 모두 매니페스트 등재 항목일 때 병합된다고 쓴다. 승격은 여전히 실패 0건과 빈 매니페스트를 요구한다. 이 변경은 다른 실행의 활성화 절차가 함께 반영했다.
정정 과정에서 충돌도 하나 있었다. 다른 실행이 만든 핀 시험이 올바른 문장 변경을 실패로 만들었다. 그 시험은 이 실행이 소유한 것이 아니었다. 소유자가 마스크를 병합한 뒤, 이 실행은 develop을 시험 PR에 병합했다. 시험 파일 자체는 바꾸지 않았다.
승격은 막혔다. 병합 뒤 develop에 push된 실행은 엄격 모드로 평가되어, 다른 실행이 남긴 29건의 등재 RED를 지적하고 실패했다. 승격 단계는 건너뛰어졌다. 게이트가 설계대로 동작한 결과다. 등재된 실패는 논리 PR에서는 허용하지만 develop 승격에서는 허용하지 않기 때문이다.
한계
- 정책상 재리뷰가 없었던 정정이 있다. 우회 두 건은 고정 시험이 아니라 임시 점검으로만 확인됐다.
- 리뷰 요청 구성이 실패한 경우가 있었다. 한 번은 병합 후보 전체가 빠져 '공급되지 않았다'는 차단이 나왔고, 한 번은 시험 소스가 문구 리뷰 범위 밖이라 차단이 나왔다. 둘 다 요청을 다시 구성해 재요청했다.
- 일부 리뷰는 기록한 선택과 실제 실행 인자가 달랐다. 이벤트에 실제 설정이 남지 않은 리뷰도 있어서, 리뷰어 설정은 기록된 선택으로만 적는다.
- 게이트는 SETUP 실패를 덮지 못한다. 등재 항목은 논리 PR이 지워야 하며, 지우지 않으면 STALE이 실패로 드러난다.
- 다른 실행의 RED가 남아 있는 동안 develop의 엄격 모드는 실패한다. 게이트는 논리 PR의 교착을 풀었지만 승격은 여전히 막는다.
- 다른 저장소나 다른 검사 구성에서도 같은 결과가 나오는지는 확인하지 않았다. 이 글의 수치는 두 개인 저장소의 기록에 한정된다.
외부 자료로 본 맥락
아래 두 자료는 맥락을 보강하기 위해 읽었다. 경험의 근거는 앞의 기록이고, 이 자료들은 근거가 아니다. 2026-10-29에 조회했다.
GitHub의 보호된 브랜치 문서는 필수 상태 검사가 성공, 건너뜀, 중립 중 하나의 상태를 가져야 보호된 브랜치에 변경을 반영할 수 있다고 설명한다. 필수 검사가 실패하면 푸시 권한이 있는 사람과 앱도 병합이 막힌다고 적는다. GitHub 문서: 보호된 브랜치. 이 문서가 말하는 것은 병합 자체의 차단 규칙이다. 우리 게이트가 실패 목록을 비교하는 방식은 이 문서에 나오지 않는다.
pytest 문서는 xfail과 XPASS가 기본값에서는 테스트 스위트를 실패시키지 않는다고 적는다. strict 매개변수를 True로 설정하면, 예상과 달리 통과한 XPASS가 스위트를 실패시키도록 바뀐다. pytest 문서: strict 매개변수. 우리 게이트는 pytest의 기능이 아니다. 다만 예상한 실패 목록과 실제 결과가 어긋나면 실패로 본다는 발상이 가까워서 비교 대상으로 읽었다.
자주 묻는 질문
테스트를 먼저 병합하면 다른 PR의 CI가 꼭 막히는가?
이 저장소의 검사 단계는 시험 PR이 아닌 모든 PR에서 테스트 전부를 실행하고 첫 실패에서 멈추는 방식이었다. 그 설정에서는 막힌다. 막히는지는 CI 설정과 검사 범위에 따라 다르다.
실패 목록을 그냥 통과 조건으로 쓰면 안 되는가?
목록을 "실패가 있으면 통과"로 쓰면 목록 밖의 새 실패가 가려진다. 그래서 목록에 없는 실패(UNLISTED)와 목록에만 남은 낡은 항목(STALE)을 모두 실패로 두었다.
AI 에이전트가 쓴 시험을 어디까지 믿어도 되는가?
이 실험에서는 작성과 리뷰를 다른 벤더에 나누고 실행 결과를 기록했다. 그래도 리뷰는 여러 번 차단했고, 정정 뒤 재리뷰를 하지 않은 부분이 남았다. 시험과 리뷰 기록을 함께 봐야 하며, 리뷰 통과만으로 정확성이 보장된다고 읽으면 안 된다.
관련 글
작성 역할과 검증 역할을 벤더별로 나누는 기준은 한 모델만 믿지 말고 AI 코드 교차 검증하는 법에서 다뤘다. 이 글은 그 기준을 CI 게이트로 옮길 때 생기는 교착에 집중한다.
정리하면, 이 교착을 풀 때 쓴 방법은 세 가지다. 의도된 RED는 식별자가 붙은 등재 목록으로 관리한다. 통과 여부는 실패 집합과 등재 목록이 정확히 같은지로 정한다. 엄격 모드는 develop 승격과 main 겨냥 PR에만 둔다. 이 방법이 다른 구성에서도 같은 결과를 낸다는 보장은 없다.
아직 댓글이 없습니다.