-
opencode 한 줄 호출에 숨은 3가지 함정 — `--` 구분자, 5분 timeout, 직후 헬스체크 (Ralph Loop 시리즈 5편)IT 2026. 5. 17. 22:00
자율 코딩 루프의 핵심은 결국 외부 CLI 한 줄 호출 이다.
opencode run --model X -f file1 -f file2 -- "<prompt>"라는 한 줄. 그런데 이 한 줄에 세 가지 함정 이 숨어있어 ralph-loop 를 짜면서 모두 한 번씩 부딪혔다. 함정마다 한 번씩 사고를 겪고 한 줄씩 추가해서 — 이제는 안전한 호출이 됐다.이 글은 그 한 줄의 진화를 따라간다. (1) yargs 의
-farray 가 prompt 를 삼키는 함정, (2) vLLM 응답 hang 시 무한 대기를 끊는 timeout, (3) opencode 호출 직후 vLLM 죽음 감지하는 헬스체크. 시리즈 5편이고, 1~4편은 컨텍스트 3축, 부팅·셧다운, vLLM 헬스 보장, prompt 다층 메시지를 다뤘다.최종 형태 — 그 한 줄
먼저 결과물부터 보자. ralph-loop.sh 안에서 opencode 를 부르는 부분이다.
# prompt 크기 측정 (vLLM 죽음 트리거 분석용) prompt_size=$(printf '%s' "$prompt" | wc -c) echo " prompt: ${prompt_size} chars | CHECKLIST: $(wc -c < CHECKLIST.md) chars" # `--` 구분자로 -f array 파서가 prompt 를 삼키는 것 방지 # timeout 300 — vLLM 죽음 등으로 응답 hang 시 5분 후 강제 종료 (124 반환) opencode_rc=0 timeout 300 opencode run \ --model "$MODEL" \ -f PLAN.md \ -f CHECKLIST.md \ -- "$prompt" 2>&1 | tee "$log" || opencode_rc=$? if [ "$opencode_rc" -eq 124 ]; then echo "⏱ opencode 5분 timeout — vLLM 응답 hang 의심, 다음 iter 로 진행" fi # opencode 직후 vLLM 죽음 감지 — 죽었다면 직전 prompt 크기와 함께 진단 캡처 if ! curl -sS --max-time 3 "$VLLM_BASE_URL/models" > /dev/null 2>&1; then echo "💀 opencode 호출 직후 vLLM 죽음 감지" ensure_vllm_alive "$iter (post-opencode)" "$item" "$prompt_size" || { echo "❌ 재기동 불가 — 루프 중단" exit 4 } fi이 17줄이 함정 셋의 답이다. 한 줄씩 처음부터 어떻게 진화했는지 보자.
함정 1 — yargs `-f` array 가 prompt 를 삼킨다
처음 시도한 호출은 단순했다.
opencode run \ --model "$MODEL" \ -f PLAN.md \ -f CHECKLIST.md \ "$prompt"실행하면 즉시 에러가 났다.
Error: File not found: 당신은 PLAN.md 의 설계를 따라 car-game 을 구현하는 자율 에이전트다. ## 컨텍스트 - **PLAN.md (불변)**: 첨부됨. 절대 수정 금지. ...모델한테 던지려던 prompt 가 "File not found: ..." 의 파일 경로로 해석된 것이다. 처음엔 이게 왜 그런지 몰랐다 — opencode 의
--help출력에 따르면-f가 file 첨부 옵션이고 마지막은 message positional argument 라 명시되어 있었다. 직관대로면 동작해야 하는데.원인은 opencode 가 내부적으로 yargs 라는 Node.js 인자 파서를 쓰는데, yargs 의
-f같은 array 옵션 이 연이은 positional argument 까지 모두 흡수 하는 동작이 default 라는 거였다.opencode run --help로 확인:-f, --file file(s) to attach to message [array]이
[array]가 핵심. yargs 는 이 옵션 뒤에 오는 모든 인자 — 다른 옵션 (--xxx) 또는 명시적 끝 표시 (--) 를 만나기 전까지 — 를 array 에 넣는다. 우리 호출에서:opencode run --model X -f PLAN.md -f CHECKLIST.md "$prompt" └────────────┴──────────────┘ ← -f 배열에 다 들어감즉
"$prompt"가 마지막-f의 세 번째 file 인자 로 해석돼 그 prompt 텍스트를 파일로 읽으려다 실패 한 것이다.해결은 yargs 의 표준 명시적 끝 표시
--. POSIX 셸 호출 컨벤션에서--뒤는 옵션이 아닌 positional 이라는 표시다.opencode run \ --model "$MODEL" \ -f PLAN.md \ -f CHECKLIST.md \ -- "$prompt" ← 이 한 줄이 핵심--가 yargs 에게 "여기까지가 옵션, 다음은 positional" 을 명시한다. 그러면-farray 는 PLAN.md 와 CHECKLIST.md 에서 멈추고"$prompt"가 정확히 message positional 로 들어간다.그림 설명 — 같은 명령에
--한 단어가 들어가고 안 들어가고의 차이가 prompt 가 message 로 가느냐 file 경로로 해석되느냐 다. yargs 의 array 옵션은 다음 옵션 (--xxx) 또는--를 만날 때까지 인자를 계속 흡수하는데, 그 사이에 positional argument 가 끼어있으면 그것까지 array 에 넣는다.--한 줄로 흡수가 멈춘다. 이게 yargs 만의 동작이 아니라 POSIX 인자 파싱 컨벤션 이라 — Pythonargparse도, Gocobra도, Rustclap도 같은 동작을 한다.함정 2 — vLLM 응답 hang 시 무한 대기
두 번째 함정은 한 시간 동안 ralph-loop 가 진척 0인 채로 멈춰있던 사고에서 알게 됐다. 그 시점 vLLM 컨테이너는 죽어있었고 (시리즈 3편의 KV cache assertion 자살), 그 사이 opencode 가 죽은 endpoint 에 호출을 던지고 있었다. opencode 는
http://localhost:8000/v1/chat/completions에 POST 했는데 OS 레벨에서 connection 이 거부되거나 timeout 응답이 매우 느렸다.관찰된 패턴은 — opencode 가 응답 시작은 받았지만 (TCP 핸드셰이크 + 첫 chunk 정도) 모델 응답이 끊겨서 무한 대기 상태가 됐다. opencode 자체에 timeout 설정이 없으면 "streaming response 가 끝날 때까지 대기" 가 default 다. 응답이 영영 안 오면 영영 대기.
해결은 외부에서 강제 timeout 을 거는 것 — bash 의
timeout명령을 사용하는 방법이다.timeout 300 opencode run \ --model "$MODEL" \ -f PLAN.md \ -f CHECKLIST.md \ -- "$prompt" 2>&1 | tee "$log" || opencode_rc=$? if [ "$opencode_rc" -eq 124 ]; then echo "⏱ opencode 5분 timeout — vLLM 응답 hang 의심" fitimeout 300은 5분 안에 끝나지 않으면 SIGTERM 을 던진다. opencode 가 SIGTERM 받으면 즉시 종료하고 timeout 명령은 종료 코드 124 를 반환한다. ralph-loop 의 다음 검증 후크 (시리즈 6편) 가 "opencode 가 비정상 종료했고 CHECKLIST 토글도 안 됐다" 로 잡아 시도 카운트를 누적하고, 3-strike 후 자동 스킵 (시리즈 7편) 한다.왜 5분(300초) 인가? 정상 iter 는 30초~2분 사이에 끝났다. 가장 무거운 작업 (테스트 작성 + 도구 호출 다수) 도 3분 안. 5분이면 정상 작업의 안전 마진 + 외부 자원이 살아있다면 회복할 마지막 시간 이다. 더 짧으면 정상 작업이 잘리고, 더 길면 죽은 vLLM 에 너무 오래 매달린다.
함정 3 — opencode 직후 vLLM 죽음 감지
세 번째 함정은 미묘하다. timeout 으로 hang 은 막았는데, opencode 가 정상 종료 했지만 그 호출 도중 vLLM 이 죽은 케이스가 있었다. 우리가 던진 prompt 가 vLLM 의 KV cache assertion 을 트리거했고, vLLM 이 죽기 직전 일부 응답을 client 에 흘려보냈다. opencode 는 짧은 응답이라도 받았으니 정상 종료했고, 우리 검증 후크는 그 응답이 부족해서 미체크/미커밋 상태로 시도 카운트만 늘렸다.
문제는 — 그 시점에 vLLM 이 죽은 줄 모르고 다음 iter 가 시작되면 — 그 iter 의 시작 헬스체크 (
ensure_vllm_alive) 가 죽음을 감지하긴 하지만, 어느 prompt 가 죽음을 일으켰는지 의 인과 정보가 사라진다. 죽음의 prompt 크기를 진단 로그에 남길 수 없게 된다.해결은 — opencode 호출 직후 즉시 vLLM 헬스를 한 번 더 체크하고, 죽었다면 그 시점에 진단을 캡처하는 것이다.
# opencode 직후 vLLM 죽음 감지 — 죽었다면 직전 prompt 크기와 함께 진단 캡처 if ! curl -sS --max-time 3 "$VLLM_BASE_URL/models" > /dev/null 2>&1; then echo "💀 opencode 호출 직후 vLLM 죽음 감지" ensure_vllm_alive "$iter (post-opencode)" "$item" "$prompt_size" || { echo "❌ 재기동 불가 — 루프 중단" exit 4 } fi핵심은
ensure_vllm_alive의 세 번째 인자로$prompt_size를 넘기는 것. 이게 진단 로그의 "직전 prompt 크기" 항목으로 박혀, 사후 분석 시 "prompt 가 N chars 였을 때 vLLM 이 죽음" 의 인과를 추적 가능하게 한다.iter 시작 시점의 헬스체크와 opencode 직후 헬스체크는 같은 함수를 호출하지만 context 인자가 다르다. iter 시작 호출은
"$iter"만 넘기고, opencode 직후 호출은"$iter (post-opencode)"와 prompt_size 를 함께 넘긴다.vllm-deaths.log만 봐도 어느 단계에서 죽었는지 즉시 구분된다.그림 설명 — 헬스체크 1 (iter 시작 시점) 만 있으면 외부 요인으로 vLLM 이 이미 죽어있는 케이스 만 감지한다. opencode 호출 도중 자살한 케이스는 다음 iter 의 헬스체크 1 에서야 감지되는데, 그 시점에는 어느 호출이 트리거였는지 알 길이 없다. 헬스체크 2 (opencode 직후) 가 그 인과 단절을 막는다. 같은 함수를 두 번 호출하는데 — 위치가 다르면 잡는 정보가 다르다.
왜 외부 timeout + 직후 헬스가 모두 필요한가
이 두 안전망이 같이 있어야 한다. 한 가지만 있을 때 막지 못하는 시나리오가 각각 있다.
시나리오 timeout 만 있을 때 직후 헬스 만 있을 때 둘 다 있을 때 vLLM 죽고 opencode hang ✅ 5분 후 timeout ❌ opencode 가 안 끝나서 헬스 호출 못 감 ✅ timeout 후 헬스로 죽음 캡처 vLLM 죽고 opencode 정상 종료 (짧은 응답) ❌ timeout 안 발동, 인과 정보 사라짐 ✅ 즉시 죽음 감지 + prompt 캡처 ✅ 직후 헬스가 잡음 vLLM 살아있고 opencode 작업 정상 ✅ 정상 종료 ✅ 헬스 OK 즉시 return ✅ 둘 다 통과 vLLM 살아있는데 opencode 자체 hang (드물지만) ✅ 5분 후 timeout ❌ opencode 가 안 끝남 ✅ timeout 발동 후 헬스도 OK 어느 한 가지 안전망만으론 모든 케이스를 덮지 못한다. 두 안전망이 직교 한다 — timeout 은 시간 차원 (얼마나 오래 기다렸나), 직후 헬스는 상태 차원 (지금 의존성이 살아있나) 을 본다. 시리즈 7편의 시도 카운트가 반복 차원 의 안전망이라면, 이 둘은 그 앞단의 두 차원이다.
왜 prompt_size 를 호출 직전에 미리 측정하나
코드에 한 가지 디테일이 더 있다 — opencode 호출 직전 에 prompt_size 를 변수에 박는다.
# prompt 크기 측정 (vLLM 죽음 트리거 분석용) prompt_size=$(printf '%s' "$prompt" | wc -c) echo " prompt: ${prompt_size} chars | CHECKLIST: $(wc -c < CHECKLIST.md) chars" opencode_rc=0 timeout 300 opencode run ...왜? opencode 호출 도중 vLLM 이 죽으면 prompt 변수 값을 그 시점에 읽을 수 없을 수도 있어서 다. bash 의 변수는 같은 셸 프로세스 안에서는 안전하지만 — 만약 ralph-loop 가 SIGKILL 같은 비정상 종료를 당하고 다시 시작되면 그 사이 prompt 값이 사라진다. 미리 echo 로 출력하면 stdout 또는 log 파일에 박혀 영구적이다.
또 한 가지 —
echo로 stdout 에 미리 출력하면 cron 보고나 로그 추적 시 이 iter 의 prompt 크기가 즉시 보인다. 운영 중 어느 iter 에서 prompt 가 갑자기 커졌나 같은 질문을 빠르게 답할 수 있다.echo 와 stdout 통합 — `2>&1 | tee`
마지막 작은 디테일.
opencode run ... 2>&1 | tee "$log"의 의미는 이렇다.2>&1— stderr 를 stdout 으로 합친다. opencode 가 에러를 stderr 로 보내도 모두 한 stream 으로 모임| tee "$log"— 그 stream 을 화면에도 출력하고 동시에$log파일에 저장|| opencode_rc=$?— opencode 가 0이 아닌 코드로 종료해도 즉시 죽지 않고 exit code 를 변수에 저장
이 조합이 두 가지를 보장한다 — (1) 사용자가 화면에서 실시간으로 진행 보면서 동시에 로그에도 영구 저장, (2)
set -e가 활성화돼있어도 opencode 의 비정상 종료가 ralph-loop 를 죽이지 않게 한다 (검증 후크가 그 결과를 처리하도록).특히
set -e와|| opencode_rc=$?의 조합이 중요.set -e는 어느 명령이든 0이 아닌 코드로 종료하면 즉시 죽는데, opencode 가 도구 호출 schema 에러 등으로 비정상 종료하는 건 예상된 실패 다.|| ...가 없으면 ralph-loop 자체가 그 자리에서 죽고 cleanup → exit. 그러면 자동 스킵이나 시도 카운트 같은 dead-letter 처리가 작동 못 한다.|| opencode_rc=$?한 줄이 예상된 실패를 ralph-loop 안에서 처리 하는 길을 만든다.결론 — 한 줄 호출의 17줄짜리 답
opencode 한 줄 호출이 17줄로 늘어난 게 과도해 보일 수 있다. 그런데 각 줄이 한 가지 실패 시나리오에 대한 답이다.
--← yargs-farray 함정timeout 300← vLLM 응답 hang 무한 대기2>&1 | tee← 화면 + 로그 동시|| opencode_rc=$?←set -e와 예상된 실패 분리if rc -eq 124← timeout 발동 시 사용자 알림- opencode 직후 헬스 + ensure_vllm_alive ← 자살 감지 + 인과 캡처
- prompt_size 미리 echo ← 운영 추적성 + 진단 인자 보존
각 줄이 한 사고에서 한 줄씩 추가됐다 — 처음부터 이렇게 짠 게 아니다. ralph-loop.sh 의 git log 를 따라가면 각 줄이 추가된 commit 과 그 commit 메시지가 어떤 사고에 대한 대응이었나 를 보여준다 (시리즈 9편 통합편 주제).
외부 CLI 한 줄을 자율 시스템 안에서 안전하게 부르려면 — 그 CLI 의 인자 파서 동작 / 응답 시간 / 의존성 살아있나 / exit code 의미 / 출력 stream / 예상된 실패와 진짜 실패의 구분 — 이 모두를 명시적으로 처리해야 한다. 간단한 한 줄 이라는 환상은 운영 중 사고 한 번이면 깨진다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글