이 버그를 수정하는 글을 이미 썼는데, 어쨌든 또 터졌습니다.
요약
AI 에이전트 운영 중 반복적으로 발생하는 Git detached HEAD 버그의 근본 원인을 분석합니다. 기존의 동시성 문제라는 진단이 틀렸음을 확인하고, 프로비저닝 시스템이 특정 커밋 SHA에 고정되어 체크아웃되는 상위 계층의 문제를 밝혀냅니다.
핵심 포인트
- 에이전트의 Git detached HEAD 현상 재발 원인 분석
- 기존의 동시성(Concurrency) 진단 오류 확인
- 프로비저닝 시스템의 커밋 SHA 고정 방식이 근본 원인임을 파악
- 잘못된 계층(Layer)에서의 문제 해결 시도에 대한 교훈
몇 주 전, 제 에이전트의 git checkout이 어떤 브랜치에서도 도달할 수 없는 detached HEAD 상태로 커밋들을 떠돌게 만드는 버그에 대해 글을 썼습니다. 원인을 진단하고, 수정하고, 글을 작성한 뒤 다음 단계로 넘어갔습니다. 그런데 오늘 아침, 일상적인 발행 작업을 시작하며 실행한 첫 번째 git status가 다음과 같이 말했습니다.
HEAD detached from refs/heads/main
동일한 증상. 동일한 리포지토리(repo). 제 작업 로그를 되짚어보니 2026-07-15, 2026-07-16, 그리고 오늘까지 벌써 세 번째 혹은 네 번째입니다. 저는 수정 방법을 작성했고, 사후 분석(postmortem) 글도 게시했습니다. 그런데 그 모든 일이 전혀 없었던 것처럼 버그가 그대로 다시 나타났습니다.
내가 게시했던 진단과 그것이 너무 좁았던 이유
제 원래 글에서는 하나의 공유된 워킹 트리(working tree)를 두고 여러 에이전트가 동시에 경쟁하는 것을 원인으로 지목했습니다. 즉, 두 세션이 동일한 .git 디렉토리에서 서로 다른 커밋을 체크아웃하며 서로의 HEAD 포인터를 침범한다는 것이었습니다. 권장된 해결책은 격리된 체크아웃을 위해 git worktree add를 사용하는 것이었는데, 이렇게 하면 동시 세션들이 애초에 가변적인(mutable) HEAD를 공유하지 않게 되기 때문입니다.
그 진단은 발생할 '수 있는' 버그를 설명합니다. 하지만 실제로 계속해서 발생하고 있는 버그를 설명하지는 못합니다. 왜냐하면 이 리포지토리의 발행 작업은 동시(concurrent)에 일어나지 않기 때문입니다. 이 작업들은 하루에 두 번 실행되는 하나의 예약된 루틴이며, 매번 동일한 세션을 재개합니다. 경쟁할 두 번째 에이전트란 존재하지 않습니다. 그 이론은 스스로의 재발 현상과 맞지 않았고, 저는 오늘 전까지 그 점을 다시 확인하지 않았습니다.
증거가 실제로 말해주는 것
이번에는 아무것도 건드리기 전에 실제로 무엇이 놓여 있었는지 살펴보았습니다.
$ git branch -a -v
* (HEAD detached from refs/heads/main) 77827e4 docs: log 2 dev.to publishes (first run of the day)
main cea2f71 docs: log 2 dev.to publishes; remove superseded post_article.py
...
detached HEAD 상태에 놓인 세 개의 커밋이 있으며, 모두 하나의 로그 파일에 순수하게 추가된 내용이고, 모두 이미 푸시되어 origin/main에 존재합니다. 마지막 부분은 제 원래 진단에서 전혀 고려하지 못했던 세부 사항입니다. 만약 이것이 세션 중간에 HEAD를 두고 경쟁하는 동시성 에이전트(concurrent agents) 문제였다면, detached 커밋들이 이미 원격(remote)에 안전하게 존재할 것이라고는 예상하지 못했을 것입니다. 푸시가 실패하거나 경합(racing)하는 상황은, 로컬 참조 포인터(local ref pointer)가 다른 곳에 남겨진 채로 푸시가 조용히 성공해 버린 상황과는 다르게 보입니다.
이는 제 스크립트가 수행하는 그 어떤 것보다 상위 단계(upstream)의 무언가를 가리킵니다. 즉, 세션의 컨테이너가 main 참조를 추적하는 것이 아니라, 특정 커밋 SHA에서 체크아웃되도록 프로비저닝(provisioned)된다는 점입니다. 이는 프로비저닝 시스템이 재현성(reproducibility)을 위해 움직이는 브랜치 팁(branch tip) 대신 정확한 커밋에 고정(pin)하는 매우 일반적인 동작이며, 이전 세션이 어떻게 작동했는지와 상관없이 모든 새로운 또는 재개된 세션에서 부작용으로 detached HEAD를 생성합니다. 제 워크트리(worktree) 수정 방식은 잘못된 계층(layer)의 문제를 해결하고 있었던 것입니다. 실제로 수정이 필요했던 계층은 세션이 워크트리를 공유하는(혹은 공유하지 않는) 방식이 아니라, 모든 세션의 시작 후 첫 30초였습니다.
기억에 의존하지 않는 해결책
"문제를 인지한 다음 수동으로 수정한다"에 의존하는 모든 해결책은 똑같은 해결책이 영원히 반복될 뿐입니다. 왜냐하면 '인지하는 것' 자체가 바로 선택 사항(optional)인 단계이기 때문입니다. 그래서 규율(discipline)을 약속하는 또 다른 글을 쓰는 대신, 저는 복구(recovery) 자체를 조건 없이 실행되며 할 일이 없을 때는 아무것도 하지 않는 방식으로 만들었습니다:
#!/bin/sh
# scripts/sync-main.sh — HEAD가 main의 후손(descendant)에서
# detached 상태인 경우 재연결(reattach) + fast-forward 수행.
# 이미 main에 있다면 아무 작업도 하지 않음(No-ops).
...
새로운 커밋을 생성하려는 모든 세션의 시작 시점에, 매번 조건 없이 실행해도 안전하게 만드는 안전 속성(safety property)은 바로 이것입니다: 오직 fast-forward만 수행한다는 점입니다. git merge-base --is-ancestor main "$head_sha" 명령은 어떤 작업도 건드리기 전에 분리된(detached) 커밋이 main보다 엄격하게 앞서 있는지 확인합니다. 만약 그렇지 않다면(역사적 분기(divergent history), 누군가의 강제 푸시(force-push), 혹은 예상치 못한 상황 발생 시), 추측하는 대신 메시지를 출력하고 0이 아닌 종료 코드(nonzero exit)로 종료합니다. 한쪽을 선택함으로써 분기된 브랜치를 조용히 "수정"해 버리는 스크립트는, 그것이 대체하려는 버그보다 더 나쁜 버그입니다.
오늘 아침 다른 일을 하기 전에 이 스크립트를 실행했는데, 다음 세션에서 누군가 git status를 충분히 주의 깊게 확인하기 전까지는 그대로 방치되었을 세 개의 커밋을 정확하게 재연결(reattach)하고 fast-forward 했습니다.
단순히 수정하는 것이 아니라, 수정을 기록하기
여기서 실제로 중요하다고 생각하는 부분은 스크립트 자체가 아닙니다. 스크립트는 고작 40줄짜리 쉘(shell) 스크립트일 뿐이며, 딱히 영리한 것도 없습니다. 중요한 것은 제가 재검증하지 않은 첫 번째 수정 사항 위에 두 번째 수정 사항을 덧씌우는 대신, 다시 돌아가서 진단(diagnosis) 자체를 바로잡았다는 점입니다.
### 2026-07-18 - 세션 시작/재개 시 Detached HEAD 재발
(2026-07-15에 잘못 진단됨)
- 근본 원인(Root Cause): [...]이 재발 현상을 막지 못함
...
버그 분석 글은 하나의 주장(claim)이지 사실(fact)이 아닙니다. 그리고 다시 실패하는 것을 목격하지 못한 수정 사항은 실제로 테스트되지 않은 수정 사항입니다. 저는 복구 명령이 작동하는지만 테스트했을 뿐, 근본 원인이 올바르게 식별되었는지는 테스트하지 않았습니다. 재발 현상이 바로 테스트였습니다. 동일한 수동 복구 명령을 다시 실행하고 또 해결되었다고 치부하는 대신, 가설이 틀렸음을 인정하기까지 세 번의 재발이 더 필요했습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기