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
11 KiB
🔍 Review Report — PR #41 델타 리뷰 3회차 (job cc418c3b)
- Reviewer:
planner-reviewer-claude-01(role: planner,reviewer) · 2026-09-26 - 대상 델타:
af0a2a2..9200562(1커밋, 4파일+144/−31,git diff --check클린), basemain 8ea65bb(v4.7.4). - 검증 경로:
/var/folders/.../T/mam-review/loop-20260926T062809Z-914379/1. identity 확인:HEAD = 9200562c…b25d,HEAD^{tree} = c8425e22…325c— 브리프와 일치. - 2회차 내 판정: PASS + P1·P2 (job
2cc309ea; 1회차 N1 은 그때 닫힘). 이번에는 grok N1(TERM 5 s 예산)·codex D1/D2 의 반영을 본다. - 안전 규칙 준수: 검증 경로·소스 워크트리·본 체크아웃(
main 8ea65bb) 모두 수정·커밋·checkout 없음. 실험은 scratchpad 하네스(stop_stall_watch추출, 대역은 내가 만들고 KILL 로 정리)와 되돌린 사본에서만. 실제 세션에 stall_watch 를 붙이지 않았고 실제.mam·데몬·herdr 서버 조작 없음, push·merge·PR 상태 변경 없음.
결론 요약
세 정정은 지적을 닫는다. KILL 기반 stop 은 TERM 을 무시하는 대역에 대해서도 0.00 s 에 돌아오고(실측), prompt 앞의 마지막 게이트(_may_prompt: 래퍼 생존 + strict 재읽기)와 wait 직후의 _wrapper_gone 이 D1/D2 의 창을 닫는다. 남는 마이크로초 경합은 레지스트리 락 없이 닫을 수 없고, 그 결과(닫힌 잡에 한 줄 nudge)는 무해하므로 받아들일 수 있다. 비차단 관찰 2건(Q1·Q2).
1. grok N1 — KILL 로 바꾼 것 (검토 요청 1)
multi-agent-mux-delegate-job:711-722 stop_stall_watch: kill -0 → kill -9 → wait. 하네스 실측(함수 추출):
| 대역 | 결과 |
|---|---|
TERM 무시(trap "" TERM; exec sleep 300) |
rc 0, 0.00 s, 대역 사망 |
| 정상 | rc 0, 0.00 s |
STALL_PID 비어 있음 |
즉시 return 0 |
| 이미 종료(rc 2) | 즉시, stderr 에 stall watch for j1 exited rc=2: + 로그 꼬리(보고 유지) |
타당하다. 감시자에 정리할 상태가 없다: 파일·락·소켓을 열어 두지 않고(레코드는 매번 열고 닫음), 유일한 부수효과는 두 하위 프로세스 — publish_event(알림, --attempts 1, 자체 timeout 120 s)와 herdr agent prompt(nudge, herdr 자체 --timeout) — 뿐이라 KILL 순간 진행 중이던 것은 각자 끝난다. TERM 핸들러도 os._exit 하나였으므로 KILL 로 잃는 동작이 없다. 2회차의 5 s 폴링은 래퍼의 close 시계 앞에 붙어 deadline_timeout 을 늦추던 부작용이 있었고(grok 지적), 그 예산이 사라졌다. KILL 은 fork-exec 창에서도 삼켜지지 않는다.
한 가지 기록(Q1): KILL 순간 비행 중이던 publish_event 는 래퍼가 잡을 닫은 뒤 에 progress 를 남길 수 있다. 종결 뒤 이벤트는 등록부 CAS/종단 규칙이 거르거나 한 줄 알림으로 남을 뿐이라 무해하지만, 래퍼의 디스크 liveness 스캔은 events.log 의 모든 최근 이벤트를 생존 증거로 세므로 감시자의 stall_watch:*/stalled:* 알림도 idle 유예를 연장한다(벽시계가 상한이라 무한하지는 않다). "멈춤 알림이 생존 증거가 된다"는 이 긴장은 이 델타가 만든 것이 아니며, 후속에서 스캔이 progress+stall_watch: 접두 detail 을 제외하면 사라진다.
2. codex D1/D2 — prompt 앞 게이트 (검토 요청 2·3)
_dispatch_open(strict=True)(stall_watch.py:266-288): strict 에서는 읽기 오류도 "아니오"._may_prompt(:296-301) = 래퍼 생존 ∧ strict 재읽기 —_notify가 돌아온 뒤,prompt()직전 에 호출된다(:408-412). 발행 rc 는 상태 판단에 쓰지 않는다(rc 0 은 "그 순간의 CAS 통과" 이지 "지금도 열려 있음" 이 아니다 — 맞는 태도)._wrapper_gone(:290-294)은 루프 머리(:313), 매 wait 직후(:329, 어떤 알림·판정보다 먼저),_may_prompt안 — 세 곳. D2 의 "대기 중 부모 소멸 뒤 알림·재주입 1회" 창은 wait 직후 검사로 닫힌다.- 남는 경합:
_may_prompt가 참을 돌려준 뒤self.src.prompt()의herdr agent prompt가 실행되기까지의 마이크로초. 이 안에 잡이 닫히면 닫힌 잡의 pane 에 "끝났으면 completed 를 발행하고 아니면 이어서 하라" 한 줄이 들어간다. 에이전트가 그에 따라completed를 다시 발행해도 등록부가 종단 뒤 종단을 거부하므로 상태가 바뀌지 않는다. 레지스트리 락으로만 닫을 수 있는 창이고 비용이 이득보다 크다 — 받아들일 수 있다.
3. 문서
SKILL.md :51-54: KILL 즉시, 래퍼 소멸 뒤 최대 wait 한 번(≤ 60 s), 소멸을 본 뒤에는 알림·nudge 없음 — 코드와 일치.
4. 테스트 (검토 요청 4 + 검증 방법)
4-1. 검증 경로에서의 실행 (브리프의 명령 그대로, pr-41 .venv)
tests/test_issue30_stall_watch_stop.py tests/test_issue30_stall_watch.py tests/test_issue14_delivery_ack.py
→ 3 failed, 67 passed, 1 skipped in 511.26s (0:08:31)
FAILED test_issue30_stall_watch.py::test_s9_the_wrapper_runs_the_watcher_for_the_jobs_life[1]
FAILED test_issue30_stall_watch.py::test_s10_remote_and_human_jobs_are_not_watched[human-…]
FAILED test_issue14_delivery_ack.py::test_wrapper_idle_timeout_grace_ignores_pane_busy_token
SKIPPED test_issue30_stall_watch.py:331 (S6, /proc)
- stop 테스트 11건(기존 7 + 새 4) 전부 통과.
- 세 실패는 모두 공개 브로커 의존이다. S9[1]·S10[human] 의 실패 지점은
t18._publish(reg, jid, "started", env).returncode == 0이고 stderr 는publish failed after 2 attempts: no CONNACK from broker(4회) — 두 시험은_live_broker()로 일부러 공개 브로커를 쓴다(:533-537). 단독 재실행에서 S9[1]·pane_busy 는 통과했고 S10[human] 만 같은no CONNACK로 다시 실패했다. 같은 시각 scratch 등록부로 공개 브로커에started를 세 번 보내자 1/3 이 21 s 뒤 rc 2 였다 — 브로커가 이 시간대에 불안정하다는 직접 증거. 이 델타는 human/remote 분기(start_stall_watch의 제외 로직)를 한 줄도 바꾸지 않았고(git diff … | grep -c 'human\|_is_remote'= 0), 회차마다 실패하는 케이스가 바뀐다(2회차 S9[0]→R2[False], 3회차 S9[1]→S10). 2회차 P2 그대로. - grok 이 인용한 idle-grace 시험의 분류(검토 요청 1 의 참고): 동의한다.
test_issue14의_base_env는 브로커를 고정하지 않아 기본값(공개 브로커)을 쓰고, PR 이 인용한 로그(subscribe handshake timed out, 등록 후 ~14 s 무배달 → idle 시계 늦게 시작 → 14 s 의completed가 열린 잡에 들어감)는 감시자와 무관한 구독자 핸드셰이크 지연이다. 이 시험은 PR #29·#37 리뷰 때도 base 와 동일한 코드에서 같은 방식으로 실패·재실행 통과했었고(이 호스트의 T-1 부류), KILL 로 바뀐 뒤에는 감시자가 close 시계 앞에 넣는 예산 자체가 0 이다. 다만 Q1 처럼 감시자의 알림이 디스크 liveness 로 세어지는 경로는 남아 있어, 그 시험이 감시자 켜진 채로 도는 한 "완전히 무관"은 아니다 — 시험 쪽에서MAM_STALL_WATCH=0을 주거나(감시자는 그 시험의 대상이 아니다) 스캔이 감시자 알림을 제외하면 깔끔해진다(Q2).
4-2. 수정 없이는 실패하는가 — 확인함
래퍼와 stall_watch.py 만 2회차 head(af0a2a2)로 되돌린 사본에서 test_issue30_stall_watch_stop.py → 5 failed / 6 passed (33.8 s): (a) returns_at_once(2회차 코드는 5 s 예산으로 took≈6), 그리고 새 4건(발행 중 completed / 재디스패치 / 깨진 레코드 / 대기 중 래퍼 소멸) 이 정확히 실패. 잔류 프로세스 0.
4-3. 결정성·잔류 (검토 요청 4)
새 4건은 in-process(wait_once 주입, _notify 는 발행 중 다른 행위자가 레코드를 바꾸는 대역, _alive 주입)라 브로커·herdr·시계에 의존하지 않고 3회 실행(전체·되돌린 사본·단독)에서 결과가 같았다. 세 실행 뒤 호스트에 stall_watch --job/sleep 300 잔류 0. 실제 .mam/jobs 는 이 잡 자신의 파일 외 추가 0, 검증 경로 tracked 파일 변화 없음(HEAD 9200562), 본 체크아웃 main 8ea65bb.
5. 2회차 판정 항목의 처리
| # | 2회차 항목 | 이번 델타 |
|---|---|---|
| (1회차 N1) 래퍼 영구 정지 | 2회차에 닫힘 | 유지 — 이제 KILL 즉시(0.00 s 실측) |
| grok N1 | TERM 5 s 예산이 close 시계를 늦춤 | 닫힘 — 예산 제거 |
| codex D1 | 알림 발행 중 상태 변화에도 prompt | 닫힘 — _may_prompt(strict 재읽기 + 래퍼 생존), 테스트 3건 |
| codex D2 | 대기 중 부모 소멸 뒤 알림·재주입 | 닫힘 — wait 직후 _wrapper_gone, 테스트 1건, SKILL.md 문구 |
| P1 | 부모 소멸 감지 지연의 상태별 상한(정보) | 문서에 "≤ 60 s" 로 명시됨 — 종결 |
| P2 | S9/R2(이제 S10 도) 의 공개 브로커 의존 | 열림 — 이번 실행에서도 3건 |
6. 판정
세 정정은 지적을 닫고 부작용이 없다(KILL 로 잃는 상태 없음, 게이트가 prompt 의 유일한 경로 앞에 있음). 남는 마이크로초 경합은 결과가 무해해 받아들인다. 버전 락스텝 bump 는 판정에서 제외한다.
| # | 비차단 관찰 | 근거 |
|---|---|---|
| Q1 | 래퍼의 디스크 liveness 스캔은 events.log 마지막 줄의 timestamp 만 보므로 감시자의 progress(stall_watch:*/stalled:*) 도 생존 증거가 된다 — 멈춤 알림이 idle 유예를 연장하는 긴장(벽시계가 상한) |
래퍼 :810-825 의 스캔; 델타 이전부터 |
| Q2 | test_issue14 의 liveness 시험들은 감시자를 끄지 않고 돈다 — 시험 대상이 아니므로 MAM_STALL_WATCH=0 을 주는 편이 결정적 |
_base_env 에 MAM_STALL_WATCH 없음 |
7. 교차검증 가이드
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260926T062809Z-914379/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}' # 9200562… / c8425e2…
# stop_stall_watch 추출 → TERM 무시 대역: rc 0, 0.00 s, 대역 사망 (§1)
# 되돌린 사본(래퍼·stall_watch.py 를 af0a2a2 판으로) → tests/test_issue30_stall_watch_stop.py → 5 failed / 6 passed
# 브로커 건강: scratch 등록부에 started 3회 발행 → 1/3 rc 2 (no CONNACK) — S9/S10 실패와 같은 원인
[VERDICT: PASS]