Files
multi-agent-mux/.agents/reports/planner-reviewer-claude-01/report-2cc309ea.md
T
GodopuandClaude Opus 5.5 53f87e4560 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
2026-09-26 16:08:37 +09:00

12 KiB
Raw Blame History

🔍 Review Report — PR #41 델타 리뷰 2회차 (job 2cc309ea)

  • Reviewer: planner-reviewer-claude-01 (role: planner,reviewer) · 2026-09-26
  • 대상 델타: 1673818..af0a2a2 (1커밋, 5파일 +406/−12, git diff --check 클린), base main 8ea65bb (v4.7.4).
  • 검증 경로: /var/folders/.../T/mam-review/loop-20260926T052018Z-597343/1. identity 확인: HEAD = af0a2a25…3a1a, HEAD^{tree} = 03106826…6ce8 — 브리프와 일치.
  • 1회차 내 판정: NOT PASS(N1) + M1~M9 (job ed0e1492). 이번에는 N1·codex R1/R2 정정과 내 비차단 항목의 처리를 본다.
  • 주의: 같은 델타(동일 base·head·tree)에 대한 직전 잡 ce114c80 은 내가 검증 중에 오케스트레이터 쪽에서 cancelled 되었고 그 검증 경로는 사라졌다. 코드 검토·하네스 실험은 그때 것을(트리가 동일하므로) 그대로 쓰고, 테스트 실행은 이 잡의 검증 경로에서 다시 돌렸다(§6).
  • 안전 규칙 준수: 검증 경로·소스 워크트리·본 체크아웃(main 8ea65bb) 모두 수정·커밋·checkout 없음. 실험은 scratchpad 하네스(stop_stall_watch 함수 추출, 대역 프로세스는 내가 만들고 내가 정리)·in-process 모듈 검사·되돌린 사본에서만. 실제 세션에 stall_watch 를 붙이지 않았고 실제 .mam·데몬·herdr 서버 조작 없음, push·merge·PR 상태 변경 없음. 1회차에서 남았던 멈춘 래퍼·감시자는 이번 시작 시점에 더 이상 없었다.

결론 요약

N1 은 닫혔다. 래퍼의 stop_stall_watch 는 TERM 을 무시하는 감시자에 대해서도 6 s 안에 돌아오고(실측), 감시자는 --watch-pid 로 래퍼가 사라지면 스스로 끝난다. R2 의 재검사(_dispatch_open)는 wait 직후·nudge 직전·루프 머리 세 곳에 있고, 예외 정책도 타당하다. 비차단 관찰 2건(P1·P2).

1. N1 — 유계 stop (검토 요청 1~2)

multi-agent-mux-delegate-job:710-727 stop_stall_watch: kill -0 → kill(TERM) → 0.2 s × 25 폴링 → 남아 있으면 stderr ignored TERM; killed + kill -9 → wait. 함수를 그대로 추출해 네 가지 대역에 돌렸다:

대역 결과
TERM 을 무시(trap "" TERM; exec sleep 300) 6 s 에 rc 0 반환, stderr stall watch for j1 ignored TERM; killed (pid …), 대역 사망
정상(TERM 에 죽음) 0 s
STALL_PID 비어 있음 즉시 return 0
이미 종료(rc 2) 즉시 반환, stderr stall watch for j1 exited rc=2: + 로그 꼬리 (기존 보고 유지)

wait 가 다시 무한이 될 경로: KILL 뒤의 wait 는 SIGKILL 된 자식에 대해 즉시 돌아오고(실측), kill -0 실패 분기의 wait 는 이미 끝난 자식의 저장된 상태를 돌려준다. 남는 이론적 경우는 자식이 D 상태(커널 내 대기)로 KILL 을 못 받는 것뿐인데 감시자는 파이프·소켓 대기라 해당하지 않는다. 1회차 N1 의 원인 가설(fork-exec 창에서 TERM 이 삼켜짐) 은 PR 이 래퍼 trace 로 확인했다고 밝혔고, 내 1회차 관찰(래퍼 __wait4, 감시자 정상 루프, 수동 TERM 에 즉시 종료)과 정합한다 — 어느 쪽이든 유계 stop 이면 닫힌다.

--watch-pid "$$"(:695): start_stall_watch 는 메인 셸의 함수라 $$ 는 래퍼 pid 다. 감시자 run() 루프 머리에서 _alive(watch_pid) 가 거짓이면 rc 0(stall_watch.py:298-303). _alive 실측: 살아 있는 자식 True, pid 1(권한 없음) True, reaped/없는 pid False — 권한 오류를 "살아 있음" 으로 보는 규칙이 맞고, watch_pid 가 0/None 이면 if self.args.watch_pid and … 가 검사를 건너뛴다(os.kill(0,0) 은 프로세스 그룹 시그널이라 항상 True 인데, 그 값이 들어올 경로는 없다).

2. R2 — 재검사 위치와 예외 정책 (검토 요청 4)

