# PR #41 수정분 델타 리뷰 — 1b4efe14 **NOT PASS.** N1의 TERM 무시 감시자에 대한 무한 wait는 해결됐고, 새 테스트가 다루는 R1/R2 상황도 통과했다. 다만 블로킹 알림 발행 뒤에는 dispatch를 재확인하지 않아 완료·재디스패치 후 prompt가 여전히 전송된다. 부모 PID를 대기 직후에 확인하지 않는 경로도 남았다. 아래 두 잔여 결함을 작은 후속 수정으로 해결할 수 있으므로 재계획 에스컬레이션은 필요하지 않다. ## 검증 identity - base: `1673818bb5fed14ed6fee87884e70d39e81fe91f` - head: `af0a2a2557768fa316203b177e1b90e9f7283a1a` - tree: `0310682605711b1dc7e31cda84ed6697d8ba6ce8` - loop: `loop-20260926T052018Z-597343` - 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260926T052018Z-597343/1` 이 경로에서 5개 파일의 delta(+406/−12)를 검토했다. 소스·본 체크아웃을 수정하거나 실제 herdr/에이전트 세션을 조작하지 않았다. 테스트와 탐침은 임시 레지스트리, HOME, 모의 herdr 및 소유 프로세스만 사용했다. 아래 파일:라인은 head 기준이다. ## 남은 차단 결함 ### D1 · P2 · R2 미완료 — 알림 발행 중 완료/재디스패치되면 이전 dispatch의 prompt를 보낸다 **근거:** `.agents/skills/lib_py/stall_watch.py:384`에서 `_dispatch_open()`을 확인하지만, `:390`의 `_notify()`가 돌아온 뒤 `:393`은 바로 prompt를 보낸다. `_notify` 내부 `:246`은 별도 발행 프로세스를 최대 120초 기다리는 블로킹 호출이다. 따라서 이것은 마지막 검사와 prompt 사이의 미세한 명령어 경합만이 아니라 네트워크 대기 구간이다. 발행 rc 0은 “현재 dispatch가 아직 열려 있음”을 보장하지 않는다. `.agents/skills/lib_py/delegation/publish_event.py:461`은 wire 발행 전에 progress를 로컬에 기록하고, `:486`의 상태 동기화는 best-effort다. `:508`~`:513`의 반환값은 wire 성공/실패에 따라 정해진다. 발행 도중 completed가 기록되거나 새 attempt가 시작돼도 rc 0이 가능하다. rc 2도 감시자에서는 로컬 기록 성공으로 받아들이므로, 실패 브로커도 최종 dispatch 검사 대신 사용할 수 없다. **재현 절차:** 임시 UUID4 잡을 running으로 등록하고 실제 `StallWatch._act()`에 idle/seq 일치/자식 없음/빈 입력 Source를 준다. `_notify` 자체는 실제 코드를 사용한다. 그 subprocess 실행 경계만 실제 `publish_event.main()` 호출로 연결하고, publisher의 wire 함수에서 다른 actor의 completed 전이를 끼워 넣은 뒤 성공을 반환한다. 재디스패치 변형에서는 wire 대기 중 completed 이후 임시 레코드를 새 token·attempt 2/running으로 교체한다. 실제 네트워크·세션은 사용하지 않는다. **기대:** 발행이 끝난 뒤 최신 job/token/attempt를 재확인하여 완료 또는 다른 dispatch면 prompt를 보내지 않는다. **실제:** completed 변형은 `publisher_rc=[0]`, `record={status: completed, attempt_id: 1}`인데 prompt 1회가 기록됐다. 재디스패치 변형도 `publisher_rc=[0]`, `record={status: running, attempt_id: 2}`인데 이전 잡/attempt의 prompt 1회를 기록했다. 이 탐침은 `_notify=True`만 반환하는 대역이 아니라 실제 발행 로직의 rc를 사용한다. **같은 경계의 일시 오류 문제:** `_dispatch_open()`의 `:276`~`:277`은 OSError/ValueError를 `True`로 바꾼다. 별도 탐침에서 완료된 임시 레코드의 최종 검사에만 ValueError 1회를 주고 이후 읽기는 정상으로 두자, 실제 publisher가 progress를 rc 0으로 발행하고 prompt가 completed 상태에 전송됐다. 관측 루프를 계속하는 것과 prompt를 허가하는 것은 분리해야 한다. **수정 방향:** `_notify` 반환 후 실제 prompt 직전에 job/token/attempt를 다시 읽어 확인한다. 읽기 실패는 감시자 전체를 즉시 죽일 필요는 없지만 해당 회차의 재주입은 보류한다. 발행 rc 0/2를 상태 확인의 대체재로 삼지 않는다. wire 발행 중 completed/redispatch, 마지막 읽기의 일시 오류에 대한 테스트를 추가한다. 긴 RPC 동안 registry lock을 잡을 필요는 없다. ### D2 · P2 · R1 잔여 경로 — 부모가 대기 중 사라져도 한 번 더 알림·재주입한다 **근거:** `.agents/skills/lib_py/stall_watch.py:298`은 루프 머리에서만 부모 PID를 확인한다. `:315`의 `wait_once` 뒤 `:332`는 dispatch만 검사하고 부모를 검사하지 않는다. 이어 `_act`의 collect/notify/prompt가 실행된다. transport error 분기 `:316`~`:328` 역시 부모 소멸을 다시 확인하기 전에 경고 발행과 backoff를 수행한다. **재현 절차:** 실제 `StallWatch.run()`에 모의 wait/Source와 시계를 준다. 첫 wait에서 idle을 관측하고 두 번째 wait 동안 부모가 사라진 것으로 바꾸되 잡은 running으로 유지한다. TIMEOUT 반환 시 idle 임계값을 넘고 입력은 비어 있으며 자식은 없다. `_alive`는 이후 False를 반환한다. **기대:** wait가 끝나면 부모 소멸을 확인해 rc 0으로 종료하고 새 알림·prompt를 보내지 않는다. **실제:** `parent_alive=False`, `notices=['stalled:idle']`, `prompts=1` 뒤에야 다음 루프 머리에서 rc 0으로 종료했다. 무기한 고아 문제는 일반 경로에서 해결됐지만, 제어 주체가 없어진 뒤의 부작용은 남아 있다. 또한 `_notify`의 120초 대기와 다른 herdr 호출이 추가될 수 있어 `:300`~`:301`의 “최대 한 번의 wait만 지연”이라는 설명도 현재 제어 흐름 전체에 대해서는 성립하지 않는다. **수정 방향:** wait에서 돌아온 직후, 오류 알림 전에 부모를 검사하고, collect/발행 같은 블로킹 작업 뒤 prompt 직전에도 검사한다. 부모 소멸 시 잡을 닫지 않고 조용히 종료하는 기존 원칙은 유지한다. 새 R1 테스트의 `MAM_STALL_IDLE_S=600`은 종료만 검증하므로, 부모 소멸과 idle 임계값 도달이 겹치는 사례도 추가한다. 실행 가능한 D1(완료/재디스패치)·D2 탐침: `/tmp/1b4efe14-tools/probe_notify_gap.py`. 검증 cwd에서 브리프의 Python으로 실행하고 위 검증 경로를 인자로 전달한다. ## 요청 항목별 판단 **N1 종료 대기 — 해결.** 래퍼 `:710`~`:731`의 TERM→25×0.2초 폴링→KILL→wait는 TERM을 무시하는 정상 OS 자식의 무한 wait를 제거한다. 5초는 TERM 유예 시간이며 fork/scheduling/회수까지 정확히 5.000초 이하라는 뜻은 아니다. KILL 뒤 자신의 자식을 wait하는 부분에서 이번 결함과 같은 TERM 무시 경로는 남지 않는다. 빈 STALL_PID는 바로 반환하고, 이미 끝난 rc 7 자식은 오류를 보고한 뒤 반환하는 것을 별도로 확인했다. 커널 자체가 회수 불가능한 프로세스 같은 일반 OS 제한을 이번 PR의 새 차단 사유로 삼지 않았다. **watch-pid 방식 — 조건부 수용.** trap 합성 자체가 필수는 아니다. 래퍼가 양의 실제 `$$`를 넘기고 Python이 소멸을 관측하는 방법은 SIGKILL에도 대응할 수 있다. `_alive`의 PermissionError→True, ProcessLookupError→False를 확인했다. 단, 정상 herdr CLI가 timeout 안에 반환한다는 가정이 있으며 `watcher.py:165`에는 독립 subprocess timeout이 없다. 이번 수정의 문제는 D2의 관측 위치다. “TERM에서도 trap을 전혀 실행할 수 없다”는 설명은 정확하지 않지만, trap 부재 자체로 NOT PASS를 내지는 않는다. 문서의 “exactly as long”은 검사 지연을 반영해 완화하는 편이 정확하다. **dispatch 검사와 blocked 알림 — 부분 수용.** 없는 레코드를 gone으로 보는 것은 맞다. 일시 오류 때 관측을 계속하는 것도 가능하지만 `True`로 prompt까지 허용하는 것은 D1에서 고쳐야 한다. token/attempt 불일치 검사는 정상 레코드에서 유효하다. blocked는 자동 응답 없이 progress만 보내므로, detection 조회 중 상태가 바뀌어 뒤늦은 진단 1회가 남는 것 자체는 이번 재주입 결함과 같은 차단 사유로 보지 않는다. 그래도 부모 소멸 후 추가 발행은 D2의 공통 검사로 막는 편이 좋다. **새 테스트 — 유효하지만 경계가 좁음.** stop 함수 자체의 TERM 무시 대역과 실제 래퍼/shim 경로를 함께 검증하는 접근은 좋다. 부모 소멸과 wait 중 완료/redispatch, 발행 거부의 7건은 현재 코드에서 통과했다. 반면 R2 테스트는 `_notify`를 즉시 True/False를 반환하는 메모리 대역으로 바꾸므로 실제 wire 대기 중 상태 변경을 검증하지 않는다. 정상 테스트 경로의 프로세스 정리는 확인했지만 실패 경로 정리는 보완할 만하다. 예를 들어 `tests/test_issue30_stall_watch_stop.py:162`의 assert가 실패하면 `:163`의 stand_in.kill에 도달하지 않으며 finally는 watcher만 죽여 sleep 300이 남을 수 있다. 이 점은 테스트 유지보수 비차단 사항이다. **M8 — 해결.** /proc에 의존하는 S6만 skip으로 좁히고 R4 모의 프로세스 사이의 timestamp 비교를 time.time으로 바꾼 것은 이 macOS/Python 3.9 환경에 맞다. 생산 감시자의 연속 idle 판정은 여전히 단일 프로세스 monotonic 시계를 쓰므로 바뀌지 않는다. S5 상주 자식 두 사례도 이번에는 원본 테스트로 실행됐다. 주석은 모든 macOS/Python 버전으로 일반화하기보다 지원 환경에서의 프로세스 간 계측 문제로 설명하는 것이 더 정확하다. ## 1회차 비차단 항목 처리표 | 1회차 항목 | 이번 상태 | 판단 | | --- | --- | --- | | 선행 0 예산 `MAM_JOB_TIMEOUT_S=08`의 bash/Python 불일치 | 미반영 | #17 항목 4의 공통 정수 파서로 넘길 기존 비차단 부채. | | delegate SKILL의 idle/total-budget/broker 라벨 및 v4.7.5 문구 | 닫힘 | 새 표가 idle과 wall clock을 구분하고 v4.8.0으로 정정됐다. | | stall 테스트 모듈 전체 Linux skip | 닫힘 | S6만 skip. 순수·모의/프로세스 테스트가 macOS에서도 실행된다. | | R4 모의 프로세스 사이 monotonic 기준점 문제 | 닫힘 | 모의 timestamp만 교체됐다. | | 기존 issue18 전달 테스트의 등록 broker/네트워크 timing 의존 | 미반영 | 이번 delta에 해당 fixture 수정 없음. B3 공개 브로커 지연(M9)과 함께 별도 격리 개선 대상. 이번 판정의 차단 근거 아님. | | bash의 예산 검증·FINALIZE_CLOCK·종결 정책 | 후속 유지 | #17 항목 4 이전 대상. 이번 TERM/KILL/wait는 외부 프로세스 관리라 bash 책임에 맞는다. | ## 검증 결과 지정된 네 모듈을 검증 경로에서 브리프의 worktree `.venv/bin/python`과 `AGENT_PYTHON_BIN`으로 실행했다. `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 외부 `--basetemp=/tmp/1b4efe14-suite`를 사용했다. | 실행 | 결과 | | --- | --- | | `test_issue30_stall_watch_stop.py`, `test_issue30_stall_watch.py`, `test_issue14_delivery_ack.py`, `test_tier1_unit.py` — 134건 | 132 passed / 1 failed / 1 skipped, 590.08초 | | 실패한 `test_wrapper_idle_timeout_grace_ignores_pane_busy_token`만 별도 basetemp에서 재실행 | 1 passed, 27.69초 | 최초 실패는 `tests/test_issue14_delivery_ack.py:350`에서 started 발행이 브로커 CONNACK을 세 차례 받지 못해 rc 2를 반환한 경우다. 재실행에서 통과했으며 제품 결함 판정에 포함하지 않았다. 선택한 사례 기준 133건의 통과를 확인했고, S6(/proc) 1건은 이 macOS 환경에서 미검증이다. 새 stop 7건, 기존 stall 47건은 원본 코드/테스트 그대로 통과했다. 전체 실행과 재실행 후 이번 임시 경로를 사용하는 감시자·heartbeat 프로세스가 남지 않았는지 확인했다. 추가 검증은 다음과 같다. - 임시 base 모듈로 새 R2 테스트 3건 실행: 완료 중 wait, 재디스패치 중 wait, 발행 거부 모두 예상한 AssertionError로 실패. head의 해당 3건은 원본 suite에서 통과. - base의 stop 함수를 임시 프로세스 그룹에서 실행: TERM 무시 자식을 기다리며 8초 후에도 반환하지 않음. 탐침 그룹을 KILL하고 회수했다. head의 상한 테스트는 원본 suite에서 통과. - head의 empty PID/이미 종료 rc 7, `_alive`의 PermissionError/ProcessLookupError 4개 추가 확인 통과. - 실제 publisher 로직을 통과하는 완료/재디스패치 중 알림 탐침 2건, 부모 소멸 중 wait 탐침 1건, 일시 읽기 오류 뒤 completed prompt 탐침 1건은 위 결함을 재현했다. 실제 MQTT나 herdr 세션에 발행하지 않았다. - `git diff --check 1673818`, 래퍼 `bash -n` 통과. 검증 트리 HEAD/tree 및 깨끗한 상태를 재확인했다. 이전 설계 합의(알림 전용, #39 분리, completed 수신 측 #40 트리거, #17 항목 4 후속)는 유지한다. 이번 NOT PASS는 설계 의견이나 미반영 비차단 사항이 아니라 재현된 D1·D2에 근거한다. [VERDICT: NOT PASS]