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

185 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 🔍 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]