- name
- reaper-inherits-the-tools-refusal
- description
- 자동 정리기(리퍼·GC·워크스페이스 회수)를 만들 때, "지워도 안전한가"를 직접 판정하지 말고 도구가 이미 하는 거부를 물려받아라. `git worktree remove`·`rmdir`·`docker rm`(force 없이)은 지우면 안 되는 상태에서 스스로 거부한다. `--force` 한 글자가 그 안전장치를 데이터 손실로 바꾼다. 그리고 정리 명령의 exit 0 은 반드시 관측이어야 한다. 트리거: 워크트리/워크스페이스/컨테이너/temp 회수, 고아 정리, 디스크 누수, "언제 지워도 되나" 판단 로직 작성.
# 리퍼는 도구의 거부를 물려받아라 — 안전 판정을 다시 구현하지 마라
## Problem
런/작업마다 리소스(워크트리·워크스페이스·컨테이너)를 만들어 놓고 아무도 안 지우면 디스크가
샌다. 그래서 리퍼를 짠다. 자연스러운 첫 설계는 이렇다:
```python
# ❌ 안전 판정을 직접 구현
if is_clean(path) and run_is_terminal(run) and pushed(branch):
force_remove(path) # 판정을 통과했으니 강제로 지운다
```
- **증상 A (손실)**: 판정이 틀린 한 경우에서 **그곳에만 존재하는 작업**이 사라진다. 취소된
런의 몇 시간짜리 산출물, 아직 커밋 안 된 편집. 리퍼가 없느니만 못한 상태가 된다.
- **증상 B (조용한 누수)**: 지웠다고 보고하는데 안 지워진다. `git worktree remove` 는
등록되지 않은 경로에 대해 실패하고, 어떤 경로에서는 **exit 0 을 내면서 아무것도 안 한다**.
exit 0 을 "회수했다"로 읽으면 누수가 **수개월간 성공으로 보고되며** 지속된다.
- **흔한 오해**: "`--force` 를 안 쓰면 정리가 자주 실패해서 리퍼가 쓸모없어질 것이다."
→ **실측하면 아니다.** 아래 표를 보면 정리를 막는 것은 정확히 막아야 할 것뿐이다.
## Solution
### 1. 도구의 거부가 곧 안전 술어다 — `--force` 를 쓰지 마라
`git worktree remove` 를 실 git 2.52 에 대고 재본 결과:
| 트리 상태 | force 없는 `remove` |
|---|---|
| 깨끗함 | 삭제, exit 0 |
| **untracked 파일 있음** | **거부** exit 128, 디렉터리 유지 |
| **tracked 수정 있음** | **거부** exit 128, 디렉터리 유지 |
| ignored 만(`.venv`, `node_modules`) | **삭제**, exit 0 |
| 디렉터리가 이미 없음 | exit 0 (등록만 해제) |
| 등록 안 된 디렉터리 | `not a working tree` exit 128 |
| 브랜치·커밋 | **건드리지 않는다** — push 안 된 커밋도 살아남는다 |
읽는 법:
- **거부하는 것 = 그곳에만 존재하는 작업을 가진 트리.** 정확히 지키고 싶은 것.
- **거부하지 않는 것 = 찌꺼기.** `.venv`/`node_modules` 가 회수를 막았다면 뭐라도 설치한
런은 전부 체크아웃을 영구 고정했을 것이다. 즉 force 없이도 리퍼는 실질적으로 동작한다.
- **커밋된 작업은 위험하지 않다.** remove 가 브랜치도 오브젝트도 안 건드리므로,
**push 가 실패한 런의 작업조차** 브랜치에 남는다 → 회수 조건을 "push 성공"까지 좁힐 필요 없다.
### 2. exit 0 은 관측이어야 한다
```sh
git -C "$REPO" worktree prune # 손삭제가 남긴 stale 등록 정리
if [ ! -d "$PATH" ]; then exit 0; fi # 회수할 게 없는 것은 실패가 아니다
if git -C "$REPO" worktree remove "$PATH"; then
[ ! -d "$PATH" ] || exit 3 # ← #665 의 교훈: 진짜 없어졌는지 본다
exit 0
fi
# 거부당했다 — 왜인지를 도구 자신의 용어로 되묻는다 (메시지 텍스트 매칭 금지: 버전·로케일)
if [ -n "$(git -C "$PATH" status --porcelain 2>/dev/null)" ]; then exit 2; fi
exit 3
```
### 3. "작업이 있어 남김"은 실패가 아니라 자기 이름을 가진 결과다
exit 코드를 셋으로 나눈다: `0`=회수됨 / `2`=보류(작업이 있어 남김) / `3`=회수 실패(관측 불가).
`2` 를 `3` 에 뭉뚱그리면 **"사용자가 찾아야 할 작업이 저기 있다"**는 사실이 로그에서 사라진다.
"아무 문제 없음"과 "관측 자체가 실패함"은 다른 사실이다.
### 4. 회수 지점은 `finally` — 모든 종료 경로가 지나는 seam
정상 종료 경로에 달면 **정작 잔해를 남기는 나쁘게 끝난 실행**에서 아무것도 회수하지 못한다.
```python
try:
return await drive_loop(...)
finally:
await manager.release(...) # proved·refuted·취소·크래시 전부 여기를 지난다
```
⚠️ 그 자리에서 **절대 raise 하지 마라.** `finally` 안의 예외는 원래 결과를 덮어써서,
디렉터리 하나 때문에 성공한 작업이 크래시로 보고된다. 전부 기록하고 삼킨다.
## Key Insights
- **안전 판정을 다시 구현하는 것 자체가 결함원이다.** 도구는 이미 그 판정을 갖고 있고,
우리 판정은 도구의 것과 드리프트한다. `--force` 는 "내 판정이 도구 것보다 낫다"는 주장이다.
- **거부 조건은 문서가 아니라 실 도구로 재라.** "ignored 파일이 remove 를 막는가"는 직관으로
갈리는데 답이 설계를 결정한다(막는다면 force 없는 리퍼는 무용지물). 5분짜리 scratch 레포
실험이 설계 판단을 확정시킨다.
- **회수와 보존이 같은 명령의 두 결과다.** 리퍼를 "지우는 것"으로 보면 force 로 가고,
"돌려받으려 시도하는 것"으로 보면 거부가 자연스러운 결과가 된다.
- 다음에 먼저 확인할 것: ①이 도구는 force 없이 무엇을 거부하는가 ②그 거부가 내가 지키려는
것과 일치하는가 ③exit 0 이 관측인가 주장인가.
## Red Flags
- 정리 코드에 `--force` / `-f` / `rm -rf` 가 있다
- 정리 전에 `is_clean()` / `is_safe_to_delete()` 같은 자체 판정 함수가 있다
- 정리 명령의 성공을 exit code 만으로 판단하고 **결과 상태를 안 본다**
- 정리가 happy path(성공 종료)에만 달려 있다
- 로그에 "reclaim failed" 한 종류만 있고 "보류"가 없다
- 디스크 사용량이 실행 횟수에 비례해 단조 증가한다
عرض على GitHub