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
21 KiB
🔍 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, basemain @ 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 로 안내한다. 기존 단일 워크스페이스 사용자에게 영향이 없는 이유:
--workspace는 원래부터 필수 인자이고(:42), 기존 행의pane.cwd는 그 워크스페이스 경로다. 본 체크아웃의 실제.mam/agent-sessions.yaml5개 행 모두cwd: /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux로, 평소 호출과 일치한다.- 비교는
mam_abs_workspace(lib.sh:1606-1609,cd -P … pwd -P)로 정규화한 뒤 하므로 심링크·상대경로 표기 차이는 통과한다. 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-processpytest.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]