merge: fix/issue-60-opencode-v2 (support opencode 2.x only; v4.9.6)
This commit was merged in pull request #61.
This commit is contained in:
commit
1d7b7dab58
44 files changed
+1525
-340
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]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261018T103110Z-8a6969",
|
||||
"base_sha": "74333f87d0c02888c2b5b281e912d97e74348c01",
|
||||
"head_sha": "0e05b1b6777ceb837066454ac688e04a0f5d14a0",
|
||||
"tree_sha": "c5cb1b16af283b3fdb447bafb865ba1abbfbb5a8",
|
||||
"review_commit": "0e05b1b6777ceb837066454ac688e04a0f5d14a0",
|
||||
"reviewer": "planner-reviewer-codex-01",
|
||||
"job_id": "598fe58e",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-18T10:34:16Z"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Issue #60, review 3 (head 0e05b1b)
|
||||
|
||||
**Scope and identity.** Reviewed base `74333f87d0c02888c2b5b281e912d97e74348c01` through head `0e05b1b6777ceb837066454ac688e04a0f5d14a0` in the assigned review-only worktree. Its HEAD/tree matched the brief and `git status --short` was empty. No code or branch changes were made.
|
||||
|
||||
**Review result.** The second-round isolation defect is addressed: `tests/_agent_home_guard.py:21` now drops inherited `XDG_DATA_HOME` for each test, and `:48-49` snapshots the OpenCode directory under the ambient XDG root so data written there is still detected. `tests/test_issue60_opencode_v2.py:297-304` probes `--version` with a temporary `HOME` and `TMPDIR`; the opt-in real 2.x contract test also uses its own temporary home and standalone API processes (`:311-352`). I ran that contract test here; it passed, with no service operation or model prompt. The worktree had no changes after testing, and process inspection showed only the pre-existing user `opencode serve --service` process (PID 86524), which was left untouched.
|
||||
|
||||
The status change preserves the prior `yes`/`MISSING`/`no` behavior while resolving the database through the adapter and querying `session_v2`: `.agents/skills/multi-agent-mux-status/scripts/status.sh:105-121`. The same branch exists in both embedded status paths. This avoids the stale fixed `~/.local/share` lookup and does not change status formatting or exit handling. Adapter path resolution at `.agents/skills/lib_py/agents/adapters/opencode.py:90-123` applies caller XDG data only to the caller's own home, runs `debug paths` with the requested home, and falls back to that home/XDG-derived 2.x database path.
|
||||
|
||||
No blocking defect found in the cumulative change. The prior data-safety and purge concerns remain covered: focused tests exercise parent/child deletion, legacy rows retained by migrated databases, preservation of unrelated sessions, and residual reporting after a failed delete. `discover` selects top-level `session_v2` rows and verifies child sessions independently. The fake CLI and real 2.x opt-in test both cover the expected v2 contract. Search of active skills found no remaining OpenCode assumptions on `db path`, `FROM session`, or an OpenCode `--agent` option; only explanatory comments and generic delegation syntax matched.
|
||||
|
||||
**Validation.** On macOS / Python 3.9.6, ran the related suite in the review worktree with a temporary `HOME`/`TMPDIR`, XDG variables unset, requested environment scrubbed, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and an external temporary basetemp:
|
||||
|
||||
`tests/test_issue60_opencode_v2.py tests/test_a4_adapter_contract.py tests/test_issue31_purge_residual.py tests/test_issue55_agent_homes.py tests/test_issue42_skill_blocks.py tests/test_issue10_13_purge.py tests/test_tier2_component.py::test_comp_opencode_create_and_reconcile_and_stop`
|
||||
|
||||
Result: **187 passed, 1 skipped** (118.14s). Also ran `git diff --check` over base..head and `bash -n` on the modified status, reconcile, create, and resume shell scripts; both passed. The full suite and Linux runtime were not rerun in this reviewer turn; the brief records the orchestrator's full-suite result (1949 passed, 56 skipped). No Linux-specific failure was observed, but this review's runtime validation was macOS-only.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,100 @@
|
||||
# Issue #60 — OpenCode 2.x Mac review
|
||||
|
||||
**NOT PASS.** 기본 2.x 실행·조회·삭제 계약과 테스트는 통과했다. 그러나 새 재귀 삭제 경로가 자식 세션의 구버전 데이터를 남긴 뒤 부모 purge의 residual을 빈 목록으로 보고하는 결함을 확인했다. 실제 OpenCode 2.0.32에서도 재현됐다. 국소 수정과 회귀 테스트로 해결할 수 있으므로 재계획 요청은 없다.
|
||||
|
||||
## Identity / 범위
|
||||
|
||||
- Job `614b6263`, loop `loop-20261018T055915Z-abe16e`
|
||||
- Base `74333f87d0c02888c2b5b281e912d97e74348c01`
|
||||
- Head `6b4b96fa1875862ef3bed830fe7b04166b1fad58`
|
||||
- Tree `9cfb2106081eba96b58cc6b7573031de0acba399`
|
||||
- 검증 트리 `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261018T055915Z-abe16e/1`
|
||||
- macOS, 시스템 Python 3.9.6, bash 3.2, 실제 OpenCode `v2.0.32`.
|
||||
|
||||
시작/종료 HEAD와 tree가 일치하고 `git status --short`는 비어 있다. creator 트리 실행·수정, 제품 코드 수정, 브랜치/커밋 변경 없음. 추가 탐침과 의도적 회귀는 검증 HEAD의 임시 git archive 복사본에서만 수행했다. `tea issues 60 --login pugit --repo tmpl/multi-agent-mux --comments`로 본문과 정정 댓글도 읽었다.
|
||||
|
||||
## 수정 필요 — P2: 자식의 legacy 데이터가 남아도 부모 purge는 residual 없음
|
||||
|
||||
**파일 근거:** `.agents/skills/lib_py/agents/adapters/opencode.py:141`~`:145`는 네이티브 CLI에 부모와 자식들의 삭제를 맡기고 이를 성공으로 보고한다. 이어지는 `:158`~`:169`의 legacy SQL은 요청한 **부모 uuid 하나**만 삭제한다. `:188`~`:193`의 residual도 그 uuid 하나만 조회한다.
|
||||
|
||||
**재현 절차:**
|
||||
|
||||
1. HOME/TMPDIR가 임시이고 XDG_*가 unset인 환경에서 실제 2.0.32의 `api --standalone POST /api/session -d ...`로 부모 P, `parentID=P`인 자식 C, 무관한 세션 O를 만든다. 모델 프롬프트는 보내지 않는다.
|
||||
2. 이 임시 DB에 마이그레이션 잔존 상태를 모형화하는 legacy `session(id,parent_id)`와 `message(id,session_id)` 테이블을 만들고 P/C/O에 해당하는 행을 넣는다. 실제 사용자 DB나 실제 migration 파일을 수정한 것이 아니다.
|
||||
3. HEAD 어댑터로 `purge_artifacts(P, ctx)` 후 `residual_artifacts(P, ctx)`를 호출한다.
|
||||
4. session_v2, legacy session/message, C의 residual을 읽는다.
|
||||
|
||||
**기대:** 삭제 범위를 자식까지 확장했으므로 C의 소유 legacy 데이터도 정리하거나, 최소한 남은 C 데이터를 부모 purge 결과의 residual에 포함해야 한다. 무관한 O는 유지해야 한다.
|
||||
|
||||
**실제:**
|
||||
|
||||
- session_v2에는 O만 남아 네이티브 부모/자식 삭제는 성공했다.
|
||||
- legacy session에는 **C와 O**가 남았고 legacy message의 C 행도 남았다.
|
||||
- purged에는 `opencode session (with its child sessions): P`, `sqlite rows for session: P`가 출력됐다.
|
||||
- **부모 residual은 `[]`**였다. C를 별도로 조회해야 `session.id: 1 rows`, `message.session_id: 1 rows`가 드러났다.
|
||||
- 같은 상황을 저장소 fake CLI로 시험한 추가 테스트도 실패했다. 즉 fake와 실제 CLI의 불일치가 아니라 어댑터의 자식 범위 추적 누락이다.
|
||||
|
||||
실제 재현 ID와 결과는 `/tmp/review-614b6263-c_7f_2xc/real-probe/result.json`, 재현 스크립트는 `real_probe.py`에 있다. 스크립트는 위 재현에 이어 독립 TUI 종료 확인까지 수행한다. `probe-copy/tests/test_issue60_opencode_v2.py::test_review_child_legacy_rows_are_not_silently_abandoned`에도 작은 fake 기반 재현이 있다.
|
||||
|
||||
**수정 방향:** 네이티브 삭제 전에 session_v2의 해당 부모 아래 자손 ID 집합을 확보하고, 삭제 후 legacy 정리와 residual 검증을 그 정확한 집합에 적용한다. 보존 대상 테이블의 자식 잔여물도 누락하지 않아야 한다. 실패/부분 실패 시에도 범위를 잃지 않고 WARN/residual을 남긴다. prefix나 workspace만으로 범위를 넓히면 다른 세션을 지우므로 피한다. 부모·자식·손자·무관한 세션이 있는 migrated DB 회귀 테스트를 추가한다.
|
||||
|
||||
## 기존 코드에서 이어지는 호환성 주의점
|
||||
|
||||
다음 두 가지는 추가 탐침으로 확인했지만 **이번 diff가 새로 도입한 결함으로 분류하지 않았다**. 위 자식 residual 결함만으로 NOT PASS이며, 아래 사항은 Issue #60/Linux 후속 검증에서 명시적으로 다룰 필요가 있다.
|
||||
|
||||
### XDG_DATA_HOME 지정 시 실제 세션 저장소를 놓침
|
||||
|
||||
`opencode.py:80`~`:84`에서 호출 환경의 XDG_DATA_HOME을 항상 `<ctx.home_dir>/.local/share`로 덮어쓴다. debug paths를 사용해도 이미 다른 환경으로 질의하므로 호출자의 XDG 경로를 발견하지 못한다. status.sh의 `resume_on_disk`도 기본 HOME 경로를 직접 쓴다.
|
||||
|
||||
임시 홈 H와 임시 `XDG_DATA_HOME=D`를 두고 fake CLI가 따르는 `D/opencode/opencode.db`에만 ses_xdg를 넣었다. 기대 discover는 `[ses_xdg]`이지만 실제 `_resolve_db`는 H/.local/share/opencode/opencode.db, discover는 `[]`였다. 이 시험에서 custom XDG를 받은 실행 파일은 **저장소 fake뿐**이며 실제 바이너리는 실행하지 않았다. 원본 이전 구현도 같은 강제 override를 하므로 신규 회귀라고 주장하지 않는다.
|
||||
|
||||
수정 시 대상 홈 격리 계약을 유지하면서 같은 사용자 환경의 명시적 XDG_DATA_HOME을 존중하고, spawn/discovery/status/purge가 동일한 data root를 쓰도록 해야 한다. 별도 홈을 지정할 때 ambient XDG가 실제 홈으로 새는 문제도 함께 막아야 한다. 이슈 정정 댓글의 XDG 경로 요구와 현재 동작에는 이 차이가 남아 있다.
|
||||
|
||||
### 공유 DB의 열린 FD가 모든 세션을 in-use로 만듦
|
||||
|
||||
OpenCode는 `base.py:307`~`:309`의 공통 판정을 상속하고, artifact_path가 세션 전용 파일이 아닌 전체 opencode.db다. `base.py:78`~`:83`의 lsof 결과는 어떤 프로세스든 그 DB를 열면 True다.
|
||||
|
||||
임시 DB의 종료된 세션에 대해 먼저 in-use=False를 확인한 뒤, UUID 인자 없이 DB 파일만 읽기 모드로 열고 잠드는 Python 프로세스를 만들었다. 같은 세션의 in-use가 True가 됐다. holder는 finally에서 kill/wait했다. 따라서 다른 standalone TUI나 사용자 서비스가 공용 DB를 열고 있으면 종료된 세션도 purge gate에서 거절될 수 있다. 실제 사용자 서비스에 대해 in-use나 DB API를 호출해 검증하지는 않았다. 공유 파일 기반 판정은 base에도 있던 동작이며 이번 변경의 신규 회귀는 아니다.
|
||||
|
||||
후속 수정은 단순히 안전 검사를 제거하기보다 OpenCode 세션별 보유/실행 상태 증거를 사용해야 한다. `--standalone`은 서버 생명주기를 분리하지만 DB를 세션마다 분리하는 옵션은 아니라는 점이 중요하다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
### 원본 HEAD: 181 passed, 1 skipped
|
||||
|
||||
실행 모듈:
|
||||
|
||||
- tests/test_issue60_opencode_v2.py
|
||||
- tests/test_a4_adapter_contract.py
|
||||
- tests/test_issue31_purge_residual.py
|
||||
- tests/test_issue42_skill_blocks.py
|
||||
- tests/test_issue55_agent_homes.py
|
||||
- tests/test_issue10_13_purge.py
|
||||
|
||||
**181 passed, 1 skipped in 53.41s**, pytest exit 0. skip은 worktree에 untracked `.claude/skills`가 없어 `test_issue42_skill_blocks.py:266`을 건너뛴 것이다. 실제 OpenCode 2.x 계약 테스트는 skip되지 않고 통과했다. 전체 스위트는 오케스트레이터 범위라 실행하지 않았다.
|
||||
|
||||
원본 검증은 지정 venv의 `python -m pytest -p no:cacheprovider --basetemp=/tmp/review-614b6263-c_7f_2xc/pytest-head -q -ra ...`, `PYTHONDONTWRITEBYTECODE=1`로 수행했다. HERDR/MAM/WORKSPACE 바인딩을 제거하고 HOME/TMPDIR를 임시화, 실제 바이너리 계약 테스트는 XDG_DATA_HOME/STATE_HOME/CONFIG_HOME/CACHE_HOME unset으로 수행했다. Claude 관련 테스트의 실제 /tmp fallback도 격리했다.
|
||||
|
||||
### 추가 검증
|
||||
|
||||
- 추가 edge 탐침 **3 failed, 10 deselected in 2.23s**: 자식 legacy residual 누락, XDG 경로 누락, 공유 DB FD false positive. 원본 제공 스위트의 실패로 합산하지 않는다.
|
||||
- discover의 SQL만 임시 복사본에서 session_v2→session으로 되돌린 대조군: **2 failed, 11 deselected in 1.30s**. 기존 `test_discover_and_verify_read_session_v2_and_skip_child_sessions`, `test_a_fresh_2x_database_has_no_session_table_and_still_works`가 빈 결과를 거부했다. 원본은 변경하지 않았고 대조군 수정도 복원했다.
|
||||
- 가짜 herdr/가짜 OpenCode를 쓰는 추가 전체 연결 시험: **1 passed in 45.07s**. 실제 create_session.sh 실행 → mock DB에 parent/더 최신 child 구성 및 own ID 비우기 → reconcile.sh --once에서 drift C가 parent만 확정 → status.sh --json의 resume_state=yes → stop_session.sh로 stopped → resume_session.sh의 `--session <동일 parent> --auto --standalone` 확인. 첫 모델 메시지 대신 mock DB seed를 사용했으며 실제 프롬프트나 실제 herdr 서버는 쓰지 않았다. fixture 안의 모의 온보딩 잡만 생성됐다.
|
||||
- 실제 2.0.32: 임시 홈에서 API 부모/자식 생성 및 삭제, 무관한 세션 보존 확인. 별도 PTY에서 살아남은 세션을 `--session ... --auto --standalone`으로 재개하고 `/exit` 입력 후 0.3초 간격을 두어 Enter 전송, **exit 0** 확인. 모델 프롬프트 없음.
|
||||
- 시험 후 프로세스 확인: 기존 사용자 OpenCode 서비스 PID 86524만 남았다. 그 프로세스에는 제어 명령을 보내지 않았으며 시험으로 만든 OpenCode 프로세스는 남지 않았다.
|
||||
- 변경 Python 파일 모두 시스템 Python 3.9 AST 검사 통과. 변경 셸 파일 모두 `/bin/bash -n` 통과. base..head `git diff --check` 통과.
|
||||
|
||||
## 코드/문서 검토 결론과 한계
|
||||
|
||||
- session_v2.directory/time_created(ms), discover의 parent_id IS NULL 필터는 새 subagent가 본 세션을 덮어쓰는 것을 피하며 새 DB와 migrated DB 가정에 맞는다.
|
||||
- `debug paths`의 db 줄 파싱, `session delete --standalone -- <id>`, 실패/바이너리 부재 WARN 및 부모 residual, 없는 ID에 대한 불필요 CLI 호출 회피는 관련 테스트에서 확인됐다.
|
||||
- create/resume/update_yaml의 플래그와 adapter 생성 명령은 일치한다. active `.agents/skills`, docs, README 검색에서 운영 명령으로 남은 `opencode db path`나 TUI `--agent` 사용은 찾지 못했다. 설명용 과거 동작 언급, 의도적인 legacy DELETE, `opencode run -m`은 해당하지 않는다. VERSIONS/과거 리포트는 판단에서 제외했다.
|
||||
- fake CLI는 기본 db path 및 v2 재귀 삭제/legacy 보존을 실제 계약과 일치하게 모형화한다. 다만 mock lifecycle은 실제 TUI의 모든 시그널 동작을 증명하지 않는다. 실제 PTY에서는 정상 `/exit` 경로를 확인했고 실제 SIGKILL/SIGHUP 전 조합을 반복 검증하지 않았다.
|
||||
- auth_ok와 auth.json 검사는 브리프의 의도된 제외 범위로 취급했다.
|
||||
- Linux에서 실행한 결과는 없다. Python 3.9/bash 3.2 구문과 플랫폼 전용 새 의존성 여부를 검토했지만 Linux 런타임, 서비스/PTY 동작은 예정된 별도 검증이 필요하다.
|
||||
|
||||
## 증거
|
||||
|
||||
임시 루트 `/tmp/review-614b6263-c_7f_2xc` (실경로 `/private/tmp/...`): `head.log`, `probes.log`, `mutation.log`, `flow.log`, `real_probe.py`, `real-probe/result.json`, `real-probe/tui-output.bin`, `processes-after.json`. 추가 pytest 재현은 `probe-copy/tests/test_issue60_opencode_v2.py` 및 `probe-copy/tests/test_review60_flow.py`에 있다.
|
||||
|
||||
[VERDICT: NOT PASS]
|
||||
@@ -0,0 +1,120 @@
|
||||
# Issue #60 2차 리뷰 — NOT PASS
|
||||
|
||||
이전 자식 legacy 데이터 누락은 실제 OpenCode 2.0.32에서도 해결됐다. 새 기능의 행동 테스트와 수정 전 실패도 확인했다. 그러나 새 XDG_DATA_HOME 읽기가 테스트의 환경 격리 목록에 반영되지 않아 원본 HEAD 검사 1건이 실패하며, 실제로 sandbox 밖 DB 경로를 선택하는 회귀가 있다. 국소 수정이 필요하고 재계획은 필요하지 않다.
|
||||
|
||||
## Identity
|
||||
|
||||
- Job: `c4e96018`; loop: `loop-20261018T064723Z-79133f`
|
||||
- Base: `74333f87d0c02888c2b5b281e912d97e74348c01`
|
||||
- Head: `cb5f439cce41a2741a6b9974a308a765e50f00ad`
|
||||
- Tree: `cfec95403cbde10a6340b9007e1246c83be461cd`
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261018T064723Z-79133f/1`
|
||||
|
||||
시작/종료의 HEAD/tree가 일치하고 검증 트리는 clean이다. creator 트리에서 실행·수정하지 않았다. 제품 원본과 원본 테스트는 수정하지 않았다. 아래 대조군만 검증 트리의 별도 git archive 복사본에서 변경했다. 판정은 base..head 누적 변경을 대상으로 한다.
|
||||
|
||||
## 수정 필요 — P1: 상속된 XDG_DATA_HOME이 테스트 sandbox 밖을 가리킴
|
||||
|
||||
**근거:** `.agents/skills/lib_py/agents/adapters/opencode.py:90`~`:93`은 HOME과 ctx.home_dir가 같으면 ambient XDG_DATA_HOME을 반환하도록 새로 변경됐다. 그런데 `tests/_agent_home_guard.py`의 `DROPPED_VARS`에는 XDG_DATA_HOME이 없고, `tests/conftest.py::_isolate_agent_homes`는 그 목록만 제거한다. `mam_sandbox`는 HOME/HOME_DIR를 임시 홈으로 바꾸지만 XDG_DATA_HOME은 제거하지 않는다. 따라서 이 시점에 own=True가 되면서 호출자의 외부 XDG data root가 그대로 테스트에 유입된다. `watch_dirs()` 역시 opencode의 기본 HOME/.local/share 경로만 감시한다.
|
||||
|
||||
**원본 HEAD에서 관측:** `tests/test_issue55_agent_homes.py:61`의 `test_l1_every_path_variable_the_code_reads_is_accounted_for`가 다음 값으로 실패했다.
|
||||
|
||||
```text
|
||||
path variables the adapters/lib.sh read but conftest._isolate_agent_homes does not drop: ['XDG_DATA_HOME']
|
||||
```
|
||||
|
||||
이는 새 파일 생성에 대한 외부 에이전트 활동 오탐이 아니라, 소스 코드가 읽는 변수와 격리 목록의 결정적 불일치다. 테스트 프로세스 시작 전에 XDG를 unset한 이번 리뷰 환경에서도 목록 검사는 실패한다.
|
||||
|
||||
**안전한 재현 절차:**
|
||||
|
||||
1. 검증 HEAD 복사본에 `mam_sandbox` fixture를 사용하는 짧은 pytest 테스트를 둔다.
|
||||
2. pytest 호출자의 HOME은 임시 H, XDG_DATA_HOME은 별개의 임시 D로 설정한다. 실제 사용자 경로는 사용하지 않는다.
|
||||
3. 테스트 안에서 OpenCode 어댑터의 `_bin`을 None으로 대체해 실제 CLI 실행을 막고, `DiscoveryContext(workspace=sandbox, agent_name='opencode', home_dir=sandbox)`로 `_resolve_db`를 호출한다.
|
||||
4. 결과가 sandbox 내부인지 검사한다.
|
||||
|
||||
**기대:** 각 테스트가 명시적으로 설정하지 않은 ambient XDG는 격리되어 `<sandbox>/.local/share/opencode/opencode.db`가 선택돼야 한다.
|
||||
|
||||
**실제:**
|
||||
|
||||
```text
|
||||
sandbox: /private/tmp/review-c4e96018-i02oz_61/pytest-isolation/test_inherited_data_home_does_0
|
||||
resolved_db: /tmp/review-c4e96018-i02oz_61/ambient-data/opencode/opencode.db
|
||||
```
|
||||
|
||||
추가 탐침은 **1 failed in 0.09s**. 실제 파일 쓰기나 삭제를 하지 않고 경로 선택만 확인했다. 일반 개발자 환경에서 D가 실제 XDG 디렉터리라면 어댑터/테스트가 그 DB를 읽거나 변경할 수 있으므로, 단순한 목록 테스트의 기대값 갱신으로 넘길 문제는 아니다. 실제 사용자 데이터 훼손이 관측됐다는 뜻은 아니다.
|
||||
|
||||
**수정 방향:** `DROPPED_VARS`에 XDG_DATA_HOME을 추가하여 테스트마다 제거하고, XDG 동작을 검증하는 테스트만 명시적으로 임시 XDG를 설정하게 한다. 세션 guard의 ambient 변수 기록 및 `watch_dirs()`도 새 data-root 계약을 반영하여 실제 XDG 경로를 감시하게 한다. `LEFT_ALONE`에만 추가하거나 목록 단언을 약화하면 sandbox 경로 문제는 남는다.
|
||||
|
||||
**대조군:** 임시 복사본에서 DROPPED_VARS에 XDG_DATA_HOME만 추가하자 위 경로 탐침과 기존 L1 검사 두 건이 **2 passed in 0.09s**로 바뀌었다. 이는 격리 누락 원인의 확인이며, 원본 수정이나 전체 검증 완료를 의미하지 않는다. 감시 경로 보완도 함께 필요하다.
|
||||
|
||||
## 이전 지적 및 이번 변경 확인
|
||||
|
||||
### 자식 legacy 데이터 삭제와 residual
|
||||
|
||||
`purge_artifacts`가 CLI 호출 전에 `_descendants`로 session_v2와 legacy session의 parent_id를 따라 모든 깊이의 ID를 모은다. 중복/순환은 found와 root 비교로 제한한다. legacy message/messages/part/parts/todo/session_share/session/sessions를 해당 ID 집합에 대해 삭제한다. 관련 없는 세션은 집합에 넣지 않는다.
|
||||
|
||||
이전 리뷰의 실제 재현을 새로운 임시 홈에서 반복했다. 실제 2.0.32 standalone API로 부모 P, 자식 C, 무관한 O를 생성하고, 임시 DB에 P/C/O의 legacy session/message를 추가한 뒤 현재 어댑터로 P를 purge했다.
|
||||
|
||||
- session_v2와 legacy session 모두 **O만 남았다**.
|
||||
- P와 C의 residual은 각각 `[]`였다.
|
||||
- purged는 부모/자식 네이티브 삭제와 `and 1 child session(s)` legacy 정리를 보고했다.
|
||||
- 원래 차단 지적이었던 C의 legacy 행 생존은 재현되지 않았다.
|
||||
|
||||
새 테스트는 손자 포함 삭제, 무관한 세션 보존, 삭제 실패 시 `[child session ...]` residual도 확인한다. todo의 변경된 계약은 issue31 테스트가 삭제됨을 단언하며 통과했다. session_share도 구현의 명시적 legacy 집합에 포함됐다.
|
||||
|
||||
### 서비스 보유자 / XDG / resident / drift C
|
||||
|
||||
- 공유 DB에 대한 서비스 보유자 제외, 일반 보유자 및 UUID 명령행 보유자 유지 테스트 통과.
|
||||
- 호출자 자신의 HOME에서 XDG_DATA_HOME 존중, 다른 HOME에서는 그 홈의 기본 data root 사용 테스트 통과. 제품 경로 계약은 의도대로 바뀌었으나 위 테스트 격리 갱신이 누락됐다.
|
||||
- resident_children=('opencode',) 테스트 통과. standalone 서버 자체는 idle 판단에서 제외하지만 그 아래 작업 프로세스는 계속 탐색하여 작업으로 본다.
|
||||
- `test_comp_opencode_create_and_reconcile_and_stop` 통과. 더 최신 자식이 있어도 실제 reconcile.sh 경로가 부모를 확정한다. 이는 문자열 검사만이 아니라 임시 fixture 안의 행동 검증이다.
|
||||
- 실제 바이너리 계약 테스트는 `_bin`을 발견한 바이너리로 고정하여 임시 HOME에서 PATH에 의존하지 않도록 바뀌었고 실제 2.0.32로 통과했다.
|
||||
|
||||
**수정 전 대조군:** 현재 `test_issue60_opencode_v2.py`를 이전 HEAD `6b4b96f`의 별도 archive에 넣어 아래 다섯 노드만 실행했다. 모두 수정 전 동작 때문에 실패했다(**5 failed, 10 deselected in 2.57s**).
|
||||
|
||||
1. test_purge_takes_the_1x_rows_of_the_child_sessions_too
|
||||
2. test_a_failed_delete_reports_the_child_sessions_left
|
||||
3. test_the_background_service_alone_does_not_hold_a_session
|
||||
4. test_the_data_dir_follows_the_callers_xdg_data_home_only_for_its_own_home
|
||||
5. test_the_standalone_server_is_a_resident_child_but_its_tools_are_work
|
||||
|
||||
같은 다섯 노드는 현재 HEAD에서 통과했다. drift-C 필터 제거 변형은 이번 리뷰에서 별도로 반복하지 않았으며 현재 행동 테스트 통과를 확인했다.
|
||||
|
||||
## 원본 HEAD 실행 결과
|
||||
|
||||
다음 6개 모듈과 tier2 노드 1개 실행:
|
||||
|
||||
- tests/test_issue60_opencode_v2.py
|
||||
- tests/test_a4_adapter_contract.py
|
||||
- tests/test_issue31_purge_residual.py
|
||||
- tests/test_issue42_skill_blocks.py
|
||||
- tests/test_issue55_agent_homes.py
|
||||
- tests/test_issue10_13_purge.py
|
||||
- tests/test_tier2_component.py::test_comp_opencode_create_and_reconcile_and_stop
|
||||
|
||||
**1 failed, 186 passed, 1 skipped in 91.16s**, pytest exit 1. 유일한 실패는 위 XDG 변수 목록 검사다. skip은 untracked `.claude/skills`가 검증 worktree에 없는 issue42 검사이며, 실제 OpenCode 테스트는 skip되지 않았다.
|
||||
|
||||
변경 Python 파일의 시스템 Python 3.9 AST 구문 검사, 변경 셸 파일의 `/bin/bash -n`, base..head `git diff --check` 모두 통과. 전체 스위트는 오케스트레이터가 별도로 실행하는 범위라 반복하지 않았다.
|
||||
|
||||
## 안전 범위와 남은 제한
|
||||
|
||||
- 지정 venv, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 검증 트리 밖 basetemp로 실행했다. HERDR/MAM/WORKSPACE/CLAUDE_CONFIG_DIR 바인딩은 제거했다.
|
||||
- 실제 바이너리는 HOME/TMPDIR 임시화, XDG_* unset, 서버 사용 명령은 --standalone으로만 실행했다. `_run`이 설정하는 XDG_DATA_HOME도 해당 임시 홈 아래다. 모델 프롬프트나 실제 사용자 서비스/API 호출은 하지 않았다.
|
||||
- XDG ambient 재현에는 임시 경로와 `_bin=None`만 사용했다. 실제 opencode 데이터나 실제 DB는 건드리지 않았다.
|
||||
- 종료 후 OpenCode 프로세스는 기존 사용자 서비스 PID 86524만 남았다. 그 서비스는 제어하지 않았다. 시험 프로세스가 남지 않았다.
|
||||
- 1차 리뷰에서 확인한 create→drift C→status→stop→resume 연결 및 실제 PTY의 `/exit` 성공 근거는 `.mam/jobs/614b6263/codex-reports/report-final.md`에 있다. 이번에는 새 tier2 행동 테스트와 실제 API 삭제 계약을 재검증했고, 실제 모델 첫 메시지는 보내지 않았다.
|
||||
- status.sh의 `resume_on_disk`는 아직 기본 HOME 경로를 직접 찾는다. 사용자 지정 XDG 저장소에 대한 status 표시는 어댑터의 새 경로 지원과 다를 수 있다. 이는 1차 리뷰에서도 있던 비차단 후속 사항이며 이번 NOT PASS의 추가 사유로 삼지 않았다.
|
||||
- Linux 실제 실행 결과는 없다. 구문 및 플랫폼 코드 경로를 검토했고 Linux 런타임/서비스 동작 검증은 예정된 후속 작업으로 남는다.
|
||||
|
||||
## 증거 파일
|
||||
|
||||
`/tmp/review-c4e96018-i02oz_61` (실경로 `/private/tmp/...`):
|
||||
|
||||
- head.log — 원본 결과 및 유일한 실패.
|
||||
- before-fix.log — 이전 HEAD에서 새 회귀 검사 5건 실패.
|
||||
- real_probe.py, real-probe/result.json — 실제 2.0.32 부모/자식 legacy 정리 확인.
|
||||
- isolation.log — sandbox 밖 임시 XDG data root 선택 재현.
|
||||
- isolation-control.log — 임시 격리 목록 보완 후 2건 통과.
|
||||
- isolation-probe/tests/test_review_xdg_isolation.py — 읽기만 하는 재현 테스트. 이 복사본의 guard에는 대조군 변경이 적용돼 있으므로 원본 재현 시 HEAD의 guard를 사용해야 한다.
|
||||
- processes-after.json — 시험 후 OpenCode 프로세스 확인.
|
||||
|
||||
[VERDICT: NOT PASS]
|
||||
@@ -0,0 +1,56 @@
|
||||
# Review Report: Issue #60 (`fix/issue-60-opencode-v2`)
|
||||
|
||||
- **Job ID**: `1abe0a90`
|
||||
- **Reviewer**: `herdr:reviewer-agy-01` (`agy`)
|
||||
- **Commit Range**: `74333f87d0c02888c2b5b281e912d97e74348c01` .. `6b4b96fa1875862ef3bed830fe7b04166b1fad58` (3 commits across 13 files)
|
||||
- **Verification Worktree**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261018T055915Z-abe16e/1`
|
||||
- **Host / Runtime**: macOS `arm64-apple-darwin25` (Darwin 25.3.0), `/usr/bin/python3` 3.9.6 & `.venv/bin/python` 3.9.6, `/bin/bash` 3.2.57, `opencode` v2.0.32 (`/Users/godopu16/.opencode/bin/opencode`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Review Findings by Focus Area
|
||||
|
||||
### 1.1 OpenCode 2.x Schema, CLI Contract, and Purge Safety
|
||||
- **`_resolve_db` (`opencode debug paths`)**:
|
||||
- `.agents/skills/lib_py/agents/adapters/opencode.py:88-107` invokes `self._run(ctx, ["debug", "paths"], timeout=5)` with `HOME`, `HOME_DIR`, and `XDG_DATA_HOME=<ctx.home_dir>/.local/share`, parses the `db` line, and falls back to `f"{ctx.home_dir}/.local/share/opencode/opencode.db"`.
|
||||
- Verified against real `opencode v2.0.32` in an isolated temporary `HOME`: `debug paths` returns `<home>/.local/share/opencode/opencode.db` without creating the database file.
|
||||
- **`verify_artifact` and `discover` (`session_v2` & `parent_id IS NULL`)**:
|
||||
- `verify_artifact` (`.agents/skills/lib_py/agents/adapters/opencode.py:113-147`) queries `SELECT directory, time_created FROM session_v2 WHERE id=?`, converts millisecond `time_created` (`float(started_at) / 1000.0`) against `ctx.epoch`, and checks `workspace_key(found_cwd) == workspace_key(ctx.cwd)`.
|
||||
- `discover` (`.agents/skills/lib_py/agents/adapters/opencode.py:198-235`) queries `SELECT id FROM session_v2 WHERE parent_id IS NULL`, filtering out subagent child sessions (which share `directory` and have newer `time_created` than the top-level TUI session).
|
||||
- Verified against read-only inspection (`mode=ro`) of the host's migrated `opencode.db` and a fresh `2.0.32` DB in a temporary `HOME`: fresh 2.x databases contain `session_v2` (with `id`, `parent_id`, `directory`, `time_created`) and no `session`/`message`/`part`/`todo`/`session_share` tables.
|
||||
- **`purge_artifacts` and `residual_artifacts`**:
|
||||
- `purge_artifacts` (`.agents/skills/lib_py/agents/adapters/opencode.py:149-181`) checks `self._has_session(db, uuid)` (`SELECT 1 FROM session_v2 WHERE id=?`) before calling `self._run(ctx, ["session", "delete", "--standalone", "--", uuid], timeout=60)`.
|
||||
- Using `--` before `uuid` prevents option-flag injection if an ID ever starts with `-`, and `--standalone` avoids starting or attaching to a per-user background service.
|
||||
- In a fresh 2.0.32 DB, `session_inbox`, `session_message`, `session_pending`, `instruction_entry`, and `instruction_state` all have `ON DELETE CASCADE` foreign keys to `session_v2(id)`, and `opencode session delete` also removes `event` and `event_sequence` rows (`aggregate_id`) for the session and its child sessions while leaving other sessions untouched.
|
||||
- For databases migrated from 1.x, `purge_artifacts` subsequently deletes matching `uuid` rows from `part`/`parts`/`message`/`messages`/`session`/`sessions` (if those tables exist) and `residual_artifacts` reports any surviving `todo.session_id` or `session_v2.id` (e.g., if `session delete` failed or the binary was missing, in which case `purge_artifacts` emits a `WARN:` on `stderr`).
|
||||
|
||||
### 1.2 End-to-End Lifecycle (`create` → `reconcile` drift C → `status` → `stop` → `resume`)
|
||||
- **`create_session.sh`, `resume_session.sh`, `update_yaml_resumed.sh`**:
|
||||
- All three scripts and `OpenCodeAgentAdapter.spawn_spec` / `resume_spec` consistently use `opencode --auto --standalone` (and `opencode --session <uuid> --auto --standalone` on materialized resume), removing the defunct `--agent build` flag rejected by OpenCode 2.x TUI.
|
||||
- **`reconcile.sh` (drift C) and `status.sh`**:
|
||||
- `.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh:955-990` inspects `PRAGMA table_info(session_v2)`, filters by `parent_id IS NULL`, `directory = ?`, and `time_created >= ?` (ms), and validates candidates via `verify_session_uuid(cwd, 'opencode', uuid, s_eval, mode="discover")`.
|
||||
- `.agents/skills/multi-agent-mux-status/scripts/status.sh:127,337` checks `SELECT 1 FROM session_v2 WHERE id=?` in both human and JSON status paths.
|
||||
- **`--standalone` process lifecycle & `stop_session.sh`**:
|
||||
- Verified in an isolated PTY with real `opencode v2.0.32`: `opencode --auto --standalone` spawns `opencode serve --stdio --port 0` as a direct child of the TUI process; closing the PTY master terminates both the TUI and its `opencode serve --stdio --port 0` child cleanly within ~0.1s with zero lingering background processes.
|
||||
- `stop_session.sh`'s graceful `/exit` (`send_keys_safe` with echo wait before `Enter`) -> `herdr kill-session` (closes PTY) -> `SIGKILL` fallback and `in-use` gate (`ps` argv check for `--session <uuid>`) work cleanly with `--standalone`.
|
||||
|
||||
### 1.3 Linux Portability, Shell/Python Compatibility, and Completeness
|
||||
- **Linux & XDG portability**:
|
||||
- `OpenCodeAgentAdapter._run` explicitly sets `HOME`, `HOME_DIR`, and `XDG_DATA_HOME = os.path.join(ctx.home_dir, ".local/share")`, ensuring that even on Linux hosts where `XDG_DATA_HOME` is set in the ambient desktop environment, adapter CLI invocations remain isolated to `ctx.home_dir`.
|
||||
- `test_real_opencode_2x_contract` explicitly unsets `XDG_DATA_HOME`, `XDG_STATE_HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, and `HOME_DIR` via `monkeypatch.delenv`.
|
||||
- **Python 3.9 & Bash 3.2 compatibility**:
|
||||
- Verified on macOS `/usr/bin/python3` / `.venv/bin/python` (3.9.6) and `/bin/bash` (3.2.57 with `set -u`).
|
||||
- **Repository-wide search for stale 1.x assumptions**:
|
||||
- Grepping `.agents/skills/`, `tests/`, and `docs/` for `db path`, `--agent build`, `FROM session\b`, `INTO session\b`, and `table_info(session)` confirms no remaining 1.x code or documentation assumptions exist outside explanatory comments.
|
||||
- **Test coverage & negative regression verification**:
|
||||
- `tests/test_issue60_opencode_v2.py` (10/10 passed, including `test_real_opencode_2x_contract` against real `opencode v2.0.32`), `tests/test_a4_adapter_contract.py`, `tests/test_issue31_purge_residual.py`, `tests/test_issue42_skill_blocks.py`, `tests/test_issue55_agent_homes.py`, and `tests/test_tier1_unit.py` all pass.
|
||||
- Reverting `SELECT id FROM session_v2 WHERE parent_id IS NULL` to `SELECT id FROM session` in an isolated `/tmp` copy immediately fails `test_discover_and_verify_read_session_v2_and_skip_child_sessions` and `test_a_fresh_2x_database_has_no_session_table_and_still_works`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Non-Blocking Observation (Out of Scope for Issue #60)
|
||||
|
||||
- **[Note — `stall_watch.py` `background_children` with `--standalone`]**:
|
||||
- Because `opencode --auto --standalone` keeps `opencode serve --stdio --port 0` alive as a child process of the TUI while idle, and `OpenCodeAgentAdapter.resident_children` is `()`, `lib_py.stall_watch.background_children(agent_pid, adapter.resident_children)` sees the `opencode serve --stdio --port 0` child process and conservatively withholds the optional idle nudge (`NOTIFY_IDLE_AND_NUDGE`). If idle nudging is desired for `--standalone` OpenCode in the future, `stall_watch.background_children` can exempt children whose `argv` matches `opencode serve --stdio` (while still walking any tool subprocesses spawned beneath that server). This does not affect any session lifecycle, discovery, or purge behavior in Issue #60.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,60 @@
|
||||
# Issue #60 Code Review Report — Round 3 (`0e05b1b`)
|
||||
|
||||
- **Job ID**: `5420eae1`
|
||||
- **Reviewer**: `agy` (`herdr:reviewer-agy-01`)
|
||||
- **Branch**: `fix/issue-60-opencode-v2`
|
||||
- **Base .. Head**: `74333f87d0c02888c2b5b281e912d97e74348c01` .. `0e05b1b6777ceb837066454ac688e04a0f5d14a0` (5 commits; Round 3 delta: `0e05b1b`)
|
||||
- **Verification Worktree**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261018T073639Z-b5cb7f/1`
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Commit `0e05b1b` cleanly resolves all blocking and non-blocking findings raised in Round 2 (`cb5f439`) without introducing regressions:
|
||||
|
||||
1. **Test isolation & Issue #55 path-variable guard (`tests/_agent_home_guard.py`)**:
|
||||
- `XDG_DATA_HOME` is added to `DROPPED_VARS`, so `conftest._isolate_agent_homes` drops any ambient `XDG_DATA_HOME` for every test (preventing a sandbox that overrides `HOME` from resolving the runner's ambient `XDG_DATA_HOME` via `_data_home(ctx)`).
|
||||
- `watch_dirs(ambient)` now watches `os.path.join(ambient.get("XDG_DATA_HOME") or os.path.join(home, ".local", "share"), "opencode")` so the session leak guard monitors the real opencode data directory even when `XDG_DATA_HOME` is set in the ambient environment.
|
||||
- `tests/test_issue55_agent_homes.py::test_l1_every_path_variable_the_code_reads_is_accounted_for` passes.
|
||||
2. **`status.sh` DB path resolution (`.agents/skills/multi-agent-mux-status/scripts/status.sh:108-111, 308-311`)**:
|
||||
- Both `--json` and text modes now resolve the opencode DB path via `get_adapter('opencode')._resolve_db(DiscoveryContext(workspace=cwd, agent_name='opencode', home_dir=home))` instead of a hardcoded `~/.local/share/opencode` lookup, keeping `status.sh` consistent with `reconcile.sh` and the adapter's `debug paths` / `XDG_DATA_HOME` resolution.
|
||||
3. **Real binary `--version` probe isolation (`tests/test_issue60_opencode_v2.py:294-301`)**:
|
||||
- `_real_opencode()` now runs `opencode --version` inside a `tempfile.TemporaryDirectory()` with `HOME` and `TMPDIR` pointed at that temp dir and `XDG_*` omitted from `env`, preventing import-time log writes to the user's real `~/.local/share/opencode/log`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Verification of Cumulative Changes (`74333f8..0e05b1b`)
|
||||
|
||||
| Area | Verification Result |
|
||||
| :--- | :--- |
|
||||
| **Adapter CLI & Schema (`opencode.py`)** | `spawn_spec` / `resume_spec` use `--auto --standalone` (and `--session <id>` when materialized); `_resolve_db` parses `opencode debug paths` with fallback to `<_data_home>/opencode/opencode.db`; `discover` (`WHERE parent_id IS NULL`) and `verify_artifact` query `session_v2`. |
|
||||
| **Purge & Residual (`opencode.py`)** | Collects `_descendants(db, uuid)` from `session_v2` and legacy `session` before invoking `opencode session delete --standalone -- <uuid>`, deletes `_LEGACY_TABLES` rows for `[uuid] + descendants`, and reports remaining `session_v2` / legacy rows (tagging child sessions with `[child session <id>]`). |
|
||||
| **In-Use & Stall Watch (`opencode.py`)** | `conversation_in_use` ignores `serve --service` holders of `opencode.db` while detecting `--standalone` / `--session` processes; `resident_children = ('opencode',)` prevents idle `opencode serve --stdio --port 0` from tripping `background_children` while still detecting tool subprocesses under it. |
|
||||
| **Shell Scripts (`create_session.sh`, `resume_session.sh`, `update_yaml_resumed.sh`, `reconcile.sh`, `status.sh`)** | All shell launch/resume commands use `--auto --standalone` without `--agent`; `reconcile.sh` drift C queries `session_v2` with `parent_id IS NULL`; `status.sh` uses the adapter's `_resolve_db` and queries `session_v2`. |
|
||||
| **Tests & Guard (`_agent_home_guard.py`, `_fake_opencode.py`, `test_issue60_opencode_v2.py`, `test_a4_adapter_contract.py`, `test_issue31_purge_residual.py`, `test_tier2_component.py`)** | `XDG_DATA_HOME` is in `DROPPED_VARS` and watched by `watch_dirs`; `_real_opencode()` runs `--version` in an isolated temporary `HOME`/`TMPDIR`. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Test Execution Summary
|
||||
|
||||
All tests were executed in the verification worktree (`/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261018T073639Z-b5cb7f/1`) with `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, external `--basetemp`, and isolated environment variables:
|
||||
|
||||
1. **Adapter, Guard, Purge/Residual, and Issue #60 Suites**:
|
||||
- `tests/test_issue60_opencode_v2.py` (15 passed, including `test_real_opencode_2x_contract` against real opencode v2.0.32 in isolated `HOME`/`TMPDIR`)
|
||||
- `tests/test_issue55_agent_homes.py` (11 passed, including `test_l1_every_path_variable_the_code_reads_is_accounted_for`)
|
||||
- `tests/test_a4_adapter_contract.py` (33 passed)
|
||||
- `tests/test_issue31_purge_residual.py` (67 passed)
|
||||
- **Total**: `126 passed in 48.31s`
|
||||
2. **Tier 2 Component Suite (`opencode or status`) with `XDG_DATA_HOME` Canary**:
|
||||
- Ran `tests/test_tier2_component.py -k "opencode or status"` with `XDG_DATA_HOME` pointed at a canary temp directory.
|
||||
- **Result**: `7 passed in 62.22s`, and `XDG_CANARY` remained completely empty.
|
||||
3. **Post-check**:
|
||||
- Verification worktree is clean (`git status -s` empty, no `.mam` directory), and no stray `opencode` test processes remain.
|
||||
|
||||
---
|
||||
|
||||
## 4. Findings
|
||||
|
||||
No blocking or non-blocking issues remaining. The branch `fix/issue-60-opencode-v2` (`74333f8..0e05b1b`) is ready to merge.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,62 @@
|
||||
# Review Report (Round 2): Issue #60 (`fix/issue-60-opencode-v2`)
|
||||
|
||||
- **Job ID**: `c9d06e85`
|
||||
- **Reviewer**: `herdr:reviewer-agy-01` (`agy`)
|
||||
- **Commit Range**: `74333f87d0c02888c2b5b281e912d97e74348c01` .. `cb5f439cce41a2741a6b9974a308a765e50f00ad` (4 commits; Round 2 delta: `6b4b96fa1875862ef3bed830fe7b04166b1fad58..cb5f439cce41a2741a6b9974a308a765e50f00ad`, 1 commit)
|
||||
- **Verification Worktree**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261018T064723Z-79133f/1`
|
||||
- **Host / Runtime**: macOS `arm64-apple-darwin25` (Darwin 25.3.0), `/usr/bin/python3` 3.9.6 & `.venv/bin/python` 3.9.6, `/bin/bash` 3.2.57, `opencode` v2.0.32 (`/Users/godopu16/.opencode/bin/opencode`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Round 1 Findings Verification (`6b4b96f..cb5f439`)
|
||||
|
||||
All five Round 1 items addressed in commit `cb5f439` work as designed and their new tests fail against pre-fix Round 1 code (`6b4b96f`):
|
||||
|
||||
1. **[Resolved — Child session 1.x row purge & residual reporting (`_descendants`, `_LEGACY_TABLES`)]**:
|
||||
- `.agents/skills/lib_py/agents/adapters/opencode.py:160-222` resolves all descendant session IDs (`_descendants(db, uuid)`, walking `parent_id` across both `session_v2` and 1.x `session` tables to arbitrary depth with cycle protection in read-only mode) before calling `opencode session delete --standalone -- <uuid>`.
|
||||
- `purge_artifacts` then deletes rows for `[uuid] + descendants` across all 1.x legacy tables (`_LEGACY_TABLES`: `message`, `messages`, `part`, `parts`, `todo`, `session_share`, `session`, `sessions`).
|
||||
- `residual_artifacts` (`.agents/skills/lib_py/agents/adapters/opencode.py:229-238`) checks `[uuid] + self._descendants(db, uuid)` and tags surviving descendant rows with `[child session <id>]`.
|
||||
2. **[Resolved — `conversation_in_use` ignores background `serve --service` daemon]**:
|
||||
- `.agents/skills/lib_py/agents/adapters/opencode.py:240-262` checks `conversation_in_use(uuid, [])` first, then inspects `lsof -t <db>` PIDs via `ps -o command= -p <pid>` and ignores processes with `serve --service` in their command line while still treating `--standalone` TUI/server holders as in-use (and failing safe to `True` on `lsof`/`ps` errors).
|
||||
3. **[Resolved — `XDG_DATA_HOME` handling in `_data_home(ctx)`]**:
|
||||
- `.agents/skills/lib_py/agents/adapters/opencode.py:89-99` uses the caller's `XDG_DATA_HOME` when `ctx.home_dir` resolves to `os.path.expanduser("~")` (or is empty), and `<ctx.home_dir>/.local/share` when `ctx.home_dir` is a separate directory.
|
||||
4. **[Resolved — `resident_children = ('opencode',)`]**:
|
||||
- `.agents/skills/lib_py/agents/adapters/opencode.py:72-76` declares `resident_children = ('opencode',)`, so `stall_watch.background_children` skips the idle `opencode serve --stdio --port 0` child while still walking any tool subprocesses spawned beneath it.
|
||||
5. **[Resolved — Test coverage & pre-fix failure check]**:
|
||||
- `test_real_opencode_2x_contract` binds `adapter._bin` to `REAL` (no `PATH` dependency), and `test_comp_opencode_create_and_reconcile_and_stop` (`tests/test_tier2_component.py:1252-1273`) verifies that `reconcile.sh` drift C pins the top-level session rather than a newer child session in the same directory.
|
||||
- Running the 5 new tests in `tests/test_issue60_opencode_v2.py` against Round 1 commit `6b4b96f` in an isolated `/tmp` copy fails all 5 (`5 failed, 10 deselected`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Issue Blocking Merge
|
||||
|
||||
### [P1] `tests/test_issue55_agent_homes.py::test_l1_every_path_variable_the_code_reads_is_accounted_for` fails (`XDG_DATA_HOME` missing from `tests/_agent_home_guard.py`)
|
||||
|
||||
- **Location**: `tests/_agent_home_guard.py:21` (triggered by `.agents/skills/lib_py/agents/adapters/opencode.py:94` reading `os.environ.get("XDG_DATA_HOME")`)
|
||||
- **Symptom**:
|
||||
```text
|
||||
FAILED tests/test_issue55_agent_homes.py::test_l1_every_path_variable_the_code_reads_is_accounted_for
|
||||
AssertionError: path variables the adapters/lib.sh read but conftest._isolate_agent_homes does not drop: ['XDG_DATA_HOME']
|
||||
```
|
||||
- **Root Cause & Linux Isolation Impact**:
|
||||
- `test_issue55_agent_homes.py` scans `.agents/skills/lib_py/agents/*.py`, `lib_py/paths.py`, and `lib.sh` for any `*_DIR` / `*_HOME` environment variable read by the adapters and requires every such variable to be accounted for in `tests/_agent_home_guard.py` (`DROPPED_VARS` or `LEFT_ALONE`).
|
||||
- Furthermore, `XDG_DATA_HOME` **must** be in `DROPPED_VARS` (not `LEFT_ALONE`): in `opencode.py:95`, `own = os.path.realpath(ctx.home_dir or "") == os.path.realpath(os.path.expanduser("~"))`. When a test isolates `HOME` via `monkeypatch.setenv("HOME", str(tmp_path))` (such as `mam_sandbox`), `os.path.expanduser("~")` equals `ctx.home_dir` (`own` is `True`). On a Linux machine where `XDG_DATA_HOME` is set in the outer shell environment, if `conftest._isolate_agent_homes` does not drop `XDG_DATA_HOME`, `_data_home(ctx)` would use the outer environment's real `XDG_DATA_HOME` instead of `<tmp_path>/.local/share`!
|
||||
- **Required Fix**:
|
||||
Add `"XDG_DATA_HOME"` to `DROPPED_VARS` in `tests/_agent_home_guard.py:21` (and optionally honour `ambient.get("XDG_DATA_HOME")` for `"opencode"` in `watch_dirs(ambient)` at `tests/_agent_home_guard.py:48`):
|
||||
```python
|
||||
DROPPED_VARS = ("HOME_DIR", "CLAUDE_PROJECT_DIR", "CLAUDE_CONFIG_DIR", "CODEX_HOME", "XDG_DATA_HOME", "LOCAL_BIN")
|
||||
```
|
||||
*(Verified in an isolated `/tmp` copy: adding `"XDG_DATA_HOME"` to `DROPPED_VARS` makes both `tests/test_issue55_agent_homes.py` (11/11) and `tests/test_issue60_opencode_v2.py` (15/15, including `test_the_data_dir_follows_the_callers_xdg_data_home_only_for_its_own_home` which sets `XDG_DATA_HOME` inside the test) pass cleanly).*
|
||||
|
||||
---
|
||||
|
||||
## 3. Verification Summary
|
||||
|
||||
- `tests/test_issue60_opencode_v2.py`: **15 passed** (including `test_real_opencode_2x_contract` against `opencode v2.0.32`)
|
||||
- `tests/test_a4_adapter_contract.py`: **33 passed**
|
||||
- `tests/test_tier2_component.py -k opencode`: **1 passed** (`test_comp_opencode_create_and_reconcile_and_stop`)
|
||||
- `tests/test_issue31_purge_residual.py -k opencode`: **1 passed** (`test_opencode_purge_leaves_no_rows`)
|
||||
- `tests/test_issue55_agent_homes.py`: **10 passed, 1 failed** (`test_l1_every_path_variable_the_code_reads_is_accounted_for`)
|
||||
- Verification worktree `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261018T064723Z-79133f/1` verified clean (`git status -s` empty, no `.mam` directory created).
|
||||
|
||||
[VERDICT: NOT PASS]
|
||||
@@ -0,0 +1,43 @@
|
||||
# Review 4709b38f — Issue #60, opencode 2.x
|
||||
|
||||
Reviewed only the snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261018T055915Z-abe16e/1` (`loop-20261018T055915Z-abe16e`). Head `6b4b96fa1875862ef3bed830fe7b04166b1fad58`, tree `9cfb2106081eba96b58cc6b7573031de0acba399`, base `74333f87d0c02888c2b5b281e912d97e74348c01` is an ancestor. Three commits, 13 files, +460/−275. `git diff --check` is clean. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane main is `74333f87d0c02888c2b5b281e912d97e74348c01`, porcelain empty. No product file was edited.
|
||||
|
||||
Host tools: `/usr/bin/python3` 3.9.6, the control-plane venv the same 3.9.6, `/bin/bash` 3.2.57. `bash -n` accepts `create_session.sh`, `resume_session.sh`, `update_yaml_resumed.sh`, `reconcile.sh`, and `status.sh`. `opencode --version` in a temporary home, with `XDG_DATA_HOME`, `XDG_STATE_HOME`, `XDG_CONFIG_HOME`, and `XDG_CACHE_HOME` unset, printed `opencode v2.0.32`.
|
||||
|
||||
The user's background service stayed up for the whole review. Its pid is 86524, the command is `opencode serve --service`, and the service file mtime stayed 1792246838. The real database mtime stayed 1792247140. Nothing in this review called `session delete`, `service stop`, or an API against that database. After the tests the only `opencode` binary still running was that service.
|
||||
|
||||
## What this Mac's database already stores
|
||||
|
||||
A read-only open of `~/.local/share/opencode/opencode.db` shows a migrated database. `session_v2` and `session` each have 4 rows. Every `session_v2` row has `parent_id` NULL, and every id matches `[A-Za-z0-9_]`. The 2.x tables present are `session_v2`, `session_message`, `session_inbox`, `session_pending`, `event`, and `event_sequence`. The 1.x tables a migration keeps are also present: `session`, `message`, `part`, `todo`, and `session_share`. `todo` and `session_share` are keyed by `session_id`. Column names only were read. No row body was printed.
|
||||
|
||||
That matches the adapter. `discover` reads `session_v2` where `parent_id IS NULL` (`opencode.py:220`). A child shares the workspace directory and can be newer than the TUI session, so drift C in `reconcile.sh:966-968` uses the same filter. `verify_artifact` still accepts a known child id (`opencode.py:115`). `status.sh` checks `session_v2` in both copies of `resume_on_disk`.
|
||||
|
||||
## Purge scope
|
||||
|
||||
`purge_artifacts` calls `opencode session delete --standalone -- <id>` only when `session_v2` contains that id (`opencode.py:136-141`). The fake CLI, and the real 2.0.32 contract below, delete that id and its children from the 2.x tables and leave the other session alone. A missing id does not invoke the CLI (`test_purge_does_not_run_opencode_for_a_session_it_does_not_hold`). A missing binary or a non-zero delete writes a `WARN` and leaves the `session_v2` row for `residual_artifacts` (`opencode.py:143-149`, `test_a_failed_delete_is_warned_and_reported`).
|
||||
|
||||
The SQL that follows deletes `message`, `part`, and `session` (and the plural names) for that same id. It does not delete `todo` or `session_share`. `db_rows_naming` reports any remaining `session_id` or `aggregate_id` rows, so a migrated `todo` stays visible: `test_opencode_residual_reports_event_rows` expects residual `[<db> (todo.session_id: 1 rows)]` and eight entries for the other session. `session_share.session_id` is on that same report path. The append of `sqlite rows for session` happens when one of those 1.x tables exists (`opencode.py:172-173`), including when the delete matched no row. A fresh 2.x database has none of those tables, and the real contract asserts the purged list is only the session-delete line.
|
||||
|
||||
## Spawn, resume, and the real binary
|
||||
|
||||
`spawn_spec` is `opencode --auto --standalone`. `resume_spec` with a materialized id is `opencode --session <id> --auto --standalone` (`opencode.py:198-204`). The same strings are the `opencode` arms in `create_session.sh`, `resume_session.sh`, and `update_yaml_resumed.sh`. `test_spawn_and_resume_never_pass_agent_and_run_standalone` reads those three scripts and rejects a line that still contains `--agent`. Live skill files and `docs/` have no `opencode db path` and no `--agent build`. `VERSIONS.md`, `bug-report/`, and `.agents/reports/` still describe the 1.x command; the brief leaves those historical notes in place. `create_session.sh` still treats `auth.json` or a provider key as enough, and otherwise accepts `opencode --version`. That check was left as it was, and `--version` exits 0 on this install.
|
||||
|
||||
`_run` points `HOME` and `XDG_DATA_HOME` at the discovery home before `debug paths` or `session delete` (`opencode.py:80-87`). `_resolve_db` reads the `db` line. The fallback is `<home>/.local/share/opencode/opencode.db`.
|
||||
|
||||
The opt-in contract `test_real_opencode_2x_contract` ran here, because 2.0.32 is installed. It used a pytest temporary home and tmp, unset the four `XDG_*` variables, and called only `--help`, `api --standalone`, and `session delete --standalone`. `--help` contains `--standalone` and `--session`. Discover returned the parent id. `verify_artifact` on the child was true. Purge of the parent cleared parent and child residuals. A second delete exited 1 with `Session not found`. No model prompt was sent.
|
||||
|
||||
This review did not start a herdr pane, so it did not drive create, a first TUI message, drift C, stop, and resume as one live chain. The pieces of that chain are the command lines above, drift C's `session_v2` query, and `verify_session_uuid`, which calls the adapter. Stop's graceful `/exit` then kill-session then SIGKILL path is unchanged. `--standalone` is what makes the server a child of that TUI. The id charset on this database has no shell metacharacters, so the unquoted `$UUID` in `resume_session.sh` is safe for the ids stored here.
|
||||
|
||||
The new code uses `HOME`, `XDG_DATA_HOME`, sqlite, and `subprocess`. Nothing in it is macOS-only. This host is macOS, so Linux was not executed here.
|
||||
|
||||
## Tests
|
||||
|
||||
Pytest used the control-plane venv, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp` `/tmp/mam4709-4709b38f-pybase`. Inherited `HERDR_*`, `MAM_*`, `MQTT_*`, `SKS_*`, `WORKSPACE_ROOT`, `DELEGATE_JOB_LOGS_DIR`, `HOME_DIR`, `CLAUDE_PROJECT_DIR`, `CLAUDE_CONFIG_DIR`, `CLAUDE_CODE_TMPDIR`, `CODEX_HOME`, and `PYTHONPATH` were dropped.
|
||||
|
||||
On the snapshot, 44 tests passed in 7.56 s: `tests/test_issue60_opencode_v2.py` (including the real-binary contract), `tests/test_a4_adapter_contract.py`, and `tests/test_issue31_purge_residual.py::test_opencode_residual_reports_event_rows`. No skip and no error.
|
||||
|
||||
A separate clone whose `discover` query read `session` instead of `session_v2` failed both `test_discover_and_verify_read_session_v2_and_skip_child_sessions` and `test_a_fresh_2x_database_has_no_session_table_and_still_works` (`assert [] == ['ses_main']`), in 1.07 s. A fresh 2.x database has no `session` table, the query raises, and `discover` returns `[]`. The clone was discarded. Snapshot porcelain stayed empty.
|
||||
|
||||
After the report, snapshot porcelain is still empty, the snapshot still has no `.mam`, and control-plane porcelain is still empty at `74333f87`. The herdr config mtime is still 1784456394. The opencode database mtime is still 1792247140 and service pid 86524 is still `opencode serve --service`.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,53 @@
|
||||
# Review 5ab36507 — Issue #60, second pass
|
||||
|
||||
Reviewed only the snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261018T064723Z-79133f/1` (`loop-20261018T064723Z-79133f`). Head `cb5f439cce41a2741a6b9974a308a765e50f00ad`, tree `cfec95403cbde10a6340b9007e1246c83be461cd`, base `74333f87d0c02888c2b5b281e912d97e74348c01` is an ancestor. Four commits, 14 files, +689/−300. `git diff --check` is clean. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane main is `74333f87d0c02888c2b5b281e912d97e74348c01`, porcelain empty. No product file was edited.
|
||||
|
||||
The new commit on top of the first-pass head `6b4b96f` is `cb5f439` (`fix(opencode): a purge takes its child sessions' 1.x rows, the background service alone holds no session, XDG_DATA_HOME is followed`). The checks below are against the whole base..head range.
|
||||
|
||||
Host tools: `/usr/bin/python3` 3.9.6, the control-plane venv the same 3.9.6, `/bin/bash` 3.2.57. `bash -n` accepts `create_session.sh`, `resume_session.sh`, `update_yaml_resumed.sh`, `reconcile.sh`, and `status.sh`. `opencode --version` in a temporary home, with `XDG_DATA_HOME`, `XDG_STATE_HOME`, `XDG_CONFIG_HOME`, and `XDG_CACHE_HOME` unset, printed `opencode v2.0.32`.
|
||||
|
||||
The user's background service stayed up for the whole review. Its pid is 86524, the command is `opencode serve --service`, and both service-file mtimes stayed 1792246838. The real database mtime stayed 1792247140, and a read-only count stayed at 4 `session` rows and 4 `session_v2` rows. `lsof` on that database shows only pid 86524, and that command line contains `serve --service`. Nothing in this review called `session delete`, `service stop`, or an API against that database.
|
||||
|
||||
## What cb5f439 changes
|
||||
|
||||
`purge_artifacts` records descendants before `opencode session delete` (`opencode.py:162`). `_descendants` walks `parent_id` in `session_v2` and, when that column exists, in the 1.x `session` table, at every depth, through a read-only URI (`opencode.py:195-217`). The SQL that follows deletes `message`, `part`, `todo`, `session_share`, and `session` (and the plural names) for the parent and those descendants (`opencode.py:178-190`). `todo` and `session_share` are keyed by `session_id` on this machine's migrated database; the purge deletes those rows and the report path is what remains. A fresh 2.x database has none of those tables, and the real contract still expects the purged list to be only the session-delete line.
|
||||
|
||||
`residual_artifacts` asks `db_rows_naming` for the parent and for each descendant still on record, and suffixes the descendant lines with `[child session <id>]` (`opencode.py:229-237`). `test_purge_takes_the_1x_rows_of_the_child_sessions_too` seeds a parent, a child, a grandchild, and an unrelated session, and expects the unrelated session to keep six entries. `test_a_failed_delete_reports_the_child_sessions_left` expects the child `session_v2` row, with that suffix, when the binary is missing.
|
||||
|
||||
`conversation_in_use` still treats a command line that names the id as in use. A process that holds the shared database counts as well, except a command line that contains `serve --service` (`opencode.py:240-262`). On this Mac that is the only holder of the real database, so a purge of a session in that file is not refused solely because the user's service is running. Any other holder still returns true, and the purge CLI still exits 4 before it deletes (`agents/__main__.py:146-148`). `lsof` or `ps` raising is treated as in use. When `lsof` is absent the database-holder check returns false; the command-line check still runs. This host has `lsof`.
|
||||
|
||||
`_data_home` returns the caller's `XDG_DATA_HOME` when that variable is set and `ctx.home_dir` is the caller's own home (realpath), and `<home>/.local/share` for any other home (`opencode.py:87-94`). `_run` puts that value in the child environment (`opencode.py:103-106`). `test_the_data_dir_follows_the_callers_xdg_data_home_only_for_its_own_home` covers both sides and the unset case.
|
||||
|
||||
`resident_children` is `('opencode',)` (`opencode.py:70-73`). `background_children` records a process's children before it skips a resident name (`stall_watch.py:126-132`), so the `opencode serve --stdio` child is idle and a tool under that child still counts. `test_the_standalone_server_is_a_resident_child_but_its_tools_are_work` passed here. The Linux `_proc_table` reads `/proc`; the name check also uses `argv[0]`, so a process whose comm is the binary and whose argv0 is `opencode` still matches. This host took the `ps` path.
|
||||
|
||||
The real-binary test binds `_bin` to the absolute path it already resolved (`test_issue60_opencode_v2.py` `test_real_opencode_2x_contract`), so the later calls do not depend on `PATH` under the temporary home. Drift C in `reconcile.sh:966-968` keeps `parent_id IS NULL`. `test_comp_opencode_create_and_reconcile_and_stop` inserts a newer child in the same directory and requires the pinned id to be the parent. That test passed.
|
||||
|
||||
## Tests that catch a revert
|
||||
|
||||
On the snapshot, 50 tests passed in 65.40 s: `tests/test_issue60_opencode_v2.py` (including the real-binary contract), `tests/test_a4_adapter_contract.py`, `tests/test_issue31_purge_residual.py::test_opencode_purge_leaves_no_rows`, and `tests/test_tier2_component.py::test_comp_opencode_create_and_reconcile_and_stop`. No skip and no error. Pytest used the control-plane venv, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp` `/tmp/mam5ab3-5ab36507-pybase`. Inherited `HERDR_*`, `MAM_*`, `MQTT_*`, `SKS_*`, `WORKSPACE_ROOT`, `DELEGATE_JOB_LOGS_DIR`, `HOME_DIR`, `CLAUDE_PROJECT_DIR`, `CLAUDE_CONFIG_DIR`, `CLAUDE_CODE_TMPDIR`, `CODEX_HOME`, `PYTHONPATH`, and the four `XDG_*` variables were dropped.
|
||||
|
||||
A separate clone, discarded afterward, failed three probes:
|
||||
|
||||
- SQL limited to the parent id: `test_purge_takes_the_1x_rows_of_the_child_sessions_too` failed in 0.66 s. The purged list was only the session-delete line.
|
||||
- Every database holder treated as in use: `test_the_background_service_alone_does_not_hold_a_session` failed in 0.96 s (`True` where the service-shaped holder must leave the session free).
|
||||
- Drift C without `parent_id IS NULL`: `test_comp_opencode_create_and_reconcile_and_stop` failed in 17.08 s. `opencode_session_id_own` stayed unset, so the parent was not pinned.
|
||||
|
||||
Snapshot porcelain stayed empty.
|
||||
|
||||
The real contract ran under a pytest temporary home and tmp, with the four `XDG_*` variables unset, and called `--help`, `api --standalone`, and `session delete --standalone`. `--help` contains `--standalone` and `--session`. Discover returned the parent. `verify_artifact` on the child was true. Purge cleared parent and child residuals. A second delete exited 1 with `Session not found`. No model prompt was sent. After that run the real database still had 4 and 4 rows.
|
||||
|
||||
## Boundaries
|
||||
|
||||
`status.sh` `resume_on_disk` queries `session_v2` once it has opened a database (`status.sh:127` and the second copy at `:337`). The path it opens is still `$home/.local/share/opencode` (then `storage.db`, then a `project/*.db` walk). Drift C asks the adapter, which follows `XDG_DATA_HOME` for the caller's own home. On this host `XDG_DATA_HOME` is unset, so both look at `~/.local/share/opencode/opencode.db`. A caller who sets `XDG_DATA_HOME` would see drift C and `resume_on_disk` use different files.
|
||||
|
||||
The append of `sqlite rows for session` happens when one of the 1.x tables exists (`opencode.py:188-190`), including when the delete matched no row. `_run` copies the rest of the environment and sets `HOME`, `HOME_DIR`, and `XDG_DATA_HOME`. The contract test clears `XDG_STATE_HOME`, `XDG_CONFIG_HOME`, and `XDG_CACHE_HOME` before it starts the binary.
|
||||
|
||||
This review did not start a herdr pane, so it did not drive create, a first TUI message, drift C, stop, and resume as one live chain against a real TUI. The tier-2 test drives create, drift C, and stop against the mock herdr and the fake CLI, in a sandbox home. Stop's graceful `/exit` then kill-session then SIGKILL path is unchanged. `--standalone` is what makes the server a child of that TUI. The id charset on this database matches `[A-Za-z0-9_]`, so the unquoted `$UUID` in `resume_session.sh` is safe for the ids stored here.
|
||||
|
||||
Live skill files and `docs/` have no `opencode db path` and no `--agent build`. `VERSIONS.md`, `bug-report/`, and `.agents/reports/` still describe the 1.x command; the brief leaves those historical notes in place. `create_session.sh` still treats `auth.json` or a provider key as enough, and otherwise accepts `opencode --version`. That check was left as it was, and `--version` exits 0 on this install.
|
||||
|
||||
The new code uses `HOME`, `XDG_DATA_HOME`, sqlite, `subprocess`, `lsof`, and `ps`. `stall_watch` reads `/proc` when that directory exists. Nothing in the new adapter code is macOS-only. This host is macOS, so Linux was not executed here.
|
||||
|
||||
After the report, snapshot porcelain is still empty, the snapshot still has no `.mam`, and control-plane porcelain is still empty at `74333f87`. The herdr config mtime is still 1784456394. The opencode database mtime is still 1792247140 and service pid 86524 is still `opencode serve --service`.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,37 @@
|
||||
# Review d384cdc0 — Issue #60, third pass
|
||||
|
||||
Reviewed only the snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261018T073639Z-b5cb7f/1` (`loop-20261018T073639Z-b5cb7f`). Head `0e05b1b6777ceb837066454ac688e04a0f5d14a0`, tree `c5cb1b16af283b3fdb447bafb865ba1abbfbb5a8`, base `74333f87d0c02888c2b5b281e912d97e74348c01` is an ancestor. Five commits, 15 files, +705/−330. `git diff --check` is clean. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane main is `74333f87d0c02888c2b5b281e912d97e74348c01`, porcelain empty. No product file was edited.
|
||||
|
||||
The new commit on top of `cb5f439` is `0e05b1b`. It changes three files: `tests/_agent_home_guard.py`, `.agents/skills/multi-agent-mux-status/scripts/status.sh` (both copies of `resume_on_disk`), and the `--version` probe in `tests/test_issue60_opencode_v2.py`.
|
||||
|
||||
Host tools: the control-plane venv is Python 3.9.6, `/bin/bash` is 3.2.57. `bash -n` accepts `status.sh` and `create_session.sh`. `opencode --version` in a temporary home, with an environment of only `PATH`, `HOME`, and `TMPDIR`, printed `opencode v2.0.32`.
|
||||
|
||||
The user's background service stayed up. Its pid is 86524, the command is `opencode serve --service`, and both service-file mtimes stayed 1792246838. The real database mtime stayed 1792247140, with 4 `session` rows and 4 `session_v2` rows. The real `opencode.log` mtime stayed 1792308195 and its size stayed the same. The herdr config mtime stayed 1784456394.
|
||||
|
||||
## What 0e05b1b fixes
|
||||
|
||||
`_agent_home_guard.DROPPED_VARS` now includes `XDG_DATA_HOME` (`_agent_home_guard.py:21`). `conftest._isolate_agent_homes` drops that tuple for every test, and `pytest_configure` records the value first so the session guard can watch it. `watch_dirs` uses `<XDG_DATA_HOME>/opencode` when the recorded environment has the variable, and `<home>/.local/share/opencode` otherwise (`_agent_home_guard.py:48-49`).
|
||||
|
||||
That matches the second-pass failure. The adapter follows the caller's `XDG_DATA_HOME` when `ctx.home_dir` is the caller's own home. A sandbox that only changed `HOME` still inherited the runner's `XDG_DATA_HOME`, so discovery and purge resolved the runner's data directory. Dropping the variable makes `HOME` govern, which is what `_data_home` does when the variable is unset.
|
||||
|
||||
`test_l1_every_path_variable_the_code_reads_is_accounted_for` scans adapters for `*_HOME` reads. `XDG_DATA_HOME` is one of them (`opencode.py` `os.environ.get("XDG_DATA_HOME")`). A clone whose `DROPPED_VARS` omitted it failed that test in 0.04 s with `['XDG_DATA_HOME']`. The suite below was started with `XDG_DATA_HOME` pointed at an empty directory `/tmp/mamd384-d384cdc0-xdg`. After 65 passed tests that directory was still empty.
|
||||
|
||||
`status.sh` `resume_on_disk`, in the `--json` block and in the text block, asks `get_adapter('opencode')._resolve_db(DiscoveryContext(..., home_dir=home))` (`status.sh:108-111` and `:305-308` in the second copy). `home` is `HOME_DIR`. `env_python` passes the parent environment through, so a set `XDG_DATA_HOME` reaches `_data_home` when `HOME_DIR` is the caller's home. A copy of the tree, with the fake opencode on `PATH` and the only database under a separate `XDG_DATA_HOME`, returned exit 0. `--json` `resume_state` was `yes` for `ses_probe` and `MISSING` for an id the database does not hold. Text mode exited 0 and the session's line contained `yes`. `~/.local/share/opencode/opencode.db` under that sandbox home was not created. The existing status checks in the same suite (`test_status_json_schema_fields`, `test_status_text_headers_presence`, and the claude and agy `resume_on_disk` helpers) also exited 0.
|
||||
|
||||
`_real_opencode` runs `--version` inside `tempfile.TemporaryDirectory` with `HOME` and `TMPDIR` set to that directory and no `XDG_*` variables (`test_issue60_opencode_v2.py:298-300`). The same shape, run on its own, wrote a log only under that temporary home. The real log was unchanged. The opt-in contract ran as part of the 65 (no skip).
|
||||
|
||||
## Suite
|
||||
|
||||
Pytest used the control-plane venv, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp` `/tmp/mamd384-d384cdc0-pybase`. Inherited `HERDR_*`, `MAM_*`, `MQTT_*`, `SKS_*`, `WORKSPACE_ROOT`, `DELEGATE_JOB_LOGS_DIR`, `HOME_DIR`, `CLAUDE_*`, `CODEX_HOME`, and `PYTHONPATH` were dropped. `XDG_DATA_HOME` was the empty sentinel above. `XDG_STATE_HOME`, `XDG_CONFIG_HOME`, and `XDG_CACHE_HOME` were unset.
|
||||
|
||||
65 passed in 68.75 s: `tests/test_issue55_agent_homes.py`, `tests/test_issue60_opencode_v2.py` (including the real-binary contract), `tests/test_a4_adapter_contract.py`, `tests/test_issue31_purge_residual.py::test_opencode_purge_leaves_no_rows`, `tests/test_tier2_component.py::test_comp_opencode_create_and_reconcile_and_stop`, and the four status cases named above. No skip and no error. Snapshot porcelain stayed empty.
|
||||
|
||||
`opencode debug paths` in a temporary home prints the `db` line under that home's `.local/share/opencode` and creates `log/opencode.log` there. It does not create `opencode.db`. So an opencode row in `status.sh` now runs that command, and a real status check appends the log under the data directory `_resolve_db` is about to open. The RESUME value and the exit code measured above stay 0 / `yes` / `MISSING`. The database file is untouched by the path lookup.
|
||||
|
||||
The earlier commits are unchanged in this pass: purge still collects descendants before `session delete` and deletes the 1.x rows of parent and children, `conversation_in_use` still ignores only a `serve --service` holder, drift C still requires `parent_id IS NULL`, and spawn/resume stay `--auto --standalone`. The tier-2 test in this suite pins the parent when a newer child shares the directory, against the mock herdr. This review did not drive a live herdr pane through create, a first TUI message, stop, and resume.
|
||||
|
||||
The new code uses the environment, sqlite, and `subprocess`. Nothing added in `0e05b1b` is macOS-only. This host is macOS, so Linux was not executed here.
|
||||
|
||||
After the report, snapshot porcelain is still empty, the snapshot still has no `.mam`, and control-plane porcelain is still empty at `74333f87`. The herdr config mtime is still 1784456394. The opencode database mtime is still 1792247140, the opencode log mtime is still 1792308195, and service pid 86524 is still `opencode serve --service`.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -29,7 +29,7 @@ export WORKSPACE_ROOT
|
||||
# Framework semantic version. Single runtime source of truth; kept in lockstep
|
||||
# with VERSIONS.md and the 8 SKILL.md frontmatters by tests/test_version_consistency.py.
|
||||
# NOTE: unlike other MAM_* variables this one is intentionally NOT env-overridable.
|
||||
MAM_VERSION="4.9.5"
|
||||
MAM_VERSION="4.9.6"
|
||||
export MAM_VERSION
|
||||
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import os, sys, sqlite3, shutil
|
||||
import os, sys, sqlite3, shutil, subprocess
|
||||
from urllib.parse import quote
|
||||
from typing import Optional, Any, List
|
||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, scan_names, db_rows_naming
|
||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, scan_names, db_rows_naming, conversation_in_use
|
||||
from lib_py.verify_session import workspace_key
|
||||
|
||||
# Tables a 1.x opencode wrote and a migration to 2.x keeps but no longer
|
||||
# writes (`opencode session delete` leaves them), and their session column.
|
||||
_LEGACY_TABLES = (("message", "session_id"), ("messages", "session_id"), ("part", "session_id"),
|
||||
("parts", "session_id"), ("todo", "session_id"), ("session_share", "session_id"),
|
||||
("session", "id"), ("sessions", "id"))
|
||||
|
||||
_PROVIDER_ENV_VARS = (
|
||||
'ANTHROPIC_API_KEY',
|
||||
'OPENAI_API_KEY',
|
||||
@@ -59,56 +66,61 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
||||
# blanks after the ┃) or the `Build auto · <model>` status line.
|
||||
return r'^\s{20,}\S|^\s*(Build|Plan)\b.*·'
|
||||
|
||||
def _resolve_db(self, ctx: DiscoveryContext) -> Optional[str]:
|
||||
# 1. Try opencode CLI "opencode db path"
|
||||
try:
|
||||
import subprocess
|
||||
@property
|
||||
def resident_children(self) -> tuple:
|
||||
# --standalone keeps its server, `opencode serve --stdio --port 0`, as
|
||||
# the TUI's child while idle (measured on 2.0.32, Issue #60).
|
||||
return ('opencode',)
|
||||
|
||||
def _bin(self, ctx: DiscoveryContext) -> Optional[str]:
|
||||
bin_path = shutil.which("opencode")
|
||||
if not bin_path:
|
||||
if bin_path:
|
||||
return bin_path
|
||||
for candidate_bin in [
|
||||
f"{ctx.home_dir}/.opencode/bin/opencode",
|
||||
os.path.expanduser("~/.opencode/bin/opencode"),
|
||||
]:
|
||||
if os.path.exists(candidate_bin):
|
||||
bin_path = candidate_bin
|
||||
break
|
||||
if bin_path:
|
||||
return candidate_bin
|
||||
return None
|
||||
|
||||
def _data_home(self, ctx: DiscoveryContext) -> str:
|
||||
"""The XDG data dir opencode uses for ctx's home: the caller's
|
||||
XDG_DATA_HOME when ctx is the caller's own home, else <home>/.local/share."""
|
||||
ambient = os.environ.get("XDG_DATA_HOME")
|
||||
own = os.path.realpath(ctx.home_dir or "") == os.path.realpath(os.path.expanduser("~"))
|
||||
if ambient and (own or not ctx.home_dir):
|
||||
return ambient
|
||||
return os.path.join(ctx.home_dir, ".local/share")
|
||||
|
||||
def _run(self, ctx: DiscoveryContext, args: list, timeout: int):
|
||||
"""Run the opencode CLI against ctx's home (its data dir follows HOME and
|
||||
XDG_DATA_HOME); None when there is no binary."""
|
||||
bin_path = self._bin(ctx)
|
||||
if not bin_path:
|
||||
return None
|
||||
env = dict(os.environ)
|
||||
if ctx.home_dir:
|
||||
env["HOME"] = ctx.home_dir
|
||||
env["HOME_DIR"] = ctx.home_dir
|
||||
env["XDG_DATA_HOME"] = os.path.join(ctx.home_dir, ".local/share")
|
||||
env["XDG_DATA_HOME"] = self._data_home(ctx)
|
||||
cwd = ctx.workspace or ctx.cwd or os.getcwd()
|
||||
res = subprocess.run(
|
||||
[bin_path, "db", "path"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
env=env,
|
||||
cwd=cwd if os.path.exists(cwd) else None
|
||||
)
|
||||
if res.returncode == 0:
|
||||
out = res.stdout.strip()
|
||||
if out and os.path.exists(out):
|
||||
return out
|
||||
return subprocess.run([bin_path] + args, capture_output=True, text=True, timeout=timeout,
|
||||
env=env, cwd=cwd if os.path.exists(cwd) else None)
|
||||
|
||||
def _resolve_db(self, ctx: DiscoveryContext) -> Optional[str]:
|
||||
# opencode 2.x prints its paths with `debug paths` (the 1.x `db path`
|
||||
# is gone: it prints the help and exits 0). Fallback: the default.
|
||||
try:
|
||||
res = self._run(ctx, ["debug", "paths"], timeout=5)
|
||||
if res is not None and res.returncode == 0:
|
||||
for line in res.stdout.splitlines():
|
||||
parts = line.split(None, 1)
|
||||
if len(parts) == 2 and parts[0] == "db":
|
||||
return parts[1].strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Filesystem fallbacks
|
||||
base_dir = f"{ctx.home_dir}/.local/share/opencode"
|
||||
candidates = [
|
||||
f"{base_dir}/opencode.db",
|
||||
f"{base_dir}/storage.db",
|
||||
]
|
||||
for cand in candidates:
|
||||
if os.path.exists(cand):
|
||||
return cand
|
||||
if os.path.isdir(f"{base_dir}/project"):
|
||||
for root, _, files in os.walk(f"{base_dir}/project"):
|
||||
for f in files:
|
||||
if f.endswith(".db"):
|
||||
return os.path.join(root, f)
|
||||
return candidates[0]
|
||||
return os.path.join(self._data_home(ctx), "opencode", "opencode.db")
|
||||
|
||||
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
|
||||
db = self._resolve_db(ctx)
|
||||
@@ -119,44 +131,18 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
||||
if not db or not os.path.exists(db):
|
||||
return False
|
||||
try:
|
||||
# opencode 2.x keeps sessions in session_v2 (a 1.x `session` table
|
||||
# survives a migration but is no longer written).
|
||||
conn = sqlite3.connect(db)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("PRAGMA table_info(session)")
|
||||
cols = [col[1] for col in cursor.fetchall()]
|
||||
if not cols:
|
||||
conn.close()
|
||||
return False
|
||||
|
||||
cwd_col = "directory" if "directory" in cols else ("cwd" if "cwd" in cols else None)
|
||||
time_col = "time_created" if "time_created" in cols else ("created_at" if "created_at" in cols else ("started_at" if "started_at" in cols else None))
|
||||
|
||||
query_cols = []
|
||||
if cwd_col:
|
||||
query_cols.append(cwd_col)
|
||||
if time_col:
|
||||
query_cols.append(time_col)
|
||||
query = "SELECT " + (", ".join(query_cols) if query_cols else "id") + " FROM session WHERE id=?"
|
||||
r = cursor.execute(query, (uuid,)).fetchone()
|
||||
r = conn.execute("SELECT directory, time_created FROM session_v2 WHERE id=?", (uuid,)).fetchone()
|
||||
conn.close()
|
||||
if not r:
|
||||
return False
|
||||
|
||||
found_cwd = None
|
||||
started_at = None
|
||||
idx = 0
|
||||
if cwd_col:
|
||||
found_cwd = r[idx]
|
||||
idx += 1
|
||||
if time_col:
|
||||
started_at = r[idx]
|
||||
idx += 1
|
||||
|
||||
found_cwd, started_at = r
|
||||
if ctx.epoch and started_at is not None:
|
||||
try:
|
||||
val = float(started_at)
|
||||
if time_col == "time_created" or val > 1e11:
|
||||
val = val / 1000.0
|
||||
if val < float(ctx.epoch):
|
||||
if float(started_at) / 1000.0 < float(ctx.epoch):
|
||||
return False
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
@@ -169,45 +155,126 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
||||
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||
purged = []
|
||||
db = self._resolve_db(ctx)
|
||||
if db and os.path.exists(db):
|
||||
if not db or not os.path.exists(db):
|
||||
return purged
|
||||
# The child sessions are taken before opencode deletes them with
|
||||
# their parent: their 1.x rows are this purge's to remove too.
|
||||
ids = [uuid] + self._descendants(db, uuid)
|
||||
if self._has_session(db, uuid):
|
||||
# opencode deletes a 2.x session with its child sessions and every
|
||||
# 2.x row it keeps for them; --standalone so no background service
|
||||
# is started for it.
|
||||
try:
|
||||
res = self._run(ctx, ["session", "delete", "--standalone", "--", uuid], timeout=60)
|
||||
if res is None:
|
||||
sys.stderr.write(f"WARN: purge opencode: no opencode binary to delete session {uuid}\n")
|
||||
elif res.returncode == 0:
|
||||
purged.append(f"opencode session (with its child sessions): {uuid}")
|
||||
else:
|
||||
sys.stderr.write(f"WARN: purge opencode: session delete {uuid} exited {res.returncode}: "
|
||||
f"{(res.stderr or res.stdout).strip()}\n")
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"WARN: purge opencode: session delete {uuid} failed: {e}\n")
|
||||
# The rows a 1.x opencode left (a migration keeps those tables).
|
||||
try:
|
||||
conn = sqlite3.connect(db)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
tables = {row[0] for row in cursor.fetchall()}
|
||||
if "message" in tables:
|
||||
conn.execute("DELETE FROM message WHERE session_id=?", (uuid,))
|
||||
if "messages" in tables:
|
||||
conn.execute("DELETE FROM messages WHERE session_id=?", (uuid,))
|
||||
if "part" in tables:
|
||||
conn.execute("DELETE FROM part WHERE session_id=?", (uuid,))
|
||||
if "parts" in tables:
|
||||
conn.execute("DELETE FROM parts WHERE session_id=?", (uuid,))
|
||||
if "session" in tables:
|
||||
conn.execute("DELETE FROM session WHERE id=?", (uuid,))
|
||||
if "sessions" in tables:
|
||||
conn.execute("DELETE FROM sessions WHERE id=?", (uuid,))
|
||||
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
legacy = [(t, c) for t, c in _LEGACY_TABLES if t in tables]
|
||||
for t, c in legacy:
|
||||
for i in ids:
|
||||
conn.execute(f'DELETE FROM "{t}" WHERE "{c}"=?', (i,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
purged.append(f"sqlite rows for session: {uuid}")
|
||||
if legacy:
|
||||
kids = f" and {len(ids) - 1} child session(s)" if len(ids) > 1 else ""
|
||||
purged.append(f"sqlite rows for session: {uuid}{kids}")
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"WARN: purge opencode db records failed: {e}\n")
|
||||
return purged
|
||||
|
||||
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||
# Issue #31: the purge leaves event-sourced rows (event, todo, ...);
|
||||
# deleting those is not known to be safe, so they are reported.
|
||||
found = scan_names(f"{ctx.home_dir}/.local/share/opencode", uuid)
|
||||
return found + db_rows_naming(self._resolve_db(ctx), uuid, ("session_id", "aggregate_id"),
|
||||
id_tables=(("session", "id"),))
|
||||
def _descendants(self, db: str, uuid: str) -> list:
|
||||
"""Child sessions (any depth) of uuid in session_v2 and a 1.x session table."""
|
||||
found, todo = [], [uuid]
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{quote(os.path.abspath(db))}?mode=ro", uri=True)
|
||||
except Exception:
|
||||
return found
|
||||
try:
|
||||
tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
sources = [t for t in ("session_v2", "session") if t in tables
|
||||
and "parent_id" in {r[1] for r in conn.execute(f'PRAGMA table_info("{t}")')}]
|
||||
while todo:
|
||||
cur = todo.pop()
|
||||
for t in sources:
|
||||
for (cid,) in conn.execute(f'SELECT id FROM "{t}" WHERE parent_id=?', (cur,)):
|
||||
if cid and cid != uuid and cid not in found:
|
||||
found.append(cid)
|
||||
todo.append(cid)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
return found
|
||||
|
||||
def _has_session(self, db: str, uuid: str) -> bool:
|
||||
try:
|
||||
conn = sqlite3.connect(db)
|
||||
try:
|
||||
return conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (uuid,)).fetchone() is not None
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||
# Issue #31: whatever still names the session or a child session still
|
||||
# on record (a failed `session delete`) is reported.
|
||||
db = self._resolve_db(ctx)
|
||||
found = scan_names(os.path.join(self._data_home(ctx), "opencode"), uuid)
|
||||
for i in [uuid] + (self._descendants(db, uuid) if db and os.path.isfile(db) else []):
|
||||
rows = db_rows_naming(db, i, ("session_id", "aggregate_id"),
|
||||
id_tables=(("session", "id"), ("session_v2", "id")))
|
||||
found += rows if i == uuid else [f"{r} [child session {i}]" for r in rows]
|
||||
return found
|
||||
|
||||
def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool:
|
||||
"""A process naming the session, or an opencode process holding the
|
||||
shared database. The per-user background service (`serve --service`)
|
||||
holds it for every session and runs none of MAM's (MAM spawns
|
||||
--standalone), so it alone does not make a session in use."""
|
||||
if conversation_in_use(uuid, []):
|
||||
return True
|
||||
db = self._resolve_db(ctx)
|
||||
if not db or not os.path.exists(db) or not shutil.which("lsof"):
|
||||
return False
|
||||
try:
|
||||
pids = subprocess.run(["lsof", "-t", db], capture_output=True, text=True, timeout=10).stdout.split()
|
||||
except Exception:
|
||||
return True
|
||||
for pid in pids:
|
||||
try:
|
||||
cmd = subprocess.run(["ps", "-o", "command=", "-p", pid], capture_output=True, text=True,
|
||||
timeout=10).stdout
|
||||
except Exception:
|
||||
return True
|
||||
if cmd.strip() and "serve --service" not in cmd:
|
||||
return True
|
||||
return False
|
||||
|
||||
def in_use_hints(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||
return super().in_use_hints(uuid, ctx) + [
|
||||
f"another opencode session holding {self._resolve_db(ctx)}: lsof {self._resolve_db(ctx)}"]
|
||||
|
||||
# opencode 2.x: the default agent is build (`--agent` is gone from the
|
||||
# TUI), and --standalone keeps the server a child of the pane's TUI
|
||||
# instead of the per-user background service.
|
||||
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
|
||||
return f"{binary} --auto --agent build"
|
||||
return f"{binary} --auto --standalone"
|
||||
|
||||
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
|
||||
if materialized and session_uuid:
|
||||
return f"{binary} --session {session_uuid} --auto --agent build"
|
||||
return f"{binary} --auto --agent build"
|
||||
return f"{binary} --session {session_uuid} --auto --standalone"
|
||||
return f"{binary} --auto --standalone"
|
||||
|
||||
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
|
||||
if any(os.environ.get(v) for v in _PROVIDER_ENV_VARS):
|
||||
@@ -221,38 +288,20 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
||||
if not db or not os.path.exists(db):
|
||||
return []
|
||||
try:
|
||||
conn = sqlite3.connect(db)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("PRAGMA table_info(session)")
|
||||
cols = [col[1] for col in cursor.fetchall()]
|
||||
if not cols:
|
||||
conn.close()
|
||||
return []
|
||||
|
||||
cwd_col = "directory" if "directory" in cols else ("cwd" if "cwd" in cols else None)
|
||||
time_col = "time_created" if "time_created" in cols else ("created_at" if "created_at" in cols else ("started_at" if "started_at" in cols else None))
|
||||
|
||||
query = "SELECT id FROM session"
|
||||
# Top-level sessions only: a subagent's session (parent_id set)
|
||||
# shares the directory and is newer than the one the TUI shows.
|
||||
query = "SELECT id FROM session_v2 WHERE parent_id IS NULL"
|
||||
params = []
|
||||
where_clauses = []
|
||||
if cwd_col and ctx.workspace:
|
||||
where_clauses.append(f"{cwd_col} = ?")
|
||||
if ctx.workspace:
|
||||
query += " AND directory = ?"
|
||||
params.append(ctx.workspace)
|
||||
if time_col and ctx.epoch:
|
||||
if time_col == "time_created":
|
||||
where_clauses.append(f"{time_col} >= ?")
|
||||
if ctx.epoch:
|
||||
query += " AND time_created >= ?"
|
||||
params.append(int(float(ctx.epoch) * 1000))
|
||||
else:
|
||||
where_clauses.append(f"{time_col} >= ?")
|
||||
params.append(ctx.epoch)
|
||||
query += " ORDER BY time_created DESC LIMIT 20"
|
||||
|
||||
if where_clauses:
|
||||
query += " WHERE " + " AND ".join(where_clauses)
|
||||
if time_col:
|
||||
query += f" ORDER BY {time_col} DESC"
|
||||
query += " LIMIT 20"
|
||||
|
||||
rows = cursor.execute(query, params).fetchall()
|
||||
conn = sqlite3.connect(db)
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
candidates = []
|
||||
for (cand,) in rows:
|
||||
|
||||
@@ -13,7 +13,7 @@ import sys
|
||||
from typing import List, Sequence
|
||||
|
||||
# Framework version locked with framework constants
|
||||
FRAMEWORK_VERSION = "4.9.5"
|
||||
FRAMEWORK_VERSION = "4.9.6"
|
||||
|
||||
CORE_MODULES: Sequence[str] = (
|
||||
# Standard library modules
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-create
|
||||
description: "Create a new agent session (claude, antigravity/agy) in a dedicated herdr session for context-preserving long-running work. Always creates a herdr session — never backgrounds with nohup/disown. Writes the new session to .mam/agent-sessions.yaml. Use when you want to start a fresh agent (no prior UUID) for a new project workspace."
|
||||
version: 4.9.5
|
||||
version: 4.9.6
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
@@ -213,7 +213,7 @@ if [ -z "$CMD_FULL" ]; then
|
||||
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;;
|
||||
hermes) CMD_FULL="${RESOLVED_BIN} --yolo --accept-hooks" ;;
|
||||
grok) CMD_FULL="${RESOLVED_BIN} --permission-mode bypassPermissions" ;;
|
||||
opencode) CMD_FULL="${RESOLVED_BIN} --auto --agent build" ;;
|
||||
opencode) CMD_FULL="${RESOLVED_BIN} --auto --standalone" ;;
|
||||
codex) CMD_FULL="${RESOLVED_BIN} --dangerously-bypass-approvals-and-sandbox" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-delegate-job
|
||||
description: "Delegate a unit of work to any autonomous agent (claude-code, hermes, agy, grok-build, codex, or a human) and observe it asynchronously over an MQTT event channel. Supported roles include orchestrator, worker, and reviewer."
|
||||
version: 4.9.5
|
||||
version: 4.9.6
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos, windows]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-loop
|
||||
description: "Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. Automatically orchestrates plan discussion, code changes, and peer reviews until a unanimous PASS is achieved or the maximum iteration limit is reached."
|
||||
version: 4.9.5
|
||||
version: 4.9.6
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-monitor
|
||||
description: "Run a long-lived reconciler that watches .mam/agent-sessions.yaml against the actual herdr/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Runs as a persistent loop (`reconcile.sh --subscribe`) that keeps going until it times out, idles out, or is interrupted."
|
||||
version: 4.9.5
|
||||
version: 4.9.6
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
@@ -952,15 +952,20 @@ for s in d.get('herdr_sessions', []):
|
||||
try:
|
||||
conn = sqlite3.connect(odb)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("PRAGMA table_info(session)")
|
||||
# opencode 2.x keeps sessions in session_v2 (a migrated 1.x `session`
|
||||
# table is no longer written).
|
||||
cursor.execute("PRAGMA table_info(session_v2)")
|
||||
cols = [col[1] for col in cursor.fetchall()]
|
||||
if cols:
|
||||
cwd_col = "directory" if "directory" in cols else ("cwd" if "cwd" in cols else None)
|
||||
time_col = "time_created" if "time_created" in cols else ("created_at" if "created_at" in cols else ("started_at" if "started_at" in cols else None))
|
||||
|
||||
query = "SELECT id FROM session"
|
||||
query = "SELECT id FROM session_v2"
|
||||
params = []
|
||||
where_clauses = []
|
||||
if "parent_id" in cols:
|
||||
# a subagent's session shares the directory and is newer
|
||||
where_clauses.append("parent_id IS NULL")
|
||||
if cwd_col and cwd:
|
||||
where_clauses.append(f"{cwd_col} = ?")
|
||||
params.append(cwd)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-orc-onboard
|
||||
description: "Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture."
|
||||
version: 4.9.5
|
||||
version: 4.9.6
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-resume
|
||||
description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a herdr session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a herdr session of the matching name, and runs `claude -r <id>` or `agy --conversation <id>` inside. Use when you want to reattach to a previous session's context, or revive a session whose herdr died but the agent's conversation is still on disk."
|
||||
version: 4.9.5
|
||||
version: 4.9.6
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
@@ -178,7 +178,7 @@ if [ "$FRESH_SPAWN" = "1" ]; then
|
||||
case "$AGENT" in
|
||||
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;;
|
||||
hermes) CMD_FULL="${RESOLVED_BIN} --yolo --accept-hooks" ;;
|
||||
opencode) CMD_FULL="${RESOLVED_BIN} --auto --agent build" ;;
|
||||
opencode) CMD_FULL="${RESOLVED_BIN} --auto --standalone" ;;
|
||||
codex) CMD_FULL="${RESOLVED_BIN} --dangerously-bypass-approvals-and-sandbox" ;;
|
||||
esac
|
||||
fi
|
||||
@@ -190,7 +190,7 @@ else
|
||||
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --conversation $UUID" ;;
|
||||
hermes) CMD_FULL="${RESOLVED_BIN} --resume $UUID --no-restore-cwd --yolo --accept-hooks" ;;
|
||||
grok) CMD_FULL="${RESOLVED_BIN} --resume $UUID --permission-mode bypassPermissions" ;;
|
||||
opencode) CMD_FULL="${RESOLVED_BIN} --session $UUID --auto --agent build" ;;
|
||||
opencode) CMD_FULL="${RESOLVED_BIN} --session $UUID --auto --standalone" ;;
|
||||
codex) CMD_FULL="${RESOLVED_BIN} resume --dangerously-bypass-approvals-and-sandbox $UUID" ;;
|
||||
*) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
@@ -238,10 +238,10 @@ elif agent == 'grok':
|
||||
elif agent == 'opencode':
|
||||
target['pane']['cmd'] = 'opencode'
|
||||
if uuid:
|
||||
target['pane']['cmd_full'] = f'opencode --session {uuid} --auto --agent build'
|
||||
target['pane']['cmd_full'] = f'opencode --session {uuid} --auto --standalone'
|
||||
target['opencode_session_id_own'] = uuid
|
||||
else:
|
||||
target['pane']['cmd_full'] = os.environ.get('CMD_FULL_OPT', '') or 'opencode --auto --agent build'
|
||||
target['pane']['cmd_full'] = os.environ.get('CMD_FULL_OPT', '') or 'opencode --auto --standalone'
|
||||
cp = os.environ.get('CHILD_PID', '0')
|
||||
if cp.isdigit() and int(cp) > 0:
|
||||
target['child_pid'] = int(cp)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-status
|
||||
description: "Read-only instant snapshot of all agent herdr sessions — name, YAML status, herdr alive, pane cmd/cwd, resume UUID on disk, and any drift. No mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up the monitor loop."
|
||||
version: 4.9.5
|
||||
version: 4.9.6
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
@@ -105,26 +105,16 @@ def resume_on_disk(s):
|
||||
if agent == 'opencode':
|
||||
u = s.get('opencode_session_id_own')
|
||||
if u:
|
||||
base_dir = f"{home}/.local/share/opencode"
|
||||
odb = None
|
||||
for cand in [f"{base_dir}/opencode.db", f"{base_dir}/storage.db"]:
|
||||
if os.path.exists(cand):
|
||||
odb = cand
|
||||
break
|
||||
if not odb and os.path.isdir(f"{base_dir}/project"):
|
||||
for root, _, files in os.walk(f"{base_dir}/project"):
|
||||
for f in files:
|
||||
if f.endswith(".db"):
|
||||
odb = os.path.join(root, f)
|
||||
break
|
||||
if odb:
|
||||
break
|
||||
# the adapter's db: `opencode debug paths`, which follows XDG_DATA_HOME (Issue #60)
|
||||
from lib_py.agents.registry import get_adapter
|
||||
from lib_py.agents.base import DiscoveryContext
|
||||
odb = get_adapter('opencode')._resolve_db(DiscoveryContext(workspace=cwd, agent_name='opencode', home_dir=home))
|
||||
if not odb or not os.path.exists(odb):
|
||||
return 'MISSING'
|
||||
try:
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(odb)
|
||||
r = conn.execute("SELECT 1 FROM session WHERE id=?", (u,)).fetchone()
|
||||
r = conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (u,)).fetchone() # opencode 2.x
|
||||
conn.close()
|
||||
return 'yes' if r else 'MISSING'
|
||||
except Exception:
|
||||
@@ -315,26 +305,16 @@ def resume_on_disk(s):
|
||||
if agent == 'opencode':
|
||||
u = s.get('opencode_session_id_own')
|
||||
if u:
|
||||
base_dir = f"{home}/.local/share/opencode"
|
||||
odb = None
|
||||
for cand in [f"{base_dir}/opencode.db", f"{base_dir}/storage.db"]:
|
||||
if os.path.exists(cand):
|
||||
odb = cand
|
||||
break
|
||||
if not odb and os.path.isdir(f"{base_dir}/project"):
|
||||
for root, _, files in os.walk(f"{base_dir}/project"):
|
||||
for f in files:
|
||||
if f.endswith(".db"):
|
||||
odb = os.path.join(root, f)
|
||||
break
|
||||
if odb:
|
||||
break
|
||||
# the adapter's db: `opencode debug paths`, which follows XDG_DATA_HOME (Issue #60)
|
||||
from lib_py.agents.registry import get_adapter
|
||||
from lib_py.agents.base import DiscoveryContext
|
||||
odb = get_adapter('opencode')._resolve_db(DiscoveryContext(workspace=cwd, agent_name='opencode', home_dir=home))
|
||||
if not odb or not os.path.exists(odb):
|
||||
return 'MISSING'
|
||||
try:
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(odb)
|
||||
r = conn.execute("SELECT 1 FROM session WHERE id=?", (u,)).fetchone()
|
||||
r = conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (u,)).fetchone() # opencode 2.x
|
||||
conn.close()
|
||||
return 'yes' if r else 'MISSING'
|
||||
except Exception:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-stop
|
||||
description: "Stop an agent herdr session (claude, antigravity/agy) and update .mam/agent-sessions.yaml. Default stops gracefully and marks status=stopped with conversation preserved for resume. Does NOT delete on-disk conversation artifacts (jsonl/db) — those are preserved unless --purge-conversation is passed. Use when ending a work session, switching to a different one, or cleaning up before a fresh start."
|
||||
version: 4.9.5
|
||||
version: 4.9.6
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다.
|
||||
|
||||
[](VERSIONS.md)
|
||||
[](VERSIONS.md)
|
||||
|
||||
새로운 에이전트는 이 안내서에 기술된 절차를 순차적으로 실행하여 초기 환경을 안정적으로 설정할 수 있습니다.
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
This document guides you through the setup and initialization procedures required to adopt the `herdr_agent_orchestration` orchestration and messaging backplane workflow in a new project, enabling a new developer or agent to get up and running quickly.
|
||||
|
||||
[](VERSIONS.md)
|
||||
[](VERSIONS.md)
|
||||
|
||||
A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Messaging System Technical Analysis & Architecture Report
|
||||
|
||||
Applies to: MAM v4.9.5
|
||||
Applies to: MAM v4.9.6
|
||||
|
||||
This report provides a comprehensive, deep-dive analysis of the messaging system implemented in the `multi-agent-mux-delegate-job` skill. It covers the MQTT broker architecture, event protocols, job lifecycles, codebase internals, cross-system integration, and a list of known limitations along with production recommendations.
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
[herdr](https://herdr.dev)와 MQTT 위에 만든 **다중 에이전트 오케스트레이션·메시징 백플레인**입니다. 장시간 실행되는 코딩 에이전트(`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`)를 격리된 herdr pane 에서 돌리고, 작업을 추적되는 잡으로 위임하며, 모든 리뷰어가 통과시킬 때까지 planner / creator / reviewer 루프를 돌립니다.
|
||||
|
||||
[](VERSIONS.md)
|
||||
[](VERSIONS.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
A **Multi-Agent Orchestration & Messaging Backplane** built on [herdr](https://herdr.dev) and MQTT. It runs long-lived coding agents (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`) in isolated herdr panes, delegates work to them as tracked jobs, and drives planner / creator / reviewer loops until every reviewer passes the change.
|
||||
|
||||
[](VERSIONS.md)
|
||||
[](VERSIONS.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+31
-10
@@ -6,7 +6,7 @@
|
||||
|
||||
## 📌 현재 버전 개요 (Current Release)
|
||||
|
||||
- **프레임워크 버전**: `v4.9.5`
|
||||
- **프레임워크 버전**: `v4.9.6`
|
||||
- **최신 릴리스 일시**: 2026-10-18 (KST)
|
||||
- **기준 브랜치**: `main`
|
||||
- **핵심 아키텍처**:
|
||||
@@ -24,23 +24,44 @@
|
||||
|
||||
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
|
||||
|
||||
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.9.5`로 동기화되어 배포됩니다.
|
||||
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.9.6`로 동기화되어 배포됩니다.
|
||||
|
||||
| 스킬명 | 버전 | 역할 및 주요 책임 | 상태 |
|
||||
| :--- | :---: | :--- | :---: |
|
||||
| **`multi-agent-mux-create`** | `4.9.5` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
|
||||
| **`multi-agent-mux-stop`** | `4.9.5` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
|
||||
| **`multi-agent-mux-resume`** | `4.9.5` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
|
||||
| **`multi-agent-mux-status`** | `4.9.5` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
|
||||
| **`multi-agent-mux-monitor`** | `4.9.5` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
|
||||
| **`multi-agent-mux-delegate-job`** | `4.9.5` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
|
||||
| **`multi-agent-mux-loop`** | `4.9.5` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
|
||||
| **`multi-agent-mux-orc-onboard`** | `4.9.5` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
|
||||
| **`multi-agent-mux-create`** | `4.9.6` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
|
||||
| **`multi-agent-mux-stop`** | `4.9.6` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
|
||||
| **`multi-agent-mux-resume`** | `4.9.6` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
|
||||
| **`multi-agent-mux-status`** | `4.9.6` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
|
||||
| **`multi-agent-mux-monitor`** | `4.9.6` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
|
||||
| **`multi-agent-mux-delegate-job`** | `4.9.6` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
|
||||
| **`multi-agent-mux-loop`** | `4.9.6` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
|
||||
| **`multi-agent-mux-orc-onboard`** | `4.9.6` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
|
||||
|
||||
---
|
||||
|
||||
## 📋 버전별 상세 변경 내역 (Changelog)
|
||||
|
||||
### 🚀 `v4.9.6` — opencode 2.x 전용 지원 (Issue #60) (2026-10-18)
|
||||
|
||||
> **패치 (PATCH Release)**: opencode 가 1.x 에서 2.x 로 올라가면서 TUI 가 `--agent`·`-m` 을 거부하고(실행 즉시 종료), 세션 저장소가 `session_v2` 로 바뀌고, `opencode db path` 가 없어졌다. MAM 은 opencode **2.x 만** 지원한다(1.x 호환 경로 없음). 사실은 이 Mac 에서 opencode 2.0.32 를 임시 HOME·TMPDIR 로 실측해 확인했다.
|
||||
|
||||
#### 🌟 주요 개선 사항
|
||||
1. **실행 명령**: 생성·재개 모두 `opencode --auto --standalone`(재개는 `--session <id>` 추가)이다. 기본 실행은 사용자당 하나인 백그라운드 서비스(`opencode serve --service`, 고정 포트)에 붙는다. `--standalone` 은 세션마다 TUI 의 자식 서버(`serve --stdio`)를 띄우고, 그 서버는 TUI 가 끝나면(SIGKILL·pty 닫힘) 함께 끝난다. 모델은 실행 명령으로 고를 수 없고 opencode 설정을 따른다.
|
||||
2. **세션 저장소 `session_v2`**: discover·verify·status·reconcile(drift C)이 `session_v2` 를 읽는다. 시각은 ms 단위이고, 자식 세션(`parent_id`)은 대화 세션으로 고르지 않는다.
|
||||
3. **DB 경로**: `opencode debug paths` 의 `db` 줄로 찾는다. 실패하면 `XDG_DATA_HOME`(호출자 자신의 홈일 때만) 또는 `<home>/.local/share` 아래 `opencode/opencode.db` 를 쓴다. `status.sh` 도 어댑터의 경로를 쓴다.
|
||||
4. **purge**: `opencode session delete --standalone -- <id>` 로 지운다. 이 명령은 자식 세션까지 2.x 테이블에서 지운다. 1.x 에서 이전된 DB 에 남는 옛 테이블(`session`·`message`·`part`·`todo`·`session_share`)의 행은 본인과 자손 세션 모두 MAM 이 지운다. 삭제가 실패하면 경고를 내고 남은 행을 residual 로 보고한다.
|
||||
5. **사용 중 판정**: 백그라운드 서비스는 DB 를 늘 열어 두므로 DB 보유자 중 `serve --service` 는 사용 중으로 보지 않는다. 다른 보유자나 argv 에 uuid 를 가진 프로세스는 여전히 사용 중이다. `resident_children` 은 `opencode` 이다.
|
||||
6. **문서**: `docs/OPENCODE_OLLAMA_GUIDE.md`(TUI 는 `-m`·`--agent` 를 받지 않음, MAM 은 `--standalone` 으로 실행), `docs/NEW_AGENT_INTEGRATION_GUIDE.md` 의 OpenCode 행.
|
||||
|
||||
#### ✅ 품질 검증
|
||||
- **회귀**: `tests/test_issue60_opencode_v2.py`(2.x 스키마·CLI·자식 세션·이전된 DB·삭제 실패·서비스 보유자·XDG 규칙·실행 문자열), 가짜 2.x CLI `tests/_fake_opencode.py`. 설치된 opencode 2.x 가 있으면 임시 HOME 에서 실제 바이너리 계약(세션 생성·discover·purge·residual·없는 id)도 확인한다. 테스트 격리는 `XDG_DATA_HOME` 을 지우고, 홈 가드는 그 경로의 opencode 폴더도 감시한다.
|
||||
- **리뷰 (`--review-only`, `--all-reviewer`, 이 Mac 정합성 중심)**:
|
||||
- 1회차(@6b4b96f): claude·agy·grok PASS, codex NOT PASS(자식 세션의 1.x 행 잔존). 오케스트레이터가 수정했다(cb5f439: 자손 정리·residual, 서비스 보유자 제외, `XDG_DATA_HOME`, `resident_children`).
|
||||
- 2회차(@cb5f439): grok PASS, claude·agy·codex NOT PASS. 공통 지적은 테스트 격리가 `XDG_DATA_HOME` 을 지우지 않아 sandbox 가 실제 data 폴더를 가리킬 수 있다는 것(차단)이다. 비차단으로 `status.sh` 의 고정 경로, 실제 바이너리 확인(`--version`)이 실제 HOME 의 opencode 로그에 기록하는 문제가 있었다. 모두 0e05b1b 에서 수정했다.
|
||||
- 3회차(@0e05b1b): claude·agy·grok PASS. codex 는 사용 한도로 요청을 받지 못해 같은 head 를 단독으로 다시 리뷰해 PASS. **4/4 PASS**. 리포트는 `.agents/reports/<session>/` 에 있다.
|
||||
- **전체 스위트 (macOS, Python 3.9.6, @0e05b1b)**: 1949 passed, 56 skipped(tomllib·서브모듈·Linux 전용). 실제 `~/.claude`·`/private/tmp/claude-<uid>`·opencode 의 data·state·config 폴더·opencode 로그·herdr 서버 pane 목록은 바뀌지 않았고, 사용자의 opencode 서비스와 실제 DB 의 세션은 그대로였다.
|
||||
- **Linux 후속 확인(별도)**: 실제 2.x 화면 캡처로 입력 영역 fixture 갱신, `/exit` 정상 종료, 홈 화면 안내 문구의 입력 판정(stall_watch), `auth_ok`(`auth.json`, 현재 미사용), 실제 바이너리 계약 테스트.
|
||||
|
||||
### 🚀 `v4.9.5` — claude purge 가 세션이 소유한 경로를 모두 지우고 남은 것을 보고한다 (Issue #31, PR #59) (2026-10-18)
|
||||
|
||||
> **패치 (PATCH Release)**: claude `--purge-conversation` 이 `.jsonl` 만 지우고 `file-history`·`session-env` 등을 남기면서 `residual: none` 으로 보고하던 결함. purge 범위는 정규형 UUID 가 가리키는 세션 소유 경로로 한정된다.
|
||||
|
||||
@@ -264,7 +264,7 @@ def test_adapter_required_properties():
|
||||
|
||||
| 에이전트 백엔드 | 세션 저장소 형식 | 인증 방식 | 프롬프트 감지 난이도 | 난이도 Tier | 예상 소요 공수 |
|
||||
| :--- | :--- | :--- | :--- | :---: | :---: |
|
||||
| **OpenCode** | `~/.local/share/opencode/` (SQLite, `opencode db path`) | 멀티 Provider Key / `auth.json` | 쉬움 (`OpenCode\|Chat`) | **Tier 1 (낮음)** | **~1.0일** |
|
||||
| **OpenCode** (2.x 전용, Issue #60) | `~/.local/share/opencode/opencode.db` (SQLite `session_v2`, 경로는 `opencode debug paths`) | 멀티 Provider Key (DB `credential`) / 무료 모델 | 쉬움 (`OpenCode\|Chat`) | **Tier 1 (낮음)** | **~1.0일** |
|
||||
| **Codex CLI** | `~/.codex/sessions/*/*/*/rollout-*.jsonl` (SQLite `state_*.sqlite`) | `auth.json` / `OPENAI_API_KEY` | 쉬움 (`Codex\|Ask Codex to do anything`) | **v4.2.0 완료** | **완료** |
|
||||
| **Kimi CLI** | `~/.kimi/history.db` (SQLite) | `~/.kimi/config` | 쉬움 (`Moonshot\|Kimi`) | **Tier 1 (낮음)** | **~0.5일** |
|
||||
| **Grok-Build** | `~/.grok/sessions/*.json` | 토큰 파일 / Env | 쉬움 (`Grok\|Building`) | **Tier 1 (낮음)** | **~0.5일** |
|
||||
|
||||
@@ -93,10 +93,8 @@ opencode
|
||||
```
|
||||
|
||||
### 4.2 CLI 플래그로 특정 모델 즉시 실행 (`-m`)
|
||||
opencode 2.x 의 대화형 TUI(`opencode`)는 `-m`/`--model`·`--agent` 를 받지 않습니다(`Unrecognized flag`). TUI 의 모델은 설정 파일의 `"model"` 로 정하고, `-m` 은 비대화형 `run` 에서만 씁니다.
|
||||
```bash
|
||||
# 대화형 TUI 실행
|
||||
opencode -m "ollama/qwen2.5-coder:32b"
|
||||
|
||||
# 비대화형(Headless) 무인 단발 태스크 실행
|
||||
opencode run -m "ollama/qwen2.5-coder:32b" "tests/ 단위 테스트를 실행하고 실패하는 테스트를 고쳐줘"
|
||||
```
|
||||
@@ -108,7 +106,7 @@ opencode run -m "ollama/qwen2.5-coder:32b" "tests/ 단위 테스트를 실행하
|
||||
|
||||
## 5. Multi-Agent Mux (MAM) 연동 예시
|
||||
|
||||
MAM 환경에서 OpenCode를 reviewer/creator 에이전트로 띄울 때의 표준 명령어:
|
||||
MAM 환경에서 OpenCode를 reviewer/creator 에이전트로 띄울 때의 표준 명령어입니다. MAM 은 **opencode 2.x 만** 지원하며(Issue #60), pane 에서 `opencode --auto --standalone`(재개는 `--session <id>` 추가)으로 띄웁니다. `--standalone` 이라 서버가 TUI 의 자식으로 떠서 세션을 닫으면 함께 끝나고, 사용자별 백그라운드 서비스는 쓰지도 띄우지도 않습니다.
|
||||
|
||||
```bash
|
||||
# 1. 세션 생성 및 온보딩
|
||||
|
||||
@@ -18,7 +18,7 @@ import tempfile
|
||||
|
||||
# The pane-inherited variables conftest._isolate_agent_homes drops for every
|
||||
# test, so HOME (which the tests set) governs, as it does in a plain shell.
|
||||
DROPPED_VARS = ("HOME_DIR", "CLAUDE_PROJECT_DIR", "CLAUDE_CONFIG_DIR", "CODEX_HOME", "LOCAL_BIN")
|
||||
DROPPED_VARS = ("HOME_DIR", "CLAUDE_PROJECT_DIR", "CLAUDE_CONFIG_DIR", "CODEX_HOME", "XDG_DATA_HOME", "LOCAL_BIN")
|
||||
# Read by the code under test but left alone, on purpose: HOME is where the
|
||||
# real herdr tests and the shim bootstrap find the server socket, and it is
|
||||
# what the suite's own tests set for an isolated home; XDG_CONFIG_HOME and
|
||||
@@ -45,7 +45,8 @@ def watch_dirs(ambient):
|
||||
for d in ("conversations", "cache", "brain", "annotations", "presence")]
|
||||
dirs += [("codex", os.path.join(codex, "sessions")),
|
||||
("grok", os.path.join(home, ".grok", "sessions")),
|
||||
("opencode", os.path.join(home, ".local", "share", "opencode")),
|
||||
("opencode", os.path.join(ambient.get("XDG_DATA_HOME") or os.path.join(home, ".local", "share"),
|
||||
"opencode")),
|
||||
("hermes", os.path.join(home, ".hermes"))]
|
||||
return dirs
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""A fake opencode 2.x CLI for tests (Issue #60): only what MAM calls.
|
||||
|
||||
- `debug paths` prints the `data` and `db` lines of the real command (the db
|
||||
follows XDG_DATA_HOME, else HOME/.local/share; the database is not created);
|
||||
- `session delete [--standalone] [--] <id>` deletes the session and its child
|
||||
sessions from the 2.x tables (session_v2, session_message, session_inbox,
|
||||
session_pending, event, event_sequence) and leaves the tables a 1.x
|
||||
migration keeps (session, message, part, todo, session_share), as the real
|
||||
2.0.32 command was measured to do; prints `Session <id> deleted`, or
|
||||
`Session not found: <id>` with exit 1.
|
||||
|
||||
Tests never run a real opencode: a real one would migrate the test's
|
||||
database. The contract against a real binary is checked by the opt-in test in
|
||||
tests/test_issue60_opencode_v2.py.
|
||||
"""
|
||||
import os
|
||||
import stat
|
||||
|
||||
SCRIPT = r'''#!/usr/bin/env python3
|
||||
import os, sqlite3, sys
|
||||
args = sys.argv[1:]
|
||||
data = os.environ.get("XDG_DATA_HOME") or os.path.join(os.environ.get("HOME", ""), ".local", "share")
|
||||
db = os.path.join(data, "opencode", "opencode.db")
|
||||
if args == ["debug", "paths"]:
|
||||
print("data " + os.path.dirname(db))
|
||||
print("db " + db)
|
||||
sys.exit(0)
|
||||
if args[:2] == ["session", "delete"]:
|
||||
sid = args[-1]
|
||||
conn = sqlite3.connect(db)
|
||||
try:
|
||||
if not conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (sid,)).fetchone():
|
||||
print("Session not found: " + sid, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
ids, todo = [], [sid]
|
||||
while todo:
|
||||
cur = todo.pop()
|
||||
ids.append(cur)
|
||||
todo += [r[0] for r in conn.execute("SELECT id FROM session_v2 WHERE parent_id=?", (cur,))]
|
||||
tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
v2 = (("session_message", "session_id"), ("session_inbox", "session_id"),
|
||||
("session_pending", "session_id"), ("event", "aggregate_id"),
|
||||
("event_sequence", "aggregate_id"), ("session_v2", "id"))
|
||||
for t, col in v2:
|
||||
if t in tables:
|
||||
for i in ids:
|
||||
conn.execute('DELETE FROM "%s" WHERE "%s"=?' % (t, col), (i,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
print("Session " + sid + " deleted")
|
||||
sys.exit(0)
|
||||
sys.exit(0)
|
||||
'''
|
||||
|
||||
|
||||
def write(path) -> str:
|
||||
"""Write the fake as an executable `path` and return it as a str."""
|
||||
path = str(path)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
f.write(SCRIPT)
|
||||
os.chmod(path, os.stat(path).st_mode | stat.S_IEXEC)
|
||||
return path
|
||||
+5
-21
@@ -131,6 +131,7 @@ if _TESTS_DIR not in sys.path:
|
||||
sys.path.insert(0, _TESTS_DIR)
|
||||
import _mam_state_guard # noqa: E402
|
||||
import _agent_home_guard # noqa: E402
|
||||
import _fake_opencode # noqa: E402
|
||||
|
||||
_BINDING_VARS = ("WORKSPACE_ROOT", "AGENT_SESSIONS_YAML", "MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR")
|
||||
_RECORDED_BINDING = {}
|
||||
@@ -971,9 +972,9 @@ elif cmd1 == "agent":
|
||||
os.makedirs(opencode_dir, exist_ok=True)
|
||||
odb = os.path.join(opencode_dir, "opencode.db")
|
||||
conn = sqlite3.connect(odb)
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS session (id TEXT PRIMARY KEY, directory TEXT, time_created INTEGER, cwd TEXT, created_at REAL)")
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS session_v2 (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT, time_created INTEGER)")
|
||||
now_ms = int(time.time() * 1000)
|
||||
conn.execute("INSERT OR REPLACE INTO session (id, directory, time_created, cwd, created_at) VALUES (?, ?, ?, ?, ?)", (session_uuid, ws_abs, now_ms, ws_abs, time.time()))
|
||||
conn.execute("INSERT OR REPLACE INTO session_v2 (id, directory, time_created) VALUES (?, ?, ?)", (session_uuid, ws_abs, now_ms))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
elif agent_type == "codex":
|
||||
@@ -1466,25 +1467,8 @@ sys.exit(0)
|
||||
""")
|
||||
grok_bin.chmod(0o755)
|
||||
|
||||
# 5. opencode mock
|
||||
opencode_bin = bin_dir / "opencode"
|
||||
opencode_bin.write_text("""#!/usr/bin/env python3
|
||||
import sys, os
|
||||
args = sys.argv[1:]
|
||||
if len(args) >= 2 and args[0] == "db" and args[1] == "path":
|
||||
home = os.environ.get("HOME", "")
|
||||
db_path = f"{home}/.local/share/opencode/opencode.db"
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
if not os.path.exists(db_path):
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS session (id TEXT PRIMARY KEY, directory TEXT, time_created INTEGER)")
|
||||
conn.close()
|
||||
print(db_path)
|
||||
sys.exit(0)
|
||||
sys.exit(0)
|
||||
""")
|
||||
opencode_bin.chmod(0o755)
|
||||
# 5. opencode mock (the 2.x commands MAM calls, Issue #60)
|
||||
_fake_opencode.write(bin_dir / "opencode")
|
||||
|
||||
# 6. codex mock
|
||||
codex_bin = bin_dir / "codex"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import os, pytest
|
||||
from lib_py.paths import resolve_home
|
||||
from lib_py.agents.registry import get_adapter, own_key, agent_of_row
|
||||
import _fake_opencode # tests/ is on sys.path (conftest)
|
||||
|
||||
def test_resolve_home_contract():
|
||||
# 1. When explicit home_dir is passed
|
||||
@@ -132,73 +133,19 @@ def shlex_quote(s):
|
||||
import shlex
|
||||
return shlex.quote(s)
|
||||
|
||||
def _seed_opencode_session(db_path, session_id, ws_path, epoch_sec):
|
||||
def _seed_opencode_session(db_path, session_id, ws_path, epoch_sec, parent_id=None):
|
||||
"""A session row of the opencode 2.x schema (session_v2, Issue #60)."""
|
||||
import sqlite3
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys = OFF")
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
tables = {row[0] for row in cursor.fetchall()}
|
||||
if "session" not in tables:
|
||||
conn.execute("CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT, time_created INTEGER, cwd TEXT, created_at REAL)")
|
||||
if "project" in tables:
|
||||
cursor.execute("PRAGMA table_info(project)")
|
||||
p_cols = [col[1] for col in cursor.fetchall()]
|
||||
p_ins = ["id"]
|
||||
p_vals = ["default"]
|
||||
if "worktree" in p_cols:
|
||||
p_ins.append("worktree")
|
||||
p_vals.append(ws_path)
|
||||
if "time_created" in p_cols:
|
||||
p_ins.append("time_created")
|
||||
p_vals.append(0)
|
||||
if "time_updated" in p_cols:
|
||||
p_ins.append("time_updated")
|
||||
p_vals.append(0)
|
||||
conn.execute(f"INSERT OR IGNORE INTO project ({', '.join(p_ins)}) VALUES ({', '.join(['?']*len(p_vals))})", p_vals)
|
||||
|
||||
cursor.execute("PRAGMA table_info(session)")
|
||||
cols = [col[1] for col in cursor.fetchall()]
|
||||
|
||||
insert_cols = ["id"]
|
||||
vals = [session_id]
|
||||
if "project_id" in cols:
|
||||
insert_cols.append("project_id")
|
||||
vals.append("default")
|
||||
if "slug" in cols:
|
||||
insert_cols.append("slug")
|
||||
vals.append("default")
|
||||
if "title" in cols:
|
||||
insert_cols.append("title")
|
||||
vals.append("default")
|
||||
if "version" in cols:
|
||||
insert_cols.append("version")
|
||||
vals.append("1.0.0")
|
||||
if "directory" in cols:
|
||||
insert_cols.append("directory")
|
||||
vals.append(ws_path)
|
||||
if "cwd" in cols:
|
||||
insert_cols.append("cwd")
|
||||
vals.append(ws_path)
|
||||
if "time_created" in cols:
|
||||
insert_cols.append("time_created")
|
||||
vals.append(int(float(epoch_sec) * 1000))
|
||||
if "time_updated" in cols:
|
||||
insert_cols.append("time_updated")
|
||||
vals.append(int(float(epoch_sec) * 1000))
|
||||
if "created_at" in cols:
|
||||
insert_cols.append("created_at")
|
||||
vals.append(float(epoch_sec))
|
||||
|
||||
placeholders = ", ".join(["?"] * len(vals))
|
||||
col_str = ", ".join(insert_cols)
|
||||
conn.execute(f"INSERT OR REPLACE INTO session ({col_str}) VALUES ({placeholders})", vals)
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS session_v2 (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT NOT NULL, time_created INTEGER NOT NULL)")
|
||||
conn.execute("INSERT OR REPLACE INTO session_v2 (id, parent_id, directory, time_created) VALUES (?, ?, ?, ?)",
|
||||
(session_id, parent_id, ws_path, int(float(epoch_sec) * 1000)))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_purge_artifacts_composite(tmp_path):
|
||||
def test_purge_artifacts_composite(tmp_path, monkeypatch):
|
||||
import sqlite3
|
||||
from lib_py.agents.base import DiscoveryContext
|
||||
ws = str(tmp_path / "ws")
|
||||
@@ -272,60 +219,31 @@ def test_purge_artifacts_composite(tmp_path):
|
||||
assert not os.path.exists(g_path)
|
||||
assert not os.path.exists(os.path.dirname(g_path))
|
||||
|
||||
# 5. OpenCode (SQLite tables session, message, part)
|
||||
# 5. OpenCode 2.x: `opencode session delete` removes the session (the
|
||||
# fake opencode, as the real one does), and the rows a 1.x opencode left
|
||||
# behind (message, part) are deleted directly (Issue #60).
|
||||
opencode_adapter = get_adapter('opencode')
|
||||
fake = _fake_opencode.write(tmp_path / "fakebin" / "opencode")
|
||||
monkeypatch.setattr(opencode_adapter, "_bin", lambda ctx: fake)
|
||||
opencode_ctx = DiscoveryContext(workspace=ws, agent_name='opencode', home_dir=home)
|
||||
o_db = opencode_adapter.artifact_path('uuid-o', opencode_ctx)
|
||||
assert o_db == f"{home}/.local/share/opencode/opencode.db"
|
||||
_seed_opencode_session(o_db, 'uuid-o', ws, 2000.0)
|
||||
conn = sqlite3.connect(o_db)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys = OFF")
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
tables = {row[0] for row in cursor.fetchall()}
|
||||
if "message" not in tables:
|
||||
conn.execute("CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT)")
|
||||
conn.execute("CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT)")
|
||||
if "part" not in tables:
|
||||
conn.execute("CREATE TABLE part (id TEXT PRIMARY KEY, session_id TEXT)")
|
||||
|
||||
cursor.execute("PRAGMA table_info(message)")
|
||||
m_cols = [c[1] for c in cursor.fetchall()]
|
||||
m_ins = ["id", "session_id"] if "id" in m_cols else ["session_id"]
|
||||
m_vals = ["m1", "uuid-o"] if "id" in m_cols else ["uuid-o"]
|
||||
if "time_created" in m_cols:
|
||||
m_ins.append("time_created")
|
||||
m_vals.append(0)
|
||||
if "time_updated" in m_cols:
|
||||
m_ins.append("time_updated")
|
||||
m_vals.append(0)
|
||||
if "data" in m_cols:
|
||||
m_ins.append("data")
|
||||
m_vals.append("{}")
|
||||
conn.execute(f"INSERT OR REPLACE INTO message ({', '.join(m_ins)}) VALUES ({', '.join(['?']*len(m_vals))})", m_vals)
|
||||
|
||||
cursor.execute("PRAGMA table_info(part)")
|
||||
p_cols = [c[1] for c in cursor.fetchall()]
|
||||
p_ins = ["id", "session_id"] if "id" in p_cols else ["session_id"]
|
||||
p_vals = ["p1", "uuid-o"] if "id" in p_cols else ["uuid-o"]
|
||||
if "message_id" in p_cols:
|
||||
p_ins.append("message_id")
|
||||
p_vals.append("m1")
|
||||
if "time_created" in p_cols:
|
||||
p_ins.append("time_created")
|
||||
p_vals.append(0)
|
||||
if "time_updated" in p_cols:
|
||||
p_ins.append("time_updated")
|
||||
p_vals.append(0)
|
||||
if "data" in p_cols:
|
||||
p_ins.append("data")
|
||||
p_vals.append("{}")
|
||||
conn.execute(f"INSERT OR REPLACE INTO part ({', '.join(p_ins)}) VALUES ({', '.join(['?']*len(p_vals))})", p_vals)
|
||||
conn.execute("INSERT INTO session_message VALUES ('sm1', 'uuid-o')")
|
||||
conn.execute("INSERT INTO message VALUES ('m1', 'uuid-o')")
|
||||
conn.execute("INSERT INTO part VALUES ('p1', 'uuid-o')")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
purged_o = opencode_adapter.purge_artifacts('uuid-o', opencode_ctx)
|
||||
assert len(purged_o) == 1
|
||||
assert purged_o == ["opencode session (with its child sessions): uuid-o", "sqlite rows for session: uuid-o"]
|
||||
conn = sqlite3.connect(o_db)
|
||||
assert conn.execute("SELECT count(*) FROM session WHERE id='uuid-o'").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT count(*) FROM session_v2 WHERE id='uuid-o'").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT count(*) FROM session_message WHERE session_id='uuid-o'").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT count(*) FROM message WHERE session_id='uuid-o'").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT count(*) FROM part WHERE session_id='uuid-o'").fetchone()[0] == 0
|
||||
conn.close()
|
||||
@@ -353,10 +271,10 @@ def test_adapter_spawn_and_resume_specs():
|
||||
assert grok.resume_spec('grok', 'u1', materialized=False) == 'grok --session-id u1 --permission-mode bypassPermissions'
|
||||
|
||||
opencode = get_adapter('opencode')
|
||||
assert opencode.spawn_spec('opencode', 'u1') == 'opencode --auto --agent build'
|
||||
assert opencode.spawn_spec('opencode', '') == 'opencode --auto --agent build'
|
||||
assert opencode.resume_spec('opencode', 'u1', materialized=True) == 'opencode --session u1 --auto --agent build'
|
||||
assert opencode.resume_spec('opencode', 'u1', materialized=False) == 'opencode --auto --agent build'
|
||||
assert opencode.spawn_spec('opencode', 'u1') == 'opencode --auto --standalone'
|
||||
assert opencode.spawn_spec('opencode', '') == 'opencode --auto --standalone'
|
||||
assert opencode.resume_spec('opencode', 'u1', materialized=True) == 'opencode --session u1 --auto --standalone'
|
||||
assert opencode.resume_spec('opencode', 'u1', materialized=False) == 'opencode --auto --standalone'
|
||||
|
||||
codex = get_adapter('codex')
|
||||
assert codex.spawn_spec('codex', 'u1') == 'codex --dangerously-bypass-approvals-and-sandbox'
|
||||
@@ -821,12 +739,13 @@ def test_opencode_real_schema_directory_and_time_created_ms(tmp_path):
|
||||
odb = f"{home}/.local/share/opencode/opencode.db"
|
||||
os.makedirs(os.path.dirname(odb), exist_ok=True)
|
||||
conn = sqlite3.connect(odb)
|
||||
conn.execute("CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT NOT NULL, time_created INTEGER NOT NULL)")
|
||||
conn.execute("CREATE TABLE session_v2 (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT NOT NULL, time_created INTEGER NOT NULL)")
|
||||
|
||||
epoch_floor_sec = 1724920000
|
||||
conn.execute("INSERT INTO session VALUES ('u-target-fresh', ?, ?)", (ws1, int((epoch_floor_sec + 100) * 1000)))
|
||||
conn.execute("INSERT INTO session VALUES ('u-target-old', ?, ?)", (ws1, int((epoch_floor_sec - 100) * 1000)))
|
||||
conn.execute("INSERT INTO session VALUES ('u-foreign', ?, ?)", (ws2, int((epoch_floor_sec + 200) * 1000)))
|
||||
ins = "INSERT INTO session_v2 (id, directory, time_created) VALUES (?, ?, ?)"
|
||||
conn.execute(ins, ('u-target-fresh', ws1, int((epoch_floor_sec + 100) * 1000)))
|
||||
conn.execute(ins, ('u-target-old', ws1, int((epoch_floor_sec - 100) * 1000)))
|
||||
conn.execute(ins, ('u-foreign', ws2, int((epoch_floor_sec + 200) * 1000)))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -844,7 +763,8 @@ def test_opencode_real_schema_directory_and_time_created_ms(tmp_path):
|
||||
|
||||
|
||||
def test_opencode_resolve_db_cli_path(tmp_path, monkeypatch):
|
||||
"""Verify _resolve_db correctly executes opencode db path and falls back to candidate paths."""
|
||||
"""_resolve_db reads the `db` line of `opencode debug paths` (2.x; the 1.x
|
||||
`db path` only prints the help) and falls back to the default path."""
|
||||
import stat
|
||||
from lib_py.agents.registry import get_adapter
|
||||
from lib_py.agents.base import DiscoveryContext
|
||||
@@ -855,18 +775,15 @@ def test_opencode_resolve_db_cli_path(tmp_path, monkeypatch):
|
||||
os.makedirs(home, exist_ok=True)
|
||||
os.makedirs(ws, exist_ok=True)
|
||||
|
||||
expected_db = f"{home}/.local/share/opencode/custom_opencode.db"
|
||||
os.makedirs(os.path.dirname(expected_db), exist_ok=True)
|
||||
with open(expected_db, "w") as f:
|
||||
f.write("mock db")
|
||||
|
||||
# Create mock opencode binary that outputs custom DB path
|
||||
expected_db = f"{tmp_path}/elsewhere/opencode/opencode.db"
|
||||
bin_dir = tmp_path / "bin"
|
||||
os.makedirs(bin_dir, exist_ok=True)
|
||||
mock_bin = bin_dir / "opencode"
|
||||
mock_bin.write_text(f"""#!/bin/sh
|
||||
if [ "$1" = "db" ] && [ "$2" = "path" ]; then
|
||||
echo "{expected_db}"
|
||||
if [ "$1" = "debug" ] && [ "$2" = "paths" ]; then
|
||||
echo "home {home}"
|
||||
echo "data {tmp_path}/elsewhere/opencode"
|
||||
echo "db {expected_db}"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
@@ -877,6 +794,9 @@ exit 1
|
||||
ctx = DiscoveryContext(workspace=ws, agent_name='opencode', home_dir=home)
|
||||
assert adapter._resolve_db(ctx) == expected_db
|
||||
|
||||
monkeypatch.setattr(adapter, "_bin", lambda ctx: None)
|
||||
assert adapter._resolve_db(ctx) == f"{home}/.local/share/opencode/opencode.db"
|
||||
|
||||
|
||||
def _seed_codex_session(home_dir, session_id, ws_path, epoch_sec):
|
||||
import sqlite3, json, time
|
||||
|
||||
@@ -34,6 +34,7 @@ sys.path.insert(0, str(SKILLS))
|
||||
from lib_py.agents.registry import get_adapter # noqa: E402
|
||||
from lib_py.agents.base import DiscoveryContext # noqa: E402
|
||||
from lib_py.verify_session import workspace_key # noqa: E402
|
||||
import _fake_opencode # noqa: E402 (tests/ is on sys.path via conftest)
|
||||
|
||||
SESSION_ENV = ("CLAUDE_CODE_SESSION_ID", "ANTIGRAVITY_CONVERSATION_ID",
|
||||
"HERMES_SESSION_ID", "GROK_SESSION_ID", "OPENCODE_SESSION_ID")
|
||||
@@ -895,10 +896,15 @@ def _db(path: Path, ddl: list, rows: list):
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_opencode_residual_reports_event_rows(home, tmp_path, monkeypatch):
|
||||
def test_opencode_purge_leaves_no_rows(home, tmp_path, monkeypatch):
|
||||
# A database migrated from 1.x (Issue #60): `opencode session delete`
|
||||
# (the fake, as measured on 2.0.32) removes the 2.x rows and the purge
|
||||
# deletes the rows of the 1.x tables 2.x no longer writes.
|
||||
u, o = f"ses_{uuidlib.uuid4().hex}", f"ses_{uuidlib.uuid4().hex}"
|
||||
db = home / ".local" / "share" / "opencode" / "opencode.db"
|
||||
ddl = ["CREATE TABLE session (id TEXT PRIMARY KEY)",
|
||||
"CREATE TABLE session_v2 (id TEXT PRIMARY KEY, parent_id TEXT)",
|
||||
"CREATE TABLE session_message (id TEXT, session_id TEXT)",
|
||||
"CREATE TABLE message (id TEXT, session_id TEXT)",
|
||||
"CREATE TABLE part (id TEXT, session_id TEXT)",
|
||||
"CREATE TABLE event (id TEXT, aggregate_id TEXT)",
|
||||
@@ -907,6 +913,8 @@ def test_opencode_residual_reports_event_rows(home, tmp_path, monkeypatch):
|
||||
rows = []
|
||||
for s in (u, o):
|
||||
rows += [("INSERT INTO session VALUES (?)", (s,)),
|
||||
("INSERT INTO session_v2 VALUES (?, NULL)", (s,)),
|
||||
("INSERT INTO session_message VALUES ('sm', ?)", (s,)),
|
||||
("INSERT INTO message VALUES ('m', ?)", (s,)),
|
||||
("INSERT INTO part VALUES ('p', ?)", (s,)),
|
||||
("INSERT INTO event VALUES ('e1', ?)", (s,)),
|
||||
@@ -915,14 +923,16 @@ def test_opencode_residual_reports_event_rows(home, tmp_path, monkeypatch):
|
||||
("INSERT INTO todo VALUES ('t', ?)", (s,))]
|
||||
_db(db, ddl, rows)
|
||||
adapter = get_adapter("opencode")
|
||||
monkeypatch.setattr(adapter, "_resolve_db", lambda ctx: str(db)) # never run a real opencode
|
||||
fake = _fake_opencode.write(tmp_path / "fakebin" / "opencode")
|
||||
monkeypatch.setattr(adapter, "_bin", lambda ctx: fake) # never run a real opencode
|
||||
ctx = DiscoveryContext(workspace=str(tmp_path), agent_name="opencode", home_dir=str(home))
|
||||
assert f"{db} (session_v2.id: 1 rows)" in adapter.residual_artifacts(u, ctx)
|
||||
|
||||
adapter.purge_artifacts(u, ctx)
|
||||
assert adapter.purge_artifacts(u, ctx) == [f"opencode session (with its child sessions): {u}",
|
||||
f"sqlite rows for session: {u}"]
|
||||
|
||||
assert adapter.residual_artifacts(u, ctx) == [f"{db} (event.aggregate_id: 2 rows)",
|
||||
f"{db} (event_sequence.aggregate_id: 1 rows)",
|
||||
f"{db} (todo.session_id: 1 rows)"]
|
||||
assert adapter.residual_artifacts(u, ctx) == []
|
||||
assert len(adapter.residual_artifacts(o, ctx)) == 8 # the other session is untouched: one entry per table
|
||||
|
||||
|
||||
def test_codex_residual_reports_name_and_thread_rows(home, tmp_path, monkeypatch, conv, other):
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
"""Issue #60: opencode 2.x.
|
||||
|
||||
Measured on opencode 2.0.32 (macOS): the TUI rejects `--agent`, the database
|
||||
keeps sessions in session_v2 (a fresh 2.x database has no `session` table; a
|
||||
migrated one keeps the 1.x tables but no longer writes them), `db path` is
|
||||
gone (`debug paths` prints the db), and `session delete` removes a session
|
||||
with its child sessions from the 2.x tables only.
|
||||
|
||||
The unit tests use the fake opencode of tests/_fake_opencode.py. The last
|
||||
test runs a real opencode 2.x, when one is installed, in a home and tmp dir
|
||||
of its own with --standalone (no background service is started or reached).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SKILLS = REPO_ROOT / ".agents" / "skills"
|
||||
sys.path.insert(0, str(SKILLS))
|
||||
from lib_py.agents.registry import get_adapter # noqa: E402
|
||||
from lib_py.agents.base import DiscoveryContext # noqa: E402
|
||||
import _fake_opencode # noqa: E402
|
||||
|
||||
V2_DDL = ("CREATE TABLE session_v2 (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT NOT NULL, time_created INTEGER NOT NULL)",
|
||||
"CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT)",
|
||||
"CREATE TABLE event_sequence (aggregate_id TEXT PRIMARY KEY, seq INTEGER)")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oc(tmp_path, monkeypatch):
|
||||
"""The opencode adapter on a home of its own, with a fake opencode that
|
||||
logs every call."""
|
||||
home = tmp_path / "home"
|
||||
ws = tmp_path / "ws"
|
||||
ws.mkdir(parents=True)
|
||||
log = tmp_path / "calls.log"
|
||||
fake = _fake_opencode.write(tmp_path / "fakebin" / "opencode-real")
|
||||
wrapper = tmp_path / "fakebin" / "opencode"
|
||||
wrapper.write_text(f'#!/bin/sh\necho "$*" >> "{log}"\nexec "{fake}" "$@"\n')
|
||||
wrapper.chmod(0o755)
|
||||
adapter = get_adapter("opencode")
|
||||
monkeypatch.setattr(adapter, "_bin", lambda ctx: str(wrapper))
|
||||
ctx = DiscoveryContext(workspace=str(ws), agent_name="opencode", home_dir=str(home))
|
||||
db = home / ".local" / "share" / "opencode" / "opencode.db"
|
||||
db.parent.mkdir(parents=True)
|
||||
conn = sqlite3.connect(str(db))
|
||||
for stmt in V2_DDL:
|
||||
conn.execute(stmt)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def calls():
|
||||
return log.read_text().splitlines() if log.exists() else []
|
||||
|
||||
return adapter, ctx, db, ws, calls
|
||||
|
||||
|
||||
def _add(db, sid, ws, ms, parent=None):
|
||||
conn = sqlite3.connect(str(db))
|
||||
conn.execute("INSERT INTO session_v2 VALUES (?, ?, ?, ?)", (sid, parent, str(ws), ms))
|
||||
conn.execute("INSERT INTO session_message VALUES (?, ?)", (f"msg-{sid}", sid))
|
||||
conn.execute("INSERT INTO event_sequence VALUES (?, 1)", (sid,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def _rows(db, sid):
|
||||
conn = sqlite3.connect(str(db))
|
||||
try:
|
||||
return (conn.execute("SELECT count(*) FROM session_v2 WHERE id=?", (sid,)).fetchone()[0]
|
||||
+ conn.execute("SELECT count(*) FROM session_message WHERE session_id=?", (sid,)).fetchone()[0]
|
||||
+ conn.execute("SELECT count(*) FROM event_sequence WHERE aggregate_id=?", (sid,)).fetchone()[0])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_the_db_comes_from_debug_paths(oc):
|
||||
adapter, ctx, db, ws, calls = oc
|
||||
assert adapter._resolve_db(ctx) == str(db)
|
||||
assert calls() == ["debug paths"]
|
||||
|
||||
|
||||
def test_discover_and_verify_read_session_v2_and_skip_child_sessions(oc):
|
||||
adapter, ctx, db, ws, calls = oc
|
||||
_add(db, "ses_main", ws, 2_000_000)
|
||||
_add(db, "ses_child", ws, 3_000_000, parent="ses_main") # a subagent's session: newer, same dir
|
||||
_add(db, "ses_old", ws, 500_000)
|
||||
ctx.epoch = 1000
|
||||
assert adapter.discover(ctx) == ["ses_main"]
|
||||
assert adapter.verify_artifact("ses_main", ctx) is True
|
||||
assert adapter.verify_artifact("ses_old", ctx) is False
|
||||
|
||||
|
||||
def test_a_fresh_2x_database_has_no_session_table_and_still_works(oc):
|
||||
adapter, ctx, db, ws, calls = oc
|
||||
_add(db, "ses_main", ws, 2_000_000)
|
||||
conn = sqlite3.connect(str(db))
|
||||
assert conn.execute("SELECT count(*) FROM sqlite_master WHERE name='session'").fetchone()[0] == 0
|
||||
conn.close()
|
||||
assert adapter.discover(ctx) == ["ses_main"]
|
||||
|
||||
|
||||
def test_purge_asks_opencode_to_delete_the_session_and_its_children(oc):
|
||||
adapter, ctx, db, ws, calls = oc
|
||||
_add(db, "ses_main", ws, 2_000_000)
|
||||
_add(db, "ses_child", ws, 3_000_000, parent="ses_main")
|
||||
_add(db, "ses_other", ws, 2_500_000)
|
||||
purged = adapter.purge_artifacts("ses_main", ctx)
|
||||
assert purged == ["opencode session (with its child sessions): ses_main"] # no 1.x tables: nothing else
|
||||
assert "session delete --standalone -- ses_main" in calls()
|
||||
assert _rows(db, "ses_main") == 0 and _rows(db, "ses_child") == 0
|
||||
assert _rows(db, "ses_other") == 3
|
||||
assert adapter.residual_artifacts("ses_main", ctx) == []
|
||||
|
||||
|
||||
def test_purge_does_not_run_opencode_for_a_session_it_does_not_hold(oc):
|
||||
adapter, ctx, db, ws, calls = oc
|
||||
assert adapter.purge_artifacts("ses_missing", ctx) == []
|
||||
assert not any(c.startswith("session delete") for c in calls())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("broken", ["exit", "missing"])
|
||||
def test_a_failed_delete_is_warned_and_reported(oc, monkeypatch, capsys, broken):
|
||||
adapter, ctx, db, ws, calls = oc
|
||||
_add(db, "ses_main", ws, 2_000_000)
|
||||
if broken == "missing":
|
||||
monkeypatch.setattr(adapter, "_bin", lambda ctx: None)
|
||||
want = "no opencode binary"
|
||||
else:
|
||||
failing = Path(ctx.home_dir).parent / "fakebin" / "failing"
|
||||
failing.write_text('#!/bin/sh\n[ "$1" = debug ] && exec "$(dirname "$0")/opencode-real" "$@"\necho boom >&2\nexit 3\n')
|
||||
failing.chmod(0o755)
|
||||
monkeypatch.setattr(adapter, "_bin", lambda ctx: str(failing))
|
||||
want = "exited 3: boom"
|
||||
assert adapter.purge_artifacts("ses_main", ctx) == []
|
||||
assert want in capsys.readouterr().err
|
||||
assert f"{adapter._resolve_db(ctx)} (session_v2.id: 1 rows)" in adapter.residual_artifacts("ses_main", ctx)
|
||||
|
||||
|
||||
def test_purge_takes_the_1x_rows_of_the_child_sessions_too(oc):
|
||||
"""A migrated database: the child sessions opencode deletes with their
|
||||
parent keep 1.x rows that only the purge removes (review 614b6263)."""
|
||||
adapter, ctx, db, ws, calls = oc
|
||||
conn = sqlite3.connect(str(db))
|
||||
conn.execute("CREATE TABLE session (id TEXT PRIMARY KEY, parent_id TEXT)")
|
||||
conn.execute("CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT)")
|
||||
conn.execute("CREATE TABLE todo (id TEXT, session_id TEXT)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
tree = [("ses_main", None), ("ses_child", "ses_main"), ("ses_grand", "ses_child"), ("ses_other", None)]
|
||||
for sid, parent in tree:
|
||||
_add(db, sid, ws, 2_000_000, parent=parent)
|
||||
conn = sqlite3.connect(str(db))
|
||||
conn.execute("INSERT INTO session VALUES (?, ?)", (sid, parent))
|
||||
conn.execute("INSERT INTO message VALUES (?, ?)", (f"m-{sid}", sid))
|
||||
conn.execute("INSERT INTO todo VALUES ('t', ?)", (sid,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
assert any(r.endswith("[child session ses_grand]") for r in adapter.residual_artifacts("ses_main", ctx))
|
||||
purged = adapter.purge_artifacts("ses_main", ctx)
|
||||
assert purged == ["opencode session (with its child sessions): ses_main",
|
||||
"sqlite rows for session: ses_main and 2 child session(s)"]
|
||||
for sid in ("ses_main", "ses_child", "ses_grand"):
|
||||
assert adapter.residual_artifacts(sid, ctx) == [], sid
|
||||
# the unrelated session is untouched: session_v2, session_message, event_sequence, session, message, todo
|
||||
assert len(adapter.residual_artifacts("ses_other", ctx)) == 6
|
||||
|
||||
|
||||
def test_a_failed_delete_reports_the_child_sessions_left(oc, monkeypatch):
|
||||
adapter, ctx, db, ws, calls = oc
|
||||
_add(db, "ses_main", ws, 2_000_000)
|
||||
_add(db, "ses_child", ws, 2_000_000, parent="ses_main")
|
||||
monkeypatch.setattr(adapter, "_bin", lambda ctx: None)
|
||||
adapter.purge_artifacts("ses_main", ctx)
|
||||
residual = adapter.residual_artifacts("ses_main", ctx)
|
||||
assert f"{db} (session_v2.id: 1 rows) [child session ses_child]" in residual
|
||||
assert f"{db} (session_v2.id: 1 rows)" in residual
|
||||
|
||||
|
||||
def _holder(tmp_path, db, *argv):
|
||||
"""A live process holding `db` open, with `argv` on its command line."""
|
||||
ready = tmp_path / f"ready-{len(argv)}"
|
||||
code = ("import sys, time; f = open(sys.argv[1]); open(sys.argv[2], 'w').close(); time.sleep(60)")
|
||||
p = subprocess.Popen([sys.executable, "-c", code, str(db), str(ready), *argv])
|
||||
for _ in range(100):
|
||||
if ready.exists():
|
||||
break
|
||||
time.sleep(0.05)
|
||||
return p
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("lsof") is None, reason="no lsof")
|
||||
def test_the_background_service_alone_does_not_hold_a_session(oc, tmp_path):
|
||||
"""The per-user service keeps the shared database open for every session
|
||||
(2.x); MAM runs none of its sessions there."""
|
||||
adapter, ctx, db, ws, calls = oc
|
||||
_add(db, "ses_main", ws, 2_000_000)
|
||||
assert adapter.conversation_in_use("ses_main", ctx) is False
|
||||
service = _holder(tmp_path, db, "serve", "--service")
|
||||
try:
|
||||
assert adapter.conversation_in_use("ses_main", ctx) is False
|
||||
other = _holder(tmp_path, db, "--auto", "--standalone") # another session's TUI or its server
|
||||
try:
|
||||
assert adapter.conversation_in_use("ses_main", ctx) is True
|
||||
finally:
|
||||
other.kill()
|
||||
other.wait()
|
||||
finally:
|
||||
service.kill()
|
||||
service.wait()
|
||||
named = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)", "--session", "ses_main"])
|
||||
try:
|
||||
time.sleep(0.2)
|
||||
assert adapter.conversation_in_use("ses_main", ctx) is True
|
||||
finally:
|
||||
named.kill()
|
||||
named.wait()
|
||||
|
||||
|
||||
def test_the_data_dir_follows_the_callers_xdg_data_home_only_for_its_own_home(tmp_path, monkeypatch):
|
||||
adapter = get_adapter("opencode")
|
||||
monkeypatch.setattr(adapter, "_bin", lambda ctx: None)
|
||||
own, other, xdg = tmp_path / "own", tmp_path / "other", tmp_path / "xdg"
|
||||
monkeypatch.setenv("HOME", str(own))
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(xdg))
|
||||
ws = str(tmp_path)
|
||||
assert adapter._resolve_db(DiscoveryContext(workspace=ws, agent_name="opencode", home_dir=str(own))) \
|
||||
== f"{xdg}/opencode/opencode.db"
|
||||
assert adapter._resolve_db(DiscoveryContext(workspace=ws, agent_name="opencode", home_dir=str(other))) \
|
||||
== f"{other}/.local/share/opencode/opencode.db"
|
||||
monkeypatch.delenv("XDG_DATA_HOME")
|
||||
assert adapter._resolve_db(DiscoveryContext(workspace=ws, agent_name="opencode", home_dir=str(own))) \
|
||||
== f"{own}/.local/share/opencode/opencode.db"
|
||||
|
||||
|
||||
def test_the_standalone_server_is_a_resident_child_but_its_tools_are_work():
|
||||
"""--standalone keeps `opencode serve --stdio` under the TUI while idle;
|
||||
the stall watcher skips it but still sees what runs under it."""
|
||||
from lib_py.stall_watch import background_children
|
||||
resident = get_adapter("opencode").resident_children
|
||||
tui = subprocess.Popen(["bash", "-c", "(exec -a opencode sleep 300) & exec sleep 300"], start_new_session=True)
|
||||
busy = subprocess.Popen(["bash", "-c", "(exec -a opencode bash -c 'sleep 300; :') & exec sleep 300"],
|
||||
start_new_session=True)
|
||||
try:
|
||||
time.sleep(0.5)
|
||||
assert background_children(tui.pid, ()) != []
|
||||
assert background_children(tui.pid, resident) == []
|
||||
assert background_children(busy.pid, resident) != []
|
||||
finally:
|
||||
for p in (tui, busy):
|
||||
os.killpg(p.pid, 9) # the whole group: children and grandchildren
|
||||
p.wait()
|
||||
|
||||
|
||||
def test_spawn_and_resume_never_pass_agent_and_run_standalone():
|
||||
"""The shell copies of the opencode command line match the adapter."""
|
||||
adapter = get_adapter("opencode")
|
||||
assert adapter.spawn_spec("opencode") == "opencode --auto --standalone"
|
||||
assert adapter.resume_spec("opencode", "ses_x", materialized=True) == "opencode --session ses_x --auto --standalone"
|
||||
for rel in ("multi-agent-mux-create/scripts/create_session.sh",
|
||||
"multi-agent-mux-resume/scripts/resume_session.sh",
|
||||
"multi-agent-mux-resume/scripts/update_yaml_resumed.sh"):
|
||||
text = (SKILLS / rel).read_text()
|
||||
lines = [l for l in text.splitlines() if "opencode" in l and "--auto" in l]
|
||||
assert lines, rel
|
||||
for l in lines:
|
||||
assert "--agent" not in l and "--standalone" in l, (rel, l)
|
||||
|
||||
|
||||
def test_status_and_reconcile_read_session_v2():
|
||||
for rel in ("multi-agent-mux-status/scripts/status.sh", "multi-agent-mux-monitor/scripts/reconcile.sh"):
|
||||
text = (SKILLS / rel).read_text()
|
||||
assert not re.search(r"FROM session\b(?!_)|table_info\(session\)", text), rel
|
||||
assert "session_v2" in text, rel
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# a real opencode 2.x, in a home of its own
|
||||
# ---------------------------------------------------------------------------
|
||||
def _real_opencode():
|
||||
path = shutil.which("opencode") or os.path.expanduser("~/.opencode/bin/opencode")
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
import tempfile
|
||||
try:
|
||||
# Even --version writes opencode's log under its data dir: give it a
|
||||
# home of its own (review 8a485fa0 N2).
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env = {"PATH": os.environ.get("PATH", ""), "HOME": tmp, "TMPDIR": tmp}
|
||||
out = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=20, env=env).stdout
|
||||
except Exception:
|
||||
return None
|
||||
m = re.search(r"(\d+)\.\d+\.\d+", out)
|
||||
return path if m and int(m.group(1)) >= 2 else None
|
||||
|
||||
|
||||
REAL = _real_opencode()
|
||||
|
||||
|
||||
@pytest.mark.skipif(REAL is None, reason="no opencode 2.x installed")
|
||||
def test_real_opencode_2x_contract(tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
ws = tmp_path / "ws"
|
||||
for d in (home, ws, tmp_path / "tmp"):
|
||||
d.mkdir()
|
||||
ws_real = os.path.realpath(str(ws))
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
monkeypatch.setenv("TMPDIR", str(tmp_path / "tmp"))
|
||||
for k in ("XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "HOME_DIR"):
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
env = {**os.environ, "TERM": "dumb"}
|
||||
|
||||
def cli(*args, check=True):
|
||||
res = subprocess.run([REAL, *args], capture_output=True, text=True, timeout=120, cwd=ws_real, env=env)
|
||||
if check:
|
||||
assert res.returncode == 0, (args, res.stdout, res.stderr)
|
||||
return res
|
||||
|
||||
help_text = cli("--help").stdout
|
||||
assert "--standalone" in help_text and "--session" in help_text
|
||||
assert not re.search(r"^\s*--agent\b", help_text, re.M), "the TUI takes --agent again: revisit spawn_spec"
|
||||
|
||||
import json
|
||||
parent = json.loads(cli("api", "--standalone", "POST", "/api/session", "-d", '{"title":"mam test"}').stdout)["data"]["id"]
|
||||
child = json.loads(cli("api", "--standalone", "POST", "/api/session", "-d",
|
||||
json.dumps({"title": "mam child", "parentID": parent})).stdout)["data"]["id"]
|
||||
|
||||
adapter = get_adapter("opencode")
|
||||
# The binary was found with the real HOME; under the test's HOME only a
|
||||
# PATH entry would find it (review f4cedbae N1).
|
||||
monkeypatch.setattr(adapter, "_bin", lambda ctx: REAL)
|
||||
ctx = DiscoveryContext(workspace=ws_real, agent_name="opencode", home_dir=str(home))
|
||||
db = adapter._resolve_db(ctx)
|
||||
assert db == str(home / ".local" / "share" / "opencode" / "opencode.db")
|
||||
assert adapter.discover(ctx) == [parent]
|
||||
assert adapter.verify_artifact(child, ctx) is True
|
||||
|
||||
purged = adapter.purge_artifacts(parent, ctx)
|
||||
assert purged == [f"opencode session (with its child sessions): {parent}"]
|
||||
assert adapter.residual_artifacts(parent, ctx) == []
|
||||
assert adapter.residual_artifacts(child, ctx) == []
|
||||
gone = cli("session", "delete", "--standalone", "--", parent, check=False)
|
||||
assert gone.returncode == 1 and "Session not found" in gone.stdout + gone.stderr
|
||||
@@ -1249,6 +1249,17 @@ def test_comp_opencode_create_and_reconcile_and_stop(mam_sandbox, mock_herdr, mo
|
||||
assert s["opencode_session_id_own"] is None
|
||||
assert s["session_id_source"] == "pending-discovery"
|
||||
|
||||
# A subagent's session in the same directory, newer than the TUI's own
|
||||
# (opencode 2.x, Issue #60): drift C must pin the top-level session.
|
||||
import sqlite3
|
||||
odb = os.path.join(os.environ["HOME"], ".local", "share", "opencode", "opencode.db")
|
||||
conn = sqlite3.connect(odb)
|
||||
(parent, directory, created), = conn.execute("SELECT id, directory, time_created FROM session_v2").fetchall()
|
||||
conn.execute("INSERT INTO session_v2 (id, parent_id, directory, time_created) VALUES (?, ?, ?, ?)",
|
||||
(f"{parent}-child", parent, directory, created + 1000))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Run reconcile.sh to trigger drift-C materialization
|
||||
reconcile_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
||||
res_rec = subprocess.run(["bash", str(reconcile_script), "--once", "--emit-diff"], capture_output=True, text=True, cwd=str(mam_sandbox), env={**os.environ, "WORKSPACE_ROOT": str(mam_sandbox)})
|
||||
@@ -1259,7 +1270,7 @@ def test_comp_opencode_create_and_reconcile_and_stop(mam_sandbox, mock_herdr, mo
|
||||
with open(yaml_path) as f:
|
||||
data_after = yaml.safe_load(f)
|
||||
s_after = [x for x in data_after.get("herdr_sessions", []) if x.get("name") == session_name][0]
|
||||
assert s_after.get("opencode_session_id_own") is not None
|
||||
assert s_after.get("opencode_session_id_own") == parent
|
||||
assert s_after.get("last_visible_status") in ("pinned", "resume_verified")
|
||||
|
||||
# Stop session
|
||||
|
||||
Reference in new issue
Block a user