Skip to content

fix(agent): preview 브랜치 push 실패가 성공으로 보고되던 문제 - #174

Merged
dldnsgkr merged 1 commit into
developfrom
unhak/push-failure-must-fail
Aug 27, 2026
Merged

fix(agent): preview 브랜치 push 실패가 성공으로 보고되던 문제#174
dldnsgkr merged 1 commit into
developfrom
unhak/push-failure-must-fail

Conversation

@dldnsgkr

Copy link
Copy Markdown
Collaborator

문제

git pushexec 로 돌고 있었는데 그 메서드는 종료 코드를 읽지 않는다. 인증 실패든 보호 브랜치든 그냥 문자열이 돌아왔고, 호출자는 성공으로 알고 다음으로 갔다.

// PreviewBranchPushService:68 (수정 전)
dockerService.exec(containerId, "cd /workspace/app && git push -u origin preview");

// RepositoryBindingService:132-144 — 그 직후
previewBranchPushService.push(...);                       // 실패해도 안 던짐
auditRecorder.record(AuditAction.PREVIEW_BRANCH_PUSHED);  // 감사 로그: 성공
return new BindResult(fullName, created, true);           // 스텝 완료

사용자는 이런 안내를 받는다.

작업물을 preview 브랜치에 올렸습니다. 프리뷰가 만료돼도 코드는 남습니다.

push 가 실패했다면 이건 거짓이다. 컨테이너가 만료되는 순간 작업물이 사라지고, 감사 로그에는 성공으로 남아 나중에 추적도 안 된다. 안전하다고 들은 것을 잃는다.

CLAUDE.md 에 적힌 보장도 실제로는 성립하지 않았다.

"두 게이트 모두 자기가 발동한 스텝의 markStepCompleted 를 소유하므로, push 실패 시 스텝이 완료로 넘어가지 않는다"

이 문장은 push 실패가 예외로 올라올 때만 참이다.

해결

실패가 의미 있는 단계만 execWithExitCode 로 바꾸고 비-0 이면 던진다 — git init, remote add/set-url, checkout -B, add, commit, push.

apk addgit config 는 그대로 둔다. 이미 git 이 깔려 있거나 이미지가 alpine 이 아닐 수 있어서, 그것까지 실패로 보면 멀쩡히 돌던 컨테이너에서 푸시가 막힌다. 정말 git 이 없으면 뒤의 strict 단계가 드러낸다.

commit 은 strict 로 둬도 안전하다. 변경이 없으면 git diff --cached --quiet 가 0 으로 끝나 커밋을 건너뛴다. 변경이 있으면 1 을 주고 커밋이 돌며, 그 커밋이 실패해야 비-0 이 된다. "고칠 것이 없었다"가 오류가 되지 않는다.

예외 메시지에 명령 전문을 넣지 않는다. 이 클래스는 자격 증명을 다루고 메시지는 로그와 사용자 화면으로 흘러간다 — 단계 이름과 git 출력 꼬리만 남긴다.

호출부는 이미 이 예외를 전제로 쓰여 있었다

  • ResultApprovalGate javadoc(:75-77): 예외가 AgentPlanExecutor 의 catch-all 로 전파돼 태스크가 FAILED(재시도 가능)가 된다고 적어뒀다
  • RepositoryBindingService(:112-115): "push 가 실패하면 트랜잭션이 통째로 롤백되므로" 를 근거로 연결 저장을 push 앞에 둔다
  • 저장소 생성은 repositoryExists 로 멱등(:100-107)이라 재시도가 같은 저장소를 재사용한다

설계는 이미 그렇게 돼 있었고 예외만 오지 않았다. 이 PR 은 그 설계를 실제로 동작하게 만든다.

검증

  • 마이그레이션 없음
  • 테스트 6개 추가: 실패한 push 가 성공으로 보고되지 않는지 / 실패 메시지에 토큰이 없는지 / 앞 단계가 깨지면 push 전에 멈추는지 / 빈 커밋은 실패가 아닌지 / 성공 경로가 모든 단계를 거치는지 / apk add 실패는 허용되는지
  • 기존 DeployAgentServiceTest 를 strict 경로에 맞춰 갱신
  • ./gradlew test 전체 통과

execWithExitCode#115 에서 만든 것이다. 그때는 프리뷰 빌드에만 썼고, 이번이 두 번째 적용이다.

🤖 Generated with Claude Code

git push 가 exec 로 돌고 있었는데 그 메서드는 종료 코드를 읽지 않는다. 인증 실패든
보호 브랜치든 그냥 문자열이 돌아왔고, 호출자는 성공으로 알고 다음으로 갔다.

그 대가가 크다. 감사 로그에는 PREVIEW_BRANCH_PUSHED 가 성공으로 남고, 사용자에게는
"작업물을 preview 브랜치에 올렸습니다 — 프리뷰가 만료돼도 코드는 남습니다"가
표시된다. 실제로는 아무것도 올라가지 않았고, 컨테이너가 만료되면 작업물은 사라진다.
사용자는 안전하다고 들은 것을 잃는다.

CLAUDE.md 에 적힌 "push 실패 시 스텝이 완료로 넘어가지 않는다"는 보장도 실제로는
성립하지 않았다. 그 문장은 push 실패가 예외로 올라올 때만 참이다.

실패가 의미 있는 단계만 execWithExitCode 로 바꾸고 비-0 이면 던진다 — git init,
remote add/set-url, checkout -B, add, commit, push. apk add 와 git config 는 그대로
둔다. 이미 git 이 깔려 있거나 이미지가 alpine 이 아닐 수 있어서, 그것까지 실패로
보면 멀쩡히 돌던 컨테이너에서 푸시가 막힌다 — 정말 git 이 없으면 뒤의 strict 단계가
드러낸다.

commit 은 변경이 없으면 git diff --cached --quiet 가 0 으로 끝나 건너뛴다. 변경이
있으면 1 을 주고 커밋이 돌며 그 커밋이 실패해야 비-0 이 된다. 그래서 strict 로 둬도
"고칠 것이 없었다"가 오류가 되지 않는다.

예외 메시지에는 명령 전문을 넣지 않는다. 이 클래스는 자격 증명을 다루고, 메시지는
로그와 사용자 화면으로 흘러간다 — 단계 이름과 git 출력만 남긴다.

호출부는 이미 이 예외를 전제로 쓰여 있었다. ResultApprovalGate 는 javadoc 에
AgentPlanExecutor 의 catch-all 로 전파돼 태스크가 FAILED 가 된다고 적어뒀고,
RepositoryBindingService 는 "push 가 실패하면 트랜잭션이 통째로 롤백된다"를 근거로
연결 저장을 push 앞에 둔다. 저장소 생성도 repositoryExists 로 멱등이라 재시도가
같은 저장소를 재사용한다. 설계는 이미 그렇게 돼 있었고 예외만 오지 않았다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dldnsgkr
dldnsgkr merged commit 2dc6c9f into develop Aug 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant