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

117 lines
11 KiB
Markdown

# Issue #40 리뷰 — Class A 세션 ID 자동 확정, agy 다른 작업 공간 ID 오등록 방지 (job 5bdb3a81)
- 리뷰어: planner-reviewer-claude-01
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261020T133532Z-a1684a/1`
- identity: HEAD `feccfd5e0356c8acf0438bc68bce7ea36ce2588e`, tree `0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a` 가 일치한다. 작업 트리는 clean 이고 `.mam` 은 없다.
- 범위: base `ff8b381`..head 1커밋, 19개 파일. 판정은 base..head 전체에 대한 것이다.
- 기준 문서: 이슈 본문과 댓글 전체(`issue40.txt`)를 끝까지 읽고 합의 사항과 대조했다.
- 환경: macOS, Python 3.9.6.
- 실제 에이전트 홈은 `ls` 와 `lsof -p <agy pid>`(프로세스 테이블 조회)로만 봤다. 파일 자체를 연 적은 없다.
- 재현과 테스트는 모두 scratchpad 의 임시 HOME, mock herdr, git archive 사본에서 했다.
## 결론
합의된 설계(PR #41 리뷰·이슈 댓글)대로 구현됐고, 완료 조건 1-4 가 테스트로 고정됐다. 각 조건의 테스트는 base 에서 실패하고 head 에서 통과한다. 이 변경이 만든 회귀는 찾지 못했다. 남은 것은 테스트 보강 권고와 운영 위험 기록이다.
## 설계 확인 (합의 대비)
**어댑터는 사실과 판정만 제공한다.**
- `id_lifecycle`: 기본값은 `POST_DISCOVERED`. claude·grok 만 `PRE_ASSIGNED` 이다(`base.py:378-381`, `claude.py:188`, `grok.py:18`).
- `capture_active_session_id`(`base.py:383-401`): `discover()` 후보 가운데 `verify_session_uuid` 를 통과하고 다른 행이 기록하지 않은 것이 정확히 하나일 때만 `captured` 를 돌려준다. 그 밖에는 이유가 담긴 결과(`capture_result`)를 돌려준다. 읽기 전용이다.
- 설정 추가나 리팩터링은 없다.
**agy 경로 (`agy.py`)**
- `verify_artifact` discover 모드(`:97-106`): `last_conversations.json` 이 그 id 를 다른 cwd 에 매핑하면 거절한다(workspace_key 로 비교). 다른 행이 기록한 id 는 캐시가 일치해도 항상 거절한다. 이제 캐시 일치가 이 검사를 건너뛰지 못한다.
- `capture_active_session_id`(`:129-152`): 후보는 `last_conversations.json[cwd]` 하나뿐이다. 그 대화의 presence lock 이나 DB 를 그 행의 pane 프로세스 또는 그 자손이 열고 있어야 확정한다(`_owner_check`, `:154-183`, lsof/ps). 증명하지 못하면 확정하지 않는다.
- 이 근거를 실제로 확인했다. 이 Mac 에서 실행 중인 agy 3개(`--conversation <id>`)는 모두 자기 `presence/<id>.lock` 을 열고 있다(`lsof -p`).
- `_holder_pids`·`_process_parents` 를 macOS 에서 직접 확인했다. 아무도 안 연 파일은 `[]`, 연 파일은 보유 pid 를 돌려준다(0.13s). ps 표에는 자기 pid 가 들어 있다.
**reconcile drift C (agy·hermes·opencode·codex) 가 하나로 합쳐졌다** (`reconcile.sh:778-811`).
- 네 에이전트가 모두 `autopin.capture` 를 거쳐 같은 어댑터 판정을 쓴다. agy 의 `*.db` 전체 glob 은 사라졌다.
- 기존 안전장치는 그대로다: 후보가 둘 이상이면 C-ambiguous, 뷰포트가 다르면(rc 1) C-warn 으로 pin 안 함, 뷰포트 확인이 불가하면(그 밖의 rc) degraded pin.
- 다른 행이 가진 id 배제는 더 엄격해졌다. 예전에는 같은 cwd 의, 멈추지 않은, 같은 에이전트 행만 봤다. 지금은 다른 모든 행의 모든 own key 를 본다(`autopin.claimed_elsewhere`).
- 지운 hermes·opencode·codex SQL 은 각 어댑터의 `discover()` 와 같은 범위(cwd·epoch·`parent_id IS NULL`)다. codex 는 `CODEX_HOME` 을 따른다(`codex.py:65`).
**수신 측 처리기 (`lib_py/agents/autopin.py`)**
- 호출 지점:
- 래퍼: `finalize_unfinished` 뒤 `completed` 일 때(`multi-agent-mux-delegate-job:454-458`). stderr 로만 출력하므로 마지막 stdout 줄(audit-log 경로) 계약은 그대로다.
- `create --onboard` 의 completed 분기(`create_session.sh:754-756`)
- 루프 `wait_for_job` 직후의 서브프로세스(`runner.py:679, 690-705`, G9 경계를 지킴)
- 공통 헬퍼 `delegate_autopin_completed`(`lib.sh:2470-2480`)는 `|| true` 라 잡 결과와 종료 코드를 바꾸지 않는다. `set -u` 에서 쓰는 변수(`AGENT_SESSIONS_YAML`, `_disp_tok`, `DELEGATE_JOB_TOKEN`, `task_reg_dir`)는 모두 정의돼 있다.
- 중복 방지:
- 키는 `(job_id, dispatch_token, attempt_id)` 이다. 잡 디렉터리의 `autopin.lock`(flock)과 `autopin.json` 마커를 쓴다. 마커는 원자 교체로 쓴다.
- 쓰기는 `atomic_dump_yaml` 의 mutation 으로 한다. 이 모듈의 `_MUTATION` 을 `sys.modules[__name__]` 로 찾아 `python -m` 에서도 같은 객체를 쓴다. 쓸 것이 없으면 `SystemExit(0)` 으로 쓰지 않고 끝나며, `atomic_yaml.py:195` 의 finally 가 정리한다.
- 건너뛰는 잡: remote, human, completed 가 아닌 잡, 오래된 token. pre-assigned 와 이미 id 가 있는 행은 쓰기 없이 끝난다.
- 메타데이터: pin 은 `session_id_source=discovered`, `session_id_verified=true` 를 함께 기록한다. reconcile 의 `_pin_and_verify_resume` 도 같은 `pin_row` 를 쓴다. 이슈 댓글이 짚은 불일치(선택 항목)가 해결됐다.
- publish_event.py 는 바뀌지 않았다. 에이전트는 제어 평면에 쓰지 않는다(`test_call_sites_and_publisher_boundary`).
- 잠금 안에서 뷰포트 확인과 resume `--dry-run` 을 서브프로세스로 실행한다. 예전 reconcile 도 mutation 안에서 똑같이 했다. `load_state_json` 은 YAML flock 없이 SQLite 를 읽으므로 교착은 없다.
## 완료 조건별 테스트 (base 실패 / head 통과)
`tests/test_issue40_auto_pin.py`(27개)를 base 사본에 넣어 실행하면 **16 failed, 13 errors**(errors 는 base 에 없는 `agy._holder_pids` 를 패치하는 fixture 때문), **41 passed**(issue64 포함) 이다. head 에서는 issue40+issue64 **70 passed**.
| 조건 | 테스트 | base 결과 |
|---|---|---|
| 1 | `test_agy_verify_rejects_id_cached_for_another_workspace` | `True is False` |
| 1 | `test_reconcile_does_not_pin_another_workspaces_agy_conversation` | `'b4000002…' is None` — 실제로 B 의 id 를 A 행에 기록함 |
| 1 | `test_autopin_does_not_pin_…` | — |
| 1 | issue64 `:407` 단언 | `is False` 로 바뀜 |
| 2 | `test_completed_onboarding_pins_agy_without_daemon`(MAM_NO_SUBSCRIBER_DAEMON=1, B 가 더 늦게 갱신) | — |
| 2 | `test_completed_first_job_pins_opencode_codex_hermes`[3] | 실패 |
| 2 | `test_reconcile_pins_agy_owned_by_the_pane`, `test_lib_sh_helper_pins_through_the_cli`, `test_create_onboard_pins_without_daemon` | 실패 |
| 3 | `test_wrapper_and_loop_recovery_pin_once`(capture 1회, YAML mtime 불변), `test_loop_recovery_pins_when_the_wrapper_did_not` | 실패 |
| 3 | `test_ambiguous_candidates_leave_reason_without_pin`(행에 `autopin ambiguous (job amb): 2 candidates`) | 실패 |
| 4 | `test_preassigned_and_existing_ids_are_untouched`, `test_ineligible_jobs_pin_nothing`, `test_stale_dispatch_token_pins_nothing` | — |
- reconcile 테스트는 세션을 mock herdr state 에 running 으로 등록한다(`_mock_agent`). 그래서 drift A 에서 멈추지 않고 실제로 drift C 까지 간다. base 에서 B 의 id 가 기록되는 것이 그 증거다.
뮤테이션: git archive 사본에서 issue40 모듈만 돌렸다.
| 뮤테이션 | 결과 |
|---|---|
| 다른 cwd 거절 제거 | 1 failed |
| 중복 마커 무시 | 2 failed |
| 모호 판정 제거 | 1 failed |
| agy 소유 확인 제거 | 4 failed(같은 cwd 형제 교차 pin 포함) |
| 이미 id 가 있는 행 보호 제거 | 1 failed |
| remote·human 제외 제거 | 2 failed |
| 래퍼 호출 제거 | 1 failed(정적 검사만 잡음) |
| **pre-assigned 보호 제거** | **실패 0** (아래 T1) |
## 관련 테스트 묶음
- 조건: `env -i`, 임시 HOME, `XDG_DATA_HOME=<scratchpad>`, basetemp 는 scratchpad.
- 대상 18개 모듈: issue40, issue64, a4, issue10_13, issue31, issue50_dispatch, issue55, issue57, issue60_linux_fixes, issue60_opencode_v2, tier1, tier2, uuid_target, orc_onboard, issue21_phase2_loop_a, issue5_phase3_loop, issue21_worktree_prereqs, issue17_workspace_root_isolation.
- 결과: **577 passed, 1 skipped, 3 failed**, 18분.
- 실패 3개(`test_issue21_phase2_loop_a::test_w10_…[install.sh|install_mam.sh]`, `test_w11_…`)는 환경 탓이다.
- 설치 스크립트가 시스템 python3 의 PyYAML 을 찾는데, 이 Mac 의 PyYAML 은 사용자 site(`~/Library/Python/3.9/...`)에만 있다. 내가 HOME 을 임시로 바꿔서 보이지 않았다.
- base 사본에서도 같은 3개가 같은 이유로 실패하고, 설치 스크립트는 이 diff 에 없다. 판정 근거로 쓰지 않았다.
- skip 1개: PATH 에 opencode 가 없는 실바이너리 계약 테스트.
- 부작용 확인:
- 실제 `~/.gemini/antigravity-cli/conversations`·`~/.hermes`·`~/.local/share/opencode` 목록은 전후가 같았다.
- scratchpad XDG 디렉터리는 실행 뒤에도 비어 있었다.
- #55 가드 오탐은 없었다.
- 린트:
- `git diff --check` 는 깨끗하다.
- 바뀐 셸 4개(lib.sh, create_session.sh, 래퍼, reconcile.sh)가 `/bin/bash -n`(3.2)을 통과한다.
- 바뀐 Python 은 3.9 로 파싱된다. `/usr/bin/python3` 로 `lib_py.agents.autopin`·`lib_py.loop.runner` 를 import 할 수 있다.
## 비차단
- **T1. pre-assigned 보호(`autopin.capture` 의 `id_lifecycle != POST_DISCOVERED`)는 테스트로 고정되지 않았다.**
- 테스트의 claude·grok 행은 이미 id 가 있어서 `already-set` 에서 먼저 끝난다.
- id 가 비어 있는 claude 행이 생기면(예: 행을 손으로 고침), 이 보호 없이는 기본 capture 가 claude `discover()` 로 남의 transcript 를 pin 할 수 있다.
- 권고: `claude_session_id_own: None` 인 claude 행에서 `pre-assigned` 가 나오는지 확인하는 한 줄을 추가한다.
- **T2.** 래퍼 진입점은 정적 검사(`test_call_sites_…`)로만 고정됐다. 헬퍼(`lib.sh`)와 CLI, create, 루프는 실행으로 검증됐다.
- **R1. agy 의 pin 은 이제 세 가지에 기댄다**(reconcile drift C 도 같다):
- (a) lsof·ps 가 있어야 한다
- (b) 행에 pane pid 가 있어야 한다
- (c) agy 가 presence lock 을 열고 있어야 한다
- (c) 는 resume 된 agy 3개에서 확인했다. 새로 띄운 agy 의 첫 턴 직후는 실제 agy 를 띄울 수 없어 확인하지 못했다.
- lsof 가 없는 Linux 에서는 agy 가 영영 pin 되지 않는다. 예전 glob 경로는 pin 했다(오등록 위험을 감수하고). autopin 경로는 행에 `autopin unverifiable-owner … lsof is unavailable` 을 남기지만, reconcile 은 아무것도 남기지 않는다.
- 설계상 의도된 안전 쪽 선택이다. 그래도 Linux 의존성으로 문서에 한 줄 적어 두기를 권한다.
- **R2.** 중복 마커는 실패 결과도 기록한다. 래퍼 시점에 `no-candidate` 였던 잡은 루프 복구 경로에서 다시 시도되지 않는다. 다음 잡의 completed 나 reconcile 이 다시 시도한다. 합의("job/token/attempt 단위로 중복 금지")와 맞다.
- **범위 밖(보고만).** stop 의 캡처 경로는 정리하지 않았다(이슈가 "크면 하지 말고 보고만"이라고 함).
[VERDICT: PASS]