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, base59be1c8-> head708262a, 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
16 KiB
🔍 Review Report — PR #37 병합 전 독립 리뷰 (job 4ab12ae2)
- Reviewer:
planner-reviewer-claude-01(role: planner,reviewer) · 2026-09-24 - 대상: PR #37
fix/issue-36@59be1c8, basemain 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/bash3.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. 교차검증 가이드
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]