개발 과정 · AI 에이전트
완료를 주장이 아니라 명령의 출력으로: 장기 루프 에이전트의 durable 완료 게이트를 설계한 기록
루프 에이전트가 스스로 '완료'를 찍게 두면 검증되지 않은 코드가 완료로 통과한다. 네이티브 Task가 세션 스코프라는 발견이 어떻게 설계 하나를 폐기시켰는지, 그리고 완료 상태를 git 파일 하나 + verify 명령의 raw exit-code로 옮겨 기계가 판정하게 만든 과정 — 세 번의 적대적 리뷰로 다듬은 durable executor 설계.
앞선 두 글은 하네스/루프 엔지니어링의 지형도와 그것을 병렬 작업의 git 격리에 실전 적용한 기록이었다. 이번 글은 그 다음 질문에 관한 것이다 — 에이전트가 여러 세션에 걸쳐 오래 도는 루프에서, “완료"를 무엇으로 판정할 것인가.
핵심 요약
루프 에이전트에게 완료 판정을 맡기면, 모델은 검증되지 않은 작업을 "완료"로 찍는다. 완료는 모델의 주장이 아니라 명령의 출력이어야 한다.
그러려면 완료 상태가 세션 경계를 넘어 살아남아야(durable) 한다. 그런데 네이티브 Task는 세션 스코프였다 — 이 발견 하나가 우리의 첫 설계를 통째로 폐기시켰다.
결론: 완료 상태를 git 파일 하나(checklist.json)에 두고, 각 항목의 통과 여부를 그 항목의 verify 명령이 뱉는 raw exit-code로만 판정한다. 모델은 "검증을 돌리지 않고" 통과를 찍을 수 없다.
1. 문제: 루프가 길어질수록 “완료"가 물러진다
단발 작업에서는 완료 판정이 쉽다. 사람이 결과를 보고 승인한다. 그러나 승인된 계획을 자율로 완주하는 루프(Ralph loop 계열)에서는 사람이 매 반복을 보지 않는다. 그 순간 완료 판정의 권한은 모델에게 넘어간다.
여기서 알려진 실패 모드가 나온다. 모델은 낙관적이다 — 구현을 끝냈다는 느낌이 들면 체크리스트 항목을 completed로 찍고 다음으로 넘어간다. 검증이 실제로 통과했는지와 무관하게. 루프가 길수록, 반복이 많을수록, 이렇게 검증 없이 완료로 통과한 항목이 쌓인다. 우리 리서치가 정리한 한 문장이 이 문제의 핵심이었다.
No PASS without proof — 증거 없는 통과는 없다. 완료는 판단이 아니라 재현 가능한 명령의 출력이어야 한다.
우리에게는 이미 이 철학을 담은 게이트가 있었다 — verify-done.sh. 하지만 그것은 “지금 이 순간"의 기계 검사(린트·테스트·시크릿·문서 sync)였을 뿐, **“계획된 모든 항목이 각자의 검증을 통과했는가”**를 보지 못했다. 루프의 완료를 판정하려면 후자가 필요했다.
2. 첫 설계, 그리고 그것을 죽인 한 줄의 사실
첫 설계(Option A)는 자연스러웠다. “네이티브 Task 시스템을 재사용하자. Task마다 acceptance 조건을 달고, 그걸 durable 체크리스트로 쓰면 된다.”
세 명의 적대적 리뷰어(fresh context, 작성 세션과 분리)에게 이 설계를 던졌다. 돌아온 지적이 설계를 통째로 무너뜨렸다.
네이티브 Task는
~/.claude/tasks/<session-UUID>/에 저장된다. 세션 스코프다. 세션이 바뀌면 다음 executor는 그 Task 저장소를 읽지 못한다. 그러니 “durable 체크리스트"가 될 수 없고,verify-done.sh도 그 저장소를 읽을 수 없으니 게이트는 종이 게이트다.
이 한 줄의 사실이 Option A를 죽였다. 그리고 동시에 진짜 요구사항을 드러냈다 — 완료 상태는 세션 경계를 넘어 살아남는 곳, 즉 디스크의 git 파일에 있어야 한다. 이것이 Anthropic의 Initializer-Executor 패턴이 “durable project environment"를 강조하는 이유이기도 하다. 루프의 상태는 대화(세션)가 아니라 파일 시스템에 산다.
교훈: 재사용할 수 있어 보이는 네이티브 프리미티브라도 스코프(수명)가 요구사항과 맞는지 먼저 확인하라. "세션 스코프 vs 크로스세션 durable"의 차이가 설계의 생사를 갈랐다.
3. 설계: 완료 상태를 파일 하나로, 통과를 exit-code로
폐기 후 남은 설계는 단순하다.
(a) 완료 상태의 단일 authority = checklist.json. Work마다 docs/works/<W>/checklist.json 파일 하나가 완료 상태를 소유한다. 스키마는 최소한이다.
[
{ "id": "R2", "description": "verify-done 게이트",
"acceptance": "active checklist 미완 시 FAIL",
"verify": "bash scripts/verify-done.sh", "passes": false }
]
계획(planning-results.md)에서 파생되고, passes는 생성 시 항상 false로 강제된다(default-FAIL 계약). 상태 표현을 하나로 축소한 것도 의도적이다 — progress.md에서 상태 컬럼을 걷어내 서술 전용으로 두면, 완료 상태가 두 곳에서 어긋나는 drift가 원천 차단된다.
(b) 통과는 모델이 아니라 명령이 결정한다. 핵심은 pass 연산이다. 모델이 “이거 됐어"라고 말한다고 passes:true가 되지 않는다. checklist.sh pass <id>는 그 항목의 verify 명령을 실제로 실행하고, exit code가 0일 때만 통과로 뒤집는다.
def cmd_pass(work_dir, item_id):
# ... 항목 조회 ...
r = subprocess.run(verify, shell=True, capture_output=True,
timeout=600)
if r.returncode != 0:
# 실패한 verify는 절대 flip하지 않는다 — 증거 없는 통과 거부
return 1
target["passes"] = True
target["evidence"] = f"verify exit 0: {verify}"
이 한 줄 — if r.returncode != 0: return 1 — 이 설계 전체의 급소다. 통과 여부는 모델의 “됐다"는 느낌이 아니라 verify 명령의 raw exit-code로 결정된다.
다만 정직하게 적어둘 한계가 있다. 이 계층이 막는 것은 “verify를 돌리지 않고 통과를 찍는” 것이지, “verify를 true로 적어두는” 것까지는 아니다. verify 문자열 자체는 계획 단계(plan-task)가 planning-results에서 파생하는 것이 전제이고, executor가 self-author한 trivial verify(true/echo ok)는 이 코드가 아니라 계획 리뷰에서 걸러야 한다. 게이트는 입력이 정직할 때만 정직하다 — 이건 이 설계에 대한 fresh-context 적대적 리뷰가 정확히 짚어준 지점이다(아래 6절).
(c) verify-done.sh가 이 파일을 직접 읽어 결정론으로 판정한다. active Work의 checklist.json에 passes:false가 하나라도 남아 있으면 완료 게이트는 FAIL한다. 체크리스트가 없으면 스킵(회귀 없음). 종이 게이트가 아니라 기계 게이트다.
여기에 test-ratchet 하나를 더 얹었다 — diff에서 테스트/assert 수가 TEST-RATCHET-ALLOW 마커 없이 순감소하면 FAIL. “구현을 통과시키려고 테스트를 지우는” 우회를, 산문 규율이 아니라 코드로 막는다.
4. 정직한 범위 선언: 재발명하지 않은 것
설계에서 가장 중요한 결정은 만들지 않기로 한 것이었다.
kit은 “fresh-context-per-iteration 루프 엔진"을 스스로 구현하지 않는다. 프로그래밍으로 새 세션을 못 띄우기 때문이다(/loop·ultracode는 사용자 트리거, 대화형 전용). 그래서 루프 엔진은 네이티브에 위임한다 — 세션 내 반복은 Task별 fresh subagent(이미 존재), 크로스프로세스 반복은 사용자의 /loop. kit이 더하는 것은 그 패턴의 상태·게이트 레이어뿐이다. 네이티브에 이미 있는 것을 재구현하는 것은 기술 부채이지 기능이 아니다.
마찬가지로, 새 rule 파일을 만들고 싶은 유혹도 눌렀다. 규율은 새 문서가 아니라 이미 있는 문서에 한두 줄로 얹었다 — loop-engineering.md에 “요약이 아니라 planning-results 원본을 재독하라”(장기 루프에서 대화 요약은 열화한다)와 “커밋 0건이면 idle로 보고 종료”(모델의 ‘작업 중’ 주장이 아니라 관찰 가능한 git 신호로 판정). single-source-of-truth를 지키는 것이 규율을 늘리는 것보다 중요하다.
5. 다시, 작성자는 오염돼 있다
구현을 마친 뒤 — verify-done.sh가 초록불을 켠 뒤 — 나는 그 코드를 fresh context의 적대적 리뷰어에게 다시 넘겼다. 이건 지난 글의 교훈을 그대로 실천한 것이다. 완료 게이트를 통과했다는 것과 결함이 없다는 것은 다르다. 자기 검증은 자기가 닫았다고 믿은 구멍을 놓친다.
그리고 이 원칙 자체를 코드에도 새겼다 — review-code 에이전트 문서에 “작성자≠검증자(fresh context, 읽기 전용)는 충족“이라고 명시하되, “같은 모델 계열 리뷰어의 self-preference bias(자기 계열 산출물을 관대하게 보는 편향)는 아직 미충족 갭“이라고 정직하게 남겼다. 충족한 것과 못 한 것을 구분해 적는 것 — 그것이 다음 사람이 신뢰할 수 있는 유일한 기록이다.
6. 그 리뷰가 실제로 잡은 것
원칙은 그럴듯하지만, 검증은 결과로 말한다. fresh-context 리뷰어는 초록불이 켜진 코드에서 두 개의 진짜 블로커를 찾아냈다.
하나 — 완료 경로가 애초에 동작하지 않았다. pass가 verify를 실행할 때의 작업 디렉토리를 나는 work_dir의 고정된 상위 깊이(parents[2])로 계산했다. 그런데 실제 Work 경로는 docs/works/active/<W>/이고, 그 깊이에서 parents[2]는 repo 루트가 아니라 docs/였다. 결과: pytest tests/...나 ./scripts/x.sh 같은 정상적인 verify가 전부 “파일 없음"으로 exit 1 → 통과가 영구히 거부 → 완료 게이트가 영원히 FAIL. 완료를 판정하려고 만든 코드가 완료를 원천 봉쇄하고 있었다. fail-safe이긴 했지만(잘못된 PASS는 없다) 기능 자체가 죽어 있었다. git rev-parse --show-toplevel로 실제 루트를 구하도록 고쳤다.
둘 — 막겠다고 한 우회를 정작 놓쳤다. Bash로 쓴 .py를 검증에 포함시키는 안전망을 만들면서, 나는 셸 리다이렉트(>, tee, sed -i)만 감지했다. 리뷰어는 정확히 이 방어가 겨냥한 코드젠 벡터 — python -c "open('gen.py','w').write(...)" — 가 셸 write 연산자를 안 쓰므로 그대로 통과한다고 지적했다. 인터프리터 인라인 쓰기(open(...'w'), write_text, .write()도 감지하도록 확장했다.
여기에 더해 이 글의 3절이 “피검자가 게이트 입력을 세팅할 수 없다"고 단언했던 것도 리뷰어가 붙잡았다 — verify 문자열은 executor가 init에서 직접 적으므로, 그 주장은 verify가 정직할 때만 성립한다. 그래서 이 글도, 코드의 docstring도 그 한계를 명시하도록 고쳤다(3절의 현재 문장이 그 결과다).
교훈은 지난 글과 같지만 더 날카롭다. 내 코드는 초록불이었다. 기계 게이트를 전부 통과했다. 그런데도 완료 경로는 죽어 있었고 방어는 뚫려 있었다. 자기 검증으로는 절대 못 봤을 것들이다 — 작성자는, 언제나, 오염돼 있다.
부산물 하나. 이 작업을 하던 긴 세션에서 도구 호출이 자꾸 깨지며 작업이 중간에 멈추는 일이 반복됐다. 원인을 플러그인·터널·모델로 옮겨다니며 헤맸지만, 소거법 끝에 남은 답은 롱컨텍스트 열화였다 — 세션이 수 시간에 걸쳐 극도로 길어지면 특수 토큰(함수 호출) 생성이 먼저 불안정해진다. 우리가 이번에 리서치한 “context rot"이 정확히 그 현상이었다. 컨텍스트를 압축(/compact)하자 즉시 복구됐다. 이론이 예측한 실패를 우리 자신이 겪은, 조금 부끄럽지만 정직한 각주다.