Files
multi-agent-mux/.agents/reports/planner-reviewer-claude-01/report-ff5f9925.md
T
2026-09-13 17:54:52 +09:00

16 KiB
Raw Blame History

🔍 Review Report — Job ff5f9925 (branch fix/issue-19, reviewed HEAD 134fe97)

  • Reviewer: planner-reviewer-claude-01 (role: planner,reviewer) · 2026-09-13
  • 대상: git log 8e7282c..HEAD 6커밋, git diff 8e7282c...HEAD 22파일 +791/−50.
    • 커밋: ce315d8(fix #19), 38d8ccb(release v4.4.5), 4da4a50, c096cb4, 8f9745b, 134fe97.
    • 이번 라운드에 새로 들어온 것은 134fe97 하나다. grok 의 N1·B1 을 반영했고, 4파일 +97/−27 이다.
    • 브리프에는 HEAD 가 38d8ccb 로 적혀 있다. 실제로 검토한 HEAD 는 134fe97 이며, completed 발행 명령에서 HEAD 를 다시 확인한다.
  • 방법:
    • 브랜치 전체 diff 와 134fe97 을 정독했다.
    • macOS /bin/bash 3.2.57(PATH 의 bash 와 같다)에서 B1 을 재현하고 수정을 확인했다.
    • paho 2.1.0 의 연결 중 시그널 지연을 데몬 없이 python 단독으로 실측했다.
    • 선택 테스트 5파일을 실행하고, 신규 시그널 테스트를 3회 반복했다. 정적 검사도 했다.
    • 리뷰 중 45초 간격으로 progress 를 발행했다. 코드는 수정하지 않았다.
  • 안전 규칙 준수:
    • 실제 monitor 데몬 35505(이 저장소)와 8152(다른 프로젝트), 그리고 .mam/monitor.lock 은 읽기 전용 ps 로만 확인했다.
    • scrub-secrets·purge 는 실행하지 않았다.
    • 데몬·트랩 실험은 pytest 샌드박스, bash -c 단독 스크립트, python 단독 프로세스에서만 했다.

0. 이전 리뷰와 이번 커밋

리뷰 대상 HEAD 판정 요지
planner-reviewer-claude-01(9114ac86) 8f9745b PASS B1 을 놓쳤다(§0-1)
reviewer-agy-01(0f0327f6) 8f9745b PASS —
reviewer-grok-01(0dc6400b) 8f9745b NOT PASS N1(차단): 구독 직후의 SIGTERM 이 정리 경로를 타지 않아 test_mqtt_loop_sigterm_exits_cleanly 가 실패. B1(비차단): 첫 & 전의 TERM 에서 트랩이 rc 1 로 끝남
planner-reviewer-codex-01(236cc993) 8f9745b PASS —

creator(fdb85679)는 반론 없이 N1·B1 을 134fe97 에 반영했다. N1 은 grok 의 대안 1(핸들러를 연결 전에 설치), B1 은 set +u 로 읽는 방식이다.

0-1. 지난 리뷰의 누락 인정

  • 나는 9114ac86 §1-1 에서 ${_sub_pid:-${!:-}} 를 승인했다. 근거는 "$! 는 비어 있거나 구독자 자신" 이라는 추론뿐이었고, 대상 셸인 bash 3.2 에서 실제로 실행해 보지 않았다.
  • bash 3.2 는 set -u 아래에서 첫 & 전에 ${!:-} 를 $!: unbound variable 로 처리해 셸을 죽인다. 이것을 grok 이 찾았다(B1).
  • 이번에는 트랩과 set -u 경로를 /bin/bash 3.2.57 에서 직접 실행해 확인했다(§1-1).

1. 134fe97 검증

1-1. B1: _sub_stop 의 $! 읽기 (reconcile.sh:80-98)

수정 내용(:86-91): 먼저 local child="${_sub_pid:-}" 로 읽고, 비어 있을 때만 set +u; child="$!"; set -u 로 $! 를 읽는다.

실측 (/bin/bash 3.2.57, set -euo pipefail) 결과
set -u; x="${!:-}" (수정 전 방식), 첫 & 전 $!: unbound variable. B1 재현
수정 전 트랩 본문 + kill -TERM $$, 자식 없음 같은 오류로 rc 1
수정 후 _sub_stop 원문, 자식 없음 rc 143
수정 후 _sub_stop 원문, sleep 30 & 자식 있음 rc 143. sleep 도 종료됨
  • set +u 가 풀려 있는 구간은 대입 한 줄뿐이다. 트랩은 곧바로 exit 143 으로 끝나므로 nounset 해제가 다른 코드로 새지 않는다.
  • 테스트 test_sub_stop_trap_exits_143_before_the_first_background_child(tests/test_issue19_subscriber_daemon_lifecycle.py:395-411):
    • _sub_stop 을 정규식으로 원문 그대로 추출한다. 정규식은 두 칸 들여쓴 첫 } 까지 잡는데, 함수 본문의 fi 는 네 칸 들여쓰기라 경계가 정확하다.
    • set -euo pipefail 인 bash -c 에서 자식 없이 한 번, 자식이 있는 상태로 한 번 실행한다.
    • 자식이 있는 경우는 0.5 s 기다린 뒤 TERM 을 보낸다. §1-3 의 fork-exec 창과 섞이지 않게 분리한 것이다.
    • 이 테스트는 PATH 의 bash 로 실행된다. 따라서 B1 회귀는 bash 3.2 환경(macOS)에서 잡힌다. 이 머신에는 bash 4 이상이 없어 다른 버전의 동작은 확인하지 못했다.

