release(v4.14.0): multi-agent-mux-reset skill purges and recreates resident agents; registrations refuse during a reset; lsof holders in purge hints; codex purges its capability dir (Issue #78) - archive the review reports, bump the lockstep version

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-10-30 11:43:21 +09:00
1 parent b49314c3d0
commit d400add841
24 files changed
+491 -32

No files matched your search

@@ -0,0 +1,72 @@
# 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]
@@ -0,0 +1,163 @@
# PR #82 / Issue #78 — b49314c 리뷰: PASS
잡 `035792f2` 리뷰 결과다. 이전 리뷰의 **공유 live fence 덮어쓰기(R1)**와 **미생성 control_plane registry 누락(R2)**는 모두 해소됐다. 원래 실패를 일으켰던 공개 등록 CLI 탐침으로 확인했고, 이번 검토에서 차단 결함을 찾지 못했다.
## Identity·검토 범위
- Loop `loop-20261029T184637Z-6f4228`, round 3.
- Base `a3364e82e1f9e9c6ac504eef377e8dbefba02b8d`.
- Head `b49314c3d039027d3f9478cacecfda587fdc8abc`.
- Tree `d49ff60bbf89437e997f7da21e2fae8df570456b`.
- 검증 경로 `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261029T184637Z-6f4228/3`.
- 기준은 읽기 전용 `tea issues 78 --comments`, `tea pulls 82`, `INSTRUCTION.md`이다. 누적 22파일과 직전 `568385c..HEAD`의 6파일 변경을 실제 snapshot에서 확인했다. 이전에 대조한 누적 reset/adapter/배포 코드와 이번 변경을 함께 검토했으며, truncated summary만으로 판단하지 않았다.
- 파일:줄은 검증 경로 기준이다. creator 작업 트리에서 실행·수정하거나 커밋/브랜치 변경을 하지 않았다.
## 이전 차단 사항의 판정
### R1 — 공유 live fence 덮어쓰기: 해소
- `.agents/skills/lib_py/delegation/registry.py:97`의 reset_marker가 absent/stale/live/unknown 판정을 공유한다.
- `.agents/skills/lib_py/reset.py:507`–`:518`은 각 registry_lock 안에서 기존 marker를 확인한다. 다른 PID의 live 또는 unknown owner이면 FenceBusy를 발생시키고 기존 marker를 교체하지 않는다.
- `reset.sh:154`–`:163`은 fence가 성공한 경로와 거부 사유를 구분한다. EXIT cleanup(:117–124)은 이미 얻은 자신의 marker만 해제한다.
- 체크인 테스트 `test_other_reset_cannot_take_a_live_fence[False/True]`는 B에 대상이 없는 경우/있는 경우를 모두 검증한다. A의 fence bytes 유지, B의 행·대화 보존, B marker cleanup, B 종료 후 외부 등록 거부 및 A 정상 완료를 확인한다.
**별도 재현:** [probe_shared_reset.py](evidence/probe_shared_reset.py), [shared.log](evidence/shared.log).
임시 A의 stop을 마지막 job 검사 뒤 gate에서 대기시킨 채, 같은 MAM_JOBS_DIR를 쓰는 독립된 임시 B에서 원본 reset.sh --yes를 실행했다. B rc=1, `fenced by a reset of A` 사유, 공유 marker bytes 동일, A는 계속 살아 있다. 이어 외부 public registry.py register도 rc=1로 거부돼 잡이 생기지 않았고 A는 rc=0으로 정상 완료했다. 이전 탐침에서 B가 rc0으로 marker를 제거하던 동작이 사라졌다.
추가로 ps 판정 불가를 주입한 탐침에서 신규 register/API와 새 fence 획득 모두 거부됐고 기존 foreign marker bytes가 보존됐다([unknown.log](evidence/unknown.log)). stale marker 교체 및 registry lock barrier는 체크인 테스트로 확인했다.
### R2 — 아직 없는 행 지정 registry가 fence에서 빠짐: 해소
- `.agents/skills/lib_py/update.py:150`–`:163`의 jobs_dirs는 존재 여부와 무관하게 canonical·MAM_JOBS_DIR·행의 control_plane.mam_jobs_dir를 realpath 기준으로 중복 제거하고 addressed_only 범위를 반환한다.
- `reset.py:507`–`:509`가 같은 jobs_dirs를 사용해 없는 디렉터리도 만들고 lock/fence를 획득한다.
- `update.py:166`–`:184`의 open_jobs도 같은 열거 함수를 사용한다. 읽기 검사는 없는 디렉터리를 계속 건너뛰고, 기존 unreadable-directory/record 거부 및 타 제어 평면의 addressed-only 의미를 보존한다.
**별도 재현:** [probe_absent_row_registry.py](evidence/probe_absent_row_registry.py), [absent.log](evidence/absent.log).
reset 시작 전에 target 행에 아직 없는 `<임시 WS>/elsewhere/jobs`를 control_plane.mam_jobs_dir로 기록하고, MAM_JOBS_DIR는 canonical 그대로 뒀다. 마지막 target 검사 뒤 같은 public CLI로 그 경로에 등록을 시도했다. 이번에는 rc=1, `a reset of ... is in progress`로 거부돼 새 잡이 없고 reset은 rc=0으로 끝났다. 중간 행 변경이나 raw 잡 JSON 쓰기를 사용하지 않았다.
체크인 `test_registry_cli_is_refused_after_the_target_recheck[row-absent]`와 fence의 absent 경로 생성·stale 교체·realpath dedupe 테스트도 통과했다. update의 open/terminal/dry-run, unreadable jobs directory, 타 control-plane 대상 필터 회귀 3건도 통과했다.
위 두 탐침의 `old_transcript_exists=false`는 외부 등록이 거부된 뒤 사용자가 승인한 정상 reset이 완료된 결과다. pending 잡을 허용한 상태로 대화를 지우던 이전 실패와 구분된다.
### 이전 라운드 보호의 유지
- canonical/alias/custom 공개 등록 거부는 체크인 매개변수 테스트로 재검증했다.
- `reset.py:522`의 target_id_owned는 post-discovered ID의 cwd 소유권을 기존 verify_session_uuid(revalidate)로 확인한다. `reset.sh:283`에서 false이면 purge 대신 skip한다.
- 이전 foreign Codex 탐침 재실행 결과는 **A 검증 false / B 검증 true / 외부 파일 보존 / reset rc4 / purge 명령 없음**이다([foreign.log](evidence/foreign.log)). own/foreign 체크인 대조도 통과했다.
- strict spec 필드·타입·agent·workspace·중복 이름 검사, row agent/server 비교, pre-assigned ID 없는 행 거부, captured old ID 저장, created/done 미재purge, 시그널·중간 실패·rc4·rc5 재실행 보호가 유지됐다.
## 제안 1–6 및 SKILL.md 주장 대조
| 기준/문서 줄 | 실행 코드·테스트 근거 | 판정 |
|---|---|---|
| 제안1, SKILL:3·23 — root running, worktree/non-running/orchestrator 제외, metadata 유지 | reset.py:105–178 select_targets; reset.sh:306–317 create 인자; 선택/realpath/제외 행/2-target 정상 E2E | 충족 |
| 제안2, SKILL:25 — 열린 잡·loop·update·자신·invalid agent/role·ID/spec 사전 거부 | reset.py:424–473; reset.sh:38–40·98–174; 각 거부·동시 admission 테스트 | 충족 |
| 제안3, SKILL:17–18·24 — parser·확인·dry-run 무변경 | reset.sh:27–79·177–203; 비TTY rc3, dry-run .mam 파일무변경 | 충족. TTY 해석은 아래 사용자 확인 사항 |
| 제안4, SKILL:27 — 첫 stop 전에 원자 spec, 재개 | reset.py:183–272 mkstemp/fsync/replace·strict schema; :275–353 record_old_id/classify_entry; reset.sh:203·228–230·337 | 충족. 5-state, 같은 ID resume, 중단 후 재실행 |
| 제안4, SKILL:28 — 순차 stop → recorded/owned ID → purge → create/onboard | reset.sh:217–323, reset.py:522–537; 기존 stop/create 재사용 | 충족, 병렬 create 없음 |
| 제안5, SKILL:29 — changed/open job/no ID/unverified/in-use skip, 외부 holder 미종료 | reset.sh:233–302, reset.py SKIP_RESULTS; skip·파일/행 보존·rerun 테스트 | 충족 |
| 제안6, SKILL:33–39 — 결과 표와 exit0/1/2/3/4/5, INT/TERM/HUP | reset.py:599–683 build_table, reset.sh trap:126–134·결과:325–349 | 충족. 원본 Mac signal 테스트 사용 |
| SKILL:26·47 — update admission → loop/reset marker → release; run_loop/update/다른 reset 거부; configured registry/alias·owner onboarding·stale 표식 | reset.sh:98–174·317; registry.py:97–135·232 이후; reset.py:492–519; canonical/alias/custom/absent·shared live fence·barrier·owner/stale | 충족. 이번 R1/R2 해소 |
| 함께 고칠 것, SKILL:44 — lsof PID/명령줄·기존 ps 안내·서비스 제외·외부 미종료 | agents/base.py:89–115·385–403, agy in_use_paths, opencode _is_shared_service/_holder_counts | 충족. lsof 부재·timeout·holder·서비스 제외, 실제 Mac lsof 탐침 |
| 함께 고칠 것 — Codex capability UUID leaf만 삭제 | adapters/codex.py:134–179, plausible ID/leaf/symlink 처리, delete 성공·실패 및 타 UUID 보존 | 충족 |
| SKILL:43 — 비가역·먼저 dry-run | 도움말/동의 및 dry-run 분리 | 운영 권고. 과거 dry-run 실행 이력을 강제하는 규칙은 아님 |
| SKILL:45 — Grok shared memory 보존 | 기존 Grok의 세션별 제거 유지, purge/adapter 회귀 | 충족. 메모리 삭제 옵션 추가 없음 |
| SKILL:46 — AGY edit hook | hooks/loop_delegation_guard.sh:51과 reset.sh:110이 같은 loop marker 사용 | 코드 일치 |
| SKILL:48 — 같은 종류 post-discovered 순차 ID 대기 | create의 기존 MAM_ID_ORDER_WAIT_S/adapter ordering, #63 회귀 | 코드 일치 |
명령 예시, 옵션/동작 표, exit 표, safety notes를 코드와 대조했다. 순서·사전 거부·원자 저장·잠금·정리는 스크립트에 있고 테스트로 고정된다. 이번 검토에서 SKILL에만 있고 실행 코드에 빠진 기계적 규칙을 찾지 못했다. 수기 잡 파일 쓰기는 등록 프로토콜 밖이므로 문서대로 target 재검사 때 이미 보이는 기록만 검출한다. 이 문구를 임의 파일 쓰기의 원자적 admission 보장으로 확대하지 않았다.
## 일반 registration·remote·update 영향과 단순화
- marker 없는 registry에서 register_job 10회 + remote mirror/replay 2회 동안 subprocess.run을 금지한 탐침이 통과했다. **일반 경로의 ps/subprocess 호출은 0회**다([fast.log](evidence/fast.log)).
- live fence에서 remote.mirror_job은 ResetInProgressError를 처리해 None을 반환하고 잡 JSON/brief 디렉터리를 만들지 않는다. 기존 registry stdout/CLI/import/환경 계약과 remote finished/newer attempt 재전송도 통과했다.
- 기존과 달리 live marker의 owner 예외·타 session 범위 판단 전에 reset_marker가 liveness를 조회한다. 따라서 marker가 존재하는 registry에서는 최대 두 ps 호출이 있을 수 있다. reset이 없는 보통 등록의 비용 증가와 구분해야 하며 차단 사유는 아니다.
- addressed-only 표식이 있는 타 control-plane registry에서 covered session은 거부하고 unrelated session은 등록되는 별도 탐침도 통과했다([unknown.log](evidence/unknown.log)).
- 이전 위험한 중복이었던 registry 경로 열거가 jobs_dirs 한 함수로 합쳐졌다. reset은 없는 경로에 보호를 설치하고 update는 읽기만 하는 차이는 각 호출부에 남아 적절하다. 순서/필터를 바꾸는 불필요한 재설계는 아니다.
- reset.py 934줄/reset.sh 349줄이라는 양 자체를 결함으로 세지 않았다. 기존 stop/create·verify_session_uuid·open_jobs 재사용이 유지된다. reset.py:54–100의 read-only state 로더는 실제 SQLite -shm까지 바꾸지 않는 dry-run 요구 때문에 복사본을 읽는다. 환경 JSON만 읽는 update._state(:375)로 단순 치환할 수 없으며 추가 추상화는 승인 조건이 아니다.
## 패키징·버전
install.sh의 일반 .agents 복사(:367)와 reset 도움말(:673), remove.sh:96 fallback, gitea-ci.yml:45 ShellCheck 대상, 두 README:60, code-block inventory 및 10-skill layout을 확인했다. detached snapshot에 실제 .claude/skills가 없어 관련 1건은 skipped이며 합성 layout 검사는 통과했다. SKILL version과 lib.sh MAM_VERSION은 4.13.0으로 일치한다. test_version_consistency 목록과 VERSIONS 매트릭스는 브리프대로 릴리스 때 오케스트레이터가 넣을 사항으로 확인했고 누락 결함으로 세지 않았다.
## Mac 실행·정적 검사
Darwin arm64, /bin/bash 3.2.57, 지정 제어 루트 venv Python 3.9.6. 검증 tree에는 venv가 없어 브리프 상단의 명시적 인터프리터를 썼다. 모든 pytest cwd는 검증 경로다. 전체 스위트는 실행하지 않았다.
공통 명령:
```bash
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 -u AGENT_SESSIONS_YAML \
PYTHONDONTWRITEBYTECODE=1 \
/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python \
-m pytest -p no:cacheprovider --basetemp=<TMPDIR/아래 이름> -q <선택>
```
| basetemp 이름 (TMPDIR 아래) | 선택 파일/옵션 | 결과 |
|---|---|---|
| mam-eval-035792f2-main | tests/test_issue78_reset.py tests/test_issue78_hints_codex.py tests/test_issue42_skill_blocks.py | **84 passed, 1 skipped, 1 session-teardown error / 700.03s**. [main.log](evidence/main.log) |
| mam-eval-035792f2-purge | tests/test_issue10_13_purge.py tests/test_issue31_purge_residual.py tests/test_a4_adapter_contract.py tests/test_issue63_same_cwd_ids.py | **150 passed / 282.73s**. [purge.log](evidence/purge.log) |
| mam-eval-035792f2-contracts | 아래 9 node, 등록 node 양 mode 포함 | **10 passed / 16.38s**. [contracts.log](evidence/contracts.log) |
| mam-eval-035792f2-shared | -s -p probe_shared_reset tests/test_issue78_reset.py | **1 passed / 33.96s**. [shared.log](evidence/shared.log) |
| mam-eval-035792f2-absent | -s -p probe_absent_row_registry tests/test_issue78_reset.py | **1 passed / 32.64s**. [absent.log](evidence/absent.log) |
| mam-eval-035792f2-foreign | -s -p probe_foreign_id tests/test_issue78_reset.py | **1 passed / 20.60s**. [foreign.log](evidence/foreign.log) |
| mam-eval-035792f2-fast | -s -p probe_registry_paths tests/test_issue78_reset.py | **2 passed / 0.19s**. [fast.log](evidence/fast.log) |
| mam-eval-035792f2-native | -s -p probe_native_holders tests/test_issue78_reset.py | **2 passed / 0.28s**. [native.log](evidence/native.log) |
| mam-eval-035792f2-unknown | -s -p probe_unknown_fence tests/test_issue78_reset.py | **2 passed / 0.16s**. [unknown.log](evidence/unknown.log) |
일반 선택 회귀는 **244 passed, 1 skipped, 1 session-teardown error**, 별도 외부 탐침은 **9 passed**다. 같은 사례를 반복 실행해 합산하지 않았다.
종료 오류 분석용 재실행(중복 합산 안 함):
- `mam-eval-035792f2-layout-rerun`: `tests/test_issue42_skill_blocks.py::test_claude_skills_layouts[broken_entry-False]` — **1 passed / 0.04s**.
- `mam-eval-035792f2-agy-rerun`: `tests/test_issue78_hints_codex.py::test_in_use_hints_list_lsof_holders[agy]`, `tests/test_issue78_reset.py::test_post_discovered_without_recorded_id_8b_and_exit_5` — **2 passed / 64.39s**.
contracts의 node 목록:
```text
tests/test_issue5_phase1_delegation.py::test_c05_submit_job_stdout_is_exactly_the_job_id
tests/test_issue5_phase1_delegation.py::test_registry_cli_contract_snapshot
tests/test_issue5_phase1_delegation.py::test_delegation_package_imports_no_sibling_lib_py_modules
tests/test_issue5_phase1_delegation.py::test_importing_delegation_does_not_mutate_environment
tests/test_issue18_liveness_contract.py::test_remote_runner_ignores_a_resent_dispatch_of_a_finished_attempt
tests/test_issue18_liveness_contract.py::test_remote_runner_never_rolls_a_newer_attempt_back
tests/test_issue72_update.py::test_open_jobs_refuse_and_terminal_or_dry_run_do_not
tests/test_issue72_update.py::test_a_jobs_directory_that_cannot_be_listed_refuses
tests/test_issue72_update.py::test_other_control_planes_count_only_jobs_addressed_to_this_registry
```
외부 플러그인은 명령 앞에 `PYTHONPATH=<evidence 절대 디렉터리>`를 더하고 `-s -p <probe 모듈> tests/test_issue78_reset.py`로 선택한다. probe 파일은 collection을 해당 탐침으로 바꾸며 snapshot의 fixtures/helpers를 그대로 쓴다. shared 탐침은 이전 실패 때의 B rc0 기대만 이번 보호 정책인 rc1/동일 marker bytes 기대로 바꿨고, 외부 register·실제 reset 동작은 유지했다. 제품 코드/체크인 테스트는 수정하지 않았다.
변경 Python 11파일 ast.parse(feature_version=(3,9)), lib.sh/reset.sh/delegate wrapper 각각 /bin/bash -n, git diff --check가 통과했다([static.json](evidence/static.json)). 새 reset 경로에 mapfile/declare -A/flock(1)/setsid(1)/proc, GNU 전용 sed -i/stat -c/readlink -f 의존을 추가하지 않았다. 빈 배열·trap은 native 동작 테스트로 확인했다. 실제 Mac lsof가 자신이 만든 holder PID를 찾았고, timeout 주입 시 10초 상한과 빈 추가 힌트를 확인했다. ShellCheck는 설치되어 있지 않아 실행하지 않았다.
## 세션 종료 guard 오류와 판정 한계
main 실행은 기능 단언 **84 passed, 1 skipped**였지만 세션 종료의 기존 agent_home_leak_guard가 **1 error**를 냈다. 따라서 이 명령의 exit code는 **1**이며, 오류 없는 전체 스위트 통과라고 보고하지 않는다([main.log](evidence/main.log)).
감시기는 실제 AGY 홈에서 `637fea1e-9288-4a72-857e-7d23e04bb12d.db-wal`과 `.db-shm`의 새 디렉터리 항목을 발견했다. 기존 conftest.py:1582–1598도 병행 실제 agent가 새 파일을 만들면 false positive가 가능하다고 설명한다. 오류는 마지막 레이아웃 사례에 붙었지만 그 사례는 tmp_path의 symlink layout만 만든다.
- DB를 SQLite로 열거나 수정하지 않고 lsof의 PID와 ps의 실행 파일·경과 시간만 확인했다. 보유자는 실제 `agy` PID **66608**(약 11시간 실행), **91891**(약 34분 실행)으로, 11분 40초의 main 실행보다 먼저 시작한 외부 프로세스다([ambient-holder.json](evidence/ambient-holder.json)).
- main basetemp 안에는 해당 UUID의 artifact가 없었다([ambient-basetemp.json](evidence/ambient-basetemp.json)). AGY reset 시험은 UA 상수와 임시 HOME을 쓰며, hints 시험은 임시 home/HOME_DIR/ctx와 mock lsof를 쓴다.
- 마지막 레이아웃 node 단독 재실행은 **1 passed / 0.04s**, 종료 guard도 통과했다([layout-rerun.log](evidence/layout-rerun.log)).
- AGY hints와 post-discovered stop/purge/create/재실행 경로의 선택 재실행: **2 passed / 64.39s, 종료 guard 통과**([agy-rerun.log](evidence/agy-rerun.log)). 재실행 수는 일반 회귀 합계에 중복 합산하지 않았다.
위 프로세스·fixture·재실행 증거로 이 종료 오류는 **외부 AGY 동시 활동에 따른 환경 감시 false positive로 판단한다(추론)**. 테스트 누수라고 단정하거나 실제 홈 파일을 제거하는 방식으로 감시기를 통과시키지 않았다. 제품의 이번 변경에 의한 기능 실패는 없어 PASS지만, main 명령 자체의 종료 오류는 그대로 증거와 함께 남긴다.
## 발견 사항·사용자 확인 필요
- **차단 사항 없음.** 이전 R1/R2는 해소됐으며 재계획이 필요하지 않다.
- 비차단: marker가 남아 있는 경로의 liveness 조회 비용은 위 설명과 같다. 추가 작업을 승인 조건으로 삼지 않는다.
- 순차 create, Grok 메모리 옵션 없음, stopped 초기 대상 제외는 작성자의 선택대로 검토했다.
- 이슈의 “--yes 없이는 실행하지 않는다”와 TTY 명시적 y/yes 동의 방식의 차이는 이전 판정처럼 사용자 확인 사항이다. 동의를 받는다는 의미로 검토했으며 flag 자체 강제를 요구한 것으로 보아 차단하지 않았다.
## 격리·종료 확인
실제 작업 공간에 reset/dry-run/purge를 실행하지 않았다. 파괴적 동작은 임시 HOME·자체 .mam·mock herdr 안에서만 수행했다. 실제 .mam.env 값 출력/export, 실제 herdr 서버/설정 변경, SQLite로 실제 에이전트 홈 DB 열기, 실제 opencode 서비스 제어를 하지 않았다. guard 오류 분석의 실제 홈 확인은 위 PID/실행 파일·나이 메타데이터뿐이며 DB 내용은 읽지 않았다. pkill/killall/패턴 kill을 쓰거나 실제 모니터에 신호를 보내지 않았다. native holder 탐침은 자신이 만든 Popen만 회수했다.
이번 basetemp에 속한 테스트 잔류 프로세스는 0개, 실제 모니터는 전후 PID 3714로 같았다([process-audit.json](evidence/process-audit.json)). 시작·종료 HEAD/tree는 위 identity와 일치했고 git status는 빈 출력이다. 소스/브랜치/이슈·PR 상태 변경, push/merge는 없었다. 제어 평면 쓰기는 승인된 lifecycle 이벤트와 리포트·증거뿐이다.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261030T002908Z-7d0414",
"base_sha": "a3364e82e1f9e9c6ac504eef377e8dbefba02b8d",
"head_sha": "b49314c3d039027d3f9478cacecfda587fdc8abc",
"tree_sha": "d49ff60bbf89437e997f7da21e2fae8df570456b",
"review_commit": "b49314c3d039027d3f9478cacecfda587fdc8abc",
"reviewer": "reviewer-agy-01",
"job_id": "8eb66b73",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-30T00:44:15Z"
}
@@ -0,0 +1,107 @@
# Code Review Report — PR #82 (Issue #78) — Loop 3 Iteration 1 (`@b49314c3`)
- **Job ID**: `8eb66b73`
- **Reviewer**: `agy` (`herdr:reviewer-agy-01`)
- **Loop ID**: `loop-20261030T002908Z-7d0414`
- **Review Snapshot**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261030T002908Z-7d0414/1`
- **Commit Range**: `a3364e82e1f9e9c6ac504eef377e8dbefba02b8d..b49314c3d039027d3f9478cacecfda587fdc8abc` (11 commits)
- **Tree**: `d49ff60bbf89437e997f7da21e2fae8df570456b`
---
## 1. 확인한 것 (파일:줄)
### 1.1 `568385c7` 이후 `b49314c3` 변경분 검증 (R1: 다른 live reset의 fence 침범 금지, R2: 아직 존재하지 않는 행 레지스트리 fence)
- **`.agents/skills/lib_py/delegation/registry.py:97-136`**:
- `reset_marker(registry_dir)`(`97-117`)가 `<registry_dir>/.reset-in-progress` 마커 상태를 `"absent"`(파일 없음), `"stale"`(`pid` 숫자 아님 / 프로세스 종료 / `lstart` 불일치), `"live"`(동일 `pid` 및 `lstart`), `"unknown"`(파일 읽기 불가 `OSError`/`ValueError` 또는 현재 프로세스에 대한 `ps` 실패)으로 판별.
- 일반 잡 등록 경로(reset 미실행 시)에서는 `open(marker)`가 즉시 `FileNotFoundError`를 일으켜 `"absent"`로 반환되므로 **`ps` 서브프로세스 호출이 전혀 발생하지 않음**(성능 영향 없음).
- `reset_refusal(registry_dir, agent_session)`(`120-136`)은 `with registry_lock(registry_dir):`(`150`) 내부에서 호출되며, `"absent"`/`"stale"`이거나 `sessions=`에 해당 세션이 없거나 `MAM_RESET_OWNER_PID == fields["pid"]`(reset 자신의 `create_session.sh` 온보딩 잡)인 경우 통과시키고, `"unknown"` 및 `"live"`는 거부 사유를 반환.
- **`.agents/skills/lib_py/update.py:188-238`**:
- `jobs_dirs(workspace, state, env)`(`188-202`)가 `<ws>/.mam/jobs`, `MAM_JOBS_DIR` (`addressed_only=False`), 그리고 각 행의 `control_plane.mam_jobs_dir` (`addressed_only=True`)를 **디렉터리 존재 여부와 무관하게** `_real(c)` 기준으로 중복 제거하여 반환.
- `open_jobs(workspace, state, env)`(`205-238`)와 `fence_registries`(`reset.py:492-521`)가 동일한 `jobs_dirs()` 열거 함수를 공유하여 검사 대상과 fence 대상이 정확히 일치.
- **`.agents/skills/lib_py/reset.py:488-521, 887-900` 및 `.agents/skills/multi-agent-mux-reset/scripts/reset.sh:117-125, 150-175`**:
- `fence_registries`가 `jobs_dirs`의 각 디렉터리에 대해 `os.makedirs(d, exist_ok=True)`를 수행한 뒤(존재하지 않던 `control_plane.mam_jobs_dir`도 생성하여 fence), `with registry_lock(d):` 아래에서 `reset_marker(d)`를 먼저 확인.
- 이미 다른 live 또는 unknown reset(`held in ("live", "unknown") and fields.get("pid") != me`)이 fence를 보유하고 있으면 마커를 덮어쓰지 않고 `FenceBusy`를 발생시켜 거부(`refused ...`).
- 자신이 새로 쓴 디렉터리만 `fenced <realpath>`로 출력하고 `reset.sh`의 `FENCED` 배열에 기록하며, `_release_reset_lock`(`reset.sh:118-125`)은 `FENCED`에 기록된 경로 중 `pid == $$`인 마커만 제거하므로 다른 워크스페이스의 live reset이 잡은 공유 레지스트리 fence를 절대 뺏거나 지우지 않음.
### 1.2 제안 1–6 및 "함께 고칠 것" 대조표
| 항목 | 명세 요구사항 (Issue #78) | 구현 위치 (`파일:줄`) | 테스트 커버리지 | 상태 |
|---|---|---|---|---|
| 제안 1 | `multi-agent-mux-reset` 스킬 신설 (`SKILL.md`, `scripts/reset.sh`, `lib_py/reset.py`) | `.agents/skills/multi-agent-mux-reset/SKILL.md:1-48`, `scripts/reset.sh:1-349`, `.agents/skills/lib_py/reset.py:1-934` | `tests/test_issue78_reset.py`, `tests/test_issue42_skill_blocks.py` | 충족 |
| 제안 2 | 대상 선택 (`status: running` + `realpath(pane.cwd) == realpath(WS)` 만 포함; `stopped`/`terminated`, 다른 폴더/worktree, `orchestrator_uuids` 제외) 및 필터(`--session`, `--role`, `--agent`, `--all`) | `lib_py/reset.py:157-249` (`select_targets`), `reset.sh:29-68` | `test_select_targets_and_plan_cli` | 충족 |
| 제안 3 | 사전 확인 및 잠금(update admission -> `loop-guard-active` & `reset-in-progress` -> job registry fence -> open job / 호출자 자신 / 미기록 claude·grok ID 거부, `[y/N]` 프롬프트 및 `--yes`/`--dry-run`) | `reset.sh:81-208`, `lib.sh:198-214` (`mam_reset_admit`), `lib_py/reset.py:488-589`, `lib_py/delegation/registry.py:97-160`, `lib_py/delegation/remote.py:114-116` | `test_no_yes_non_tty_exits_3_without_touching_locks`, `test_dry_run_changes_nothing_in_mam_tree`, `test_precheck_refusals_change_nothing`, `test_registry_cli_is_refused_after_the_target_recheck`, `test_other_reset_cannot_take_a_live_fence` | 충족 |
| 제안 4 | `.mam/reset-spec.json` 원자적 기록(`0600`) 및 중단/시그널/실패(`exit 4`, `exit 5`) 후 재실행 시 이미 재생성된 에이전트 재-purge 방지 | `lib_py/reset.py:252-449` (`write_spec`, `read_spec`, `entries`), `reset.sh:210-348` | `test_spec_file_write_read_and_validation`, `test_entries_5_states_and_resumed_row_9b`, `test_mid_run_failure_and_rerun_resumes_from_spec`, `test_signal_interrupts_reset_preserves_spec_and_holds_lock_until_child_exits` | 충족 |
| 제안 5 | 에이전트별 순차 실행: ⓪ 대상 재확인(`target-check`) -> ① `stop_session.sh --reason reset` (post-discovered ID 포착) -> ② ID 일치 및 폴더 소유 검증(`id-owned`) -> ③ `stop_session.sh --reason reset --purge-conversation --yes` -> ④ `create_session.sh` | `reset.sh:218-323`, `lib_py/reset.py:645-736` (`target_check`, `record_id`, `target_id_owned`) | `test_full_reset_success_spec_ordering_table_and_loop_block`, `test_post_discovered_without_recorded_id_8b_and_exit_5`, `test_post_discovered_id_is_purged_only_when_it_verifies_for_its_folder` | 충족 |
| 제안 6 | 종료 코드 표(`0`, `1`, `2`, `3`, `4`, `5`) 및 결과 요약 테이블 출력 | `SKILL.md:31-40`, `reset.sh:325-348`, `lib_py/reset.py:739-782` | `test_exit_table_agrees_with_script`, `test_purge_refused_exit_4_preserves_stopped_row_and_rerun_completes`, `test_post_discovered_uncapturable_id_8b_ii` | 충족 |
| 함께 고칠 것 1 | `in_use_hints`에서 `lsof` holder PID·명령어 표시 (`opencode` 백그라운드 서비스 제외) | `.agents/skills/lib_py/agents/base.py:268-310`, `adapters/agy.py:136-138`, `adapters/opencode.py:259-273` | `tests/test_issue78_hints_codex.py::test_in_use_hints_*`, `test_stop_purge_refusal_prints_holders` | 충족 |
| 함께 고칠 것 2 | `codex` `purge_artifacts` 및 `residual_artifacts`에 `~/.codex/tui-thread-reference-capabilities/<uuid>` 포함 | `.agents/skills/lib_py/agents/adapters/codex.py:420-467` | `tests/test_issue78_hints_codex.py::test_codex_purge_removes_tui_thread_capability`, `tests/test_a4_adapter_contract.py::test_codex_purge_artifacts` | 충족 |
| 패키징·문서 | `deploy/install.sh`, `deploy/remove.sh`, `deploy/gitea-ci.yml`, `lib_py/update.py`, `README.md`, `README.ko.md`, `docs/SKILLS_SYNC.json` 반영 | `deploy/install.sh:23,121,164`, `deploy/remove.sh:12`, `deploy/gitea-ci.yml:69`, `lib_py/update.py:38`, `README.md:107`, `README.ko.md:107` | `tests/test_issue42_skill_blocks.py`, `tests/test_deploy_layout.py` | 충족 |
### 1.3 `INSTRUCTION.md` 기준 `SKILL.md` 줄 단위 대조
- `.agents/skills/multi-agent-mux-reset/SKILL.md:1-48`의 모든 문장을 `reset.sh` 및 `lib_py/reset.py`와 줄 단위로 대조함:
- 진입점 단일 호출(`bash .agents/skills/multi-agent-mux-reset/scripts/reset.sh ...`), 옵션(`--yes`, `--dry-run`, `--session`, `--role`, `--agent`, `--all`), 대상 선택 규칙, 확인 프롬프트, 사전 확인 거부 조건, 잠금·동시성 제어(`update` admission lock, `loop-guard-active`, `reset-in-progress`, job registry `.reset-in-progress` fence, `MAM_RESET_OWNER_PID`), `.mam/reset-spec.json` 재실행 복구, 순차 실행 단계(⓪~③ 및 `create_session.sh`), 건너뜀(`exit 4`) 및 중단(`exit 5`), 종료 코드(`0`~`5`), 플레이스홀더(`0.0.0-dev`) 모두 스크립트/파이썬 코드와 1:1로 일치하며 **SKILL.md에만 있고 코드로 강제되지 않는 규칙은 없음**.
### 1.4 macOS 이식성 확인
- `/bin/bash` 3.2 호환성: `reset.sh` 및 `lib.sh`에 `mapfile`, `readarray`, `declare -A`, `sed -i`, `stat -c`, `readlink -f` 미사용. `set -uo pipefail` 아래 빈 배열 확장 시 `${FENCED[@]+"${FENCED[@]}"}` 패턴(`reset.sh:120`)을 사용하여 bash 3.2 `nounset`에서도 안전함.
- Python 3.9.6 호환성: `lib_py/reset.py`, `lib_py/delegation/registry.py`, `lib_py/update.py` 모두 `Optional[...]` / `Tuple[...]` / `List[...]`(`typing`)만 사용하며 Python 3.10+ 전용 `X | Y` 타입 유니온이나 `match` 문을 사용하지 않음.
---
## 2. 실행한 테스트와 결과 (명령·passed/failed)
검증 경로 `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261030T002908Z-7d0414/1`에서 아래 명령을 실행함:
1. **셸 문법 검사**:
```bash
cd /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261030T002908Z-7d0414/1 && \
/bin/bash -n .agents/skills/multi-agent-mux-reset/scripts/reset.sh .agents/skills/lib.sh deploy/install.sh deploy/remove.sh
```
- 결과: **exit 0**
2. **Issue #78 및 Skill Blocks 테스트 스위트**:
```bash
cd /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261030T002908Z-7d0414/1 && \
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 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python -m pytest -p no:cacheprovider \
--basetemp=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-pytest-8eb66b73 \
-v tests/test_issue78_reset.py tests/test_issue78_hints_codex.py tests/test_issue42_skill_blocks.py
```
- 결과: **84 passed, 1 skipped** in `699.23s` (exit 0)
- `b49314c3` 신규/보강 케이스 모두 통과:
- `test_registry_cli_is_refused_after_the_target_recheck[canonical/alias/custom/row-absent]` (`4 passed`)
- `test_fence_waits_for_a_registration_and_covers_every_registry_read` (`PASSED`)
- `test_other_reset_cannot_take_a_live_fence[False/True]` (`2 passed`)
3. **Deploy Layout / Adapter Contract / Purge 관련 스위트**:
```bash
cd /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261030T002908Z-7d0414/1 && \
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 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python -m pytest -p no:cacheprovider \
--basetemp=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-pytest-8eb66b73-2 \
-v tests/test_deploy_layout.py tests/test_a4_adapter_contract.py tests/test_issue10_13_purge.py tests/test_issue31_purge_residual.py
```
- 결과: **128 passed** in `71.65s` (exit 0)
4. **작업 트리 상태 및 잔여 프로세스 확인**:
- 임시 `--basetemp` 디렉터리 정리 완료.
- `git status --short`: 빈 출력(깨끗함).
- `ps -ef | grep -E 'reconcile\.sh|reset\.sh'`: 본 체크아웃의 기존 모니터 데몬(`PID 3714`) 1개만 유지되고 테스트 잔여 프로세스 없음.
---
## 3. 발견 사항
### 차단 (Blocking)
- 없음.
### 비차단 (Non-Blocking)
- 코드 양 및 중복: `lib_py/reset.py:139-145`의 `_real()` 헬퍼와 `lib_py/update.py:140-144`의 `_real()` 헬퍼가 동일한 역할(`os.path.realpath(os.path.abspath(p))`)을 수행하므로 추후 리팩터링 시 공통 유틸로 합칠 수 있음(동작·안전성에는 영향 없음).
---
## 4. 사용자 확인 필요
- 이슈 #78 본문의 "정할 것" 3가지에 대해 작성자가 선택한 설계(① 모든 에이전트를 멈추기만 하고 새로 띄우지 않는 옵션 없이 순서대로 `create_session.sh`까지 수행, ② `grok` 메모리 관찰 기록 보존 옵션 없이 기본 purge 수행, ③ 이미 `stopped`인 행은 기본 대상에서 제외)가 의도와 맞는지 최종 확인 바랍니다.
[VERDICT: PASS]
@@ -0,0 +1,72 @@
# PR #82 (Issue #78) 리뷰
검증 경로 `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261029T184637Z-6f4228/3`. 테스트 뒤 HEAD `b49314c3d039027d3f9478cacecfda587fdc8abc`, tree `d49ff60bbf89437e997f7da21e2fae8df570456b`, porcelain 0. base `a3364e82e1f9e9c6ac504eef377e8dbefba02b8d` 의 조상이다. 스냅샷에 `.venv` 가 없어 제어 평면 Python 3.9.6 으로, 작업 디렉터리는 스냅샷으로 실행했다. 실제 작업 공간에서 `reset.sh`, `stop_session.sh --purge-conversation`, `--dry-run` 은 실행하지 않았다. 체크아웃 모니터(pid 3714, `reconcile.sh --subscribe --idle-timeout 0`)는 그대로 두고 신호를 보내지 않았다. 제품 파일은 수정하지 않았다.
이번 스냅샷의 추가분 `b49314c` 은 이전 head `568385c` 이후 6개 파일(+213/−74)이다. 두 작업 공간이 같은 잡 디렉터리를 동시에 reset 할 때 나중 fence 가 앞 마커를 지우던 겹침, 그리고 아직 없는 행 레지스트리를 fence 하지 않던 구멍을 막는지 코드를 확인했다. 브리프의 diff 는 잘려 있어 스냅샷을 직접 보았다. 브리프의 줄 수 812/286 은 현재 `lib_py/reset.py` 934줄, `reset.sh` 349줄과 다르다.
## 확인한 것
### 다른 살아있는 reset 의 펜스는 덮어쓰지 않는다
`fence_registries` (`reset.py:492-519`) 는 `jobs_dirs` 가 주는 디렉터리마다 `registry_lock` 안에서 `reset_marker` 를 본다. 상태가 `live` 또는 `unknown` 이고 pid 가 이 reset 이 아니면 `FenceBusy` 를 내고 그 파일은 그대로 둔다 (`reset.py:510-514`). 없거나 `stale` 이거나 pid 가 자기 것이면 자기 마커로 바꾼다 (`:515-518`).
`reset_marker` (`registry.py:97-116`):
- 파일이 없으면 `absent`. `ps` 를 부르지 않는다 (`:105-106`).
- 읽기 실패는 `unknown` (`:107-108`).
- pid 가 숫자가 아니면 `stale` (`:109-111`).
- 이 프로세스의 lstart 를 얻지 못하면 `unknown` (`:112-113`).
- 홀더가 죽었거나 lstart 가 다르면 `stale` (`:114-116`).
- 맞으면 `live` (`:117`).
`reset.sh` 는 `fenced <경로>` 만 정리 목록에 넣고, `refused ` 줄은 거절 이유로 찍은 뒤 exit 1 한다 (`reset.sh:152-163`). 정리는 pid 가 이 셸일 때만 마커를 지운다 (`reset.sh:120-121`). 다른 reset 의 마커는 목록에 없고, 지워지지도 않는다. fence 는 spec 쓰기와 purge 보다 앞이다 (`reset.sh:150-168`). 거절된 reset 은 대화를 지우지 않는다.
### 아직 없는 행 레지스트리도 막는다
`jobs_dirs` (`update.py:150-163`) 는 `.mam/jobs`, `MAM_JOBS_DIR`, 행의 `control_plane.mam_jobs_dir` 를 디렉터리가 아직 없어도 물리 경로당 한 번 돌려준다. 앞의 둘은 모든 등록을 거부하고, 행이 가리키는 다른 제어 평면은 이 레지스트리의 세션만 거부한다. fence 는 그 경로를 만든 다음 마커를 쓴다 (`reset.py:507-518`). `open_jobs` 는 같은 목록을 쓰되, 디렉터리가 아니면 건너뛴다 (`update.py:174-176`). 잡 목록은 `*.json` 만 보므로 마커 파일은 잡이 아니다 (`update.py:116-120`).
등록 거부는 그대로다. 마커가 없고 stale 이면 통과한다. `sessions` 에 없는 세션은 통과한다. `MAM_RESET_OWNER_PID` 가 마커 pid 이면 통과한다. `unknown` 과 `live` 는 거부한다 (`registry.py:120-135`). 호출은 레지스트리 잠금 안이다 (`registry.py:219-222`). 마커가 없을 때 일반 등록의 추가 비용은 그 파일을 한 번 열다 실패하는 것이다. `ps` 는 마커가 있을 때만 돈다.
### R2 와 이전 안전장치는 유지된다
post-discovered purge 는 `target_id_owned` 가 `verify_session_uuid(..., mode="revalidate")` 를 통과할 때만 한다 (`reset.py:522-537`). codex `verify_artifact` 는 rollout cwd 가 행의 폴더와 같을 때만 참이다 (`adapters/codex.py:99-130`). create 는 codex source 를 `pending-discovery` 로 둔다 (`create_session.sh:620-625`).
| SKILL.md | 내용 | 코드 |
|---|---|---|
| :26 | 공유 레지스트리가 다른 작업 공간의 살아있는 reset 에 막혀 있으면 아무것도 바꾸지 않고 거부한다. 그 마커를 바꾸거나 지우지 않는다 | `reset.py:510-514`, `reset.sh:120-121`, `:160-163` |
| :26 | 아직 없는 행 레지스트리도 만들어 막는다. 다른 제어 평면은 이 작업 공간의 세션만 | `update.py:150-163`, `reset.py:507-518` |
| :26 | 어떤 경로로 열어도 거부. 예외는 reset 자신의 create 온보딩 | 마커가 디렉터리 안에 있음. create 의 `MAM_RESET_OWNER_PID=$$` |
| :47 | 죽인 reset 의 마커는 홀더가 죽으면 무시 | `registry.py:114-116`. fence 는 그 stale 마커를 자기 것으로 바꾼다 (`reset.py:510-518`) |
제안 1–6, 대상 선택, spec, 잠금 순서, lsof, codex capability, 패키징은 이번 여섯 파일 diff 가 지우지 않았고, 같은 테스트 묶음이 다시 통과했다. submit 이 `.mam/loop-guard-active` 자체를 보지 않는 것은 리뷰 루프에 대한 기존 한계다. reset 은 작업 공간 마커와 레지스트리 마커로 등록을 거부한다.
## 실행한 테스트
스냅샷에 `.venv` 가 없다. cwd 는 검증 경로, 인터프리터는 `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python` (3.9.6). `HERDR_SOCKET_PATH`, `HERDR_PANE_ID`, `HERDR_TAB_ID`, `HERDR_WORKSPACE_ID`, `HERDR_SESSION_NAME`, `HERDR_SESSION`, `WORKSPACE_ROOT`, `MAM_ENV_FILE`, `MAM_JOBS_DIR`, `DELEGATE_JOB_LOGS_DIR` 는 해제했다. `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, `--basetemp=/tmp/mam-i78-30c01a28` (`mam-review` 와 `shim` 없음), `-q --tb=line`.
파일: `tests/test_issue78_reset.py`, `tests/test_issue78_hints_codex.py`, `tests/test_issue42_skill_blocks.py`, `tests/test_issue10_13_purge.py`, `tests/test_issue31_purge_residual.py`, `tests/test_issue63_same_cwd_ids.py`.
결과: **201 passed, 1 skipped in 994.63s**. 로그의 종료 줄은 `EXIT:0`. 세션 가드 오류는 없었다. 건너뛴 항목은 `tests/test_issue42_skill_blocks.py:263-266` 이다. 스냅샷에 `.claude/skills` 가 없다.
이번 커밋의 테스트도 통과했다. `test_registry_cli_is_refused_after_the_target_recheck` (`:1793`, canonical/alias/custom/row-absent), `test_fence_waits_for_a_registration_and_covers_every_registry_read` (`:1920`, 없는 디렉터리와 stale 마커), `test_other_reset_cannot_take_a_live_fence` (`:2031`, 대상 있음/없음), `test_post_discovered_id_is_purged_only_when_it_verifies_for_its_folder` (`:1984`, own/foreign).
전체 스위트, `tests/test_issue34_job_tree.py`, `tests/test_issue50_dispatch_herdr.py` 는 돌리지 않았다. 테스트 뒤에 이 basetemp 의 pytest 프로세스는 남지 않았다. 테스트 뒤 HEAD·tree·porcelain 은 위와 같다.
## 발견 사항
차단: 없음. 한 reset 은 다른 살아있는 reset 의 마커를 바꾸거나 지우지 않고, 그 경우 purge 전에 거부한다. 아직 없는 행 레지스트리는 만들어 막는다. 마커가 없을 때 일반 등록은 `ps` 를 부르지 않는다.
비차단:
- 죽은 reset 의 stale 마커는 다음 fence 가 자기 마커로 바꾼다 (`reset.py:510-518`, 테스트 `:1936-1937`). SKILL.md:26 의 "다른 reset 의 마커를 바꾸지 않는다" 는 바로 앞의 살아있는 공유 펜스에 대한 문장이고, 죽은 홀더는 SKILL.md:47 과 같이 무시된다.
- `ps` 가 이 프로세스의 lstart 를 주지 못하면 마커는 `unknown` 이다. 등록은 거부되고 (`registry.py:131-133`), 다른 reset 도 그 펜스를 가져가지 않는다 (`reset.py:511`).
- fence 가 만들었어도 종료 시 지우는 것은 `.reset-in-progress` 뿐이다 (`reset.sh:120-121`). 행이 가리키던 빈 디렉터리와 `.lock` 은 남는다.
- `MAM_RESET_OWNER_PID` 는 herdr `new-session` 의 pane env 로 넘어가지 않는다. create 가 herdr 서버를 새로 띄우면 `lib.sh:276-290` 의 deny 목록에 이 변수가 없다. 기본 상주 경로는 `new-session` 이다.
- 원격 `handle_dispatch` 는 reset 거부로 `mirror_job` 이 `None` 이면 0 으로 돌아간다 (`remote.py:307-310`).
- `--dry-run` reset 의 파이썬은 `_delegate_py_bin` 과 다를 수 있다 (`reset.sh:51-58`). `update.sh` 의 거절 문구는 reset 이 loop 마커를 잡고 있어도 review loop 라고 말한다. 줄 수는 934/349 이다.
## 사용자 확인 필요
create 와 resume 은 `mam_reset_admit` 을 부르지 않는다. 결정 변경은 필요하지 않다. reset 중에 새로 만든 행은 spec 에 없고, spec 에 있는 행은 purge 전에 agent·서버·열린 잡·ID 소유를 다시 본다.
[VERDICT: PASS]