release(v4.9.6): opencode 2.x only (Issue #60) - 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-18 19:35:48 +09:00
1 parent 0e05b1b677
commit cb2c84ccd7
29 files changed
+835 -25

No files matched your search

@@ -0,0 +1,83 @@
# Issue #60 리뷰 2차 — opencode 2.x 전용 지원 (job 8a485fa0)
- 리뷰어: planner-reviewer-claude-01
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261018T064723Z-79133f/1`
- identity: HEAD `cb5f439cce41a2741a6b9974a308a765e50f00ad`, tree `cfec95403cbde10a6340b9007e1246c83be461cd` — 일치, 작업 트리 clean, `.mam` 없음. base..head 4커밋(a311c23, f0d1c00, 6b4b96f, cb5f439). 판정은 base..head 전체에 대해 내린다.
- 1차(6b4b96f)에서 확인한 사항(측정 사실↔구현, 실바이너리 `session delete` 의 자식 삭제·cwd 무관·공유 행 보존, `--standalone` 수명, 1.x 가정 잔존 없음)은 cb5f439 가 건드리지 않은 부분이라 그대로 유효하다. 이번에는 cb5f439 를 중심으로 봤다.
- 실제 opencode 는 `HOME`/`TMPDIR` 를 scratchpad 로, `XDG_*` 를 unset 하고 `api --standalone`·`session delete --standalone`·`debug paths` 로만 실행했다. 모델 호출 프롬프트는 보내지 않았다. 매 실험 뒤 남은 opencode 프로세스가 없음을 확인했다(사용자 서비스 pid 86524 제외). 실제 경로는 `ls`·`grep`(읽기)으로만 봤다.
## 결론
1차 지적 5건의 수정은 모두 동작하고, 테스트가 각각을 잡는다. 하지만 **N4 수정(XDG_DATA_HOME 따르기)이 테스트 격리를 깨는 새 회귀를 만들었다(B1).** 테스트 러너 환경에 `XDG_DATA_HOME` 이 있으면 sandbox 테스트가 sandbox 밖의 opencode 데이터 디렉터리(Linux 라면 사용자의 실제 DB)를 대상으로 삼고, 2개 테스트가 실패한다. 사용자가 다음에 Linux 호환성 시험을 할 예정이고 브리프가 실제 opencode 데이터 접근을 금지하므로 차단으로 본다. 수정은 fixture 두 줄이다(아래). 설계 변경은 필요 없다.
## B1 (차단). XDG_DATA_HOME 이 설정된 러너에서 sandbox 테스트가 sandbox 밖 DB 를 본다
원인: `opencode.py:87-94` `_data_home` 은 `ctx.home_dir` 이 `expanduser("~")` 와 같으면 호출자의 `XDG_DATA_HOME` 을 쓴다. 테스트 fixture `mam_sandbox`(`tests/conftest.py:259`)와 `home`(`tests/test_issue31_purge_residual.py:85-96`)는 `HOME` 을 tmp 로 바꾸지만 `XDG_DATA_HOME` 은 지우지 않는다(`conftest.py:307` 은 `XDG_CONFIG_HOME` 만 지움). 그래서 sandbox 의 HOME 이 "자기 홈" 으로 판정되어 러너의 `XDG_DATA_HOME/opencode/opencode.db` 가 선택된다. 1차 head 는 `_run` 이 `XDG_DATA_HOME=<home>/.local/share` 로 강제했으므로 이런 일이 없었다.
재현(검증 경로, `env -i … XDG_DATA_HOME=<scratchpad>/j8a/xdg`):
```
pytest tests/test_tier2_component.py tests/test_issue60_opencode_v2.py tests/test_a4_adapter_contract.py tests/test_issue31_purge_residual.py -k opencode
FAILED tests/test_tier2_component.py::test_comp_opencode_create_and_reconcile_and_stop
assert s_after.get("opencode_session_id_own") == parent → None (drift C 가 sandbox DB 가 아니라 XDG 쪽을 봄)
FAILED tests/test_issue31_purge_residual.py::test_opencode_purge_leaves_no_rows
'<sandbox>/home/.local/share/opencode/opencode.db (session_v2.id: 1 rows)' in [] (residual 이 XDG 쪽 DB 를 스캔)
2 failed, 21 passed
```
XDG 위치에 미리 DB 를 둔 대조 실험에서도 residual 스캔과 purge 의 DB 해석이 그 DB 를 가리켰다. 이 실험에서는 sandbox 세션 id 가 무작위라 지워진 행은 없었다. 그래도 Linux 에서 `XDG_DATA_HOME` 을 둔 사용자가 스위트를 돌리면:
- reconcile 은 실제 DB 를 읽고,
- purge 는 실제 DB 에 `DELETE … WHERE id=?` 트랜잭션과 가짜 `session delete` 를 실행할 수 있다.
- #55 가드(`tests/_agent_home_guard.py:48`)는 `$HOME/.local/share/opencode` 만 감시하므로 이것을 잡지 못한다.
수정 확인: 사본에서 `conftest.py` 의 `mam_sandbox` 에 `monkeypatch.delenv("XDG_DATA_HOME", raising=False)` 를, issue31 `home` fixture 에 같은 줄을 넣고 같은 조건으로 실행하자 **22 passed**, XDG 위치에 DB 접근 흔적이 없었다. 아래 N2 의 `--version` 로그만 남았다. `_agent_home_guard` 의 감시 대상에 `XDG_DATA_HOME` 쪽 opencode 디렉터리를 넣는 것도 권한다.
## 1차 지적 수정 확인
| 지적 | 수정 | 확인 |
|---|---|---|
| codex P2: 자식 세션의 1.x 행 잔존, 부모 residual 이 빔 | `_descendants`(`opencode.py:195-217`)가 삭제 **전에** session_v2·1.x session 의 `parent_id` 를 모든 깊이로 따라가고, `_LEGACY_TABLES` 8개를 부모+자손 id 로 정리한다. residual 은 자손을 `[child session <id>]` 로 보고한다 | **실바이너리 재현**: 임시 HOME 에서 `api --standalone` 으로 P→C→G(3단) + 무관 O 를 만들고, 1.x 형 `session(id,parent_id)`·message·part·todo·session_share 를 덧붙여 어댑터 purge 를 실행. `_descendants(P)==[C,G]`, purged `['opencode session (with its child sessions): P', 'sqlite rows for session: P and 2 child session(s)']`, residual P·C·G 모두 `[]`, O 는 7건 그대로. 순환 방지(`cid != uuid and cid not in found`), 읽기 전용 URI(`quote(abspath)`) 확인 |
| codex 주의: 사용자 서비스가 DB 를 잡아 모든 purge 가 exit 4 | `conversation_in_use`(`:240-262`)가 argv 검사 후 lsof 보유자 중 `serve --service` 가 아닌 것만 in-use. lsof/ps 예외는 in-use(fail-closed) | in-use CLI 시뮬레이션(임시 HOME, 가짜 opencode): 보유자 없음 → rc 0. `… serve --service` 보유자 → rc 0. `… serve --stdio --port 0` 보유자 → rc 4. argv 에 id → rc 4. 힌트에 lsof 줄 추가됨 |
| N4: XDG_DATA_HOME 무시 | `_data_home` | 제품 동작은 의도대로다(`test_the_data_dir_follows…`). 다만 B1, N1 |
| agy: 상주 자식 | `resident_children=('opencode',)` | `stall_watch.background_children`(`:118-136`)는 상주 자식을 건너뛰되 그 자식들은 계속 순회하므로, 서버 아래 도구 프로세스는 여전히 작업으로 잡힌다. 1차에서 실측한 서버 자식 이름(`opencode serve --stdio --port 0`, argv0 basename `opencode`)과 맞음 |
| claude N1·N2 | 실바이너리 테스트가 `_bin` 을 `REAL` 로 고정. tier2 가 더 늦은 자식 세션을 시드하고 부모 고정을 단언 | PATH 에 opencode 가 없는 `env -i` 로 `test_real_opencode_2x_contract` 통과(아래 테스트 결과) |
뮤테이션(git archive 사본, `-k "opencode and not real_opencode"`, issue60·issue31·tier2):
| 변형 | 실패 |
|---|---|
| 자손 제거(`ids=[uuid]`) | 1 (`test_purge_takes_the_1x_rows_of_the_child_sessions_too`) |
| residual 의 자손 보고 제거 | 2 |
| 서비스 제외 제거 | 1 (`test_the_background_service_alone…`) |
| XDG 무시 | 1 |
| resident `()` | 1 |
| reconcile drift C `parent_id IS NULL` 제거 | 1 (tier2 — 1차 N2 해소) |
| `_LEGACY_TABLES` 에서 todo·session_share 제거 | 2 |
## 테스트 실행 (검증 경로, `env -i`, PATH 에 opencode 없음, basetemp 는 scratchpad, `XDG_DATA_HOME` 미설정)
- 관련 12개 모듈(a4, issue31, issue10_13, issue60, tier1, tier2, issue5_phase0, issue23_24, issue6, c1, issue9, issue30_stall_watch): **473 passed, 1 skipped in 1010 s**, rc 0. skip 은 `/proc` 전용 테스트(macOS). 1차의 N1 이 해소되어 PATH 없이도 실바이너리 계약 테스트가 통과한다. #55 오탐 없음. 실행 뒤 남은 opencode 프로세스 없음.
- 같은 테스트를 `XDG_DATA_HOME=<scratchpad>` 로 실행하면 2 failed — B1.
## 비차단
**N1. XDG 를 따르게 되면서 status.sh 가 어긋난다.** `status.sh:108`, `:318` 은 여전히 `{home}/.local/share/opencode` 고정이다(reconcile 의 `:917` 폴백도 같으나 `_resolve_db` 가 항상 경로를 돌려줘 닿지 않음). Linux 에서 `XDG_DATA_HOME` 을 둔 사용자라면 reconcile 은 XDG 쪽 DB 에서 id 를 고정하는데 status 는 같은 세션을 `MISSING` 으로 보인다. 표시만의 문제지만 N4 수정과 같은 범위이므로 status 도 어댑터의 `_resolve_db` 를 쓰는 편이 낫다. `auth_ok`·create_session.sh 의 auth.json 검사는 브리프대로 의도적 비변경으로 보았다.
**N2. (기존, f0d1c00 부터) `test_issue60_opencode_v2.py` 를 import 하기만 해도 실제 opencode 가 실제 HOME 으로 실행된다.** `_real_opencode()`(`:~300`)의 `--version` 호출이 모듈 import 시점에 러너 환경 그대로 돈다. 그 결과 실제 `~/.local/share/opencode/log/opencode.log` 에 `args="[\"--version\"]"` 줄이 붙는다. 읽기로 확인: 2026-10-18T06:46~06:47Z 에 여러 줄. 내 1차 리뷰 실행도 같은 줄을 남겼음을 밝혀 둔다. 데이터 손상은 아니지만 "실제 ~/.local/share/opencode 쓰기 금지" 에 걸리므로, 버전 확인도 임시 HOME/XDG 로 돌리거나 테스트 본문(임시 HOME 설정 뒤)으로 옮기기를 권한다.
**N3. 서비스 제외의 한계(의도된 설계로 보임).**
- 다른 워크스페이스의 MAM opencode 세션(`--standalone` 서버)이 하나라도 떠 있으면, 그 세션과 무관한 purge 도 여전히 exit 4 다. 안전한 쪽의 오탐이다.
- 반대로, 사용자가 서비스 모드 TUI 나 웹 UI 로 MAM 세션을 id 없이 열고 있으면 그 사용은 보이지 않는다. MAM 은 `--standalone` 으로만 띄우므로 실사용 위험은 낮다.
- Linux 에 lsof 가 없으면 DB 보유자 검사가 통째로 빠진다. base 와 같다.
## Python 3.9 / bash / Linux
- 변경 파일은 Python 3.9 로 파싱된다(`/usr/bin/python3` 3.9.6, venv 3.9.6). f-string 안의 따옴표 중첩 없음, `from urllib.parse import quote` 는 표준.
- `ps -o command= -p` 는 procps-ng 에서도 지원된다.
- resident 테스트의 `exec -a opencode` 는 bash 기능이라 Linux 에서도 동작한다.
- 셸 스크립트는 cb5f439 에서 바뀌지 않았다.
- `git diff --check` 깨끗.
## 판정
B1 은 fixture 두 줄로 고쳐지고 수정 효과도 확인했다. 그러나 Linux 시험 직전에 사용자 실제 데이터 접근 가능성을 만드는 테스트 격리 회귀이므로 차단으로 둔다. 그 밖의 수정은 모두 PASS 수준이다.
[VERDICT: NOT PASS]
@@ -0,0 +1,65 @@
# Issue #60 리뷰 3차 — opencode 2.x 전용 지원 (job f1132474)
- 리뷰어: planner-reviewer-claude-01
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261018T073639Z-b5cb7f/1`
- identity: HEAD `0e05b1b6777ceb837066454ac688e04a0f5d14a0`, tree `c5cb1b16af283b3fdb447bafb865ba1abbfbb5a8` — 일치, 작업 트리 clean, `.mam` 없음. base..head 5커밋(a311c23, f0d1c00, 6b4b96f, cb5f439, 0e05b1b). 판정은 base..head 전체에 대해 내린다.
- 1차(6b4b96f)·2차(cb5f439)에서 실바이너리로 확인한 사항은 0e05b1b 가 건드리지 않은 제품 코드라 그대로 유효하다. 0e05b1b 는 status.sh, tests/_agent_home_guard.py, tests/test_issue60_opencode_v2.py 만 바꿨다. 확인한 사항:
- 측정 사실과 구현의 일치
- `session delete` 의 자식 삭제, cwd 무관, 공유 행 보존
- 3단 자손 정리
- 서비스 보유자 제외
- `--standalone` 수명
- resident children
- 실제 opencode 데이터는 읽기(`grep -c`, `stat`)로만 봤다.
## 결론
2차 차단 지적(B1)과 비차단 N1·N2 가 모두 해결됐고 새 회귀는 찾지 못했다. base..head 전체를 병합 가능으로 판단한다. 남은 것은 테스트 보강 권고뿐이다.
## 2차 지적별 확인
**B1 (XDG_DATA_HOME 테스트 격리) — 해결.**
- `_agent_home_guard.DROPPED_VARS` 에 `XDG_DATA_HOME` 이 추가됐다(`tests/_agent_home_guard.py:21`). 그래서 `conftest._isolate_agent_homes`(`conftest.py:210`)가 테스트마다 이를 지운다. mam_sandbox·issue31 `home` 처럼 HOME 만 바꾸는 fixture 도 이제 `<HOME>/.local/share` 를 본다.
- 2차에서 실패한 두 테스트(tier2 opencode e2e, `test_opencode_purge_leaves_no_rows`)가 `XDG_DATA_HOME=<scratchpad>` 에서 통과한다(아래 실행 결과).
- 뮤테이션: `DROPPED_VARS` 에서 다시 빼면 그 두 테스트와 `test_issue55::test_l1_every_path_variable_the_code_reads_is_accounted_for` 가 실패한다(3 failed). L1 은 환경과 무관하게 실패하므로, 이후 누가 지워도 잡힌다.
- 세션 가드는 `XDG_DATA_HOME` 이 설정돼 있으면 `<XDG_DATA_HOME>/opencode` 를 감시한다(`:48-49`). `ambient` 는 conftest 가 drop 전에 기록한 pane 값(`_AGENT_HOME_VARS = ("HOME",) + DROPPED_VARS`)이라 올바른 위치다.
**N1 (status.sh 고정 경로) — 해결.**
- 두 Python 블록(`status.sh:105-113`, `:305-313`) 모두 `get_adapter('opencode')._resolve_db(DiscoveryContext(workspace=cwd, agent_name='opencode', home_dir=home))` 를 쓴다.
- `cwd`(`:59`/`:259`)와 `home`(`HOME_DIR`, `:41`/`:241`)은 각 블록에 정의돼 있다. `env_python` 이 `PYTHONPATH=$SKILL_DIR` 를 넣고 환경을 상속하므로(`lib.sh:1852-1871`) `lib_py` import 와 PATH·XDG 가 그대로 전달된다. 같은 파일이 이미 `lib_py.delegation.logroot` 등을 import 한다.
- 행동 확인: git archive 사본에 임시 테스트를 넣고 실행했다(mam_sandbox+mock_herdr+mock_agents → create → reconcile 로 id 고정 → status). 1 passed.
- `--json` 의 `resume_state == "yes"` 와 plain 출력 행의 `yes`, 둘 다 rc 0.
- PATH 에서 opencode 를 빼도 폴백 경로로 `yes`.
- DB 를 `XDG_DATA_HOME` 쪽으로 옮기면, XDG 없이 `MISSING`, XDG 를 주면 `yes`.
- 행을 지우면 `MISSING`. 모두 rc 0.
- status 는 이제 opencode 행마다 `opencode debug paths` 를 실행한다. 이 명령은 opencode 자신의 log 디렉터리에 기록한다. 하지만 status 가 이미 돌리는 `reconcile.sh --dry-run` 의 drift C 도 같은 `_resolve_db` 를 부르므로 새로운 종류의 부작용은 아니다.
**N2 (`--version` 탐침이 실제 로그에 기록) — 해결.**
- `_real_opencode()`(`test_issue60_opencode_v2.py:290-303`)가 `TemporaryDirectory` 를 HOME·TMPDIR 로 하고 PATH 만 넘긴다. XDG_* 는 넘기지 않는다.
- 실제 `~/.local/share/opencode/log/opencode.log` 의 `args="[\"--version\"]"` 줄 수를 읽기로 세었다. 아래 두 스위트 실행 전 50, 후 50(증가 없음) 이다.
## 테스트 실행 (검증 경로, `env -i`, basetemp 는 scratchpad)
관련 10개 모듈: a4, issue31, issue10_13, issue60, tier2, issue55, issue57(status), issue21_worktree_prereqs(status), issue30_stall_watch, tier1.
- `XDG_DATA_HOME=<scratchpad>/jf1/xdg`, PATH 에 opencode 없음: **363 passed, 1 skipped**, rc 0. 실행 뒤 그 XDG 디렉터리는 비어 있었다(아무 파일·디렉터리도 생기지 않음).
- `XDG_DATA_HOME` 미설정, PATH 에 `~/.opencode/bin` 포함(실바이너리 계약 테스트 실행): **363 passed, 1 skipped**, rc 0.
- skip 은 두 번 모두 `/proc` 전용 테스트(macOS)다. #55 가드 오탐은 없었고, 실행 뒤 남은 opencode 프로세스도 없었다.
## 비차단 (테스트 보강 권고)
- **T1.** 이번에 바뀐 두 동작은 회귀 테스트가 없다. 뮤테이션(`XDG_DATA_HOME` 설정 상태, issue55·issue60·issue31·tier2):
- 가드의 XDG 감시를 원래대로 되돌림 → 28 passed, 실패 0
- status.sh 를 고정 경로로 되돌림 → 28 passed, 실패 0
`test_issue55::test_g1` 에 `watch_dirs({"HOME": h, "XDG_DATA_HOME": x})["opencode"] == x/opencode` 한 줄을 넣고, 위 임시 테스트와 같은 status 행동 테스트(XDG 쪽 DB → `yes`)를 넣기를 권한다.
- **T2 (기존 설계).** 가드는 opencode 데이터 디렉터리의 항목 이름을 비교한다. `XDG_DATA_HOME` 이 설정된 Linux 러너에서 사용자 opencode(서비스 포함)가 그 디렉터리에 새 항목을 만들면 #55 와 같은 종류의 오탐이 날 수 있다. 판정 근거로 쓰지 말고 보고만 할 종류다.
- 1·2차 비차단 N3(다른 MAM opencode 세션이 떠 있으면 무관한 purge 도 exit 4, lsof 없는 Linux 에서는 DB 보유자 검사 생략)는 의도된 동작으로 보고 그대로 둔다.
## 병합 가능성
- base..head 전체에서 `git diff --check` 는 깨끗하다.
- 변경 Python 파일은 Python 3.9(`/usr/bin/python3` 3.9.6)로 컴파일된다. 셸 스크립트는 `/bin/bash -n`(3.2)을 통과한다.
- 1·2차의 실바이너리 검증과 뮤테이션 결과에 이번 검증이 더해져, 차단 사항이 없다.
[VERDICT: PASS]
@@ -0,0 +1,79 @@
# Issue #60 리뷰 — opencode 2.x 전용 지원 (job f4cedbae)
- 리뷰어: planner-reviewer-claude-01
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261018T055915Z-abe16e/1`
- identity 확인: HEAD `6b4b96fa1875862ef3bed830fe7b04166b1fad58`, tree `9cfb2106081eba96b58cc6b7573031de0acba399` — [리뷰 identity] 와 일치, 작업 트리 clean, `.mam` 없음. base..head 커밋 3개(a311c23, f0d1c00, 6b4b96f).
- 실제 opencode 데이터·서비스는 건드리지 않았다. 실제 경로는 `ls`·`lsof -t`·`ps` 로만 봤다. 실제 바이너리(v2.0.32)는 전부 `HOME=<scratchpad>/j60/h*`, `TMPDIR=<scratchpad>/j60/t*`, `XDG_*` unset, `--standalone`(또는 `debug paths`·`--help`)로만 실행했고, 모델 호출 프롬프트는 보내지 않았다. 각 실험 뒤 `ps` 로 남은 opencode 프로세스가 없음을 확인했다(사용자 서비스 pid 86524 `opencode serve --service` 제외).
## 결론 요약
구현은 측정 사실과 맞고, 실제 2.0.32 바이너리로 직접 재현한 결과도 일치한다. 차단 결함은 찾지 못했다. 테스트는 회귀를 잘 잡는다(뮤테이션 13개 중 12개 검출). 아래 비차단 항목 중 N1(opt-in 실바이너리 테스트의 PATH 의존)은 Linux 호환성 시험 전에 고치기를 권한다.
## 테스트 실행 (검증 경로, `env -i` + PATH 에 `~/.opencode/bin` 포함, basetemp 는 scratchpad)
- 관련 11개 모듈(`test_a4_adapter_contract`, `test_issue31_purge_residual`, `test_issue10_13_purge`, `test_issue60_opencode_v2`, `test_tier1_unit`, `test_tier2_component`, `test_issue5_phase0_contracts`, `test_issue23_24_startup_path`, `test_issue6_fail_closed`, `test_c1_tui_readiness`, `test_issue9_server_env`): **420 passed in 723.50s**, rc 0 (opt-in 실바이너리 계약 테스트 포함; #55 가드 오탐도 이번에는 없음). 실행 뒤 남은 opencode 프로세스 없음.
- `test_issue60_opencode_v2.py` 를 PATH 에 opencode 없이 실행: 1 failed, 9 passed — 아래 N1.
## 1. 측정 사실 ↔ 구현 대조 (실제 opencode 2.0.32, 임시 HOME 에서 재현)
| 항목 | 직접 측정 | 구현 |
|---|---|---|
| `debug paths` | exit 0, 0.04 s, `db <HOME>/.local/share/opencode/opencode.db` 줄 포함(공백 정렬). DB 는 만들지 않음(`log/`·`repos/` 디렉터리만 생성) | `opencode.py:93-98` 이 `split(None, 1)` 로 `db` 줄을 읽고 `strip()` — 경로의 공백도 보존됨. 실패·타임아웃(5 s) 시 기본 경로 폴백(`:101`) |
| TUI 플래그 | `--help` 에 `--standalone`·`--auto`·`--session, -s` 있음, `--agent`·`-m` 없음. `--session` 은 "continue, or to create if it does not exist" | `spawn_spec`/`resume_spec`(`:198-204`), create/resume/update_yaml 의 셸 사본 모두 `--auto --standalone` |
| 세션 저장소 | 새 2.x DB 에 `session`·`message`·`part` 테이블 없음, 세션은 `session_v2`(`parent_id`, `directory`=realpath, `time_created` ms) | verify/discover/_has_session/status/reconcile 모두 `session_v2` |
| `session delete --standalone -- <id>` | 부모 P 삭제 시 자식 C 의 `session_v2`·`session_message`·`event_sequence` 행까지 삭제, 같은 디렉터리의 다른 세션 O 는 그대로. 프로젝트의 `event_sequence`(aggregate_id=project id) 같은 공유 행은 남음. **cwd 와 무관**(다른 프로젝트 cwd 에서 지워도 동작). 없는 id → stderr `Session not found: <id>`, exit 1 | `purge_artifacts`(`:136-150`) 가 `session_v2` 에 있을 때만 CLI 호출, `--` 사용, 실패 시 `WARN` + `residual` 에 `session_v2.id` 보고(`:192-193`). 가짜 CLI(`tests/_fake_opencode.py`)의 출력 스트림·exit 코드·자식 재귀 삭제가 실제와 같음 |
| `--standalone` 수명 | pty 안에서 TUI(`opencode --auto --standalone`)가 자식 `opencode serve --stdio --port 0` 을 띄움. 시작만으로는 `session_v2` 에 행이 생기지 않음(첫 메시지 전). `/exit`(입력 후 1 s 뒤 Enter) → 종료, pty 닫힘 → 0.11 s 안에 종료, TUI SIGKILL → 서버 자식도 2 s 안에 종료. 모든 경우 남은 프로세스 0 | stop 의 graceful(`/exit` via send_keys_safe)→kill-session→SIGKILL 체인은 변경 없음이며 어느 단계에서도 프로세스가 남지 않는다 |
참고: 브리프의 "TUI 는 SIGTERM·SIGHUP 을 무시" 는 내 pty 실험에서는 재현되지 않았다(SIGTERM 한 번에 TUI·서버 모두 종료). 어느 쪽이든 stop 체인에는 유리한 방향이라 판정에 영향 없음.
## 2. 코드 리뷰
- `opencode.py:62-87` `_bin`/`_run`: 기존 `_resolve_db` 안의 탐색 로직을 그대로 뽑아낸 것. `_run` 은 HOME·XDG_DATA_HOME 을 ctx.home_dir 기준으로 덮어써 `debug paths` 와 `session delete` 가 같은 DB 를 보게 한다(일관). `cwd` 가 없으면 None — `session delete` 가 cwd 무관함을 실측했으므로 문제 없음.
- `verify_artifact`(`:107-131`): `session_v2` 의 `directory, time_created` 고정 스키마. ms→s 변환, epoch 비교, workspace_key 비교 유지.
- `discover`(`:213-239`): `parent_id IS NULL` — 실제 스키마에서 subagent 세션은 `parent_id` 가 채워지고 `directory` 가 같으며 더 늦게 생성됨을 확인(C 가 P 보다 161 ms 뒤). 필터가 없으면 reconcile 이 두 후보로 `C-ambiguous` 가 되거나 자식을 고를 수 있으므로 정당하다.
- `purge_artifacts`: `_has_session` 이 거짓이면(1.x 에만 있는 세션, 없는 id) CLI 를 부르지 않는다. 1.x 테이블 SQL 삭제는 `session_id`/`id` 정확 일치만이라 다른 세션·공유 행을 지우지 않는다. 1.x 테이블이 없으면 "sqlite rows" 항목을 보고하지 않도록 바뀐 것(`:172-173`)도 맞다.
- reconcile drift C(`reconcile.sh:952-994`): `PRAGMA table_info(session_v2)` + `parent_id IS NULL`(컬럼 있을 때만). `_resolve_db` 가 이제 항상 경로를 돌려주므로 `:916-930` 의 파일시스템 폴백은 사실상 죽은 코드(기존 코드, 비차단).
- status.sh 두 곳 `session_v2`. Python 3.9(`/usr/bin/python3` 3.9.6, venv 3.9.6) 파싱 OK, 셸 5개 `/bin/bash -n`(3.2) OK, `git diff --check` 깨끗.
- 남은 1.x 가정 검색(`agent build`, `db path`, `FROM session\b`, `table_info(session)`, `opencode -m`; VERSIONS.md·bug-report·.agents/reports 제외): 코드·문서에 남은 곳 없음(주석·테스트 docstring 의 설명적 언급만).
## 3. 테스트가 회귀를 잡는가 — 뮤테이션 (git archive 사본, `-k "opencode and not real_opencode"`, 3개 모듈)
| 뮤테이션 | 결과 |
|---|---|
| discover 를 `session` 테이블로 | 5 실패 |
| discover 의 `parent_id IS NULL` 제거 | 1 실패(`test_discover_and_verify…skip_child_sessions`) |
| verify_artifact 를 `session` 테이블로 | 6 실패 |
| purge 에서 CLI 호출 제거 | 4 실패 |
| `--` 제거 / `--standalone` 제거 | 각 1 실패 |
| `_resolve_db` 를 `db path` 로 | 2 실패 |
| residual 에서 `session_v2.id` 제거 | 3 실패 |
| reconcile `session` 테이블로 / status `session` 으로 | 각 1 실패(구조 검사) |
| create_session.sh·spawn_spec 을 `--agent build` 로 | 각 1 실패 |
| **reconcile drift C 의 `parent_id IS NULL` 제거** | **0 실패** (N2) |
## 4. 비차단 지적
**N1. opt-in 실바이너리 테스트가 opencode 가 PATH 에 없으면 거짓 실패한다** (`tests/test_issue60_opencode_v2.py:182-228`).
`_real_opencode()` 는 import 시점(실제 HOME)에 `~/.opencode/bin/opencode` 폴백으로 바이너리를 찾아 테스트를 켠다. 그런데 테스트 안에서 `HOME` 을 임시 디렉터리로 바꾼 뒤 어댑터의 `_bin()` 은 `shutil.which` → `ctx.home_dir/.opencode/bin`(임시 home) → `expanduser("~")`(역시 임시 home) 순이라 바이너리를 못 찾는다. 결과:
```
env -i PATH=/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin ... pytest tests/test_issue60_opencode_v2.py
FAILED test_real_opencode_2x_contract - assert [] == ['opencode session (with its child sessions): ses_…']
WARN: purge opencode: no opencode binary to delete session ses_…
1 failed, 9 passed
```
PATH 에 `~/.opencode/bin` 을 넣으면 10 passed. 공식 설치기의 기본 위치가 `~/.opencode/bin` 이고 PATH 등록은 셸 rc 에 의존하므로, Linux 호환성 시험(CI·비로그인 셸)에서 제품 문제로 오인될 수 있다. 같은 이유로 `_resolve_db` 단언(`:218`)은 폴백 경로와 같아서 `debug paths` 파싱을 검증하지 못한 채 통과한다. 수정 예: 테스트에서 `monkeypatch.setattr(adapter, "_bin", lambda ctx: REAL)` 또는 `monkeypatch.setenv("PATH", os.path.dirname(REAL) + os.pathsep + os.environ["PATH"])`. 브리프가 정한 실행 방식(`env -u …`, PATH 상속)에서는 통과하므로 차단으로 보지 않았다.
**N2. reconcile drift C 의 `parent_id` 필터는 행동 테스트가 없다.** `test_status_and_reconcile_read_session_v2` 는 문자열 검사뿐이고, tier2 e2e(`test_comp_opencode_create_and_reconcile_and_stop`)는 자식 세션을 시드하지 않는다. 위 표처럼 필터를 지워도 아무 테스트도 실패하지 않는다. 어댑터 discover 쪽은 잡힌다.
**N3. (기존 동작, 이번 PR 이 바꾸지 않음) opencode 의 in-use 판정은 공유 DB 를 lsof 하므로, 어떤 opencode 프로세스든 DB 를 열고 있으면 항상 "사용 중"이다.** `base.py:307-309` 가 `artifact_path`(=모든 세션이 공유하는 `opencode.db`)를 lsof 한다. 이 Mac 에서는 사용자 서비스(pid 86524 `opencode serve --service`)가 실제 DB 를 열고 있다(`lsof -t ~/.local/share/opencode/opencode.db` → 86524). 임시 HOME 재현: DB 를 아무 프로세스가 열지 않으면 `in-use opencode <id>` → `in_use: false`, rc 0, 다른 python 이 같은 DB 를 열어 두면 → `in_use: true`, rc 4. 따라서 이 Mac 에서는 `stop --purge-conversation` 이 opencode 세션에 대해 항상 exit 4 로 거부되어, 이번에 새로 만든 `session delete` 경로는 stop 을 통해서는 닿지 않는다(안전한 쪽의 오탐). 힌트 문구도 명령줄만 언급하고 DB 를 연 프로세스는 말하지 않는다. base 의 `_resolve_db` 도 같은 DB 로 귀결되므로 회귀는 아니다. 세션 단위 판정(예: `--session <id>` argv 와 그 세션의 TUI pid)으로 바꿀지는 별도 이슈로 다룰 일이다.
**N4. (기존 동작) XDG_DATA_HOME 덮어쓰기.** `_run` 은 사용자의 `XDG_DATA_HOME` 을 무시하고 `~/.local/share` 로 강제하며, status.sh·reconcile 폴백도 `~/.local/share/opencode` 고정이다. Linux 에서 `XDG_DATA_HOME` 을 따로 둔 사용자라면 pane 의 TUI 는 다른 DB 에 쓴다. 기존 코드와 같은 동작이며 기본 설정에서는 문제 없다. Linux 시험 때 확인할 항목으로 남긴다.
**N5. (사소) `_resolve_db` 가 호출마다 opencode 를 실행한다.** discover 는 후보마다 `verify_artifact` → `_resolve_db` 를 다시 부른다(최대 21회 × 약 0.04 s). 기존 `db path` 호출과 같은 구조라 회귀는 아니다.
## 5. Linux 이식성
- 경로: `debug paths` 가 HOME·XDG 를 따르고, 어댑터는 그 출력을 쓴다. `/proc`·macOS 전용 명령은 새로 쓰지 않았다. in-use 는 기존 `ps -eo` + (있으면) `lsof`.
- 테스트: 가짜 CLI 는 `#!/usr/bin/env python3`, 래퍼는 `#!/bin/sh` + `exec` — Linux 에서도 동작. 실바이너리 테스트는 `os.path.realpath(ws)` 로 디렉터리를 맞춘다(macOS `/private` 접두사도 처리). N1 만 Linux 에서 문제가 될 수 있다.
[VERDICT: PASS]