Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
11 KiB
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, tree9cfb2106081eba96b58cc6b7573031de0acba399— [리뷰 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 86524opencode 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/python33.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]