_dispatch_open(stall_watch.py:266-285): 레코드 재읽기 → 종단 상태·dispatch_token 불일치·attempt_id 불일치면 False. 호출 지점 세 곳: 루프 머리(:296), herdr wait 직후(:332-334, 블로킹 대기 중 닫힌/재디스패치된 잡), nudge 직전(:384-386, collect() 가 시간을 쓴 뒤). 충분하다 — prompt 를 보내는 유일한 자리 앞에 두 겹이 있다.

  • 예외: FileNotFoundError → False(레코드 삭제 = 잡 없음, 감시 중단이 안전), 그 외 OSError/ValueError → True(원자적 갱신 중의 일시 상태를 종결로 오인하지 않음) — 타당하다.
  • blocked 경로는 재검사하지 않는다: 보내는 것이 progress 알림뿐이고 prompt 가 없다. 닫힌 잡에 대한 progress 는 publish_event 의 CAS(토큰)·종단 뒤 거부가 막고, 설령 기록돼도 사람에게 가는 알림 한 줄이라 무해 — 그대로 둬도 된다.
  • _notify 가 False(rc ∉ {0,2})면 nudge 를 보내지 않고 nudged=True 로 두어 재시도하지 않는다(:387-391) — "잡당 1회" 계약 유지.

3. R1 — --watch-pid 만으로 닫은 판단 (검토 요청 5)

동의. 래퍼가 TERM/KILL 로 사라지면 그 셸의 어떤 trap 도 돌지 않으므로 trap 합성은 R1 을 닫지 못한다; 자식이 부모 생존을 보는 것이 유일하게 확실한 방법이고 #18 heartbeat 타이머의 --watch-pid 와 같은 계약이다. 지연은 최대 herdr wait 한 번(≤ 60 s)이며 문서(SKILL.md:51-52)가 그렇게 말한다. 감시자가 대기 중 만든 herdr 자식은 --timeout 으로 스스로 끝나고, 새 테스트 (d) 가 "TERM 받은 래퍼 뒤에 감시자가 30 s 안에 rc 0" 을 고정한다. 기존 bootstrap EXIT trap 과의 합성은 항목 4 로 미루는 것이 맞다(래퍼 흐름 변경).

4. M8 좁힘과 r4 (검토 요청 6)

test_issue30_stall_watch.py:39-40 이 모듈 마크를 PROC 데코레이터로 바꾸고 S6(/proc 직접 읽기)에만 붙였다. box 기반 시험은 실제 프로세스를 띄우고 background_children 의 macOS ps 경로를 지나므로 이 호스트에서 실제 검증이 된다(1회차에 내가 손으로 확인한 경로). r4 의 stub 시계 monotonic → time: stub 프로세스와 판정 프로세스가 서로 다른 프로세스라 macOS 의 프로세스별 monotonic 기준점 차이를 피하는 것이 맞다(codex 지적 타당).

5. 1회차 내 항목의 처리

# 1회차 항목 이번 델타
N1 래퍼가 감시자를 기다리며 영구 정지 닫힘 — 유계 stop + --watch-pid + 테스트 (a)(b)(d)
M1 유예 루프의 시계 판정이 bash 열림 → #17 항목 4 목록(PR 명시)
M2 감시 여부 판정이 bash 열림 → 항목 4
M3 감시자 수명이 래퍼 정상 경로에만 묶임 닫힘 — --watch-pid
M4 MAM_JOB_TIMEOUT_S 오류 시 러너 무언 열림 → 항목 4
M5 "before v4.7.5" 닫힘 — v4.8.0
M6 MAM_WAIT_GRACE_S 미문서 열림 → 항목 4 목록(PR 명시)
M7 MAM_TEST_UNRELIABLE_AGENT 훅 열림(후속, 사유 타당)
M8 모듈 전체 Linux skip 닫힘 — S6 만
M9 B3 의 공개 브로커 의존 열림(후속; "started 자체가 안 닿는 경우" 라는 진단에 동의 — 로컬 events.log 로 heartbeat 를 넣는 방식이 남은 선택지)

6. 테스트 (검토 요청 3 + 검증 방법)

6-1. 이 잡의 검증 경로에서의 실행 (브리프의 명령 그대로, pr-41 .venv 인터프리터)

