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
190 lines
16 KiB
Markdown
190 lines
16 KiB
Markdown
# 🔍 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]
|