Files
multi-agent-mux/.agents/reports/planner-reviewer-claude-01/report-0738caec.md
T

73 lines
9.8 KiB
Markdown

# PR #82 (Issue #78) 리뷰 - 새 루프 3라운드 (b49314c) - planner-reviewer-claude-01
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261029T184637Z-6f4228/3`
- HEAD `b49314c3d039027d3f9478cacecfda587fdc8abc`, tree `d49ff60bbf89437e997f7da21e2fae8df570456b`, base `a3364e8`.
- 리뷰 시작과 끝에서 확인한 것: HEAD·tree 가 [리뷰 identity] 와 같고, `git status --porcelain --ignored` 가 비어 있고, `.mam` 이 없다.
- 브리프는 이전 라운드(4b82e70c)와 identity(head·tree·검증 경로)와 커밋 수만 다르다. diff 는 잘려 있어 스냅샷을 직접 봤다.
- 2라운드(568385c, 내가 PASS) 뒤 새 커밋은 `b49314c` 하나다. 6개 파일, +213/-74.
- `lib_py/delegation/registry.py`, `lib_py/reset.py`, `lib_py/update.py`, `SKILL.md`, `reset.sh`, `tests/test_issue78_reset.py`
- 커밋 메시지에 따르면 codex 4라운드의 R1(다른 reset 의 fence 를 덮어씀, 내 N8 과 같은 지점)과 R2(없는 행 레지스트리를 fence 하지 않음)를 고친 것이다.
- 그 밖의 파일은 바뀌지 않았다. 이전 리포트(57b76c9a, ee7ae43d, c15c85f2, 4b82e70c)의 확인이 그대로 유효하다.
## 이번 변경
| 확인 | 근거(파일:줄) | 결과 |
|---|---|---|
| R1: 다른 live reset 의 fence 를 덮어쓰지 않는가 | `reset.py:492-519` `fence_registries`: registry lock 안에서 `reset_marker(d)` 를 읽고(`registry.py:97-117`), `live`·`unknown` 이면서 pid 가 자기 것이 아니면 `FenceBusy` 를 던진다(`reset.py:510-514`). `stale` 마커만 교체한다. | 덮어쓰지 않는다. 확인과 쓰기가 같은 lock 안이라 두 reset 이 동시에 같은 레지스트리를 잡을 수 없다. 2라운드에서 내가 재현한 N8(B 가 A 의 마커를 덮어쓰고 지움)은 B 가 거부되는 것으로 막힌다. |
| 거부 시 "nothing was changed" 가 맞는가 | `reset.sh:150-164`: `fenced <path>` 줄은 `FENCED` 에 넣고, `refused <msg>` 줄은 메시지로 쓴 뒤 exit 1. EXIT trap(`reset.sh:118-125`)이 `FENCED` 중 pid 가 `$$` 인 마커만 지운다. | 거부 전에 fence 한 자기 레지스트리는 trap 이 지운다. 다른 reset 의 마커는 pid 가 달라 남는다. 새 테스트가 B 의 loop marker·reset marker 도 남지 않음을 본다. |
| 지울 때 경합 | `reset.sh:118-125` 의 확인과 `rm` 은 lock 밖이다. | 내 마커가 live 인 동안 다른 fence 는 그것을 교체하지 못하므로(R1), 확인과 rm 사이에 남의 마커로 바뀌지 않는다. 문제없다. |
| 교착·양쪽 거부 | 디렉터리마다 짧게 lock 을 잡는다. | A 가 D1→D2, B 가 D2→D1 순서로 잡으면 둘 다 FenceBusy 로 거부될 수 있다. 교착은 없고 둘 다 아무것도 바꾸지 않은 채 끝나므로 다시 실행하면 된다. 비차단. |
| `reset_refusal` 리팩터 | `registry.py:120-135` | 판정이 2라운드와 같다. absent·stale 이면 통과하고, sessions 필터, owner pid, unknown 이면 거부, live 이면 거부. 바뀐 것은 owner 확인이 ps 뒤로 간 것뿐이다. reset 자신의 create 에서만 ps 를 두 번 더 부른다. 마커가 없는 일반 등록은 여전히 ps 를 부르지 않는다. |
| R2: 없는 레지스트리도 fence 하는가 | `update.py:150-163` `jobs_dirs` 가 존재 여부와 무관하게 (dir, addressed_only) 를 realpath 로 한 번씩 낸다. `reset.py:507-508` 이 각 dir 을 `os.makedirs` 한 뒤 fence 한다. | 등록도 `registry_lock` 이 `mkdir(parents=True)` 로 디렉터리를 만든다(`mqtt_common.py:359`). 그러므로 없는 레지스트리를 만들고 fence 하는 것이 맞다. 만들 수 없으면 OSError → `refused ...` → reset 거부(fail-closed). |
| `open_jobs` 동작이 바뀌었나 | `update.py:166-185`: `jobs_dirs` 로 열거한 뒤 `_is_jobs_dir` 가 False 면 건너뛴다. | 이전과 같은 디렉터리 집합·순서·중복 제거다(전에는 `_is_jobs_dir` 를 중복 제거 전에 봤지만, 없는 경로끼리 realpath 가 같아도 결과는 같다). `update.sh` 의 precheck 도 이 함수를 쓰므로 `test_issue72_update.py` 를 함께 돌렸다(아래, 통과). |
| SKILL.md 대조 | `SKILL.md:26`의 "created when absent", "다른 작업 공간의 live reset 이 이미 fence 한 공유 레지스트리면 아무것도 바꾸지 않고 거부", "다른 reset 의 마커를 교체·삭제하지 않음" | `reset.py:507-519`, `reset.sh:118-125,150-164` 와 맞다. SKILL.md 에만 있고 코드로 강제되지 않는 규칙은 새로 생기지 않았다. |
## 실행한 테스트 (이 Mac, Python 3.9.6, /bin/bash 3.2)
명령: `env -u HERDR_SOCKET_PATH -u HERDR_PANE_ID -u HERDR_TAB_ID -u HERDR_WORKSPACE_ID -u HERDR_SESSION_NAME -u HERDR_SESSION -u WORKSPACE_ROOT -u MAM_ENV_FILE -u MAM_JOBS_DIR -u DELEGATE_JOB_LOGS_DIR PYTHONDONTWRITEBYTECODE=1 <본 체크아웃 .venv/bin/python> -m pytest -p no:cacheprovider -q --basetemp=$TMPDIR/j18bt.*/…`
- 리뷰 트리에는 .venv 가 없어 본 체크아웃의 .venv 인터프리터를 썼다. `&` 없이 띄웠다(SIGINT 상속 문제 회피).
- 처음에 파일 이름을 잘못 넣어(`test_issue55_update.py`, 없는 파일) A 묶음이 0건으로 끝났다. 올바른 `test_issue72_update.py` 로 다시 돌린 결과가 아래다. 다시 돌린 A 는 B 와 일부 시간이 겹쳤지만 모두 통과했다.
| 대상 | 결과 |
|---|---|
| A: `test_issue78_reset.py` + `test_issue78_hints_codex.py` + `test_issue42_skill_blocks.py` + `test_issue72_update.py` (시그널 테스트, 새 `[row-absent]`, `test_other_reset_cannot_take_a_live_fence[False/True]` 포함) | **134 passed, 1 skipped** (819 s) |
| B: delegation 8개 파일 (`test_issue5_phase1_delegation.py` `test_issue12_registry_secrets.py` `test_issue14_delivery_ack.py` `test_issue18_liveness_contract.py` `test_issue34_job_tree.py` `test_issue50_onboard.py` `test_tier1_unit.py` `test_issue6_fail_closed.py`) | **249 passed** (635 s) |
`git archive` 사본 mutation (모두 대상 테스트가 실패한다):
| mutation | 실패한 테스트 |
|---|---|
| m1 fence 의 FenceBusy 검사 제거(남의 live 마커를 덮어씀) | `test_other_reset_cannot_take_a_live_fence[False]`, `[True]` (2 failed) |
| m3 없는 행 레지스트리를 건너뜀 | `test_registry_cli_is_refused_after_the_target_recheck[row-absent]`, `test_fence_waits_for_a_registration_and_covers_every_registry_read` (2 failed) |
| m4 stale 마커도 busy 로 봄 | `test_fence_waits_…` (1 failed, stale 교체 검증) |
| m5 reset.sh 가 `fenced ` 접두어를 벗기지 않음(종료 시 마커가 안 지워짐) | `test_registry_cli_is_refused_after_the_target_recheck[canonical]` (1 failed) |
추가 probe (임시 디렉터리, `lib_py.reset fence` 직접 실행):
- terminated 행의 `control_plane.mam_jobs_dir` 가 지워진(쓸 수 있는) 경로를 가리키면: rc 0. 그 경로(`gone-ws/.mam/jobs`, 부모 포함)가 **새로 만들어지고**, reset 이 끝난 뒤에도 빈 디렉터리로 남는다.
- 만들 수 없는 경로(`/nonexistent-root/...`)면: rc 1, `refused the job registries cannot be fenced against new registrations ([Errno 30] Read-only file system: '/nonexistent-root')`. 먼저 fence 한 자기 `.mam/jobs` 는 출력되어 trap 이 지운다.
프로세스: 테스트 앞뒤로 `reconcile.sh`(본 체크아웃 모니터 3714 하나), mock herdr, 구독자 목록이 같다. 바뀐 것은 오케스트레이터의 실제 `herdr agent wait planner-reviewer-claude-01`(70490 → 82377)뿐이며 테스트와 무관하다. 내 probe 의 holder 는 스크립트 안에서 정리했다.
## 발견 사항
### 차단
없음.
### 비차단
- **N11. fence 가 모든 행(stopped·terminated 포함)의 `control_plane.mam_jobs_dir` 를 만들고, 만들 수 없으면 reset 전체를 거부한다.**
- `jobs_dirs`(`update.py:150-163`)는 상태와 무관하게 모든 행을 본다. `fence_registries`(`reset.py:508`)는 각 경로를 `makedirs` 한다.
- 지워진 작업 공간을 가리키는 오래된 행이 있으면, 그 디렉터리 트리가 다시 생기고 남는다(probe 로 확인).
- 만들 수 없는 경로를 가리키는 행이 하나라도 있으면 reset 이 거부된다. 메시지가 어느 행 때문인지 말하지 않는다. 사용자가 그 행을 고치거나 지워야 한다.
- 안전한 쪽(fail-closed)이라 차단으로 보지 않는다. 다만 이 디렉터리에 등록할 수 없는 경우(만들 수 없음)는 등록도 생길 수 없으므로, 거부 대신 건너뛰어도 안전하다. 또 다른 control plane 레지스트리는 대상 행(running·control root)의 것만 fence 해도 대상 보호에는 충분하다.
- **N12. 두 reset 이 서로 다른 순서로 공유 레지스트리를 잡으면 둘 다 거부될 수 있다.** 교착은 없고, 둘 다 아무것도 바꾸지 않는다. 다시 실행하면 된다.
- 2라운드의 N8 은 해소됐다. N9(sessions 필터의 `herdr:` 접두어 정확 비교)는 그대로다. N10(죽은 reset 의 마커)은 이제 어느 작업 공간의 다음 fence 든 stale 마커를 교체하므로 범위가 줄었다.
- 이전 리포트의 N1, N2, N4, N5, N6 은 그대로다.
- 코드 양: reset.py 934줄, reset.sh 349줄. 이번 변경은 `jobs_dirs` 추출로 `open_jobs` 와 fence 의 열거를 하나로 합쳐, 오히려 중복을 줄였다. 불필요한 코드는 찾지 못했다.
## 사용자 확인 필요
- N11: fence 와 open-job 검사가 stopped·terminated 행의 control plane 레지스트리까지 보고 만드는 것이 의도인지. 아니면 만들 수 없는 경로는 건너뛰거나 대상 행으로 좁힐지.
- 수동 stop 이 pre-assigned 행의 id 를 폴더 전체에서 추정하는 동작(이전 리포트 3번)을 별도 이슈로 다룰지.
- N6: agy 에도 폴더 소유 확인이 필요한지.
- 병합 뒤 main 에 `.claude/skills/multi-agent-mux-reset` 링크를 손으로 만들어야 한다.
[VERDICT: PASS]