tests/test_issue30_stall_watch_stop.py tests/test_issue30_stall_watch.py tests/test_issue14_delivery_ack.py tests/test_tier1_unit.py
→ 1 failed, 132 passed, 1 skipped in 589.33s (0:09:49)
FAILED test_issue30_stall_watch.py::test_s9_the_wrapper_runs_the_watcher_for_the_jobs_life[0]
SKIPPED test_issue30_stall_watch.py:331 (S6, real process trees via /proc)
  • 새 stop 테스트 7건 전부 통과, test_issue30 는 M8 좁힘대로 S6 하나만 skip 이고 나머지(box 기반 S1–S5·S7·S8·S13, R1–R4 포함)가 이 호스트에서 실제로 돈다. 1회차에서 멈췄던 test_issue14::test_wrapper_honours_registry_ack_over_unconfirmed_inject 는 합동·단독 모두 통과 — N1 재현 없음.
  • S9[0] 실패는 공개 브로커 flake 다: 단언은 t18._publish(... "started").returncode == 0 이고 stderr 는 publish failed after 2 attempts: no CONNACK from broker. S9 는 _live_broker() 로 일부러 공개 브로커를 쓰며(래퍼 구독자가 와이어를 봐야 하므로), 진짜 도달 불가 브로커(127.0.0.1:9)에 대해 publish_event 는 6/6 회 rc 2 를 돌려준다 — 즉 0 을 기대하는 이 단언은 브로커가 응답할 때만 성립한다. 같은 세 시험(S9[0]·R2[True]·issue14 pane_busy)을 단독으로 다시 돌리자 그 셋은 통과하고 이번엔 R2[False] 가 같은 no CONNACK 로 실패했다 — 어느 케이스가 걸리는지가 브로커 응답 여부로 바뀌는 전형적 T-1 이다. publish_event 는 이 델타가 건드리지 않았다(P2).
  • 같은 트리(1673818..af0a2a2 는 동일 tree 0310682)에 대한 직전 잡 ce114c80 의 실행(취소 전 완료)도 130 passed / 3 failed(S9[0]·R2[True]·issue14 pane_busy — 전부 위 부류) / 1 skipped 로 같은 그림이었다.

6-2. 수정 없이는 실패하는가 — 확인함

사본에서 래퍼와 stall_watch.py 만 1673818 판으로 되돌리고 test_issue30_stall_watch_stop.py 를 돌린 결과 7 failed / 0 passed (134 s) — (a)(b)(c)(d) 는 TimeoutExpired, (e)(f)(g) 는 AssertionError. 되돌린 실행이 남긴 프로세스는 없었다(대역·shim 은 테스트가 정리).

6-3. 결정성·잔류 (검토 요청 3)

  • (a) 대역은 kill -9 로, (b) shim 은 래퍼가, (c)(d) 의 sleep 300/감시자는 테스트의 finally 가 죽인다 — 두 번의 전체 실행과 되돌린 실행 뒤 stall_watch --job/sleep 300 잔류 0(호스트에 남은 lib_py 프로세스는 오케스트레이터의 루프 러너뿐).
  • 시간 상한은 (a) 4–12 s, (b) 40 s, (c)(d) 30 s 로 stop 의 5 s 폴링과 감시자의 최대 60 s wait 를 넉넉히 감싼다. 이번 실행에서 stop 테스트들은 상위 8 느린 시험에 들지 않았다.
  • 실제 .mam/jobs 전후: 이 잡 자신의 heartbeat/레코드 파일 외 추가 0. 검증 경로 tracked 파일 변화 없음(HEAD af0a2a2), 본 체크아웃 main 8ea65bb.

7. 판정

N1(래퍼 영구 정지)은 유계 stop(실측 6 s)과 --watch-pid(부모 소멸 시 자진 종료)로 양쪽에서 닫혔고, R2 는 prompt 앞 두 겹 재검사로 닫혔으며, R1 은 --watch-pid 만으로 닫은 판단이 맞다. 1회차 판정을 뒤집을 요소는 없고, 델타 밖의 남은 항목(M1/M2/M4/M6 → 항목 4, M7·M9 후속)은 PR 이 명시했다. 버전 락스텝 bump 는 브리프대로 판정에서 제외한다.

# 비차단 관찰 근거
P1 감시자가 부모 소멸을 알아채는 지연이 최대 herdr wait 한 번(≤ 60 s)이라 문서와 일치하지만, idle 상태에서는 wait 가 30 s 단위라 실제 지연은 그보다 짧고 blocked/working 대기(60 s)에서 가장 길다 stall_watch.py:276-283; 정보
P2 S9/R2 가 공개 브로커에 의존해 no CONNACK 로 간헐 실패한다(이 호스트 3회 실행 중 매번 1–3건). _live_broker() 가 설계라면 rc 0 대신 로컬 events.log 의 started 줄로 단언하거나 rc 2 를 허용하는 편이 결정적 test_issue30_stall_watch.py S9 _publish(...).returncode == 0; T-1 부류

8. 교차검증 가이드

RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260926T052018Z-597343/1
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-41/.venv/bin/python
git -C "$RP" rev-parse HEAD 'HEAD^{tree}'                       # af0a2a2… / 0310682…
# stop_stall_watch 만 추출해 TERM 무시 대역에 → 6 s 안에 "ignored TERM; killed" 후 반환 (§1 표)
# _alive: 살아있는 자식 True / pid 1 True(권한) / reaped False (in-process)
# 되돌린 사본: 래퍼·stall_watch.py 를 1673818 판으로 → tests/test_issue30_stall_watch_stop.py → 7 failed
(cd "$RP" && AGENT_PYTHON_BIN="$PY" PYTHONDONTWRITEBYTECODE=1 "$PY" -m pytest -p no:cacheprovider -q --basetemp=/tmp/bt \
   tests/test_issue30_stall_watch_stop.py tests/test_issue30_stall_watch.py tests/test_issue14_delivery_ack.py tests/test_tier1_unit.py)

[VERDICT: PASS]