Issue #26: one .mam.env parser for the wrapper and the Python tools, decimal integer knobs, tests on a local MQTT broker. Merge-workspace review: round 1 (@b58edac) claude agy PASS, codex NOT PASS (R1-R4), grok no verdict (its late report, archived as report-c9ed5b14.md, found R1 too); round 2 (@0eec795) 4/4 PASS. Reports and round-2 identity sidecars go to .agents/reports/<session>/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
112 lines
14 KiB
Markdown
112 lines
14 KiB
Markdown
# 🔍 Review Report — PR #43 `fix/issue-26` 리뷰 (job `645adb2a`)
|
||
|
||
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-10-01
|
||
- **대상**: PR #43 @ `b58edac`, base `main e1eb22b` (v4.8.0). 6커밋, 17파일 `+1590/−94`, `git diff --check` 클린.
|
||
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20261001T131705Z-a60092/1`. **identity 확인**: `HEAD = b58edace…4893`, `HEAD^{tree} = 14b670fb…6ffa` — 브리프와 일치.
|
||
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main e1eb22b`) 모두 수정·커밋·checkout 없음. 실제 `.mam.env` 는 **값을 출력하지 않고** 형태만 셌다(`$`·`export`·인라인 `#`·따옴표 줄 수 = 모두 0). 파서 실험은 scratchpad 의 합성 파일로만. 테스트는 브리프대로 `.mam.env` 를 export 하지 않고(`MQTT_*` 미설정 확인) 돌렸다. 실제 `.mam`·데몬·세션·herdr 조작 없음, push·merge·PR 상태 변경 없음.
|
||
- **참고(이 잡의 liveness)**: 이 잡의 `started` 발행 때 브로커 `100.69.147.175:1883` 이 연결을 거부했다(`Connection refused`, 지금도 포트 닫힘). `publish_event` 는 rc 2 였지만 레코드는 `running`·`events.log` 에 `started`(토큰 포함)가 기록됐고 heartbeat 타이머도 떠 있다 — #18 계약상 로컬 증거로 충분하다. 브로커 쪽은 오케스트레이터 확인이 필요하다(아래 판정과 무관).
|
||
|
||
## 결론 요약
|
||
|
||
**병합을 막을 결함은 없다.** 두 로더는 같은 입력에서 같은 결과를 내고(합성 파일 15줄 실측), 래퍼의 eval 경로는 어떤 값도 셸 해석하지 않으며(명령 치환 주입 실험: 실행 0), bash `mam_int` 와 Python `_positive_int` 는 15가지 입력에서 결과가 같다. 로컬 브로커로 옮긴 시험들은 skip 을 fail 로 바꾸고 양쪽 로그·감사 기록까지 단언해 **오히려 강해졌다**. 비차단 관찰 5건(R1–R5).
|
||
|
||
## 1. 파서 규칙 (검토 항목 1)
|
||
|
||
`envfile.py:45-73` `parse`, `mqtt_common.py:98-101` 은 `envfile.apply` 에 위임 — Python 쪽 로더는 **하나**다. 래퍼는 같은 파일을 `--shell` 로 실행한다. 같은 합성 파일을 두 경로에 넣은 결과:
|
||
|
||
| 입력 줄 | Python 로더 | 래퍼 eval 후 |
|
||
|---|---|---|
|
||
| `BOMKEY=1` (UTF-8 BOM) | 건너뜀(invalid key, 1행 경고) | 미설정 |
|
||
| `A = spaced \r` (CRLF) | `'spaced'` | `spaced` |
|
||
| `B="q's"` | `"q's"` | 동일 |
|
||
| `C='x''` | `"x'"`(바깥 한 쌍만) | 동일 |
|
||
| `D=a=b=c` | `'a=b=c'` | 동일 |
|
||
| `E=` | `''` | `''` |
|
||
| `export F=exp` | 건너뜀(invalid key) | 미설정 |
|
||
| `G=val # trailing` | `'val # trailing'` | 동일 |
|
||
| `H=$(touch …/PWNED) \`id\` ${HOME}` | 글자 그대로 | 글자 그대로, **PWNED 파일 생성 안 됨** |
|
||
| `9BAD=x` / `noequals` | 건너뜀 + 경고 | 미설정 |
|
||
| `IFS=x` / `BASH_ENV=x` | 건너뜀(reserved) | 미설정 |
|
||
| `I="unbalanced` | `'"unbalanced'` | 동일 |
|
||
| `J= "inner"` | `'inner'` | 동일 |
|
||
|
||
**두 경로가 완전히 일치한다.** 경고에는 값이 찍히지 않는다(줄 번호·사유만). 이전 Python 로더(`strip('"').strip("'")`)는 따옴표를 여러 겹 벗기고 키 검사가 없었는데, 이제 한 쌍만 벗기고 형식 오류를 경고한다 — 문서화된 규칙과 맞다.
|
||
|
||
**R1 (비차단, 문서)**: `.mam.env.example` 은 `$HOME/...` 예시를 `/home/you/...` 로 바꿔 "셸 해석 없음" 과 **일관**되게 했지만, 머리말에 규칙 자체(셸 확장 없음, 인라인 `#` 은 값의 일부, `export` 접두어 불가, BOM 저장 시 첫 줄 무시, **이미 있는 환경변수가 우선**)를 적지 않았다. 특히 래퍼 사용자에게는 세 가지가 **동작 변경**이다 — 전에는 bash `source` 가 `export KEY=`·`$HOME` 확장·인라인 주석을 받아들였다. 실제 `.mam.env` 에는 해당 줄이 없음을 확인했다(형태만 셈).
|
||
|
||
## 2. 래퍼의 eval 경로 (검토 항목 2)
|
||
|
||
`multi-agent-mux-delegate-job:41-46`: `python -I envfile.py --shell` 의 출력을 `eval`. 출력은 `envfile.py:97-98` 의 `print(f"export {key}={shlex.quote(val)}")` 뿐이고, `key` 는 `_KEY.fullmatch` 를 통과한 것만, `val` 은 `splitlines` 로 개행이 없고 `shlex.quote` 로 감싸진다 — 주입 실험에서 `$()`·백틱·`${}` 모두 글자 그대로 남았다.
|
||
- 래퍼가 그 전에 세운 비-export 변수는 `SCRIPT_DIR`·`REPO_ROOT`·`TARGET_ENV` 셋뿐이고, 셋 다 환경으로 넘겨 파일이 덮어쓸 수 없다(`:42`). env-wins 실측: `D=fromenv` 가 파일의 `D=a=b=c` 를 이긴다.
|
||
- 실패 경로: 파일이 **없으면** rc 0·출력 없음, **읽을 수 없으면** stderr `WARNING: … cannot read (PermissionError)` 후 rc 0 — 래퍼는 설정 없이 계속한다. 래퍼의 `|| { echo "ERROR: cannot read $TARGET_ENV"; exit 1; }`(`:44`)는 **Python 실행 자체가 실패할 때**(인터프리터 없음 등)만 걸려, 메시지와 실제 원인이 어긋난다(R2, 사소).
|
||
- `-I`: `PYTHONPATH`·사용자 site 를 무시하지만 `envfile.py` 는 표준 라이브러리만 쓰고 파일 경로로 실행되므로 문제없다. 오히려 `.mam.env` 를 읽기 전 환경의 `PYTHON*` 이 파서에 끼어들 여지를 없앤다.
|
||
|
||
## 3. 우선순위 변경의 영향 (검토 항목 3)
|
||
|
||
전에는 래퍼의 `source` 가 환경을 덮어썼고 Python 로더는 환경을 지켰다 — 같은 파일이 두 경로에서 다른 결과를 냈다. 이제 둘 다 env-wins.
|
||
- **루프 러너**가 래퍼에 넘기는 env(`DELEGATE_JOB_PYTHON`, `--registry-dir` 대신 쓰는 `MAM_JOBS_DIR` 등): 전에는 파일 값에 덮일 수 있었다 → 이제 러너 값이 이긴다. **개선**.
|
||
- **워크트리 creator**: create/resume 이 넘기는 절대경로 바인딩(`AGENT_SESSIONS_YAML`·`MAM_JOBS_DIR`·…)이 이제 파일 값에 덮이지 않는다 — #21 의 의도와 맞는 **개선**.
|
||
- **원격 러너**(`remote.py`)는 원래 Python 로더라 변화 없음. **테스트**는 `conftest` 가 `MAM_ENV_FILE` 을 없는 파일로 고정해 무관.
|
||
- 동작이 나빠지는 경로: 셸에 **오래된 `MQTT_*` 를 export 해 둔 채** 파일만 고친 사용자는 이제 파일 값이 적용되지 않는다. 이 부분이 R1 의 문서 누락과 겹친다.
|
||
|
||
## 4. 정수 정규화 (검토 항목 4)
|
||
|
||
bash `mam_int`(`lib.sh:2345-2356`)와 Python `_positive_int`(`runner.py:71-82`)를 같은 15개 입력에 돌린 결과 **전부 일치**: `' 30 '→30`, `0700→700`(8진 아님), `0900→900`, `0→거부(min 1)`, `-5`·`+5`·`1e3`·`30s`·전각 `30`·빈 값 → 거부, `999999999→수용`, `1000000000→거부`(9자리 상한), `0000000000000000001→1`(앞 0 제거 후 길이 판정).
|
||
- 래퍼: 환경 노브는 `mam_int_knob`(잘못되면 경고 + 기본값, `:154-156`), 플래그는 `parse_opts` 끝의 루프(잘못되면 `ERROR … not a positive decimal integer` rc 1, `:196-200`). `MAM_INJECT_ACK_GRACE_S` 는 min 0(0 = 대기 없음 허용, 의미상 맞다), `ACK_TIMEOUT`·heartbeat·budget 은 min 1. lib.sh `SKS_*` 대기 노브는 min 0(대기 생략 허용), `delegate_wait_started` 도 0 허용(`:2379`).
|
||
- 빠진 노브: `$(( ))` 안에 환경 기본값을 직접 쓰는 자리는 남아 있지 않다(B5 정적 검사가 고정). `[ -ge ]`/`[ -le ]`/`sleep` 에만 쓰이는 노브(`MAM_READY_EMPTY_GIVEUP`·`SKS_EMPTY_GIVEUP`·`SKS_IDLE_RETRIES`·…)는 `test` 의 비교가 원래 10진수라 8진 문제가 없고, PR 이 표로 분류해 "모든 정수 노브가 분류됨" 을 시험한다(`test_b5_every_integer_env_default_is_classified`). 그 노브들에 숫자가 아닌 값이 오면 `[` 가 오류(rc 2)를 내는 기존 동작은 남는다(R3, 범위 밖).
|
||
|
||
## 5. 로컬 브로커와 이전된 시험 (검토 항목 5)
|
||
|
||
`tests/_local_mqtt.py` 는 MQTT 3.1.1 의 필요한 부분을 클라이언트가 기대하는 순서로 처리한다: CONNECT(레벨 3/4만, 그 외 CONNACK rc 1)→CONNACK, SUBSCRIBE→SUBACK(QoS 최대 1 부여) 뒤 **매칭되는 retained 전달**, PUBLISH QoS1 은 **라우팅·retained 저장 뒤에 PUBACK**(b58edac — 발행자가 ack 를 받은 시점에 브로커 상태가 이미 반영됨), 빈 payload retained 는 삭제, UNSUBSCRIBE→UNSUBACK, PINGREQ→PINGRESP, `+`/`#` 와일드카드와 `$` 토픽 예외(`topic_matches`). 미지원(QoS2·will·지속 세션)은 이 저장소가 쓰지 않는 기능이다. `127.0.0.1:0` 에 바인딩해 **포트 충돌이 없고**, 스레드는 daemon 이며 `stop()` 이 리스너·연결을 닫는다.
|
||
|
||
이전된 시험의 단언은 **약해지지 않았고 강해졌다**:
|
||
- `test_acceptance5`: 공개 브로커용 `_skip_if_broker_did_not_deliver`·`no CONNACK` skip 이 삭제되고, 대신 러너 로그의 `DISPATCH`·`published started/completed`·`DONE rc=0` 과 오케스트레이터 감사 기록의 `dispatch_verified`·`dispatch_fingerprint == sha256(token)` 을 양쪽에서 단언한다.
|
||
- `test_issue19`: "브로커 도달 불가면 skip" 이 `pytest.fail` 로 바뀌었다(로컬 브로커는 항상 떠 있으므로).
|
||
- S9/S10 의 `_live_broker()`(2·3회차 리뷰에서 내가 P2 로 지적한 공개 브로커 의존)가 `local_broker_env()` 로 대체됐다.
|
||
- `test_b19` 는 `send_keys_safe` 본문을 앞 2500자가 아니라 **함수 끝까지** 검사하게 고쳐졌다(정확성 개선).
|
||
- 정적 검사(`test_local_mqtt.py:215`)가 공개 브로커 주소의 재유입을 막는다. 시험 로그에 `hivemq`·실제 브로커 IP 흔적 0건.
|
||
|
||
## 6. 테스트 실행 (검증 방법·검토 항목 6)
|
||
|
||
```
|
||
cd <검증 경로> && AGENT_PYTHON_BIN=<pr-43 .venv> PYTHONDONTWRITEBYTECODE=1 <pr-43 .venv> -m pytest -p no:cacheprovider -q -rfEs \
|
||
--basetemp=<밖> tests/test_issue26_envfile.py tests/test_issue26_ints.py tests/test_local_mqtt.py \
|
||
tests/test_issue18_liveness_contract.py tests/test_issue33_budget.py tests/test_issue30_stall_watch.py tests/test_tier1_unit.py
|
||
(.mam.env 미export, MQTT_* 미설정)
|
||
→ 228 passed, 1 skipped in 525.44s (0:08:45) failed 0 (skip = S6, /proc)
|
||
```
|
||
|
||
- PR #41 리뷰 내내 공개 브로커 때문에 흔들리던 S9·S10·R2·B3·acceptance 계열이 **외부 브로커 없이 전부 통과**했다 — PR 의 주장 확인.
|
||
- **기준 실패 주장(검토 항목 6)**: `tests/test_issue10_13_purge.py` 를 이 PR 트리와 base(`e1eb22b` 를 `git archive` 로 풀어 놓은 사본)에서 각각 단독 실행한 결과 **둘 다 23 passed**, 실패 0. 즉 이 호스트에서 이 리뷰어의 조건으로는 "12건 기준 실패" 가 **재현되지 않는다**. 양쪽 결과가 같으므로 어느 경우든 PR 회귀는 아니다. 오케스트레이터의 전체 스위트에서만 나타난다면 시험 간 순서·상태(예: 실제 홈의 대화 파일, 이전 시험이 남긴 환경) 의존을 의심할 만하다(R4).
|
||
- 격리: 실제 `.mam/jobs` 는 이 잡 자신의 파일 외 **추가 0**. 검증 경로 tracked 파일 변화 없음(HEAD `b58edac`), 본 체크아웃 `main e1eb22b`. 실행 뒤 호스트에 남은 `stall_watch`(이 잡을 감시하는 오케스트레이터의 것)·`sleep 300`(다른 세션의 프로세스 그룹) 은 내 실행과 무관함을 부모/프로세스 그룹으로 확인했다.
|
||
|
||
## 7. 설계 경계 (검토 항목 7)
|
||
|
||
- **판정이 Python 으로 이동**: `.mam.env` 해석(키 검증·예약 변수 거부·우선순위)이 bash `source` 에서 `envfile.py` 로 옮겨졌다 — #17 항목 4 의 "`.mam.env` 파싱(#26) — bash `source` 제거" 그대로다. 래퍼에는 실행과 `eval` 만 남는다.
|
||
- **bash 에 새로 생긴 로직**: `mam_int`/`mam_int_knob` 과 `parse_opts` 의 플래그 검증. 이는 bash 산술로 들어가는 값을 그 자리에서 정규화하는 **입력 정제**이고, Python 쌍둥이(`_positive_int`)와 한 규칙으로 고정됐다(B1 시험). 판정(무엇을 할지)이 아니라 형식 변환이라 경계 위반으로 보지 않지만, 1회차 PR #41 리뷰의 M4(`MAM_JOB_TIMEOUT_S` 검증이 bash 에 있음)와 같은 부류이므로 항목 4 에서 래퍼 인자 처리가 Python 으로 갈 때 함께 옮길 목록에 둘 것을 권한다(R5).
|
||
|
||
## 8. 판정
|
||
|
||
버전 락스텝 bump 부재는 판정에서 제외한다. 병합을 막을 결함은 없다.
|
||
|
||
| # | 비차단 관찰 | 근거 |
|
||
|---|---|---|
|
||
| R1 | `.mam.env.example` 머리말에 새 파싱 규칙과 **env-wins** 가 적혀 있지 않다 — 래퍼 사용자에게 `export` 접두어·`$` 확장·인라인 주석·우선순위 4가지가 동작 변경 | `.mam.env.example:1-20`; §1·§3 |
|
||
| R2 | 래퍼의 `ERROR: cannot read $TARGET_ENV` 는 Python 실행 실패에만 걸리고, 실제 읽기 실패는 경고 후 설정 없이 진행 — 메시지가 원인과 어긋난다 | `multi-agent-mux-delegate-job:44`, `envfile.py:52-53` |
|
||
| R3 | `[ -ge ]`/`sleep` 전용 정수 노브에 숫자가 아닌 값이 오면 `[` 오류(rc 2)를 내는 기존 동작은 남는다 | PR 이 분류표로 의도적으로 범위 밖 처리 |
|
||
| R4 | "test_issue10_13_purge.py 12건 기준 실패" 가 이 리뷰어 조건(단독, PR·base 양쪽)에서는 재현되지 않음 — 전체 스위트 순서 의존 가능성 | §6 |
|
||
| R5 | `mam_int`/플래그 검증은 bash 의 입력 정제 — 항목 4 이전 목록에 추가 권고 | `lib.sh:2345-2364`, 래퍼 `:196-200` |
|
||
|
||
## 9. 교차검증 가이드
|
||
|
||
```bash
|
||
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261001T131705Z-a60092/1
|
||
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-43/.venv/bin/python
|
||
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # b58edac… / 14b670f…
|
||
# 합성 .env 15줄을 envfile.apply 와 `-I envfile.py --shell` + eval 양쪽에 → 결과 동일, $(…)/`…` 미실행 (§1 표)
|
||
# mam_int vs _positive_int 15개 입력 → 전부 동일 (§4)
|
||
# base: git -C "$RP" archive e1eb22b | tar -x -C <tmp> → tests/test_issue10_13_purge.py → 23 passed (PR 도 23 passed)
|
||
```
|
||
|
||
[VERDICT: PASS]
|