### [reviewer-opencode-01] Issue #6 정밀 검토 및 해결 방안 보고서 - **Job**: `17f2e688` / **검토자**: `reviewer-opencode-01` (opencode) - **검토 방법**: 이슈가 인용한 모든 위치를 라이브 소스에서 직접 읽고, 결함 체인 전체(worker break → 레지스트리 → wait_for_job)를 코드 라인 단위로 추적하며, 셸 루프(`run_loop.sh`)와 Python 루프(`lib_py/loop/runner.py`) 양쪽 소비자를 대조하고, 기존 테스트 커버리지를 확인했다. 요약을 신뢰하지 않고 재현 경로를 직접 검증했다. --- ## 1. 버그 진위 여부 검토 결과 ### 결함 ① (Fail-Silent on Worker Failure) — **실재 확인 (CONFIRMED REAL)** 이슈의 인용은 정확하다. `multi-agent-mux-delegate-job`의 반복 루프: - **:351-361** — `_phase == "worker"`에서 `job_status != "completed"`인 경우(주입 실패로 인한 subscriber timeout, 프롬프트 미수신 idle timeout 등) `echo ... Terminating workflow.` 후 `break`. - 이 `break` 경로에는 **어디에도** `registry.py status --set` 호출이 없다. 반비교: 루프 내 다른 종료 경로는 레지스트리를 올바르게 갱신한다 — reviewer 승인 시 `--set "completed"` (:394), 최대 반복 도달 시 `--set "error"` (:400). - 즉 worker 실패 조기 탈출이 **유일하게 레지스트리를 방치하는 종료 경로**다. 잡은 생성 시 `status: "pending"`(`lib_py/delegation/registry.py:84`)으로 등록되므로, 이 경로를 밟으면 `.json`은 **영구히 `pending`**으로 남는다. - 함수는 이후 validation hook을 돌고 audit-log 경로를 stdout으로 출력한 뒤 정상 종료한다 — `exit 0`(fail-silent). 이슈 기술과 정확히 일치. **소비자 측 교착 확인**: 셸 `wait_for_job`(`run_loop.sh:270-306`)과 Python `wait_for_job`(`lib_py/loop/runner.py:291-323`) 모두 종료 조건이 `completed`(→rc 0), `error`/`cancelled`(→rc 1)뿐이고, 그 외 상태(`pending` 포함)는 `sleep $check_interval` 후 재폴링한다. `pending`은 어떤 종료 조건에도 해당하지 않으므로 **`max_wait=3900s`를 전량 소진한 뒤 타임아웃으로 rc=1** — 정확히 이슈가 기술한 65분 영구 대기. 두 런타임(v4.4.0의 Python 코어 포함) 모두 동일하게 노출된다. ### 결함 ② (Race Condition on TUI Turn Handshake) — **실재 확인 (CONFIRMED REAL, 그러나 이미 부분 완화 장치 존재 — 임계값/대기시간 문제)** - `send_keys_safe`(`lib.sh:2231-`)는 주입 전 `_pane_quiescent`(:2189)로 **연속 2회 동일 캡처**(렌더러 정지)를 확인한다. 이것이 바로 이슈가 요구하는 settling 브리미티브의 존재 자체는 이미 구현되어 있다. - 다만 실측 파라미터: `SKS_QUIESCENT_TRIES=20` × `SKS_QUIESCENT_INTERVAL=0.5s` = **최대 10초 대기**. 에이전트가 도구로 `completed` 이벤트를 발행한 직후(턴 내부) 오케스트레이터가 즉시 다음 잡을 생성해 주입하면, 에이전트 TUI는 아직 턴 출력을 렌더링 중이므로 캡처 내용이 계속 변한다. 렌더링이 10초 이상 지속되면 `_pane_quiescent`가 rc=1을 반환하고 `send_keys_safe`는 "pane never quiesced"와 함께 **주입 자체를 거부**한다. - **결함 ②와 ①의 연쇄(핵심)**: `run_agent`의 주입 실패는 rc=1을 반환하지만, 루프의 호출부(:327) `run_agent "$JOB_ID" ... `는 **반환값을 무시한다**(`if ! run_agent` 아님). 이후 `wait "$sub_pid"`는 프롬프트가 주입되지 않은 에이전트를 대기하다 idle timeout으로 rc≥2 → `job_status="timeout"` → worker break → 결함 ①의 fail-silent → `pending` 교착. 즉 **②는 ①의 주요 트리거이며, ①은 ②의 피해를 교착으로 증폭**한다. 두 결함은 독립 패치가 아니라 하나의 수정 단위로 다뤄야 한다. --- ## 2. 결함 메커니즘 및 파급 효과 분석 | 항목 | 분석 | |---|---| | **발생 빈도** | 결함 ②는 렌더링 지연 > 10s인 에이전트/대형 출력에서 현실적으로 발생(이슈 자체가 실관측 사례). ②가 터질 때마다 ①로 인해 65분 교착이 뒤따른다. | | **루프 관점 파급** | `run_loop.sh`의 `--max-loop` 사이클 전체가 단일 worker 실패 하나로 최대 65분 정지. 병렬 리뷰어(`--all-reviewer`) 펜딘트 시 리소스 점유 + 대기 누적. | | **데이터 무결성** | `.json`의 `pending`은 "미수행"과 "수행 실패"를 구별 불가하게 만든다 — 감사 로그(events.log)에 subscriber 기록은 남지만, 상태 원장 자체가 거짓("아직 안 함"). 이는 B-14(로컬 디스크 100% 동기화)에서 수립한 "디스크 상태가 진실"이라는 계약의 잔여 예외다. | | **심각도** | ①: **HIGH** (영구 교착 + 원장 오염). ②: **MEDIUM-HIGH** (교착의 주 트리거, 다만 quiescent 브리미티브가 있어 완전 무방비는 아님). | ## 3. 수정 난이도 및 위험도 평가 | 결함 | 난이도 | 공수 | 파급 범위 / 위험 | |---|---|---|---| | ① 레지스트리 fail-closed 전이 | **하 (Low)** | ~0.5일 (테스트 포함) | worker/reviewer 양 break 경로에 `status --set error` 1줄씩 추가. 소비자(`wait_for_job` 2개)는 이미 `error`를 정상 종료 조건으로 다루므로 **소비자 변경 불필요** — 파급 최소. 유일 위험: break 시점의 상태를 `error`로 덮어쓸 때 이미 디스크에 terminal 상태가 있는 경우(rc=3 broker_unavailable 분기가 이미 디스크 우선 병합을 하므로 그대로 재사용). | | ② TUI settling 안전화 | **중 (Medium)** | ~1일 | `run_agent` 반환값 접수(루프 :327의 `if ! run_agent` 화) + quiescent 파라미터 튜닝/환경변수화(`SKS_QUIESCENT_TRIES/INTERVAL`는 이미 튜너블). 주입 실패를 즉시 `error` 전이로 연결하면 ②가 터려도 ① 덕분에 루프가 **빠르게 실패 종료**된다(사실상 ②의 폭발 반경을 ① 패치가 절단). 위험: quiescent 대기 연장은 루프 지연을 늘리므로 상한 환경변수로 봉인 필요. | ## 4. 구체적 해결 방안 및 아키텍처 개선안 ### 결함 ① — fail-closed 레지스트리 전이 1. **worker break (:360) 직전**: `"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" status --job "$JOB_ID" --set "error"` 추가 — `job_status` 변수가 이미 있으므로, 보존을 원하면 `--set "$job_status"` 계열로 세분화해도 되나 `wait_for_job`의 종료 어휘가 `error`이므로 **정규화해서 `error`로 기록** 권장. 2. **reviewer break (:365) 동일 적용** (대칭 결함, 이슈에는 미기재지만 동일 라인 구조). 3. **`run_agent` 실패 즉시 전이(② 연쇄 절단)**: 루프 :327을 `if ! run_agent ...; then ; break; fi`로 변경 — 주입 실패가 subscriber 120초 대기를 거치지 않고 즉시 실패로 확정. 4. **중복 방지**: rc=3 broker_unavailable 분기(:342-347)의 디스크 우선 병합 결과(`completed`/`error`)를 그대로 존중 — 이미 terminal이면 덮어쓰지 않는 가드(`disk_st` 체크 재사용). ### 결함 ② — TUI settling 안전 처리 1. **즉시 조치**: 주입 실패의 즉시 error 전이(위 4-3)가 핵심 — settling은 완화 장치일 뿐, 실패 시의 원장 정직성이 본질. 2. **quiescent 파라미터 환경변수화 튜닝**: `SKS_QUIESCENT_TRIES`(기본 20)를 잡 주입 맥락에서 `MAM_SKS_INJECT_TRIES`(예: 40회×0.5s=20s)로 상향하는 옵션 + **프롬프트 복귀(`>`) 감지 시 조기 탈출** — `_pane_quiescent`가 "동일 캡처"만 보는 것을, adapter의 `input_prompt`/`input_placeholder` 사실(`lib_py.agents facts`)과 교차 확인하면 idle 복귀를 정확히 포착(사실 브리지가 이미 내보내는 `MAM_INPUT_PROMPT`/`MAM_INPUT_PLACEHOLDER`/`MAM_INPUT_RULE_PATTERN`을 `send_keys_safe`가 이미 수신하므로, quiescent 성공 판정에 placeholder 프레즌스 조건을 추가하는 것이 최소 변경). 3. **선택(중기)**: 주입 재시도 1회 — 실패 시 settle 재대기 후 1회 재주입, 재실패 시 error 전이. 무한 재시도는 금지(교착의 새 형태). ## 5. 검증 및 테스트 계획 1. **결함 ① 회귀 가드 (신규)**: mock herdr/agent 환경에서 worker 주입 실패를 유도(`run_agent`가 rc=1 반환하도록) → 잡 `.json`이 `error`로 갱신되고 `wait_for_job`이 `max_wait` 소진 없이 rc=1로 즉시 복귀함을 단언. 반례: broker_unavailable+디스크 completed 시 덮어쓰기 없음. 2. **결함 ② 회귀 가드 (신규)**: `_pane_quiescent`가 연속 변화 캡처(렌더링 중)를 시뮬레이션하는 mock으로 `send_keys_safe`의 거부 → `run_agent` rc=1 → 레지스트리 `error` 전이 연쇄를 단언. 3. **기존 스위트**: `tests/test_o2_race_free_lock.py`(loop lock), Issue #5 Phase 2a/2b G-가드들, B-14/B-15의 G-1~G-10(`tests/test_b13*`, `test_issue5_*`)이 이번 변경과 충돌 없음을 확인 — 특히 `test_g5_no_blocking_io_while_the_registry_lock_is_held`가 새 `status --set` 호출 위치(락 밖)와 정합. 4. **전체 스위트**: 현재 기준선(750 passed) 유지 확인. 5. **라이브 스모크**: 실제 2-에이전트 위임에서 일부러 주입을 방해(세션 일시 정지) → 루프가 65분이 아니라 수십 초 내에 `error`로 종료되는지 실측. ### 총평 두 결함 모두 **실재하며 상호 증폭 관계**다(②가 트리거, ①이 교착으로 변환). ①은 원장 정직성 계약(B-14 계열)의 마지막 잔여 구멍이고 수정 비용이 매우 낮다(난이도 하, ~0.5일). ②는 이미 존재하는 quiescent 프리미티브를 주입 맥락에 맞게 튜닝하고 `run_agent` 반환값을 루프가 수용하도록 하는 중간 난이도 작업(~1일)이다. 둘을 한 수정 단위로 묶어 진행할 것을 권고하며, 설계 변경 수준의 재계획은 불필요하다.