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

98 lines
9.0 KiB
Markdown

# Issue #64 리뷰 — agy·codex·hermes DB 읽기 전용 열기 (job b78af5ad)
- 리뷰어: planner-reviewer-claude-01
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T134017Z-d90c23/1`
- identity: HEAD `b0eccc530149bc6e9211727e19921665c7b80492`, tree `f5a412a3ee96465db7c4b3223d2d76e95716ddfa` 가 일치한다. 작업 트리는 clean 이고 `.mam` 은 없다.
- 범위: base `3ce5d5f`(v4.9.7)..head 1커밋. 판정은 base..head 전체에 대한 것이다.
- 참고 자료: creator(agy) 리포트 `.mam/jobs/c721e4f5/agy-reports/report-final.md`
- 환경: macOS, Python 3.9.6, sqlite 3.51.0. 실제 에이전트 홈은 `ls`·`lsof`·`ps` 로만 봤고, sqlite 로 연 적은 없다.
## 결론
이슈 범위는 빠짐없이 바뀌었다. 조회 전용 열기는 모두 `_ro_connect` 로 바뀌었고, 쓰기와 MAM 자신의 DB 는 그대로다. 새 테스트는 base 에서 실패하고 head 에서 통과한다. 원인도 직접 재현해 확인했다. 차단 결함은 없다. 다만 새 런타임 테스트의 reconcile 단계가 실제로는 drift C 를 실행하지 않는다(N1). 또 기존부터 있던 workspace 간 오고정 문제를 새 테스트가 기대값으로 고정해 버렸다(N2). 둘 다 테스트를 보강하라는 권고다.
## 1. 원인 확인 — 직접 재현함
creator 의 결론: `reconcile.sh` drift C(agy, `:786-839`)는 고정되지 않은(unpinned) 실행 중 agy 행이 있으면 호스트 공용 `conversations/*.db` 를 전부 훑는다. 각 DB 에 `verify_session_uuid` 를 부르고, 이어서 `AgyAgentAdapter.verify_artifact` 가 `sqlite3.connect(path)` 로 연다. `last_conversations.json` 이 그 대화를 다른 workspace 의 것으로 적어 두어도 `cache_match=False` 는 `_sibling_claimed_uuids` 만 보고 거르지 않는다(`agy.py:60-72`). 그러면 macOS persistent WAL 때문에 `-wal`·`-shm` 이 남는다.
재현 방법:
- git archive 사본에 임시 테스트를 넣었다(mam_sandbox, 임시 HOME, mock herdr).
- 사이드카 없는 WAL agy DB 하나를 만들고, `last_conversations.json` 은 그 대화를 다른 workspace `ws_b` 에 매핑했다.
- ws 의 고정되지 않은 running 행을 mock herdr 에 살아 있는 pane 으로 등록했다.
- `reconcile.sh --once --emit-diff --dry-run` 을 실행했다.
| 사본 | DB 디렉터리 결과 |
|---|---|
| base | `<uuid>.db`, `.db-shm`, `.db-wal` — 사이드카가 생긴다 |
| head | `<uuid>.db` 만 — 바뀌지 않는다 |
이로써 v4.9.7 리뷰 때 #55 가드가 다른 workspace 의 agy DB 옆에서 잡은 사이드카는 MAM 의 reconcile drift C(status·create 가 부르는 경로 포함)가 만든 것으로 설명된다. 다만 그때 그 시각에 고정되지 않은 agy 행이 실제로 있었는지는 기록이 없어 확인하지 못했다.
## 2. 바뀐 곳 확인
- **공용 helper.** `_ro_connect` 가 `base.py:111-135` 로 옮겨졌다. 본문은 그대로이고, 임시 복사본 이름만 `os.path.basename(path)` 를 쓴다.
- opencode 는 base 의 것을 import 한다(`opencode._ro_connect is base._ro_connect`, 테스트로 고정).
- opencode 에서 쓰지 않게 된 `quote`·`tempfile` import 는 지워졌다. `shutil`·`sqlite3` 는 아직 쓰고 있다(`which`, purge).
- **`db_rows_naming` 기본 연결.** 기본값이 `_ro_connect` 가 됐다(`base.py:145`). 그래서 codex·opencode 의 residual 확인도 clean WAL DB 를 읽는다. base 에서는 `mode=ro` 가 Mac 에서 `unable to open` 으로 실패해 빈 결과를 냈다.
- **조회 전용 열기를 바꾼 곳:**
- agy `verify_artifact`(`agy.py:74`)
- codex `_find_rollout_via_db`(`codex.py:80`), `discover`(`:195`)
- hermes `verify_artifact`(`hermes.py:51`), `discover`(`:100`)
- status.sh hermes·codex 두 모드(`:90-91`, `:131-132`, `:290-291`, `:331-332`)
- reconcile.sh hermes drift(`:865`), codex drift(`:1048`)
- **남은 `sqlite3.connect`(grep 으로 전수 확인).** 모두 대상이 아니라는 판단이 맞다.
- 의도된 쓰기: codex·hermes·opencode purge 의 DELETE
- MAM 자신의 DB: `lib.sh:1599,2068`, `atomic_yaml.py:61`, `state.py:17`, `verify_session.py:41`(agent-sessions.db), `loop/runner.py:416`
- **import 정리.** agy 에서 `sqlite3` import 를 지웠고 다른 사용처는 없다. status.sh 의 opencode 블록은 이제 `lib_py.agents.base` 에서 import 한다.
## 3. 테스트 실행
조건: `env -i`, 임시 HOME, `XDG_DATA_HOME=<scratchpad>`, basetemp 는 scratchpad, `-p no:cacheprovider`, `PYTHONDONTWRITEBYTECODE=1`.
**새 모듈 `tests/test_issue64_ro_connect.py`**
- head: 43 passed.
- base 사본에 이 파일만 넣으면: **23 failed, 20 passed**. creator 의 수치와 같다.
- base 에서 실패한 것은 모든 clean·walonly 경우, 정적 검사, workspace 간 테스트다. 실패 메시지는 사이드카 `-shm`·`-wal` 이 생겼다는 것이다.
**관련 13개 모듈**
- 대상: issue64, issue60_linux_fixes, issue60_opencode_v2, a4, issue10_13_purge, issue31, orc_onboard, tier1, tier2, uuid_target, issue55, issue57, issue21_worktree_prereqs.
- 결과: **461 passed, 1 skipped, 실패 0**, 15분. skip 은 PATH 에 opencode 가 없는 실바이너리 계약 테스트다. #55 가드 오탐은 없었다.
**뮤테이션** (git archive 사본, issue64 모듈)
| 뮤테이션 | 결과 |
|---|---|
| agy `verify_artifact` 를 `sqlite3.connect` 로 | 5 failed(workspace 간 테스트 포함) |
| `db_rows_naming` 기본값을 옛 `mode=ro` URI 로 | 4 failed |
| hermes `discover` 를 `sqlite3.connect` 로 | 2 failed |
| reconcile hermes drift 를 `sqlite3.connect` 로 | 1 failed — 정적 검사만 잡음 |
| reconcile codex drift 를 `sqlite3.connect` 로 | 1 failed — 정적 검사만 잡음 |
**린트**
- `git diff --check` 는 깨끗하다.
- 바뀐 Python 6개 파일이 3.9 로 파싱된다.
- status.sh·reconcile.sh 는 `/bin/bash -n`(3.2)을 통과한다.
## 비차단
- **N1. 런타임 테스트의 reconcile 단계는 drift C 를 실행하지 않는다.**
- 위치: `test_status_and_reconcile_runtime_leave_sidecars_alone`(`:241-...`).
- 원인: 세 행을 mock herdr 에 등록하지 않는다. 그래서 drift A 가 먼저 `herdr gone … Marked terminated` 로 처리하고 drift C 를 건너뛴다. base 에서 같은 상황을 실행해 drift 결과로 확인했다.
- 결과: reconcile 의 hermes·codex 변경은 정적 문자열 검사로만 고정된다(위 뮤테이션 2개).
- 권고: 이슈 64 요구는 "실제 실행 또는 최소한 정적 검사"라 충족은 된다. 그래도 mock herdr state 에 세 세션을 `running` 으로 넣으면 실제 drift C 경로가 검증된다(내 재현처럼 `state["agents"][name] = {...}`). status 단계는 실제로 열린다. base 에서 hermes `state.db` 사이드카로 실패한다.
- **N2. 기존 결함: drift C(agy)가 다른 workspace 의 대화를 고정한다.**
- 내 재현에서 `last_conversations.json` 이 `ws_b` 의 것으로 적은 대화가 epoch 뒤 유일한 후보였다. 그러자 base·head 모두 ws 행에 `updated conversation id: <uuid>` 로 고정했다(dry-run 기준).
- 이 PR 이 만든 문제는 아니고 #64 범위 밖이다. 다만 새 테스트 `test_unpinned_agy_reconcile_…`(`:288`)가 `verify_session_uuid(ws_a, …, uuid_b) is True` 를 단언해 이 동작을 기대값으로 고정한다.
- 권고: 이 단언은 빼고 디렉터리 목록만 본다. 오고정은 별도 이슈로 다룬다. 예를 들어 `cache_match` 가 아니면서 lc 가 그 uuid 를 다른 cwd 에 매핑하면 거절한다.
- **N3.** `_ro_connect` 의 immutable 경쟁 창(ms 수준)은 #60 에서 남긴 것이 공용 helper 를 통해 agy·codex·hermes 로도 넓어졌다. 세 에이전트의 실제 DB 는 모두 WAL 형태다(`ls`: hermes·codex 는 사이드카 쌍이 있고, agy 대화 72개 중 7개에 사이드카가 있음). 실행 중에는 사이드카가 있으므로 `mode=ro` 갈래를 탄다.
## 관찰·공개
- **실제 codex DB 의 사이드카.** 관련 모듈 실행 전후로 `~/.codex/` 목록을 비교했더니 `state_5.sqlite-shm`·`-wal` 이 다시 나타나 있었다(mtime 23:29/23:35, 지금은 보유 프로세스 없음).
- 이 두 파일은 잡 시작 직후(23:04)에도 있었다가 사라졌던 것이다.
- 같은 시간대에 실제 `~/.codex` 를 쓰는 codex 프로세스들이 떠 있다: `codex resume`(이 루프의 리뷰어), app-server daemon, IDE 확장.
- 내 실행은 `env -i`·임시 HOME 이었고, head 코드는 사이드카를 만들지 않으며, #55 가드도 아무것도 잡지 않았다. 그래서 테스트 산출물이 아니라 실제 codex 활동으로 본다. 판정 근거로는 쓰지 않았다.
- **검증 트리에 임시 파일이 들어갔다 지워짐.** 재현 중 내 셸 명령 실수로 임시 테스트 파일(`tests/test_zz_review_agy.py`)이 검증 트리에 몇 초간 복사됐다. 그 파일로 아무것도 실행하지 않았고 즉시 지웠다. 이후 `git status --porcelain --ignored` 는 비어 있고 HEAD·tree 는 그대로다. 모든 재현은 scratchpad 사본에서 했다.
[VERDICT: PASS]