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
12 KiB
🔍 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클린), basemain 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 파일 변화 없음(HEADaf0a2a2), 본 체크아웃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]