1-2. N1: python 핸들러를 연결 전에 설치 (reconcile.sh:300-377)

순서:

  1. stop_event, _on_stop_signal, signal.signal ×2 (:300-317)
  2. 클라이언트 생성 (:319-323)
  3. connect (:326-331)
  4. loop_start + CONNACK 대기 (:332-341)
  5. 메인 루프 (:343-370)
  6. finally 에서 loop_stop/disconnect (:371-376)
  7. sys.exit(0) (:377)
시그널이 도착한 시점 동작 결과
인터프리터 기동·import 중 (핸들러 설치 전) python 기본 동작으로 즉시 종료 연결·구독 전이라 정리할 것이 없다. lock 은 프로세스 종료로 풀리고 bash 는 143
connect 중 (TCP 연결 대기) 핸들러는 플래그만 세우고, connect 는 PEP 475 재시도로 시간 초과까지 계속된다. 예외가 나면 stop_signal 이 있으므로 폴백하지 않는다(:329). 메인 루프 첫 검사(:346)에서 signal 15 received 를 찍고 finally 로 간다 rc 0, 폴백 없음. 최대 약 5 s 지연(§3-1)
connect 성공 뒤 CONNACK 대기 중 stop_event.wait(0.1) 이 곧바로 깨어나 대기 루프를 빠져나온다(:335-337). stop_signal 이 있으므로 폴백하지 않는다(:338) 즉시, rc 0
구독 직후 (subscribed to 로그 뒤) 메인 루프의 stop_event.wait 이 깨어난다 즉시, rc 0. grok N1 의 사례
loop_start 전 (connect 예외 경로) finally 의 loop_stop() 은 스레드가 없어 MQTT_ERR_INVAL 을 반환할 뿐이다(paho 2.1.0). disconnect 는 try 안에 있다 예외 없음
  • rc 계약:
    • 시그널을 받은 구독자는 rc 3 을 내지 않는다(:329, :338). 그래서 bash 가 폴링 폴백으로 들어가지 않는다.
    • rc 0(정상), 3(폴백), 1(lock 실패)의 의미는 그대로다.
    • python PID 에만 직접 TERM 을 보낸 경우 python 은 0, bash 는 exit 0 으로 끝난다. 수정 전(python 이 시그널로 죽고 bash 는 0)과 외부 결과가 같다.
  • paho 스레드와의 충돌 없음:
    • python 시그널 핸들러는 항상 메인 스레드에서 실행되고, 플래그와 threading.Event 만 건드린다.
    • paho 스레드는 시그널을 처리하지 않는다. loop_stop() 은 메인 스레드에서 스레드를 join 한다.
  • 테스트 test_sigterm_during_broker_handshake_takes_the_clean_path (:361-392):
    • 브로커 없이, 임의 포트에서 응답하지 않는 로컬 리스너를 쓴다.
    • connecting to 로그 1 s 뒤에 TERM 을 보내고, rc 143, signal 15 received 있음, 폴백 없음을 확인한다.
    • CONNACK 대기 5 s 중 1 s 시점이므로 약 4 s 여유가 있다. connect 전에 TERM 이 닿아도 로컬 연결은 곧바로 끝나 같은 결과가 된다.
    • 부하 때문에 로그 확인에서 TERM 까지 5 s 를 넘길 때만 실패할 수 있어, 결정적이라고 판단한다.
  • grok 이 실패를 본 test_mqtt_loop_sigterm_exits_cleanly 는 이번 실행에서 모두 통과했다. 이제 subscribed to 로그는 "핸들러 설치 이후" 를 뜻하므로, 테스트의 트리거 조건을 바꾸지 않은 판단이 옳다.

