Files
multi-agent-mux/.agents/reports/planner-reviewer-claude-01/report-291e38b5.md
T
GodopuandClaude Fable 5.1 cf5d1142e9 release(v4.7.0): bump framework and eight skills to 4.7.0 in lockstep; archive PR #29 reviews
Issue #21 items 4, 5, 7 (worktree helpers, review identity, acceptance
A1-A8), #20 test isolation, #27 .gitignore managed block, #28 host
portability. VERSIONS.md carries the entry; the eight review reports of
PR #29 (two rounds plus the codex re-review) 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-20 21:43:25 +09:00

21 KiB
Raw Blame History

🔍 Review Report — PR #29 병합 전 독립 재검토 (job 291e38b5)

  • Reviewer: planner-reviewer-claude-01 (role: planner,reviewer) · 2026-09-20
  • 대상: PR #29 feat/issue-21-items-4-5-7 @ 46ea4cc, base main @ 1134d74 (v4.6.0). 20커밋, git diff --stat 1134d74...HEAD = 25 files, +3229/−108.
  • 검토 트리: 워크트리 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-29 (detached @ 46ea4cc, 자체 .venv).
  • 안전 규칙 준수: 워크트리에 파일 수정·커밋·checkout 없음. 본 체크아웃은 main @ 1134d74 그대로(checkout/switch/merge 없음). worktree_remove.sh 를 실제 워크트리에 실행하지 않았다. 실제 .mam·monitor 데몬·에이전트 세션 조작 없음, push·merge·이슈/PR 상태 변경 없음. 재현 실험은 scratchpad 의 임시 저장소(uuid4 이름)에서만 했다.
  • heartbeat: 브리프 직후 started --heartbeat 30 을 가장 먼저 발행했다.

결론 요약

브리프의 8개 항목을 모두 확인했다. 병합을 막을 결함은 찾지 못했다. 항목 5(리뷰 identity)의 설계·구현 품질은 높고, 기본 경로(옵션 없는 루프)의 호환성은 코드상 보장된다. 아래 비차단 지적 9건(N1–N9)을 남긴다. 그중 N1 은 재현 실험으로 확인한 실제 소실 가능성(판정 증거의 gc 소멸)이므로 후속 이슈 등록을 권한다.

1. 항목 4 — 워크트리 준비·해체 헬퍼

1-1. .venv — 브리프가 물은 "실제 venv" 는 아니다 (N2, 비차단)

worktree_prepare.sh:111-118 은 여전히 심링크를 만든다(ln -s "$MAIN/.venv" "$WT/.venv"). 실제 venv 생성도, 인터프리터 경로 전달도 하지 않는다. 다만 이번 PR·base 에서 커밋 사고 경로는 3중으로 닫혀 있다:

