AI

거짓 done 다섯 번의 원인이 다 달랐다

머지되지 않았는데 done 이 찍혔다 — 셸·프롬프트·호출·세션·저장소 설정 다섯 층에서 각각

한 줄로

무인 루프가 낼 수 있는 최악의 출력은 실패가 아니라 거짓 완료다. 머지되지 않았는데 done 이 찍혔고, 그 거짓말은 루프의 유일한 기억에 조용히 남는다. 이 사고가 다섯 번 났고 원인은 다섯 번 다 달랐다. 그래서 처방이 “가장 좋은 검사 하나”가 아니라 겹친 검사가 됐다.

문제: 다섯 번의 원인이 다섯 층에 흩어져 있었다

# 무엇이 일어났나 어느 층
1 잠금 해제 명령의 종료 코드가 머지 실패의 종료 코드를 덮었다 셸
2 프롬프트의 분기가 갈라져 머지 실패 경로를 지나쳤다 프롬프트
3 같은 ref 를 태스크 브랜치와 base 브랜치 자리에 두 번 넘겼다 호출
4 구현이 파일을 쓰고 커밋하지 않았다 세션
5 실패하는 pre-commit 훅이 git commit 을 조용한 무동작으로 만들었다 저장소 설정

같은 증상에 원인이 다섯 층으로 흩어져 있다. 공통 원인이 없다는 것이 이 사고의 성질이다.

5번이 특히 나빴다 — 진단을 엉뚱한 곳으로 돌렸다

실행 중 모든 태스크가 똑같이 막혔는데, git log -1 이 base 커밋을 보여 주어 커밋이 된 것처럼 보였다. 진단은 엉뚱한 머지를 탓했다.

훅이 실패하면 git commit 은 아무것도 하지 않고 조용히 끝난다. 그러면 git log -1 은 직전 커밋, 즉 base 커밋을 보여준다. 커밋이 생긴 것과 구분되지 않는다.

그래서 훅 정책을 정했다. 먼저 평범하게 커밋하고, 훅에 막히면 우회 옵션(--no-verify)으로 다시 커밋한 뒤 우회했다는 사실을 일지에 남긴다.

다만 이 우회 비율은 실제보다 낮게 나온다. 기록하지 않은 우회는 안 잡히고, 커밋도 못 하고 보류된 시도는 분모에만 섞인다. 낮게 나와도 안심하지 않는다.

“가장 좋은 검사 하나”가 아니라 겹친 검사

다섯 번 다 원인이 달랐다는 것은, 다음 여섯 번째도 다를 거라는 뜻이다. 그래서 처방은 무언가 하나를 완벽하게 만드는 것이 아니라 서로 독립인 검사를 겹치는 것이었다.

  • 게이트 1 이 미커밋 변경을 거부한다 → 4번을 발생 지점에서 막는다
  • complete 는 세션이 게이트를 돌렸다고 말해도 다시 돈다. 머지 뒤에는 git 에 직접 묻는다 — 커밋이 있나, 두 ref 가 다른가, 실제로 머지됐나
  • 머지 자체를 CLI 가 한다 → 2번이 나던 무대(프롬프트 분기)를 없앤다

1번은 머지 잠금이 없는 단일 루프라서 사라진 것이다. 6편처럼 잠금을 다시 붙이면 다시 날 수 있다.

검사의 검사 — 발동할 수 없는 방어가 있었다

겹친 방어에는 함정이 있다. 한때 “네 번째 검사”가 있었는데, 다른 검사가 이미 덮고 있어서 절대 발동할 수 없었다.

죽은 검사는 방어의 두께를 과대평가하게 만든다. 지웠다.

그 뒤로 규칙을 하나 두었다.

검사마다, 그 검사 하나만 빼면 깨지는 테스트를 둔다.

그러면 어느 검사가 실제로 일하고 있는지 언제든 확인된다.

세션이 상태 파일을 직접 고친다면

권한 우회로 도는 세션은 tasks.yaml 을 직접 done 으로 바꿀 수도, 판정 코드를 고칠 수도 있다.

그래서 드라이버가 사이클마다 done 태스크를 git 과 대조하고, 판정 코드의 지문을 비교해 바뀌면 멈춘다. 막는 것이 아니라 잡는 것이지만, “조용한 거짓말”을 “시끄러운 정지”로 바꾼다.

직접 만든 뼈대에도 구멍이 있었다

이 연재의 뼈대를 일반화한 키트로 다시 짜면서 실제 git 저장소 위에서 도는 테스트를 붙였다. 그다음 작성자가 아닌 별도 리뷰 에이전트에게 코드를 보였고, 리뷰어가 다섯을 찾았다.

  1. 보호 파일을 옮기면 보호 검사를 빠져나갔다. git 의 이름 바꿈 감지가 새 경로만 보여 주기 때문이다 → 바뀐 파일을 셀 때 이름 바꿈 감지를 끈다(--no-renames)
  2. 머지 직후, 상태 저장 직전에 세션이 죽으면 재시도가 “커밋이 없다”로 시도를 태우고 보류됐다 → 태스크를 다시 잡을 때 이미 머지됐는지 git 에 묻고 done 으로 복구한다
  3. 시도를 다 써서 보류된 태스크를 풀어도 시도 횟수가 남아 영영 선택되지 않았다 → 보류를 풀 때 시도 횟수도 되돌린다
  4. 세션이 상태 파일이나 판정 코드를 고칠 수 있었다 → 위 절의 대조와 지문 비교가 여기서 나왔다
  5. 드라이버를 멈춰도 세션이 계속 돌며 커밋했다 → 드라이버가 멈출 때 세션의 프로세스 묶음을 함께 죽인다

자기가 쓴 코드를 자기가 승인하지 않는다는 규칙이 여기서도 값을 했다.

남는 교훈

원인이 매번 다른 사고에는 “더 좋은 검사”가 답이 아니다. 서로 독립인 검사를 겹치는 것이 답이다.

  • 다섯 번의 원인이 다섯 층(셸·프롬프트·호출·세션·저장소 설정)에 흩어져 있었다. 하나를 완벽하게 만들어도 나머지 네 층은 그대로다
  • 겹친 방어는 두께를 과대평가하게 만든다. 그래서 검사마다 “그 검사만 빼면 깨지는 테스트”를 둔다
  • 고칠 때마다 “그 고침을 되돌리면 빨갛게 되는” 테스트를 함께 둔다

지금은 거짓 완료가 의심되면 세션의 보고를 읽지 않고 git log 와 머지 여부를 직접 본다. 말이 아니라 저장소 상태를 본다.

남겨둔 것 — 우회 비율은 여전히 실제보다 낮게 나온다. 분모를 정확히 세려면 커밋 실패로 보류된 시도를 따로 세야 하는데, 아직 안 했다.

다음 편은 이 모든 검사를 통과한 결함이 어디서 왔는지다 — 태스크 계약에는 금지가 아니라 값을 쓴다.