Files
multi-agent-mux/.agents/reports/planner-reviewer-claude-01/report-4ab12ae2.md
T
GodopuandClaude Fable 5.1 c3e6a7e96d release(v4.7.3): bump framework and eight skills to 4.7.3 in lockstep; archive PR #37 merge-side reviews
Issue #36: delegated prompts travel by file, a delegation that fails ends
the loop with rc 1. VERSIONS.md carries the entry; the merge-side reports
(round 1 @59be1c8 and round 2 @708262a with identity sidecars, base
59be1c8 -> head 708262a, evidence review-tree) are archived under
.agents/reports/<session>/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-09-24 18:10:28 +09:00

132 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 — PR #37 병합 전 독립 리뷰 (job `4ab12ae2`)
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-24
- **대상**: PR #37 `fix/issue-36` **@ `59be1c8`**, base `main 1debc7d` (v4.7.2). 6커밋, 11파일 `+765/−30`, `git diff --check` 클린.
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260924T080014Z-2b3df0/1`. **identity 확인**: `HEAD = 59be1c89…cb49`, `HEAD^{tree} = a009c906…fab7` — 브리프와 일치.
- **안전 규칙 준수**: 검증 경로·소스 워크트리(`multi-agent-mux-wt/pr-37`)·본 체크아웃(`main 1debc7d`) 모두 수정·커밋·checkout 없음. 실험은 scratchpad 임시 디렉터리(uuid4)에서만(래퍼는 `--dry-run` + `--agent human` + 도달 불가 브로커, 러너는 in-process 스텁), pytest 는 `AGENT_PYTHON_BIN` + `--basetemp=<검증 경로 밖>`. 실제 herdr·세션·`.mam`·데몬 조작 없음, push·merge·PR 상태 변경 없음.
## 결론 요약
**병합을 막을 결함은 없다.** 프롬프트는 러너·래퍼·lib.sh 도우미·옛 셸 러너·create 온보딩 다섯 경로 모두에서 파일로 간다(`grep -rn -- '--prompt '` 의 잔존 호출은 전부 셸 **함수** 인자이거나 문서/기본값이다). 비차단 지적 4건(H1–H4). 그중 **H1 은 재현한 실제 결함**이다: 파이썬 러너는 프롬프트 파일 **쓰기**가 실패하면(ENOSPC 등) 부분 파일을 `.mam` 에 남기고 예외를 그대로 던진다 — PR 이 셸 쪽 두 곳에서 고친 바로 그 부류가 러너에는 빠져 있다. 디스크 고갈 급의 경계 상황이라 차단으로 보지 않았지만, 릴리스 커밋이나 즉시 후속에서 세 줄로 닫을 것을 권한다.
## 1. 전달 경로의 완전성 (항목 1)
| 경로 | 이전 | 이제 | 근거 |
|---|---|---|---|
| 파이썬 러너 `_run_cmd` | `--prompt <text>` argv | `.mam/prompt-<loop_id>-XXXX.md` 에 쓰고 `--prompt-file` | `runner.py:467-502` |
| 래퍼 `submit` → `registry.py register` | `--prompt "$PROMPT"` | 파일로 왔으면 `--prompt-file` 그대로 전달, 텍스트로 왔으면 종전대로 | `multi-agent-mux-delegate-job:186-208` |
| 래퍼 loop 타입의 회차 `update` | `--prompt "$current_prompt"` | `$job_dir/prompt.md` 에 쓰고 `--prompt-file` | `:405-450` |
| `lib.sh` `delegate_submit_job` / `_delegate_submit_job_legacy` (제출 도우미 11곳의 종착) | argv | `_delegate_prompt_file` 로 `.mam/prompt-submit-XXXXXX` 에 쓰고 `--prompt-file`, 성공·실패 모두 `rm -f` | `lib.sh:2038-2046, 2049-2064, 2160-2174` |
| `lib_py/delegation/cli.py submit` | `--prompt` 필수 | `--prompt`/`--prompt-file` 상호 배타, register 로 그대로 전달 | `cli.py:29-33, 64-66` |
| 옛 셸 러너 `MAM_LOOP_PY=0` | `delegate_job_safe … --prompt "<text>"` (함수 인자) | 함수 안에서 `--prompt` 를 `.mam/prompt-shloop-XXXXXX` + `--prompt-file` 로 바꿔 래퍼 exec | `run_loop.sh:196-221`. `run_loop.sh` 의 잔존 `--prompt "…"` 11곳은 이 **함수** 호출이라 exec 을 거치지 않는다 |
| `create_session.sh --submit-job` | 주입문에 과제 본문 포함 | 과제는 `<jobs>/<id>/brief.md` 에 쓰고 pane 에는 **경로만** | `create_session.sh:625-646` |
### 1-1. 래퍼의 파일 처리 (실측, `--dry-run --agent human`)
- 상대 `--prompt-file "my prompts/p 1.md"` 는 **호출자 cwd 기준**으로 절대화된다(`:191`, `cd "$WORKDIR"` 이전) — `--workdir` 가 다른 곳이어도 찾는다. 상대 `--registry-dir "reg dir"` 는 종전대로 `--workdir` 기준이다(기존 의미 유지). 공백 경로 모두 안전.
- 등록된 `prompt` 는 **바이트 단위로 동일**(`'line1\nline2\n\n'`, 후행 개행 보존) — registry 가 파일을 직접 읽기 때문. 래퍼의 `$PROMPT` 변수(`$(<file)`)는 후행 개행을 잃지만 그것은 브리프 본문에만 쓰인다(H4).
- 래퍼는 호출자의 파일을 **지우지 않는다**(소유권은 호출자). `--prompt` 와 `--prompt-file` 동시 → 거부, 없는 파일 → `cannot read --prompt-file <절대경로>` 거부.
- 읽는 시점: register 는 래퍼의 첫 단계(`:207`)이고 래퍼는 잡이 끝날 때까지 블록하므로, 러너·lib.sh·셸 러너가 **래퍼 반환 후** 지우는 순서는 안전하다.
### 1-2. 임시 파일의 위치·권한·정리
| 생성자 | 위치 | 권한 | 정리 |
|---|---|---|---|
| 러너 | 제어 루트 `.mam/` (`self.mam_dir`) | `NamedTemporaryFile` → 0600 | `finally: os.unlink` — 성공·실패·**OSError(exec 실패)** 모두 (`:498-502`) |
| lib.sh | `${WORKSPACE_ROOT}/.mam/` | `mktemp` → 0600 | 제출 뒤 `rm -f`; **쓰기 실패 시 `rm -f` 후 return 1** (`lib.sh:2042`) |
| 셸 러너 | `$MAM_REAL_ROOT/.mam/` | `mktemp` → 0600 | 래퍼 반환 뒤 `rm -f`; 쓰기 실패 시 제거 + 원인 로그 + return 1 (`:201-205`) |
| create | `<jobs>/<id>/brief.md` (잡 디렉터리, 보존 대상) | umask | 실패 시 잡 `error` + exit 1 (`:632-637`) |
### H1 (비차단이지만 실제 결함, 재현) — 러너의 쓰기 실패는 부분 파일을 남기고 예외를 던진다
`runner.py:472-477` 의 `with NamedTemporaryFile(...) as fh: fh.write(p_text)` 는 `try` **바깥**에 있다. 파일이 만들어진 뒤 `write` 가 실패하면(ENOSPC·EIO) `finally` 의 `unlink` 에 닿지 않는다. 검증 경로의 러너를 in-process 로 띄우고 `write` 가 `OSError(28)` 을 내도록 감싼 결과:
```
RAISED: OSError [Errno 28] No space left on device ← delegate_job 밖으로 그대로 전파(트레이스백, 126 처리 아님)
leftover prompt files in .mam: ['prompt-loop-…-2ussg8e6.md'] ← 부분 파일 잔존
wrapper called: 0
```
같은 부류를 PR 은 `lib.sh:2042`(`|| { rm -f "$f"; return 1; }`)과 `run_loop.sh:201-205` 에서 고쳤고 S9 로 고정했지만, 러너에는 대응이 없고 테스트(S4/S4b)도 `subprocess.run` 의 OSError 만 다룬다. 브리프 항목 1 의 "쓰기 실패 시 부분 파일이 남지 않는지"에 대해 러너는 **아니오**다.
→ 권고: `with` 블록을 `try` 안으로 옮기고 `except OSError` 에서 `unlink` 후 `CompletedProcess(cmd, 126, "", "cannot write the prompt file: …")` 를 돌려주면 exec 실패와 같은 "실패 제출" 경로로 합쳐진다(3–4줄). `self.mam_dir.mkdir` 실패도 같은 블록에서 잡힌다.
## 2. 실패 처리 (항목 2)
- **exec 실패(OSError → rc 126)**: `_run_cmd` 가 `CompletedProcess(cmd, 126, "", "cannot start the wrapper: … (prompt N bytes, wrapper …)")` 를 돌려주므로 첫 시도·busy 재시도 모두 같은 "실패 제출" 경로로 들어간다. 재시도 실패 시 job id 가 없는 경우에도 이제 `log_error` 를 남긴다(`runner.py:525`, 이전에는 무언). `failed_submit_jobs` 처리는 바뀌지 않았다(id 가 있으면 등록, 없으면 `None` 반환 → 호출자가 `return 1`). S4/S4b 가 E2BIG·EAGAIN 으로 고정.
- **재심·중재 미등록 → rc 1**: `:1354-1357`, `:1376-1378`. 판정 잡이 등록조차 안 됐는데 "반론 기각"이나 "중재 없음"으로 흘러가던 것을 실패로 바꾼 것이라 맞다.
- **순응(compliance) 잡 `wait_for_job` 실패 → rc 1** (`:1392-1395`): 의도된 강화로 판단한다. 근거는 **정규 수정 잡과의 대칭**이다 — 일반 correction 잡도 `if not correct_jid or not self.wait_for_job(correct_jid): return 1` (`:1323-1326`) 로 끝난다. 순응 잡은 "반론이 기각됐으니 그대로 고쳐라"는 수정 잡의 변형이므로, 시간 초과·에러로 닫힌 잡을 두고 다음 리뷰 라운드로 가면 **바뀌지 않은 트리를 다시 리뷰**하게 된다. 루프가 `wait_for_job` 실패를 통과시키는 자리는 이제 self-review(`:1187`)뿐이고 그것은 판정 리포트로 따로 걸러진다. S3c 가 세 경로(comply/adjudication/arbitration)의 미등록을 고정한다.
## 3. `MAM_DIFF_MAX_BYTES`/`MAM_DIFF_MAX_LINES` (항목 3)
- `diff_collect.sh:9-11` 주석이 "증거 크기의 상한이지 전달 상한이 아니다"를 명시하고, 상한값·잘림 마커·`runner.py` 의 l15 규칙(잘린 diff 면 diff-only 원격 리뷰어 거부, 로컬 진행)은 **변경되지 않았다**(runner 의 해당 블록은 diff 밖). S5 가 "옛 사각지대"(131,072 < diff < 200,000 바이트)의 diff 가 통째로 전달됨을 고정한다.
- **H2 (비차단, 문서)**: loop SKILL.md 에는 이 두 변수도, 잘림 마커의 의미도 적혀 있지 않다(grep 0건). delegate-job SKILL.md 는 argv 상한만 말한다. "전달은 무제한, 증거는 200 KB/4,000 줄에서 잘리며 잘린 diff 는 diff-only 원격 리뷰어에게 증거가 못 된다"는 한 문단이 loop SKILL.md 에 있어야 항목 3 의 "일관 반영"이 완성된다.
## 4. `create_session.sh` (항목 4)
- 잡 등록은 종전대로 lib.sh 의 `delegate_submit_job`(파일 경유)이고, 그 뒤 `<jobs>/<id>/brief.md` 에 과제를 쓰고 pane 에는 `Task: read the complete task in <절대경로> and follow it.` 만 주입한다(`:625-646`). `$pub`(`delegate_publish_cmd` 의 `--expect-dispatch-token`·`--attempt-id`)와 `started` 기반 판정은 **손대지 않았다** — #23 계약 유지. S8 이 주입문에 brief 경로는 있고 과제 본문은 없음, `--expect-dispatch-token`·`--event started` 가 남아 있음을 단언한다.
- 경로: `${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}` 를 절대화하므로 워크트리 creator(제어 루트 바인딩)도 자기 cwd 와 무관하게 읽을 수 있다. 래퍼가 만드는 `brief.md` 와 같은 자리다.
- 실패 시 잡 `error` + `JOB_TERMINALIZED=1` + exit 1 → 기존 트랩이 세션을 되돌린다(초기화 실패와 같은 취급).
## 5. 새 테스트 22건 (항목 5)
- **실제 경로를 지나는가**: S1 은 실제 래퍼(`bash multi-agent-mux-delegate-job submit --dry-run`)와 실제 `registry.py` 로 140,000 바이트를 등록해 **바이트 단위 동일**을 단언한다. S2/S3/S3b/S3c 는 `python -m lib_py.loop` 를 하위 프로세스로 띄우고 가짜 래퍼가 받은 인자를 검사한다. S7/S7b/S9 는 `run_loop.sh`(MAM_LOOP_PY=0)를 실제로 돈다. S8 은 mock herdr 위에서 `create_session.sh` 를 실제로 돈다. mock 으로 우회하는 것은 S4/S4b(`subprocess.run` 을 OSError 로 대체 — 커널 대신 코드 경로를 검증하는 것이 목적)뿐이다.
- **macOS**: `MAX_ARG_STRLEN` 이 없어 단일 인자 150 KB 가 통과하므로 S1b(음성 대조군)는 `linux_only` 로 **skip** 되고, S8 의 `strace` 검사는 Linux 에서만 붙는다. 즉 이 호스트에서 스위트는 **커널 상한이 아니라 코드 경로**(파일로 전달됐는가)를 검증한다 — 브리프의 우려 그대로이며, 설계상 옳다. 실제 상한 재현은 Linux CI 몫.
- **격리**: `_env()` 가 `HOME`·`WORKSPACE_ROOT`·`DELEGATE_JOB_LOGS_DIR` 를 `tmp_path` 로, 브로커를 `127.0.0.1:9` 로 고정하고 `MAM_JOBS_DIR`/`MAM_REAL_ROOT` 를 제거한다. 실제 `.mam`·홈 접촉 없음(§6 실측).
- **수정 없이는 실패하는가 — 확인함**: 검증 경로 사본에서 PR 이 바꾼 소스 8파일을 `1debc7d` 판으로 되돌리고 새 파일을 돌린 결과 **18 failed / 3 passed / 1 skipped(S1b)**. 통과 3건은 정정 전에도 성립하던 것(래퍼 실패 → rc 1 등)이다.
## 6. 검증 경로에서의 테스트 실행 (항목 5·6)
```
tests/test_issue36_prompt_transport.py tests/test_issue5_phase1_delegation.py tests/test_issue14_delivery_ack.py
tests/test_loop_cli.py tests/test_issue21_loop_b.py tests/test_issue21_acceptance.py tests/test_issue23_24_startup_path.py
tests/test_issue25_plan_reuse.py
→ 1 failed, 237 passed, 1 skipped in 422.71s (0:07:02)
```
- **skip 1**: S1b(`linux_only`) — 설계된 것(§5).
- **failed 1: S9 — 코드 결함이 아니라 테스트의 macOS 이식성 문제(H5).** S9 는 `ulimit -f 1` 로 쓰기를 실제로 실패시킨 뒤 세 가지를 단언한다: rc≠0, stderr 에 `File too large`, 부분 파일 없음 + 원인 문구. 같은 하네스를 scratchpad 에서 직접 돌린 결과 **rc=1, 부분 파일 없음, `delegate_job_safe: cannot write the prompt file` 출력** — 즉 PR 이 고친 동작(제거 + 원인 표시)은 macOS 에서도 성립한다. 실패한 것은 두 번째 단언뿐이다: macOS 의 `/bin/bash` 3.2 는 `printf` 의 쓰기 실패에 대해 `printf: write error: File too large` 문자열을 내지 않는다(Linux bash 5 의 형식). `strerror(EFBIG)` 자체는 macOS 에서도 "File too large" 이므로 순수하게 bash 버전 차이다. S1b 처럼 `linux_only` 로 두거나, 그 단언을 빼고 원인 문구 단언만 남기면 된다.
- 기존 계약 스위트(issue5 phase1 의 registry CLI 스냅샷·help 캡처, issue14, loop_cli, issue21 loop B·acceptance, issue23/24 S7*, issue25)는 **전부 통과** — `--prompt` 하위 호환과 온보딩 계약이 유지된다(항목 4·6).
### 격리·위생 실측
| 항목 | 결과 |
|---|---|
| 실제 `.mam/jobs` / `.mam/delegate_job_logs` 전후 | **차이 0 / 차이 0** |
| 실제 `.mam` 의 `prompt-*` 잔존 | **0** |
| 검증 경로 `.mam` 의 `prompt-*` 잔존 | **0** (러너·셸 러너·lib.sh 의 정리가 실제 실행에서도 성립) |
| 검증 경로 tracked 파일 | 변화 없음(HEAD `59be1c8`), 본 체크아웃 `main 1debc7d` |
| 실행 시간 | 새 파일 22건은 34 s 안팎(되돌린 사본 기준 34.5 s); 전체 7 분은 issue14/18 계열의 벽시계 시험 몫 |
## 7. 문서 (항목 7)
- delegate-job SKILL.md `:44-48`: `--prompt-file` 의 의미와 argv 상한, "루프는 항상 파일을 쓴다" 명시 ✓. 래퍼 usage(`:112-121`)도 갱신 ✓.
- loop SKILL.md `:267`: 파이프라인에서의 종료 코드(`pipefail`/`PIPESTATUS`) 안내 ✓. 그러나 `MAM_DIFF_MAX_*` 의 새 의미는 없다(H2).
- `.claude/skills`: 본 체크아웃의 것은 git 에 추적되지 않는 로컬 심링크(`../../.agents/skills/*`)라 자동 동기화된다 — 배포 사본 정합 문제 없음.
## 8. 병합 차단 여부 (항목 8)
버전 락스텝 bump 부재(`lib.sh:32` `4.7.2`, 오케스트레이터가 4.7.3 으로 처리 예정) 외에 **병합을 막을 결함은 없다**. 다만 H1 은 릴리스 커밋에 함께 넣거나 즉시 후속으로 닫기를 권한다 — 셸 쪽과 같은 부류이고 수정이 세 줄이다.
| # | 지적 | 성격 | 근거 |
|---|---|---|---|
| H1 | 러너의 프롬프트 파일 **쓰기** 실패 시 부분 파일 잔존 + 예외 전파(126 실패 제출로 합류하지 않음) — 재현 | 비차단(경계 상황), **후속 권고 1순위** | `runner.py:472-477` vs `:490-502`; §1 H1 |
| H2 | `MAM_DIFF_MAX_*` 의 "증거 상한, 전달 무제한" 의미가 loop SKILL.md 에 없음 | 문서 | `diff_collect.sh:9-11` 에만 있음 |
| H3 | 래퍼는 **빈** `--prompt-file` 을 받아들여 빈 프롬프트를 등록한다(실측) | 사소 | `multi-agent-mux-delegate-job:195` 의 `-n "$PROMPT" \|\| -n "$PROMPT_FILE"` |
| H4 | 래퍼의 `$PROMPT="$(<file)"` 는 후행 개행을 잃는다 — registry 의 프롬프트(파일 그대로)와 브리프 본문이 그 점에서 다를 수 있음 | 사소 | `:193` |
| H5 | S9 의 `File too large` 단언이 macOS bash 3.2 에서 실패(문자열 형식 차이); 실질 단언 2개는 통과 | 테스트 이식성 | §6 |
## 9. 교차검증 가이드
```bash
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260924T080014Z-2b3df0/1
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # 59be1c8… / a009c90…
grep -rn -- '--prompt ' "$RP/.agents/skills" | grep -v '\.md:' # 래퍼 usage/기본값, cli 주석, run_loop.sh 의 함수 호출뿐
# H1 재현: 러너 in-process, NamedTemporaryFile.write 가 OSError(28) → .mam/prompt-* 잔존 + 예외 전파 (§1 H1 의 스크립트)
# S9 하네스 직접 실행(macOS): rc=1, 부분 파일 없음, "cannot write the prompt file" 출력, 단 "File too large" 문자열 없음
# 되돌린 사본: 소스 8파일을 1debc7d 판으로 → tests/test_issue36_prompt_transport.py → 18 failed / 3 passed / 1 skipped
```
[VERDICT: PASS]