층 근거
저장소 .gitignore base 1134d74 의 .gitignore:15 가 .venv(슬래시 없음) — 파일·링크·디렉터리 모두 무시
공유 info/exclude worktree_prepare.sh:120-129 가 /.venv·.pytest_cache/ 를 $GIT_COMMON_DIR/info/exclude 에 추가
배포 관리 블록 deploy/install.sh:549·deploy/install_mam.sh:249 가 /.venv/ → /.venv (#27 fadd38c)

남는 위험은 두 가지이며, 둘 다 헬퍼가 유발하지는 않는다.

  • rm -rf "$WT/.venv/" 처럼 끝에 슬래시를 붙이면 링크를 따라가 제어 루트의 venv 내용을 지운다. 헬퍼는 그런 호출을 하지 않지만(스크립트 2개의 rm -rf 는 $gd/modules* 뿐), 사람·에이전트의 수작업에서는 그대로 남는 함정이다.
  • 워크트리와 본 체크아웃이 venv 를 공유하므로 브랜치별 의존성 분기가 불가능하다.

→ 권고: 헬퍼에 --venv link|create 같은 선택지를 두거나, 최소한 SKILL.md 에 ".venv 는 링크이므로 워크트리에서 pip install·rm -rf .venv/ 금지"를 명시.

1-2. info/exclude 는 공유물이다 (N3, 비차단)

linked worktree 의 info/exclude 는 $GIT_COMMON_DIR 의 것이므로 prepare 가 본 체크아웃의 무시 규칙도 바꾼다. 코드 주석(:120-122)과 설계 문서가 이를 명시하고, 추가되는 두 패턴은 본 체크아웃에서도 산출물이다. 수용 가능하나, 되돌리는 경로(remove 시 정리)는 없다.

1-3. 서브모듈 opt-in·롤백

worktree_prepare.sh:131-204: --submodules 일 때만 초기화하고, 초기화 전 상태(각 서브모듈의 git dir 존재 여부, 공용 .git/config 의 submodule.<n>.url|active)를 인덱스 배열(bash 3.2 호환)로 기록한다. 초기화 안 된 경로에 파일이 이미 있으면 아무것도 바꾸기 전에 exit 3 으로 거부한다(R4) — 롤백이 "이번 시도가 만든 것"만 지울 수 있다는 전제를 실제로 성립시키는 올바른 순서다. 실패 시 deinit → .git 파일 제거 → modules/<name> 제거 → 없던 modules/ 제거 → config 복원 순으로 되돌리고, 마지막에 git status 로 워크트리가 여전히 쓸 수 있는 체크아웃인지 확인해 메시지를 갈라 준다. 사람이 강제 제거로 더 망가뜨리지 말라는 안내까지 있다.

1-4. --force 가 작업을 잃을 수 있는가 — 잃지 않는다

worktree_remove.sh 의 판정은 변경을 가하기 전에 모두 끝난다(:57-121): 잠금, 실행 중 세션(--stop 없으면 거부), 열린 잡, 트리 청결(서브모듈 포함), 서브모듈 소견. --force 는 워크트리 git dir 에 modules/ 가 있어 git 이 plain remove 를 거부하는 경우에만, 그것도 단 한 번 쓴다(METHOD="single --force").

  • R1(정지 중 쓰기): judge_tree() 를 --stop 이후 다시 실행하고, 달라졌으면 :154 에서 거부한다.
  • R6(tag/notes 로만 보존된 커밋): lib_py/worktree.py:UNPUSHED_REVS = ("HEAD","--exclude=refs/stash","--all","--not","--remotes") 로 모든 로컬 ref 기준 미푸시 커밋을 본다. stash 는 별도 확인.
  • R5(deinit 저장소): submodule foreach 로 닿지 않는 modules/ 아래 저장소까지 --git-dir 로 직접 검사하고, 판단 불가한 "부분 저장소"는 오류로 남겨 거부한다.

즉 --force 시점에는 "미커밋·미푸시·stash 없음"이 증명돼 있다. 한 가지만 적어 둔다(N8): plain/force 어느 쪽이든 git 은 무시된 파일을 말없이 지운다. 워크트리에 테스트가 남긴 .mam/delegate_job_logs 같은 산출물은 제거와 함께 사라진다(제어 평면이 본 체크아웃이므로 설계상 무해).

또 하나(N7): 열린 잡 검사는 registry_dirs(MAM_JOBS_DIR → WORKSPACE_ROOT/.mam/jobs → 행들의 control_plane.mam_jobs_dir)만 본다. 행이 없는 워크트리 로컬 .mam/jobs 의 잡은 보이지 않는다. prepare 가 만든 creator 는 제어 루트에 묶이므로 모델상 발생하지 않는 경로지만, 수작업으로 만든 워크트리에는 해당한다.

2. 항목 4 — resume 계약 변경(2679ddf)은 기존 사용자를 깨지 않는다

resume_session.sh:50-70 이 행의 pane.cwd 와 --workspace 를 비교해 다르면 exit 2, 경로가 사라졌으면 exit 2 로 안내한다. 기존 단일 워크스페이스 사용자에게 영향이 없는 이유:

  1. --workspace 는 원래부터 필수 인자이고(:42), 기존 행의 pane.cwd 는 그 워크스페이스 경로다. 본 체크아웃의 실제 .mam/agent-sessions.yaml 5개 행 모두 cwd: /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux 로, 평소 호출과 일치한다.
  2. 비교는 mam_abs_workspace(lib.sh:1606-1609, cd -P … pwd -P)로 정규화한 뒤 하므로 심링크·상대경로 표기 차이는 통과한다.
  3. pane.cwd 가 없는 구(舊) 행은 ROW_CWD 가 비어 검사 자체를 건너뛴다.

바인딩 영속은 이미 실행 중인 경로에도 적용된다: :145 가 CONTROL_PLANE_JSON="$CP_JSON" 를 update_yaml_resumed.sh 에 넘기고, 그쪽 :168 이 이를 읽어 행에 기록한다(A13). 계산 실패 시 워크트리면 exit 2, 제어 루트면 경고 후 무기록 — create 쪽(create_session.sh:211-221)과 같은 규칙이다.

3. 항목 5 — 리뷰 identity

  • 기록: _record_round(runner.py)가 라운드마다 iteration/base_sha/head_sha/tree_sha/review_commit/isolation/review_path/jobs/verdicts/reviewers(evidence)/stale 를 남기고 체크포인트에 loop_id 와 review_rounds 를 싣는다. 승격 시에는 리포트 옆에 report-<jid>.identity.json 을 원자적으로(.tmp → replace) 쓴다.
  • 스냅샷: _worktree_tree() 가 임시 GIT_INDEX_FILE 로 read-tree HEAD → add -A → write-tree 하여 실제 인덱스·status·ref 를 건드리지 않는다. 미커밋이 있으면 commit-tree 로 ref 없는 스냅샷 커밋을 만든다. untracked 중첩 저장소는 gitlink 만 남아 고정 불가이므로 거부하고(_snapshot), dirty 서브모듈도 거부한다(_dirty_submodules 는 --ignore-submodules=none 으로 물어 submodule.*.ignore 설정이 숨기지 못하게 한다). 판정 불가(None)는 전부 실패로 떨어지는 fail-closed 다.
  • 격리: _open_review_tree 는 TMPDIR/mam-review/<loop_id>/<n> 에 detached 워크트리를 만들고, 실패하면 격리를 낮추지 않고 exit 1(로그 문구까지 그렇게 적혀 있다). _close_review_tree 는 remove 실패 시에만 prune 을 부르고, 부모 디렉터리는 비었을 때만 지운다. run() 이 _run_body() 를 finally: _close_all_review_trees() 로 감싸고 release_guard 도 같은 정리를 한다.
  • stale 재리뷰: 라운드 판정 뒤 _round_is_stale(현재 head·tree 비교)로 소스 변경을 잡아 승인 전량 폐기 후 재리뷰, 마지막 라운드면 exit 1. 승격 직전에도 _identity_now() != (accepted head, tree) 면 "source changed after approval; not promoting" 으로 exit 1(B10).
  • 원격 리뷰어: _check_remote_recipients 가 어떤 잡도 위임하기 전에 판정 수신자 전원을 검사해 기본 거부, --remote-review diff-only 만 허용하며 그때 diff 가 truncated/unavailable 이면 증거가 없다고 exit 1.
  • 기본 동작 동일성: _context_active() 가 거짓이면 _worktree_context() 는 빈 문자열을 반환하고 collect_changes_diff(None) 은 기존 호출과 동일하다. _dispatch 의 새 인자는 기본값(remote=False, purpose="work")이라 프롬프트는 바이트 동일하다. 다만 옵션과 무관하게 Loop id: 로그 1줄과 체크포인트의 loop_id 필드가 늘었다(N9, 무해).

N1 (비차단, 후속 권고) — 스냅샷 커밋이 gc 로 사라진다

_snapshot 의 스냅샷 커밋은 ref 가 없고, 유일한 도달 경로였던 리뷰 워크트리 HEAD 는 _close_review_tree 가 라운드 판정 직후 없앤다. 그래서 identity.json 의 review_commit·tree_sha 는 나중에 검증하려 할 때 이미 없을 수 있다. scratchpad 임시 저장소에서 재현했다(워크트리 제거 → reflog expire --expire=now --all → gc --prune=now → git cat-file -e <snap> 실패). 일상적으로도 unreachable 객체는 기본 2주 뒤 대상이다. 미커밋이 있던 라운드에만 해당하며(클린 트리면 review_commit == head_sha), 판정 자체의 정합성에는 영향이 없다.

→ 권고: refs/mam/review/<loop_id>/<n> 같은 ref 로 스냅샷을 고정하고, 루프 종료 시 정책적으로 정리(또는 보존).

N6 (비차단) — 강제 종료 시 리뷰 워크트리 잔존

finally/release_guard/시그널 핸들러로 정상·TERM·INT 경로는 덮이지만, SIGKILL 이면 TMPDIR/mam-review/... 디렉터리와 소스 저장소의 worktree 항목이 남는다. prune 은 remove 실패 시에만 부른다(의도된 선택, 주석에 근거 있음).

4. #20 격리 — 설계와 실측

  • tests/conftest.py 의 pytest_configure 가 수집 이전에 ambient 바인딩 4개를 기록하고 AGENT_SESSIONS_YAML·MAM_JOBS_DIR·DELEGATE_JOB_LOGS_DIR 를 제거한다. WORKSPACE_ROOT 는 다른 체크아웃을 가리킬 때만 제거한다(이 체크아웃을 가리키면 lib.sh 가 어차피 유도하는 값). import 시점에 os.environ 을 복사하는 모듈(test_orc_onboard.SCRUBBED_ENV) 때문에 세션 fixture 로는 늦다는 설명이 정확하다. pytest_unconfigure 가 되돌려 in-process pytest.main() 호출자를 배려한다.
  • autouse _isolate_mam_state_dirs 는 env 만 설정하고 디렉터리를 만들지 않으며, 숨김 이름(.mam-test-state)이라 tmp_path 를 나열하는 기존 테스트를 깨지 않는다. mam_sandbox 와 테스트 자신의 setenv/delenv 가 뒤에 실행돼 이긴다.
  • 세션 가드 mam_state_leak_guard + _mam_state_guard.py 는 보고만 하고 절대 지우지 않는다(실제 제어 평면이므로 옳다). 감시 대상은 이 체크아웃의 .mam 과 기록된 바인딩이 가리킨 제어 평면이며, 동시에 도는 실제 위임과는 "레코드 먼저 → 등록된 세션" 규칙으로 구분한다.
  • #15 옵트아웃(N4, 비차단): test_issue15_registry_isolation.py 의 누수 단언이 "전체 목록 동일"에서 "자기 잡 id 가 실제 루트에 없음 + 그 사이 늘어난 항목 중 자기 세션 이름이 없음"으로 약해졌다. 동시 실행되는 실제 위임 때문에 전체 비교가 오탐을 냈다는 근거가 타당하고, 세션 이름 스캔이 핵심 보장을 유지하므로 수용한다.

5. acceptance5 안정화(7bc3e1a·371099a·e6d2168) — 계약을 약화하지 않는가

변경 판단
세션 이름·HMAC secret 을 실행마다 유니크하게(tag = os.urandom(4).hex()) 개선. 공개 브로커에 retain 된 과거 dispatch 가 이번 실행의 잡 대신 배달되던 교차 오염을 없앤다
--heartbeat-misses 3 → 10 허용. 이 시험의 목적은 "원격 러너가 브로커만으로 잡을 끝낸다"이고, K×N 무응답 임계 자체는 AC1·AC3 가 고정한다. 원격 러너의 started --heartbeat 계약은 그대로다
단언에 advanced to 'completed' 추가 허용. retain 된 종단 이벤트만 배달된 경우도 프로토콜상 정당한 배달 확인이다
_skip_if_broker_did_not_deliver 조건부 허용(N5). skip 은 "러너가 started 를 발행했고, 구독자가 맞는 토픽에 SUBSCRIBED 했고, 그 잡에 대해 아무것도(드롭 로그조차) 받지 못했다"는 세 조건이 동시에 성립할 때만이다. HMAC·identity 드롭, 잘못된 토픽, 발행 실패는 여전히 실패한다. 다만 구독자 배달 경로의 실제 회귀가 같은 증상을 내면 skip 으로 가려질 수 있다

6. 수용 사례·신규 테스트의 결정성·격리

  • tests/test_issue21_acceptance.py(A1–A8, 278줄): 실제 herdr·실제 브로커를 쓰지 않는다. mock herdr / stub 위임 + mam_sandbox·tmp_path 기반이며, 브로커 관련 언급은 주석과 "브로커 도달 불가 조건에서 래퍼를 돌린다"는 A3 한 곳(:102)뿐이다.
  • tests/test_issue21_loop_b.py(526줄, L1–L21): 러너를 직접 구동하되 위임·대기를 스텁으로 바꾸고 임시 git 저장소에서 돈다. 브로커 의존 0, time.sleep 없음. L1/L12/L14b/L18b 가 옵션 없는 기존 동작 불변을 별도로 고정하는 점이 좋다.
  • tests/test_issue21_phase2_loop_a.py(649줄): time.sleep(0.2) 1회(:312) 외에 시간 의존 없음. deploy/install.sh 를 임시 타깃에 실행하는 경로가 있으나 모두 tmp_path 안이다.
  • tests/test_issue20_state_isolation.py(202줄): 순수 함수 단위 + 중첩 pytest 1건(설계된 skip).
  • uuid 사용은 uuid.uuid4()(issue15 의 _unique)로 브리프 규칙과 맞는다.

7. 문서 정합성과 권고의 타당성

  • docs/design/worktree-creator-model.md: 항목 표가 1·2·3·6 = 완료(v4.6.0), 4·5·7 = 이 브랜치, 8 = 보류로 갱신됐고, 실행 예시가 worktree_prepare.sh/worktree_remove.sh 기준으로 다시 쓰였다. 호환성 표에 resume 의 exit 2 조건, remote 리뷰어 거부/opt-in, "실제 herdr pane 값 확인은 단계 C" 라는 한계가 명시돼 과장 없이 정확하다.
  • multi-agent-mux-loop/SKILL.md 가 --review-isolation·--remote-review 를 표에 추가하고 기본값·거부 시점(위임 이전)·evidence 기록까지 적는다. create/resume/stop SKILL.md 도 헬퍼를 반영한다.
  • PR 본문의 비차단 권고 4건을 코드에서 확인했고 모두 사실이다: ① A4 의 _main_state 비교가 untracked .agents/·.mam/ 변화를 보지 못함, ② A4 가 info/exclude 추가를 선반영한 뒤 비교함(test_issue21_acceptance.py:121-122), ③ _close_review_tree 의 remove 실패 시 prune 이 전역, ④ worktree_remove.sh 의 서브모듈 거부 출력 중복. 넷 다 병합을 막을 성질이 아니며 후속으로 적절하다.
  • #30(멈춘 에이전트 감지) 등록은 타당하다. #18 이 "프로세스 생존"에 결속된 설계라 "살아 있지만 멈춘" 상태는 그 계약 밖이며, 해결은 알림·운영 수준이므로 별도 이슈가 맞다.

8. 전체 스위트 실행 (이 리뷰어가 직접, 워크트리에서)

cd <pr-29 워크트리> && PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m pytest -p no:cacheprovider -q -rfE tests/
→ 2 failed, 1011 passed, 2 skipped in 2266.28s (0:37:46)   (HEAD 46ea4cc, 실행 전후 동일)
실패 판정
test_issue14_delivery_ack.py::test_wrapper_idle_grace_never_outlives_the_wall_clock PR 회귀 아님. 이 시험은 PR 이 건드리지 않았다(git diff -U0 의 issue14 hunk 는 전부 _job_for_inprocess_publish 뒤쪽 :520 이후). 단독 재실행에서도 close_delay 28.5 s(임계 20+8)로 재현되며, 다른 브랜치에 이미 90a289d test(issue-14): widen the wall-clock close margin under suite load 가 있다 — 임계 자체가 좁다는 기지 문제다
test_issue19_subscriber_daemon_lifecycle.py::test_mqtt_loop_sigterm_exits_cleanly 브로커 flake. 파일 자체가 PR 변경 0줄이고, 로그가 broker did not accept connection; falling back to polling 이다. 단독 재실행 통과
  • PR 본문의 "이 호스트 기준 실패 33건(#28)" 주장은 내 실행으로는 확인되지 않는다(내 조건에서는 2건). 제어 평면 바인딩이 실린 creator pane 조건에서 재현된 수치로 보이며, 내가 재현할 수 있는 조건이 아니므로 그 수치의 참·거짓을 주장하지 않는다. 내가 말할 수 있는 것은 내 조건에서 새 회귀가 0 이라는 것이다.

격리·위생 실측 (#20, 브리프 항목 4)

항목 결과
실제 .mam/jobs(본 체크아웃) 전후 차이 0 (레코드 추가 0건)
실제 .mam/delegate_job_logs 전후 차이 0
워크트리 git status --short --ignored 전후 완전 동일(무시 파일 포함) — 스위트가 워크트리에 아무것도 남기지 않았다
워크트리 HEAD 실행 전후 46ea4cc
본 체크아웃 main @ 1134d74, 클린

이전 회차(PR #22)에서 내가 지적했던 "리뷰어 pytest 가 워크트리에 .mam/delegate_job_logs·.mam/shim 을 남긴다" 가 이번 실행에서는 재현되지 않았다. #20 수정이 의도대로 동작한다.

9. 병합 차단 여부 (브리프 항목 8)

알려진 버전 bump 미포함(오케스트레이터가 v4.7.0 으로 처리) 외에 병합을 막을 결함은 찾지 못했다. N1–N9 는 모두 비차단이며, 후속 순위는 N1 > N2 > N5 순으로 본다.

# 지적 성격
N1 스냅샷 커밋·tree 가 ref 없이 남아 gc 로 소멸 → identity.json 사후 검증 불가(재현 확인) 후속 이슈 권고
N2 prepare 의 .venv 는 여전히 심링크(실제 venv/인터프리터 경로 아님); 커밋 경로는 3중으로 닫혔으나 rm -rf .venv/·의존성 공유 위험 잔존 문서화 또는 옵션화 권고
N3 prepare 가 공유 info/exclude 를 바꿔 본 체크아웃에도 적용(되돌림 경로 없음) 기지·문서화됨
N4 issue15 누수 단언이 전체 목록 비교에서 자기 잡 id + 세션 스캔으로 약화(근거 타당) 수용
N5 acceptance5 의 skip 경로가 구독자 배달 회귀를 가릴 여지(조건은 매우 좁음), --heartbeat-misses 3→10 수용
N6 SIGKILL 시 리뷰 워크트리·worktree 항목 잔존(prune 은 실패 시에만) 사소
N7 remove 의 열린 잡 검사가 행 없는 워크트리 로컬 .mam/jobs 를 못 봄 모델 밖 경로
N8 remove 는 plain/force 모두 무시 파일을 말없이 삭제(추적 작업 손실은 없음) 정보
N9 옵션 없는 루프에도 Loop id: 로그 1줄과 체크포인트 loop_id 필드가 추가(프롬프트는 바이트 동일) 정보

10. 교차검증 가이드

WT=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-29
(cd "$WT" && PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m pytest -p no:cacheprovider -q -rfE tests/)   # 2 failed / 1011 passed
(cd "$WT" && PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m pytest -p no:cacheprovider -q \
  tests/test_issue14_delivery_ack.py::test_wrapper_idle_grace_never_outlives_the_wall_clock \
  tests/test_issue19_subscriber_daemon_lifecycle.py::test_mqtt_loop_sigterm_exits_cleanly)              # issue19 통과, issue14 재현
git -C "$WT" diff 1134d74...HEAD -U0 -- tests/test_issue14_delivery_ack.py | grep '^@@'                 # 실패 시험은 변경 밖
# N1 재현 (임시 저장소에서만)
R=$(mktemp -d); cd "$R"; git init -q .; git config user.email t@t; git config user.name t
echo a > a; git add a; git commit -qm base; H=$(git rev-parse HEAD); echo dirty > b
GIT_INDEX_FILE=$R/idx git read-tree HEAD; GIT_INDEX_FILE=$R/idx git add -A; T=$(GIT_INDEX_FILE=$R/idx git write-tree)
C=$(git commit-tree "$T" -p "$H" -m snap); git worktree add -q --detach "$R/wt" "$C"; git worktree remove --force "$R/wt"
git reflog expire --expire=now --all; git gc --prune=now -q; git cat-file -e "$C^{commit}" || echo "snapshot gone"

[VERDICT: PASS]