release(v4.8.0): bump framework and eight skills to 4.8.0 in lockstep; archive PR #41 reviews (three rounds)

Issue #33 (job budget, wall_clock_timeout vs deadline_timeout) and
Issue #30 (notify-only stall watcher). Reviews of PR #41 in the merge
workspace: round 1 (@1673818) claude N1 / codex R1 R2 NOT PASS, agy grok
PASS; round 2 (@af0a2a2) grok N1 / codex D1 D2 NOT PASS, claude agy PASS;
round 3 (@9200562) 4/4 PASS. Reports and the round-3 identity sidecars go
to .agents/reports/<session>/. The design consensus on items A-D is in the
VERSIONS.md entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-09-26 16:08:37 +09:00
1 parent 9200562c91
commit 53f87e4560
32 files changed
+1263 -26

No files matched your search

@@ -0,0 +1,103 @@
# PR #41 독립 리뷰 — 0f12973f
**NOT PASS.** 알림 전용 원칙과 두 종료 시계의 분리는 대체로 구현됐지만, 감시자의 래퍼 종료 처리와 완료 후 재주입 방지에 재현 가능한 결함이 있다. 아래 R1·R2를 수정한 뒤 재검토해야 한다. 전체 설계를 다시 짤 수준은 아니며, 설계 합의 의견이나 예정된 4.8.0 버전 변경을 판정 근거로 삼지 않았다.
## 검증 identity와 범위
- loop: `loop-20260926T025230Z-a30a6d`
- base: `8ea65bb23830b56c4530897270edaa6e556de392`
- head: `1673818bb5fed14ed6fee87884e70d39e81fe91f`
- tree: `00d5e03e2d521c87c3474c032f2af6f7a1af9e92`
- 검증 cwd: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260926T025230Z-a30a6d/1`
- 30개 파일의 누적 변경을 검토했다. 검증 전후 HEAD/tree가 일치하고 `git status --porcelain`은 비어 있다. 소스 수정·커밋·실제 에이전트/herdr 세션 조작은 하지 않았다. 테스트·탐침의 레지스트리, HOME, 모의 herdr, 프로세스는 임시 디렉터리에 격리했다.
이하 파일:라인은 이 head 기준이며 `.agents/skills/` 아래 경로는 생략하지 않았다.
## 병합 차단 결함
### R1 · P2 — 래퍼가 SIGTERM으로 끝나면 stall watcher가 고아로 남는다
**근거:** `.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:700`에서 감시자를 백그라운드로 실행하고 PID를 저장한다. 정리는 같은 파일 `:385`, `:560`의 정상 `wait` 반환 뒤에만 실행한다. `stop_stall_watch`는 `:708`부터 있지만 EXIT/TERM/INT 정리 trap에 연결돼 있지 않다. 기존 `:1088`의 EXIT trap은 agent bootstrap 전용이며 `:1102`, `:1108`, `:1114`에서 해제된다.
**재현:** 검증 트리의 실제 래퍼를 `MAM_DELEGATE_JOB_NO_MAIN=1`로 source한다. 임시 레지스트리에 UUID4에서 생성한 잡 ID와 `status=running`을 쓰고, PATH 맨 앞에 항상 `working` snapshot을 반환하는 모의 herdr를 둔다. 실제 `start_stall_watch herdr:isolated-probe codex`를 호출하고 래퍼가 해당 PID를 wait하도록 한 뒤 래퍼 PID에만 SIGTERM을 보낸다. 감시자는 실제 `python -m lib_py.stall_watch` 프로세스다.
**기대:** SIGTERM 같은 처리 가능한 래퍼 종료에서 감시자와 소유한 대기 프로세스를 정리하고 회수한다. 잡 상태는 감시자가 임의로 닫지 않는다.
**실제:** 래퍼 rc `-15` 뒤에도 `ps`가 감시자 `30374`, PPID `1`, 상태 `S`를 반환했다. 원래 잡이 열린 상태면 이 감시자는 계속 관측하고 나중에 알림·재주입할 수 있다. 정상 완료만 확인하는 `tests/test_issue30_stall_watch.py:503`의 S9로는 잡히지 않는다. 탐침 종료 후 탐침 전용 프로세스 그룹을 정리했고 해당 PID의 소멸을 확인했다.
**수정 방향:** 감시자 시작 전에 래퍼 소유 자식의 공통 EXIT/TERM/INT 정리를 설치하고 기존 bootstrap trap과 합성한다. kill과 wait는 bash가 맡되, 정상·오류·취소 경로 모두에서 실행되도록 한다. 감시자가 대기 중 생성한 herdr 자식도 유한 시간 안에 정리되는지 포함한 신호 종료 테스트를 추가한다. SIGKILL을 trap으로 처리하라는 요구는 아니다.
### R2 · P2 — herdr 대기 중 완료된 잡에도 idle 재주입을 실행한다
**근거:** `.agents/skills/lib_py/stall_watch.py:270`에서 잡 상태를 읽고, `:284`에서 최대 수십 초 대기한 뒤 `:310`에서 이전 상태 그대로 `_act`에 전달한다. `_act`는 에이전트 상태·자식·입력창만 새로 수집하며 잡 상태와 dispatch identity는 다시 확인하지 않는다. `:352`의 알림 뒤 `:354`는 무조건 `prompt`를 호출한다. `_notify`의 `:243` 부근은 발행 거부를 로그로만 남기므로 발행 CAS가 재주입을 막아주지도 않는다.
**재현:** 실제 `StallWatch.run()`에 모의 `wait_once`와 Source를 주입한다. 첫 대기에서 idle/seq=1을 관측한다. 두 번째 대기가 돌아오기 직전에 임시 잡 JSON을 `running → completed`로 바꾸고 TIMEOUT을 반환한다. 시계는 idle 임계값을 넘게 하고, collect는 같은 seq·살아 있는 PID·자식 없음·빈 입력을 반환한다. 알림/프롬프트만 메모리에 기록해 실제 발행과 세션 조작을 피한다.
**기대:** 완료를 재확인한 감시자는 종료하며 idle 알림·재주입을 하지 않는다.
**실제:** `run()`은 최종 rc 0으로 종료하지만 그 전에 `notifications=['stalled:idle']`, `prompts=[('completed', 'Job … otherwise continue where you left off.')]`를 기록했다. 즉 실제 prompt 호출 시점의 레코드는 이미 completed였다. 래퍼의 정상 stop과도 경합하고, R1로 남은 감시자에서는 더욱 명확히 발생한다. 동일 잡의 다음 dispatch에서도 status만 확인하는 현재 방식은 이전 token의 prompt를 차단하지 못한다.
**수정 방향:** 블로킹 관측 뒤와 재주입 직전에 잡의 비종결 상태 및 token/attempt 일치를 확인하고, 사라짐·불일치에서는 중단한다. 발행 결과를 구조화해 CAS 거부 이후에는 prompt를 보내지 않도록 한다. 완료/redispatch가 관측과 실행 사이에 끼어드는 결정적 테스트를 추가한다. registry 락을 긴 herdr RPC 동안 유지하는 방식은 피한다.
R1·R2의 실행 가능한 격리 탐침은 `/tmp/0f12973f-tools/probes.py`에 남겼다. 검증 cwd에서 브리프의 Python으로 이 파일과 위 검증 경로를 인자로 실행하면 재현된다. 탐침은 실제 서버 대신 자체 모의 herdr를 사용한다.
## 추가 개선 사항 — 비차단
1. **선행 0 예산 처리 불일치.** 래퍼 `:147`의 bash 산술은 `MAM_JOB_TIMEOUT_S=08`을 8진수로 해석하다 오류를 내고 `TIMEOUT=3600`으로 대체한다. 같은 값은 `lib_py/loop/runner.py:600`에서 8로 해석된다. 탐침 결과는 `value too great for base`와 `TIMEOUT=3600`이었다. 공통 Python 양의 정수 파서에서 십진수로 정규화하는 것이 낫다. 정상 정수·CLI 우선순위 테스트는 통과했다.
2. **문서의 오래된 종료 설명.** delegate-job `SKILL.md:99`는 아직 total budget도 `deadline_timeout`이라 설명하고, `:101`의 broker-unavailable deadline 도달도 같은 라벨로 적는다. 구현은 두 경우 모두 wall clock이 소진되면 `wall_clock_timeout`이다. `:100`의 “before v4.7.5”도 이번 4.8.0 릴리스 계획과 맞춰 정리해야 한다. loop `SKILL.md:209`, `:222`의 예산·하위 잡 라벨 반영은 확인했다.
3. **테스트 이식성/격리.** stall watcher 테스트 `:39`의 모듈 전체 Linux skip은 순수 judge·pane parser 테스트까지 숨긴다. /proc·Linux 프로세스 사실을 쓰는 사례만 별도로 표시하는 편이 좋다. 기존 전달 테스트 `test_issue18_liveness_contract.py:497`은 unreachable 환경을 만들기 전에 잡을 등록한다. 잡의 저장된 broker가 환경을 덮어쓰고(`mqtt_common.py:294`), `:511`의 thread join 시간까지 측정하므로 외부 브로커 지연에 따라 시간 assertion이 실패한다. 등록 단계부터 브로커를 격리해야 한다.
## 코드 계약 확인
- `--timeout > MAM_JOB_TIMEOUT_S > 3600`, loop `--job-timeout`, 공통 `contract_args()` 전달 경로와 예산+grace(기본 300초) 대기 상한을 확인했다. 구현·리뷰·수정·재심·중재·순응은 공통 delegate 경로를 사용한다. 명시적 `MAM_CHILD_WAIT_S`는 유지된다. remote dispatch는 `timeout_sec`을 운반하고 두 remote 만료 문구 모두 새 라벨을 쓴다.
- subscriber의 rc 2/4와 bash 상수 일치, `delegate-job wait`의 rc 2 호환, broker-down 재관측 및 disk-heartbeat 유예가 wall deadline에 닿는 라벨을 검토했다. registry close/engine의 새 request 및 하위 잡 `CLOSED-UNCONFIRMED` 분류 테스트가 통과했다. 별도 순수 전이 탐침에서도 `wall_clock_timeout → error` 후 늦은 completed는 `conflicting completed after terminal error; refusing`으로 거부됐다.
- stall watcher의 단일 발행 경로는 `progress`뿐이다. close/error/cancelled 발행이나 자동 승인 경로가 없고, AST 테스트도 이를 확인한다. blocked는 detection 문구를 붙이고 답하지 않는다. idle은 연속 idle 기간, seq 재확인, 어댑터 신뢰도, PID/자손, 빈 입력, 1회 제한을 조합한다. 정상 local watcher 수명, human/remote 제외, `MAM_STALL_WATCH=0`, herdr 장애 경고/종료, loop의 실시간 알림 relay는 모의 환경에서 확인했다. 이 긍정적 결과가 R1·R2의 예외 경로를 보장하지는 않는다.
- 기존 G-8에서 wall=idle=0.5였던 경합을 wall=0.5/idle=30으로 바꾼 것은 타당하다. rc 이름 상수와 CLI help snapshot, issue18 contract_args 기대값 수정도 새 인터페이스에 부합한다. conftest의 process-info shape·detection·seq·wait 지연은 모의 객체에 국한된다.
- 5종 idle pane은 herdr 0.9.1, 2026-09-25 캡처 메타데이터를 가진다. typing 변형은 실측이 아닌 합성이라는 점이 테스트에 명시돼 있다. 실측된 빈 입력과 합성 비어 있지 않은 입력은 모두 통과했다. 실제 Claude 시험 A/B 자체는 이번 브리프의 세션 조작 금지 때문에 재실행하지 않았다. 모의 blocked/idle/자식/입력 조건 검증을 실제 에이전트 실험의 독립 재현으로 부풀리지 않는다.
## bash/Python 경계 표
| 위치 | 새로 수행하는 일 | 경계 판단 / 후속 이전 대상 |
| --- | --- | --- |
| 래퍼 `:146` | env 양의 정수 검증·기본값 선택 | 정책 결정이다. Python 공통 예산 파서로 이전. |
| 래퍼 `:832` | subscriber rc를 job 상태 의미로 매핑 | 프로세스 rc 수집은 bash, 상태/실패 의미 결정은 Python. |
| 래퍼 `:902`, `:950` | 현재 시각과 deadline 비교 후 FINALIZE_CLOCK 선택 | 단순 전달이 아니라 어느 시계가 끝났는지 결정한다. #17 항목 4 대상. |
| 래퍼 `:956` | rc/clock/phase에 따라 close request·사유 선택 | 종결 정책이다. Python이 구조화된 결과를 반환하도록 이전. |
| 래퍼 `:687` | env switch·remote·human 감시 제외 | 현재는 bash에 있는 정책. 장기적으로 Python의 감시 가능 여부 결정과 합칠 수 있다. |
| 래퍼 `:700`, `:711` | 프로세스 시작·kill·wait | bash 책임에 맞는다. R1 수명 정리는 여기서 보강. |
| 래퍼 `:1214` | 외부 wait rc 4를 호환 rc 2로 변환 | 얇은 CLI 경계 변환으로 유지 가능하나 상수/계약은 Python과 일치해야 한다. |
따라서 “bash는 라벨을 나르기만 한다”는 주장은 정확하지 않다. 다만 #17 항목 4 이전이 이미 후속으로 합의됐으므로 이 경계상 부채 자체는 비차단이다.
## 실행 검증 결과
인터프리터는 브리프의 `.venv/bin/python`(macOS Python 3.9.6)을 사용했다. pytest는 `AGENT_PYTHON_BIN` 지정, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 검증 트리 밖의 `--basetemp=/tmp/0f12973f-*`로 실행했다.
| 실행 | 결과 |
| --- | --- |
| issue33, issue30, issue18, a4 adapter, tier1, issue5 phase1 — 원본 선택 265건 | 214 passed / 3 failed / 48 skipped, 342.12초 |
| 위 실패 3건만 재실행 | 2 passed / 전달 timing 1 failed, 49.73초 |
| 전달 timing: 잡 등록부터 MQTT=127.0.0.1:9로 격리 | 1 passed, 2.36초 |
| issue30: 외부 pytest plugin으로 모듈 Linux skip을 선택적으로 해제 | 43 passed / 2 failed / 3 skipped, 306.06초 |
| 위 R4 2건: 임시 herdr 모의 스크립트의 프로세스 간 계측만 보정 | 2 passed, 125.18초 |
| base parser 임시 사본을 현재 S14에 적용 | Codex/Grok/OpenCode 3건 예상 실패; agy/claude는 기존에도 통과 |
| 같은 S14에 head parser 적용 | 5건 통과 |
| 정적 검사 | `git diff --check`, 두 bash 진입점 `bash -n` 통과 |
첫 표준 실패 중 2건은 브로커 subscribe/heartbeat 타이밍이었고 재실행에서 통과했다. 나머지 전달 테스트는 위에 설명한 저장된 broker와 thread join의 영향을 분리해 통과를 확인했다. 보조 base archive 실행도 통과했으나 브로커 변동이 있으므로 이것만으로 코드 회귀라고 판정하지 않았다.
강제로 활성화한 R4 2건의 최초 실패 값은 연속 idle 시간이 각각 `-0.00066`, `-0.00741`초였다. Python 3.9.6/macOS의 각 모의 herdr 프로세스에서 monotonic 기준점이 달라지는 계측 문제다. 임시 모의 스크립트에서만 cross-process timestamp를 `time.time()`으로 바꾸고 생산 감시자 코드는 그대로 두자 둘 다 통과했다. 원본 Linux suite가 macOS에서 그대로 통과했다고 주장하지 않는다. Linux process-name 2건과 /proc heartbeat 1건은 검증하지 않았다.
종합하면 선택한 265개 사례 중 262개는 위 재실행/이식 조건에서 통과를 확인했고 3개 Linux 전용 사례는 미검증이다. 별도 R1·R2 탐침은 제품 결함을 재현했다. 테스트 수만으로 PASS를 내릴 수 없다. 검사와 진단용 임시 plugin은 `/tmp/0f12973f-tools/`에 있고 검증 트리는 수정하지 않았다.
## 설계 합의
**A — 동의.** #30은 stalled 판단으로 잡을 닫거나 blocked 승인을 자동 수락해서는 안 된다. 이 PR은 `progress` 단일 발행 경로와 close 경로 부재로 그 원칙을 구현한다. idle의 1회 계속 요청도 합의 범위에 맞는다. 다만 알림 전용이어도 prompt는 외부 부작용이므로 R1·R2의 수명/dispatch 경계를 지켜야 한다. 이번 NOT PASS는 그 실행 안전성 결함이며 알림 전용 원칙에 대한 반대가 아니다.
**B — 동의.** 연장을 #39의 명시적인 프로토콜 요청으로 분리하는 것이 타당하다. 동기 래퍼 호출 중인 러너의 관측 정체를 작업 진행 여부나 연장 승인으로 해석하기 어렵고, seq 정체도 정상 장기 작업에서 생긴다. 요청은 승인이 아니어야 한다. 오케스트레이터가 잡별 누적 최대 예산/횟수를 두고 token·attempt로 인증된 요청을 멱등 처리해 새 절대 deadline을 확정·배포하도록 권한다. 이 규칙을 remote에도 동일하게 적용하면 무한 연장과 양쪽 deadline 불일치를 줄일 수 있다.
**C — 동의.** #40의 트리거는 completed 수신 측이어야 한다. 에이전트 publish_event에 YAML/SQLite 제어 평면 쓰기를 넣으면 워크트리 creator와 `MAM_NO_SUBSCRIBER_DAEMON=1`에서 소유권·경로 문제가 다시 생긴다. 공통 Python 완료 수신 처리기를 두고 래퍼 구독/디스크 완료 관측을 기본 진입점으로 삼는 쪽이 standalone 위임까지 포괄한다. loop `wait_for_job` 직후는 누락 복구 경로로 같은 처리기를 호출할 수 있으며 job/token/attempt 단위로 중복 capture/pin을 막아야 한다. 어댑터는 id_lifecycle·capture·pin 사실/동작을 제공하고, 실행 시점과 제어 평면 쓰기의 권한은 수신 측이 갖는다. #17 항목 4에 흡수하거나 바로 다음 순서가 적절하다.
**D — 조건부 동의.** 이번 PR에서 종결 처리 전체 이전까지 요구하지 않는 것은 범위를 제한한다는 점에서 타당하다. 다만 위 표처럼 bash에 새 정책이 들어간 사실을 인정하고 후속 작업으로 추적해야 한다. #17 항목 4에서 예산 정규화, rc→상태 해석, deadline/idle 판정, FINALIZE_CLOCK와 phase별 close request·사유, 디스크 유예/재관측 종료 결정을 Python으로 옮긴다. bash에는 spawn/wait/kill, 신호 정리 및 얇은 외부 CLI 호환 처리를 남긴다. 이 분리와 별개로 R1·R2는 이번 병합 전에 수정해야 한다.
[VERDICT: NOT PASS]
@@ -0,0 +1,91 @@
# 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]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20260926T062809Z-914379",
"base_sha": "af0a2a2557768fa316203b177e1b90e9f7283a1a",
"head_sha": "9200562c914ab4f6e6d8a599cac371a07f99b25d",
"tree_sha": "c8425e2259544c3a34210858f331b0563f15325c",
"review_commit": "9200562c914ab4f6e6d8a599cac371a07f99b25d",
"reviewer": "planner-reviewer-codex-01",
"job_id": "ee9f0555",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-09-26T07:07:06Z"
}
@@ -0,0 +1,67 @@
# PR #41 3회차 델타 리뷰 — ee9f0555
**PASS.** 2회차 D1·D2를 재현했던 독립 탐침이 이제 prompt를 보내지 않는다. 감시자 정리의 5초 유예도 제거됐다. 이번 delta에서 새 병합 차단 결함을 발견하지 않았다.
## 검증 identity 및 범위
- base: `af0a2a2557768fa316203b177e1b90e9f7283a1a`
- head: `9200562c914ab4f6e6d8a599cac371a07f99b25d`
- tree: `c8425e2259544c3a34210858f331b0563f15325c`
- loop: `loop-20260926T062809Z-914379`
- 검증 cwd: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260926T062809Z-914379/1`
4개 파일(+144/−31)의 delta를 검토했다. 검증 트리는 수정하지 않았으며 HEAD/tree 일치와 깨끗한 git 상태를 확인했다. 실제 에이전트/herdr 세션을 조작하지 않았다. 테스트와 추가 탐침의 레지스트리·HOME·프로세스는 임시 경로에 격리했다.
사용자의 후속 지시에 따라 본 체크아웃의 업데이트된 `.mam.env`를 확인했다. 비밀값을 출력하지 않고 새 MQTT 연결 설정만 추가 테스트 환경에 전달했다. 실제 세션 등록부 경로나 herdr 설정은 테스트에 새로 전달하지 않았다.
## 이전 차단 항목 처리
| 항목 | 판정 | 근거 |
| --- | --- | --- |
| D1: 알림 발행 중 완료/재디스패치 후 prompt | 닫힘 | `stall_watch.py:408`의 `_may_prompt()`가 블로킹 `_notify()` 이후에 최신 레코드와 부모를 확인한다. 실제 publisher 로직을 사용한 이전 탐침에서 completed/attempt 2 모두 prompt 0회. |
| D1: 마지막 읽기 오류가 재주입 허가로 취급됨 | 닫힘 | `stall_watch.py:278`~`:279`가 strict 검사에서는 오류를 False로 처리한다. 깨진 JSON·없는 레코드·PermissionError 모두 최종 게이트에서 보류. 관측 루프의 일시 오류 허용과 분리됐다. |
| D2: herdr 대기 중 부모 소멸 뒤 알림·prompt | 닫힘 | `stall_watch.py:329`에서 wait 반환 직후, 오류 경고보다 먼저 부모를 확인한다. 독립 TIMEOUT/TRANSPORT_ERROR 탐침에서 알림·prompt 모두 0회. 발행 중 부모 소멸도 마지막 게이트에서 차단됐다. |
| grok N1: TERM 유예 5초가 종결 경로를 지연 | 닫힘 | 래퍼 `:715`~`:716`이 KILL+wait로 즉시 회수한다. 새 즉시 반환 테스트는 head에서 통과하고 base의 5초 유예 함수에서 assertion으로 실패했다. |
표의 Python 경로는 `.agents/skills/lib_py/stall_watch.py`, 래퍼 경로는 `.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job`이다.
## KILL 전환 판단
전환은 타당하다. 이 감시자는 실제 작업 에이전트가 아니라 관측·알림 보조 프로세스다. 자체 registry 트랜잭션이나 종료 때 반드시 기록해야 할 체크포인트를 보유하지 않는다. 이전 TERM 핸들러도 `os._exit()`였으므로 정상 finally 정리나 버퍼 flush를 보장하던 동작을 새로 잃는 변경은 아니다. 빈 PID와 이미 종료한 자식의 처리·오류 보고 분기도 유지된다. 자기 자식을 KILL한 뒤 wait로 회수하는 경로에 종전의 TERM 무시/5초 유예 문제는 없다.
다만 KILL은 프로세스 그룹 전체를 죽이지 않는다. 실행 중이던 herdr/publish 자식은 잠시 남을 수 있다. `agent wait`에는 요청한 timeout이 있고 publisher는 자체 CONNACK 10초/PUBACK 5초 제한을 갖는다(`lib_py/delegation/publisher.py:23`~`:24`). 일반적인 정상 CLI/네트워크 실패 경로에서는 이 자식들이 끝난다. 반면 `HerdrSource._herdr`의 30초, `_notify`의 120초는 감시자 측 `subprocess.run` timeout이므로 부모가 KILL되면 그 타이머까지 살아남는다고 주장할 수는 없다. 따라서 외부 CLI 자체가 영구 정지하는 상황까지 “모든 자식의 종료 보장”으로 확대하지 않는다. 이는 이전 `os._exit()` 종료에도 있던 한계이며 이번 delta의 새 차단 결함으로 분류하지 않았다. 이번 실행 후 테스트 감시자·heartbeat 잔류 여부를 별도로 확인했다.
## 마지막 게이트와 경합 판단
`_may_prompt()`(`stall_watch.py:296`~`:301`)가 발행 뒤에 있고, 다음 줄의 실제 prompt 호출까지 추가 네트워크 대기를 두지 않는 점이 핵심이다. 이전의 최대 120초 발행 대기를 사이에 둔 오래된 관측 문제는 해결됐다. 잡 상태를 읽지 못한 회차에는 prompt를 보류하며, 발행 rc 0/2를 최신 dispatch의 생존 증명으로 사용하지 않는다.
최종 확인과 herdr 입력 사이에 다른 actor가 완료/재디스패치를 수행하는 짧은 경합은 여전히 가능하다. 이는 원자적 입력 프로토콜이 아니므로 절대적인 선형화 보장은 아니다. 그러나 이번 기능은 잡당 한 번의 계속 요청과 알림을 제공하며 잡 종결·자동 승인을 수행하지 않는다. 이 범위에서 블로킹 I/O 뒤 최신 상태를 확인하는 현재 게이트는 수용 가능하다. registry lock을 외부 RPC 동안 붙잡는 확장 설계를 이번 PR에 요구하지 않는다.
독립 탐침에서는 `_notify=True` 대역만 쓰지 않았다. 이전 회차의 탐침을 그대로 head에 적용해 실제 `publish_event.main()`의 wire 단계에 completed/재디스패치를 끼워 넣었다. publisher rc는 여전히 0이었으나 두 경우 모두 prompt가 0회였다. 부모 소멸도 wait 내부에서 실제로 조건을 바꾸는 별도 모의 관측으로 확인했다.
## 브로커 지연 분류
브리프에 기술된 subscribe 지연 사례를 브로커 의존 테스트의 타이밍 플레이크로 분류하는 것은 타당하다. `job_subscriber.py:489`~`:491`은 구독이 확인되기 전에는 idle 기준 시각을 계속 갱신하며, 최초 이벤트 전에는 ack 여유도 포함한다. 따라서 등록 후 14초에 completed를 쓰는 테스트는 구독 시작 자체가 늦으면 아직 열린 잡에 completed가 들어갈 수 있다. 이를 곧바로 감시자 stop 지연의 증거로 삼을 수 없다. 단순히 “ignored TERM 로그가 없다”는 사실만이 아니라 구독/이벤트 시각과 이 제어 흐름을 함께 봐야 한다.
이번 원본 선택 suite는 그대로 통과했다. 이어 사용자가 업데이트한 사설 브로커 설정으로 `test_wrapper_idle_timeout_grace_ignores_pane_busy_token`을 추가 실행했고 17.00초에 통과했다. 이전 회차에는 같은 모듈에서 공개 브로커 CONNACK 실패도 직접 관측했다. 이 증거들은 브리프의 분류와 부합한다. 다만 다른 호스트의 과거 세 실행 로그 전체를 이번에 독립 재현했다고 주장하지 않는다.
## 테스트 평가 및 비차단 메모
추가 테스트는 중요한 회귀를 잡는다. 다만 다음 정밀도 개선은 권한다.
- `tests/test_issue30_stall_watch_stop.py:421`~`:423`의 부모 생존 대역은 호출 횟수 기준이다. head에서는 첫 루프 머리·첫 wait 뒤의 두 호출 다음, 두 번째 루프 머리에서 이미 False가 되어 종료한다. 이름 그대로 “두 번째 wait 도중 소멸”을 강제하려면 wait 대역 안에서 별도 alive 상태를 바꾸는 것이 낫다. 이번 리뷰의 독립 탐침은 그렇게 구성해 실제 wait 뒤 검사를 확인했다.
- unreadable 테스트를 base에 그대로 연결하면 `_dispatch_open(strict)` 시그니처 차이로 TypeError가 먼저 난다. 이를 의도한 행동 검증으로 세지 않았다. 대신 임시 head 사본에서 `return not strict`만 `return True`로 되돌려 새 API를 유지한 채 테스트를 실행했고, 실제 prompt 발생에 따른 AssertionError를 확인했다.
- SKILL.md `:53`의 “최대 한 wait, ≤60초”는 herdr wait 도중 부모가 사라진 경우의 설명으로 한정하는 편이 정확하다. 발행 중 부모가 사라지면 진행 중인 발행 호출이 끝날 때까지도 기다릴 수 있다. 현재 코드는 그 뒤 prompt를 보내지 않으므로 D1·D2는 해결됐지만, 60초를 모든 외부 호출에 대한 전역 상한으로 읽으면 과장이다.
2회차에 지적했던 테스트 실패 경로의 최종 cleanup 보강 및 이전 회차의 #17 관련 비차단 부채는 이번 delta 밖의 후속 항목으로 유지한다. 새 차단 조건으로 올리지 않았다.
## 실행 결과
- 브리프의 worktree `.venv/bin/python`, `AGENT_PYTHON_BIN`, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 외부 `--basetemp=/tmp/ee9f0555-suite` 사용.
- 지정된 `test_issue30_stall_watch_stop.py`, `test_issue30_stall_watch.py`, `test_issue14_delivery_ack.py`: **70 passed, 1 skipped, 426.01초**. skip은 Linux /proc 전용 S6이다.
- 업데이트된 MQTT 설정을 적용한 위 idle-grace 사례: **1 passed, 17.00초**. 기존 suite와 중복된 사례이며 별도 고유 테스트 수로 더하지 않았다.
- 독립 추가 확인: 실제 publisher 완료/재디스패치 2건, wait 중 부모 소멸 1건, 최종 게이트 정상/깨진 JSON/레코드 없음/권한 오류 4건, 발행 중 부모 소멸 및 오류 wait 중 부모 소멸 2건 통과.
- 부정 검증: base에서 완료·재디스패치·부모 소멸 테스트 3건 및 즉시 stop 테스트 1건이 예상한 assertion으로 실패. strict 정책만 되돌린 임시 사본의 unreadable 테스트 1건도 예상한 assertion으로 실패. 검증 트리 파일은 변경하지 않았다.
- `git diff --check af0a2a2`, 래퍼 `bash -n` 통과. HEAD/tree 및 깨끗한 git 상태 재확인.
[VERDICT: PASS]