1-3. creator 가 새로 보고한 fork-exec 창 (고치지 않음. 후속 과제에 동의)

  • 현상: TERM 이 & 의 fork 와 exec 사이 수 ms 안에 오는 경우다. 자식이 물려받은 트랩이 전달된 TERM 을 삼키고, bash 는 트랩의 wait 에 묶인다.
  • 비차단으로 보는 이유:
    • 기동 직후의 매우 좁은 창이다.
    • 그렇게 살아남은 python 도 #19 의 자기 종료가 그대로 적용된다. 워크스페이스가 삭제되거나 lock 이 교체되면 5 s 안에 종료하므로, 샌드박스에서 무한 누수가 되지는 않는다.
  • 후속 수정 때 함께 볼 점:
    • 부모 bash 를 잃은 python 구독자(argv 는 python -)는 conftest 선정자(bash|sh <root>/…/reconcile.sh --subscribe)에도 세션 가드에도 보이지 않는다.
    • creator 가 제안한 방식(자식 생존 확인 → 제한 횟수 TERM 재전송 → 마지막에 KILL)에 동의한다.

2. 브랜치 전체 검증 항목 (1)–(7)

# 항목 결과
(1) reconcile.sh ✅ 아래 1번 목록
(2) daemon.py/lib.sh/cli.py ✅ 134fe97 에서 바뀌지 않았다. 아래 2번 목록
(3) tests/conftest.py ✅ 134fe97 에서 바뀌지 않았다. 아래 3번 목록
(4) 신규 테스트 결정성 ✅ 아래 4번 목록
(5) 문서 ✅ 아래 5번 목록
(6) 버전 락스텝 · 릴리스 커밋 ✅ 아래 6번 목록
(7) 테스트 · 정적 검사 ✅ 아래 7번 목록
  1. reconcile.sh:
    • rc 계약: set +e(:101) → &(:105) → _sub_pid=$!/wait/sub_rc(:379-381) → set -e(:383) 순서다. rc 가 3 일 때만 폴백하고(:385), 그 밖에는 exit 0(:408)으로 끝나 종전 계약과 같다.
    • 트랩: 첫 자식 전(B1 수정), & 직후($!, R-1), 자식이 이미 끝난 뒤(kill/wait 가 아무 일도 하지 않음) 모두 rc 143 이다.
    • _workspace_gone(:132-150):
      • os.getcwd() 예외를 처리한다(:138-141).
      • (st_dev, st_ino) 로 비교한다(:128-129, :145-147). 원래 lock 을 열어 둔 채이므로 그 inode 는 재사용될 수 없고, 교체 판정이 안전하다.
    • paho 스레드와 충돌 없음: §1-2.
    • 폴백: trap … exit 143(:394) 과 sleep & wait $! || true(:404-405). 여기의 $! 는 바로 앞 & 로 이미 값이 있으므로 B1 과 같은 문제가 없다.
    • set -euo pipefail: B1 외에 의도치 않게 종료되는 새 경로는 없다.
  2. daemon.py/lib.sh/cli.py:
    • 옵트아웃은 리터럴 1 만 인정한다(daemon.py:28-31).
    • 옵트아웃 중에도 기존 데몬 PID 를 보고한다. stdout 은 비우고 stderr 에 한 줄 남긴다.
    • legacy 경로(lib.sh:2024-2027)가 python 본체와 바이트 단위로 같다.
  3. tests/conftest.py:
    • reaper 는 import 시점에 잡은 _REAL_POPEN(:16-26)으로 명령을 실행한다.
    • 선정자는 argv 형태와 스크립트 경로 전체로 대상을 고르고, 공백이 든 후보는 실제 파일일 때만 인정한다(:32-58).
    • 3 s 유예 뒤 SIGKILL 로 넘어간다(:86-97).
    • mam_sandbox(:113)는 finalizer 를 먼저 등록해(:123) 맨 마지막에 실행되게 하고, 옵트아웃을 기본으로 켠다(:161).
    • 세션 가드는 basetemp 기준으로만 판정한다(:1384-1398).
  4. 신규 테스트 결정성:
    • 테스트는 16건이다. 15개 함수에 파라미터화 1개(:130, 2케이스)가 더해진 수로, 실제 수집 수와 같다.
    • 신규 2건은 임의 포트 리스너와 스크립트 원문 추출만 쓰므로 외부에 의존하지 않는다.
    • 시그널 테스트 4건을 3회 반복했고 매번 모두 통과했다. 소요 시간 편차는 ±0.06 s 이하였다(§2 표).
    • 포트 9 가정, 브로커 skip 조건, from conftest import 에 대한 판단은 지난 리뷰와 같다.
  5. 문서:
    • monitor SKILL.md:86-92 의 핸들러 순서·폴백 설명이 코드와 일치한다.
    • VERSIONS.md:47(항목 3)과 :51(신규 테스트 16개, 수집 수와 같음)이 정확하다.
    • create SKILL.md:71 은 바뀌지 않았고 여전히 정확하다.
  6. 버전 락스텝 · 릴리스 커밋:
    • lib.sh:32, lib_py/loop/eager.py:16 이 4.4.5 이고, SKILL.md 8개가 모두 version: 4.4.5 다.
    • VERSIONS.md:9(현재 버전)와 :23-(매트릭스), README.md:5, BOOTSTRAP.md:5, MESSAGING.md:3 도 4.4.5 다.
    • verify_framework_version 을 통과했다.
    • 134fe97 은 버전 값을 바꾸지 않았다. VERSIONS.md 는 본문만 수정했다.
    • 릴리스 커밋 38d8ccb 의 형식은 그대로다.
  7. 테스트 · 정적 검사: 아래 표.
