Merge-workspace review: round 1 (@518beda) claude agy grok PASS, codex NOT PASS (finalize wait measured on the wall clock); fixed in4c86107. Round 2 delta (@4c86107) claude agy grok PASS; codex (24b21b9c, after 817d1650 closed deadline_timeout with no report) found R1 closed and no new defect, but withheld PASS only because its sandbox could not bind the suite's loopback broker. The same two-file suite run here at4c86107: 278 passed. Full suite at518bedaon macOS / Python 3.9: 1711 passed / 1 failed / 38 skipped; the failure (test_issue45_restore, herdr 0.9.3 cwd restore) also fails on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
9.8 KiB
리뷰 리포트 — Job e93cf9d4 (PR #51, refactor/issue-17-item4)
- 리뷰어: planner-reviewer-claude-01 (claude)
- 리뷰 identity: loop-20261007T142403Z-10d77f / base
7209750/ head518beda/ tree137b4e4— 검증 경로에서git rev-parse HEAD 'HEAD^{tree}'일치, 미커밋 변경 없음
결론 요약
동작 불변 계약이 지켜졌다. 옛 bash 판정(tests/data/issue17_item4_old_decisions.sh = 7209750 의 함수)과 새 wrapper.py 를 경로별로 대조해 차이를 찾지 못했고(옛 코드의 따옴표 경로 버그 하나는 새 코드에서 고쳐짐 — 개선), 지정 9개 파일이 이 macOS·Python 3.9·bash 3.2 에서 전부 통과한다. 차단 결함 없음. 비차단 관찰 5건(§8).
1. 동작 불변 — 경로별 대조
| 옛 bash (old_decisions.sh) | 새 Python (wrapper.py) | 판단 |
|---|---|---|
| _map_sub_rc :72-90 | sub_status :58-66 | 같음. rc 3 은 디스크 종결 상태가 이김, 그 외 코드는 timeout |
구독자 조기 종료 판정 [ $sub_exit -eq 0 ] || [ … -eq 3 ] |
subscriber_up :69-70 | 같음 |
_rc_for :92-101 + 호출부의 MAM_DELEGATE_FAIL_CLOSED=0 → exit 0 |
exit_code :77-78, fail_closed :73-74 | 같음. 빈 값은 1 로 보는 ${…:-1} 와 or "1" 일치 |
| cmd_wait rc 4→2 :261 | wait_rc :81-82 | 같음 |
| start_stall_watch 의 감시 여부 :30-36 | stall_watch :89-99 | 같음. 메시지도 동일 |
| finalize_unfinished 앞부분: FAIL_CLOSED=0·completed 면 반환, stall.out 꼬리 3줄, 디스크 종결이면 FINAL=디스크(completed 면 반환) :105-123 | finalize_begin :161-187 (keep / done completed / go|probe <final> <evidence_until> <deadline>) |
같음 |
| broker_unavailable: 마감까지 ≤3 s 간격 재관측, 매 회 시계로 마감 계산, 마감 도달 시 wall :128-146 | finalize_end :201-216 | 같음(_sleep 이 0.5 s 단위로 쪼개 소유자를 확인할 뿐, 대기 총량은 같다) |
| timeout: evidence_until 는 진단 전에 계산, 마감까지 디스크 재관측, 디스크 heartbeat 가 창을 다시 열고, 창이 지나면 종료, 마감 도달 시 wall :149-194 | finalize_begin 이 evidence_until 계산(:181) → bash 가 진단 프로브(래퍼 :137-142) → finalize_end :217-238 | 같음. 진단 순서도 같다 |
| 라벨: sub_rc 4 → wall, wall 이고 phase≠inject 면 wall_clock_timeout, preflight/idle_wait 는 cancelled + session_busy 또는 PREFLIGHT_REASON :199-209 | :242-251 | 같음 |
| close rc 4(토큰 불일치) → 이벤트 억제, FINAL 그대로 :216-224 | :256-262 | 같음 |
| close 뒤 디스크 completed 면 completed :226-232 | :263-264 | 같음 |
| direct 만 이벤트: cancelled 또는 (sub_rc≠1 또는 inject) 이면 error(inject 는 agent_blocked) :234-245 | :266-272 | 같음. 토큰 인자도 동일 |
registry.py close 출력 2>&1 을 stdout 으로, publish_event stderr 는 버림 |
_run merge=True/False :190-193, 답은 fd 3 | 같음 — 래퍼가 3>&1 1>&4 로 stdout 을 원래 stdout 으로 보낸다(래퍼 :145-149). subprocess 기본 close_fds 로 fd 3 은 자식에 새지 않는다 |
- 비교 테스트(test_c_*)가 status×sub_rc×phase 전 조합, 토큰 불일치(CLOSE_RC=4), FAIL_CLOSED=0, remote·human·loop·discuss, 늦은 completed, heartbeat 유예가 마감까지 가는 경우, 래퍼 사망(KILL·미회수 좀비)을 옛·새 양쪽으로 돌린다.
- 직접 돌려 본 미포함 경로: registry 경로에 작은따옴표(
reg it's)가 있을 때 옛_disk_status는 경로를 Python 소스에 끼워 넣어""를 내고(rc 3 + 디스크 completed 를 broker_unavailable 로 오판), 새 sub_status 는completed. 옛 버그가 고쳐진 차이이며 계약 위반이 아니다.
2. bash ↔ Python 경계
_decide(래퍼 :97-99)는"$@"로 인자를 그대로 넘긴다. 빈 값(--dispatch-token "",--phase "",--preflight-reason "")은 argparse 기본값과 같다.-로 시작하는 값이 들어갈 수 있는 인자는 PREFLIGHT_REASON 뿐인데 값은target_not_found하나(래퍼 :850).${START_TIME:+--start-time "$START_TIME"}는 빈 START_TIME 이면 인자를 빼고 Python 이 now 를 쓴다 — 옛${START_TIME:-$(date +%s)}와 같다.- 한 줄 출력: finalize-begin 은 stdout 에 답 한 줄만, 진단은 stderr. finalize-end 는 fd 3 으로 답(fd 3 이 닫혀 있으면 stdout).
- 래퍼에 판정이 남지 않았다: test_s_the_wrapper_carries_no_moved_decision(주석 제외 소스에 _map_sub_rc·_rc_for·FINALIZE_CLOCK·
${MAM_DELEGATE_FAIL_CLOSED·SUB_RC 비교·라벨 문자열 없음, lib_int.sh 에 BASH_REMATCH 없음). - knob 환경변수: 옛 bash 는 셸 변수로, 새 Python 은 환경으로 읽는다. 래퍼가 이 두 knob 을 대입하지 않고 .mam.env 는
export줄로 들어오므로(래퍼 :37-45, envfile.py :116) 차이가 없다. - Python 실패 시: 관찰 N2.
3. finalize-end 의 --watch-pid
_check_owner가 두 대기의 매 반복(0.5 s 단위)과 close 직전(:256), 이벤트 직전(:267)에 래퍼 생존을 본다. 사라졌으면 OwnerGone → 닫기·발행 없이 종료(:338-339)._alive(:122-137): kill(0) 실패면 죽음, 성공이면ps -o stat= -p가Z…이거나 빈 출력이면 죽음. macOS 와 Linux 모두 좀비는Z(macOSZ, LinuxZ/Z+)로 시작하고, 사라진 pid 는 빈 출력이다. ps 를 못 돌리면 kill(0) 결과를 믿는다(살아 있음) — 닫는 쪽이 아니라 기다리는 쪽이지만, 래퍼가 사라진 뒤라면 대기는 마감에서 끝나고 닫기가 일어날 수 있다(ps 가 없을 때만, 비현실적).- 래퍼에 TERM trap 이 없으므로(trap 은 부트스트랩 EXIT 용 :916-942 뿐) TERM 이
$(…)대기 중 지연되는 문제는 없다. 래퍼가 TERM/KILL 로 사라지면 고아가 된 Python 이 0.5 s 안에 알아채고 끝난다 — 옛 bash 루프가 래퍼와 함께 끝나던 것과 같은 결과. 테스트 test_c_finalize_ends_with_its_wrapper / _with_an_unreaped_wrapper / test_c_an_unreaped_wrapper_leaves_the_real_record_alone 통과. - 정상 경로 지연: 확인마다 ps 한 번(수 ms). 대기의 총길이는 옛과 같다.
4. 정수 규칙 (lib_py/ints.py)
- positive_int 는 runner.py 의 옛 _positive_int 를 그대로 옮긴 것이고(diff 상 동일 본문), 옛 bash mam_int 와도 같은 규칙이다: ASCII 공백 6종만 앞뒤 허용, ASCII 숫자만(
٥거부, 로케일 무관), 선행 0 제거 뒤 9자리 이하, 최솟값. knob 의 경고 문구도 동일. test_c_mam_int_and_knob 이 C·en_US.UTF-8 두 로케일에서 옛·새를 비교하고 통과. - lib_int.sh 를 lib.sh 없이 source 하는 diff_collect.sh·loop_lock.sh:
_MAM_INTS_PY를 BASH_SOURCE 기준으로 잡으므로 동작한다(test_issue21_loop_b l15 는 그 배치를 재현하도록 lib_py/ints.py 도 복사). 비용·실패는 N3.
5. 수정된 정적 테스트 4개
약해지지 않았고 오히려 강해졌다: 각 테스트가 래퍼 소스의 _decide … 호출 문자열을 계속 고정하면서, 옮겨진 표 자체(EXIT_CODES, rc 0~5 의 sub_status, rc 3 의 디스크 우선, SUB_RC_* 와 래퍼 상수의 일치)를 실제 함수로 검사한다. test_issue26_ints 의 NOT_ARITHMETIC 에서 두 knob 을 뺀 것은 그 knob 들이 셸에서 사라졌기 때문이며 정적 가드가 재유입을 막는다.
6. 성능
_decide한 번 ≈ 50 ms(5회 0.25 s), 그중 약 29 ms 는lib_py.delegation패키지 import(N1). 잡 하나당 호출은 정상 경로에서 3~5회(sub-status, exit-code, stall-watch, 미완료 시 finalize-begin/-end).mam_int/mam_int_knob는 호출당 ≈ 47 ms(10회 0.47 s; 옛 bash 는 ≈ 0.1 ms). 래퍼 기동 시 knob 4~5개 + 플래그, send_keys 경로의 knob 몇 개. 모두 폴링 루프 밖(마감 계산은 1회)이라 잡당 1 s 미만이며 에이전트 턴 시간에 비해 무시할 수준.
7. 이식성
이 macOS·/usr/bin/python3 3.9.6·bash 3.2 에서 지정 9개 파일 통과. wrapper.py·ints.py 는 3.9 문법(from __future__ import annotations, typing.List), /proc 없음(ps 사용), str.isascii(3.7+).
8. 비차단 관찰
- N1 wrapper.py 머리말의 "Stdlib only" 와 달리
python -m lib_py.delegation.wrapper는 패키지lib_py/delegation/__init__.py를 먼저 import 해 service → mqtt_common → paho 를 싣는다(paho 없는 python3 에서ModuleNotFoundError: paho확인). 래퍼의$PY는 원래 paho 가 필요하므로 동작에는 문제없지만, 문서 정정이나 패키지 밖 모듈(ints.py 처럼 경로 실행)로 두면 호출당 ~29 ms 도 줄어든다. - N2
_decide가 실패하면(모듈 import 오류 등)exit "$(_decide exit-code …)"는exit ""→ bash 3.2 에서 rc 255("numeric argument required"), finalize 도 실패해 잡이 열린 채 남는다. 옛 코드도 같은$PY로 registry.py 를 돌렸으므로 실질 회귀는 아니지만,exit "${rc:-1}"같은 기본값이 있으면 fail-closed 코드(1)가 보장된다. - N3 lib_int.sh 는
$PY가 아니라 PATH 의python3 -I를 쓴다. python3 가 없으면 mam_int_knob 이 아무것도 출력하지 않아(옛 bash 는 항상 기본값 출력)$(( $(date +%s) + $(mam_int_knob …) ))(lib.sh:2783 등)가 산술 오류가 된다. MAM 은 python3 를 이미 필수로 요구하므로 현실적 위험은 낮다. - N4 옛
_disk_status의 작은따옴표 경로 버그(§1)가 새 코드에서 고쳐졌다 — 동작 차이지만 개선. - N5 브리프·PR 설명의 "0.2 s 마다 확인"과 달리 코드는 0.5 s(
_sleep, :145-153). 설명 정정만 필요.
9. 테스트
검증 경로에서 지정 9개 파일(pr-51 .venv, Python 3.9.6): 604 passed / 1 skipped / 0 failed (1009 s, rc 0). skip = test_issue30_stall_watch.py:340 "real process trees via /proc"(Linux 전용, 기존). MQTT_* unset, .mam.env 미export. 실제 .mam/jobs 항목 수 변화 없음(1052 → 1052).
[VERDICT: PASS]