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
This commit is contained in:
GodopuandClaude Fable 5.1 committed 2026-09-20 21:43:25 +09:00
1 parent 80a5614f7c
commit cf5d1142e9
24 files changed
+849 -26

No files matched your search

@@ -0,0 +1,170 @@
# 🔍 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. 교차검증 가이드
```bash
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]
@@ -0,0 +1,189 @@
# 🔍 Review Report — PR #29 델타 리뷰 2회차 (job `f1174fc0`)
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-20
- **대상 델타**: `46ea4cc..08db1da` (3커밋, `git diff --stat` = **16 files, +159/−34**), base `main 1134d74` (v4.6.0).
- **검토 트리**: 워크트리 `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-29` (detached @ `08db1da`).
- **1회차 내 판정**: PASS (job `291e38b5`). 이번에는 **델타가 그 판정을 뒤집는지**를 본다.
- **안전 규칙 준수**: 워크트리 읽기 전용(수정·커밋·checkout 없음, `git status --short` 클린 유지). 본 체크아웃 `main @ 1134d74` 그대로. `worktree_prepare.sh`·`worktree_remove.sh` 를 실제 워크트리·본 체크아웃에 실행하지 않았다 — 모든 실행은 scratchpad 의 임시 복사본/임시 저장소(uuid4 이름)에서만 했다. 실제 `.mam`·데몬·세션 조작 없음, push·merge·PR 상태 변경 없음, purge·scrub 실행 없음.
## 결론 요약
**R1·R2·R3 은 실제로 닫혔다.** 특히 R1/R2 는 (a) 결함을 임시 저장소에서 직접 재현했고, (b) 워크트리 복사본에서 수정만 되돌렸을 때 새 회귀 4건이 정확히 실패하는 것까지 확인했다. 1회차 PASS 를 뒤집을 사유는 없다. 비차단 4건(D1–D4)만 남긴다. 그중 D1 은 이번 델타가 새로 만든 작은 구멍이다.
## 1. R1/R2 — `--untracked-files=all` (항목 1)
### 1-1. 결함 재현 (임시 저장소)
```
git config status.showUntrackedFiles no ; echo secret > untracked.txt
git status --porcelain --ignore-submodules=none → (빈 출력) ← 옛 검사: "클린"
git status --porcelain --untracked-files=all --ignore-submodules=none → ?? untracked.txt ← 수정본
```
표시 설정 하나로 "강제 제거해도 되는가"라는 판정이 뒤집혔다는 codex 의 지적은 사실이다.
### 1-2. 세 검사 모두에 들어갔는가 — 그렇다
| 파일:라인 | 검사 |
|---|---|
| `.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh:93` | `judge_tree()` 의 워크트리 청결 검사 |
| `.agents/skills/lib_py/worktree.py:170` | `submodule_findings()` 의 `dirty` 검사(`modules/` 아래 모든 저장소) |
| `.agents/skills/lib_py/loop/runner.py:718` | `_dirty_submodules()` (리뷰 스냅샷 승인 전 서브모듈 검사) |
스킬 트리 전체에서 `status --porcelain` 을 부르는 나머지 두 곳도 확인했다.
- `worktree_prepare.sh:209`: 롤백 후 "이 워크트리가 아직 쓸 수 있는 체크아웃인가"만 보는 용도(stdout 은 버리고 stderr 만 본다). 파괴 판정이 아니다 — 플래그 불필요.
- `runner.py:581`: 프롬프트의 `미커밋 변경: 있음/없음` **표시용**. 판정에 쓰이지 않는다(→ D3 참조).
### 1-3. 소스 트리 스냅샷은 별도 조치가 필요 없는가 — 필요 없다
`_worktree_tree()` 의 `git add -A` 는 `status.showUntrackedFiles` 를 **보지 않는다**. 임시 저장소에서 `showUntrackedFiles=no` 로 설정한 뒤 `read-tree HEAD` → `add -A` → `write-tree` 한 트리에 `untracked.txt` 가 그대로 들어갔다. 즉 스냅샷은 이미 untracked 를 고정하고 있었고, 구멍은 "서브모듈 검사"와 "제거 판정"에만 있었다. 수정 범위가 정확하다.
`_change_set_state()`(`runner.py`)와 `diff_collect.sh` 의 untracked 수집은 `git ls-files -o --exclude-standard` 라 이 설정의 영향을 받지 않는다. 빈 변경분 fail-closed 가드도 안전하다.
### 1-4. 새 회귀가 수정 없이는 실패하는가 — 확인함
워크트리를 `.git`·`.venv` 제외하고 scratchpad 에 복사한 뒤, **세 곳의 `--untracked-files=all` 만 제거**하고 돌렸다(워크트리 자체는 건드리지 않았다).
```
4 failed, 5 passed in 14.69s
FAILED test_issue21_phase2_loop_a.py::test_w6e2_untracked_submodule_file_hidden_by_config_blocks_the_forced_removal
FAILED test_issue21_phase2_loop_a.py::test_w6e_work_written_while_stopping_is_not_force_removed[True]
FAILED test_issue21_loop_b.py::test_l17_dirty_submodule_is_refused_or_makes_the_round_stale[untracked-hidden]
FAILED test_issue21_loop_b.py::test_l17_dirty_submodule_is_refused_or_makes_the_round_stale[during-hidden]
```
기존 변형(`[False]`, `before`, `before-ignored`, `untracked`, `during`)은 그대로 통과했다. 즉 새 회귀는 **정확히 이 수정만** 검증하며, 회귀 방지력이 실재한다. `test_w6e2` 가 단언 안에서 `assert _git(wt,"status","--porcelain","--ignore-submodules=none").stdout == "", "git hides it by default"` 로 전제 자체를 고정한 것도 좋다.
## 2. R3 — 워크트리 자체 venv (항목 2)
`worktree_prepare.sh:111-134` 의 분기를 읽고 문서·테스트와 대조했다(스크립트는 실제 트리에 실행하지 않았다).
| 요구 | 구현 | 판정 |
|---|---|---|
| 심링크가 아닌 실제 venv | `"$MAIN/.venv/bin/python" -m venv "$WT/.venv"` + `requirements.txt`·`requirements-dev.txt` 설치 | **충족**. 1회차 내 지적 N2 가 닫혔다 |
| 멱등성 | `[ -x "$WT/.venv/bin/python" ]` → `VENV_STATE="kept"` | 충족(`test_w1` 이 `VENV=created` → `VENV=kept` 순서를 고정) |
| 남은 링크 거부 | `[ -L "$WT/.venv" ]` → exit 1, **아무것도 바꾸지 않음** | 충족(`test_w1b` 가 링크 보존까지 단언) |
| 인터프리터 부재 | `WARNING: ... not found; no venv created` + `VENV=skipped` | 충족 |
| 설치 실패 시 exit 1, 워크트리 유지 | `pip install` 실패 → 메시지 `(the worktree is kept)` + exit 1. `git worktree add` 는 이미 끝난 뒤라 실제로 남는다 | 충족 |
| 문서 정합 | `create/SKILL.md:246`, `stop/SKILL.md:123`, 설계 문서 4장이 모두 "자체 venv·링크 거부·`VENV=` 요약"으로 갱신됨 | 충족 |
| ignore 규칙 정합 | `.gitignore` 관리 블록의 `/.venv`(#27) 와 공유 `info/exclude` 의 `/.venv` 는 **디렉터리에도 그대로 적용**된다. `test_w1` 이 준비 후 `status --porcelain` 이 비는지 단언 | 충족 |
`bash -n` 은 변경된 두 스크립트 모두 통과한다. `req=()` 배열 + `[ -f ... ] && req+=(...)` 가 `set -euo pipefail` 아래서 조기 종료하지 않는지 macOS `/bin/bash` 3.2.57 로 직접 확인했다(두 파일 모두 없을 때도 루프 끝까지 도달, rc 0).
### D1 (비차단, 이번 델타가 새로 만든 구멍) — 설치 실패 뒤 재실행이 조용히 `VENV=kept`
`pip install` 이 실패하면 exit 1 이지만 `$WT/.venv/bin/python` 은 **이미 만들어져 있다**(`:121` 에서 venv 생성이 먼저 끝난다). 그래서 사용자가 그대로 재실행하면 `:117-118` 의 `-x` 검사에 걸려 `VENV=kept` 로 넘어가고, **요구사항이 설치되지 않은 환경**을 정상으로 보고한다. 메시지는 "fix it and re-run" 이라 재실행을 유도한다.
→ 권고: 설치 성공을 표시하는 마커(예: `$WT/.venv/.mam-requirements-ok`)를 두고 `kept` 판정에 함께 쓰거나, `kept` 경로에서도 `requirements*.txt` 설치를 한 번 더 시도.
### D2 (비차단, 정보) — 준비 비용·네트워크 의존
이제 워크트리마다 venv 생성 + PyPI 설치가 필요하다. 오프라인이거나 PyPI 가 막힌 호스트에서는 prepare 가 exit 1 로 끝난다(이전에는 링크 한 번이라 즉시 끝났다). SKILL.md 에 설치 동작은 적혀 있으나 "네트워크가 필요하다"는 명시는 없다.
### D3 (비차단, 사소) — 프롬프트의 `미커밋 변경` 표시는 여전히 설정에 취약
`runner.py:581` 의 `git status --porcelain` 에는 플래그가 없어, `status.showUntrackedFiles=no` 인 소스에서는 untracked 만 있을 때 `미커밋 변경: 없음` 으로 표시된다. 판정·스냅샷·서브모듈 검사는 모두 안전하므로 **표시 오차**일 뿐이지만, R1 의 취지("표시 설정이 판단 재료를 바꾸면 안 된다")를 생각하면 같은 플래그를 주는 편이 일관적이다.
## 3. #28 이식성 수정(`7a94ba2`)이 의도를 약화시키는가 (항목 3)
### 3-1. `ps -eo pid=,ppid=,args=` — 양쪽에서 같은 출력인가
이 호스트(macOS 15, arm64)에서 **두 형태를 같은 프로세스에 대해 직접 비교**했다(파이프로 캡처, 코드와 동일 조건).
```
['-axo','pid=,ppid=,command='] → 길이 575, 인자 X 400개 그대로
['-eo','pid=,ppid=,args='] → 길이 575, 인자 X 400개 그대로
```
- **구분자·필드**: 동일(`pid ppid args`), 잘림 없음. 400자 argv 가 온전히 나왔다 — 파이프 출력일 때는 폭 제한이 걸리지 않는다.
- **이식성**: `-e`(모든 프로세스), `-o`(포맷), `args`(전체 명령행)는 POSIX 다. 반대로 `-a`+`-x` 조합과 `command=` 는 BSD 관용이고 procps 에서는 경고·다른 해석이 나올 수 있다. purge 12건의 원인 가설로 타당하며, **macOS 동작은 그대로**다(위 실측).
- 남는 차이: Linux 의 `args` 도 커널 `/proc/<pid>/cmdline` 기준이라 매우 긴 argv 는 환경에 따라 잘릴 수 있다. 다만 이 코드의 판정(에이전트 CLI 여부, UUID 포함 여부)은 앞쪽 토큰에 의존하므로 실질 영향은 없다.
### 3-2. `_normalize_help`
`optional arguments:` → `options:` 치환 + 공백 런 축약이다. 즉 **레이아웃(줄바꿈·들여쓰기) 동등성은 포기하고 내용 동등성만** 본다. 골든 캡처가 3.9 에서 떠졌고 대상 호스트가 3.14 라는 사정상 타당하며, 플래그·인자·설명 문구가 바뀌면 여전히 실패한다(시험의 목적은 "shim 이 구현과 CLI 상 구분 불가"이므로 유지). 약화는 제한적이고 대안(버전별 골든 캡처)은 유지비가 크다 — 수용.
### 3-3. `test_workspace_scope.py` 의 `bash -c`
`shell=True` 는 `/bin/sh` 를 쓰므로 dash 에서 `source` 가 없다. 문자열을 그대로 `["bash","-c", cmd]` 로 옮긴 것은 최소 변경이며 검증 내용(=`resolve_herdr_session` 의 출력)은 그대로다. 수용.
### 3-4. conftest 의 `MAM_ENV_FILE` 고정
`tests/conftest.py:173-177` 이 autouse 픽스처에서 `MAM_ENV_FILE` 을 `tmp_path/.mam-test-state/.mam.env.absent` 로 고정한다. 방향은 #20·#15 와 같은 **격리 강화**이고, 실제 `.mam.env` 를 읽어야 하는 시험은 스스로 `MAM_ENV_FILE` 을 설정한다(`test_issue14/18/5_phase1/21_worktree_prereqs/20_state_isolation` 모두 자체 설정을 갖고 있다). "브로커 없음" 계열 시험이 호스트의 실 설정을 주워 읽던 것이 오히려 결함이었으므로 계약을 약화시키지 않는다.
## 4. `90a289d` 벽시계 여유 8 → 15 s (항목 4)
이 시험이 고정하는 결함은 "idle grace 가 wall clock 을 **무시**해 `2 × TIMEOUT`(≈40 s) 에 닫히는 것"이다. 임계는 `20 + 15 = 35 s` 이므로 결함 시나리오(40 s)는 여전히 실패한다. 정상 종료는 내가 1회차 전체 스위트에서 실측한 값이 28.49 s·28.50 s(단독 재실행 포함 2회 일치)였다.
```
정상 28~29 s < 새 임계 35 s < 결함 40 s
```
여유는 5 s 로 좁아졌지만 계약(“wall clock 을 넘기지 않는다”)의 판별력은 남는다. 1회차에서 내가 유일한 비-flake 실패로 보고했던 항목이 이 커밋으로 해소된다. 다만 `timeout_s` 가 더 커지면 `2 × TIMEOUT` 과 `TIMEOUT + 15` 의 간격은 벌어지고 작아지면 좁아지므로, **고정 여유(+15)보다 비율(예: `timeout_s * 1.5`)** 이 계약을 더 정확히 표현한다(D4, 비차단 권고).
## 5. 테스트 실행 (워크트리, 이 리뷰어가 직접)
```
cd <pr-29 워크트리> && PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m pytest -p no:cacheprovider -q -rfEs \
tests/test_issue21_phase2_loop_a.py tests/test_issue21_loop_b.py tests/test_issue21_worktree_prereqs.py \
tests/test_issue21_acceptance.py tests/test_workspace_scope.py tests/test_issue5_phase1_delegation.py \
tests/test_deploy_freshness.py tests/test_version_consistency.py
→ 208 passed in 322.94s (failed 0 · errors 0 · skipped 0)
```
- 브리프가 지적한 **`tests/test_issue28_*.py` 는 이 트리에 없다**(#28 수정은 기존 파일에 들어갔다) — 규칙대로 건너뛰었다.
- **skip 0 건**: `test_deploy_freshness.py` 의 새 `skipif`(D-11~D-19, D-22~D-30)는 이 호스트에서 **전부 실행**됐다(서브모듈 자산이 있으므로 의도대로다). 브리프 항목 3 의 우려("로컬 자산이 있는 호스트에서는 실행되어야 한다")는 실측으로 해소.
- 가장 느린 시험 10건은 모두 herdr mock·정지/재개 경로이며 브로커 타임아웃 계열 실패는 없었다.
### 격리·위생 실측
| 항목 | 결과 |
|---|---|
| 실제 `.mam/jobs` 전후 | **차이 0** |
| 워크트리 `git status --short --ignored` 전후 | **완전 동일** |
| 워크트리 HEAD / 본 체크아웃 | `08db1da` / `main 1134d74` (둘 다 변화 없음) |
| 샌드박스 실험 | scratchpad 임시 디렉터리에서만(복사본 리버트 실행 포함). 실제 스크립트·워크트리에 prepare/remove 실행 없음 |
## 6. 병합 차단 여부 (항목 5)
버전 락스텝 bump 부재(`lib.sh:32`·`eager.py:16` 모두 `4.6.0`, 오케스트레이터가 4.7.0 으로 처리 예정) 외에 **병합을 막을 결함은 없다**. 1회차 PASS 판정은 이번 델타로 뒤집히지 않으며, 1회차에 내가 남긴 비차단 지적 중 **N2(.venv 심링크)는 이 델타로 해소**됐다. 1회차의 N1(스냅샷 커밋이 ref 없이 남아 gc 로 소멸)은 그대로 남아 있으니 후속 이슈로 유지하기를 권한다.
| # | 이번 회차 지적 | 성격 |
|---|---|---|
| D1 | pip 설치 실패 후 재실행이 `VENV=kept` 로 조용히 통과 | 후속 권고(이번 델타가 만든 것) |
| D2 | prepare 가 워크트리마다 venv 생성 + PyPI 설치를 요구(오프라인 exit 1) | 정보·문서화 |
| D3 | `runner.py:581` 의 표시용 status 에는 `--untracked-files=all` 이 없음 | 사소(일관성) |
| D4 | 벽시계 여유를 고정값(+15 s)이 아니라 비율로 두는 편이 계약에 충실 | 사소 |
## 7. 교차검증 가이드
```bash
WT=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-29
# R1 결함 재현 (임시 저장소에서만)
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; git config status.showUntrackedFiles no; echo secret > untracked.txt
git status --porcelain --ignore-submodules=none # (빈 출력)
git status --porcelain --untracked-files=all --ignore-submodules=none # ?? untracked.txt
GIT_INDEX_FILE=$R/idx git read-tree HEAD; GIT_INDEX_FILE=$R/idx git add -A
git ls-tree -r --name-only "$(GIT_INDEX_FILE=$R/idx git write-tree)" # untracked.txt 가 들어 있다(= add -A 는 설정 무시)
# 수정 없이는 회귀가 실패하는지 (워크트리 복사본에서만)
S=$(mktemp -d); rsync -a --exclude .venv --exclude .git --exclude .mam "$WT/" "$S/"
sed -i '' 's/ --untracked-files=all --ignore-submodules=none/ --ignore-submodules=none/' \
"$S/.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh" "$S/.agents/skills/lib_py/loop/runner.py"
sed -i '' 's/"git status --porcelain --untracked-files=all"/"git status --porcelain"/' "$S/.agents/skills/lib_py/worktree.py"
(cd "$S" && PYTHONDONTWRITEBYTECODE=1 "$WT/.venv/bin/python" -m pytest -p no:cacheprovider -q \
tests/test_issue21_phase2_loop_a.py::test_w6e2_untracked_submodule_file_hidden_by_config_blocks_the_forced_removal \
tests/test_issue21_phase2_loop_a.py::test_w6e_work_written_while_stopping_is_not_force_removed \
tests/test_issue21_loop_b.py::test_l17_dirty_submodule_is_refused_or_makes_the_round_stale) # 4 failed, 5 passed
# ps 형태 비교(잘림·구분자)
python3 - <<'PY'
import subprocess,sys,time
p=subprocess.Popen([sys.executable,"-c","import time;time.sleep(6)","X"*400]); time.sleep(1)
for f in (["-axo","pid=,ppid=,command="],["-eo","pid=,ppid=,args="]):
l=[x for x in subprocess.run(["ps",*f],capture_output=True,text=True).stdout.splitlines() if x.split()[:1]==[str(p.pid)]]
print(f, len(l[0]), l[0].count("X"))
p.wait()
PY
```
[VERDICT: PASS]