테스트 · 정적 검사 결과
선택 실행 5파일 (issue19 16건, test_issue5_phase1_delegation, test_tier2_component, test_version_consistency, test_herdr_shim_contract) 144 passed in 284.44 s, rc 0, skipped 0. 지난 라운드 142건에 신규 2건이 더해졌다. 브로커에 의존하는 2건도 skip 없이 실제로 실행되었다
시그널 테스트 반복 (-k "sigterm or handshake or sub_stop" ×3) 매회 4 passed. 핸드셰이크 2.30–2.32 s, 폴백 SIGTERM 1.33 s, MQTT SIGTERM 1.16–1.22 s, _sub_stop 0.51–0.52 s
데몬 누수 (정밀 필터: argv0 bash/sh + …/reconcile.sh + --subscribe) 실행 전후 모두 실제 데몬 2개(8152, 35505)만 같은 PID 로 살아 있었다. 샌드박스 데몬 0개. B1 테스트가 띄운 sleep 30 잔여도 0개
#15 레지스트리 가드 실행 전후 실제 .mam/jobs 레코드 목록이 같다(추가 0건)
정적 검사 bash -n(reconcile.sh, lib.sh) 통과. py_compile(tests/conftest.py, 신규 테스트) 통과. git diff --check 8e7282c...HEAD 클린. 작업 트리 클린, HEAD 134fe97

3. 비차단 권고

  1. 연결 중 SIGTERM 이 최대 약 5 s 늦게 처리된다 (N1 의 부작용):
    • 실측(데몬 없이 python 단독): paho 2.1.0 connect 를 응답 없는 주소(192.0.2.1, 10.255.255.1)로 걸고 1 s 뒤 TERM 을 보냈다.
      • 핸들러는 1.00 s 에 실행되었다.
      • 그러나 connect 는 5.00 s 시간 초과까지 계속되었고, 프로세스는 TERM 뒤 4.0 s 에 끝났다.
    • 수정 전에는 이 구간의 TERM 이 곧바로 프로세스를 죽였다(정리할 것은 없었다).
    • 영향: 브로커 주소가 블랙홀일 때만 생긴다. conftest reaper 는 3 s 유예(conftest.py:92) 뒤 SIGKILL 로 넘어가므로 회수는 된다. 다만 드물게 monitor SKILL.md:92 의 "SIGKILL is never required" 문구와 어긋난다.
    • 개선안(선택): 연결 단계에서만 핸들러가 예외를 던지게 하거나, 문서에 "연결 중에는 최대 약 5 s" 를 적는다.
  2. fork-exec 창: §1-3 의 후속 과제.
  3. 폴백의 남는 sleep: 지난 권고를 유지한다. 문서화되어 있고, 간격(RECONCILE_POLL_INTERVAL) 안에 스스로 끝난다.
  4. _run_real 시간 초과: 지난 권고를 유지한다. 시간 초과 시 자식 ps/pgrep 을 끝내지 않는다(conftest.py:19-26).

4. GM 참고

  • HEAD 가 브리프의 38d8ccb 에서 134fe97 로 옮겨졌다.
    • 2회차에서 agy·codex 의 PASS 와 grok 의 NOT PASS 는 모두 8f9745b 까지만 본 것이다.
    • 134fe97 을 본 것은 creator 자체 검증과 이 리포트뿐이다. 만장일치 판정 전에 각 리뷰어가 134fe97 을 확인했는지 볼 것.
  • 이 저장소의 실제 데몬 PID 35505 는 수정 전 스크립트로 계속 돌고 있다. 재기동 여부는 오케스트레이터가 판단한다.
  • 내 지난 리뷰(9114ac86)에는 B1 누락이 있었다(§0-1).

5. 종합

  • grok 의 N1(차단)과 B1 이 정확히 반영되었다.
    • python 핸들러는 연결 전에 설치된다. 시그널을 받은 구독자는 폴백하지 않고 정리 경로(rc 0, bash 143)로 끝난다.
    • _sub_stop 은 bash 3.2 에서 첫 자식 전에도 rc 143 으로 끝난다.
  • 두 수정 모두 회귀 테스트로 고정되었다.
  • 브리프의 7개 검증 항목을 브랜치 전체 기준으로 충족한다. 남은 사항(연결 중 약 5 s 지연, fork-exec 창)은 좁은 창이며 비차단이다. 설계 재검토는 필요 없다.

[VERDICT: PASS]