Compare commits
77
Commits
6b4b96fa18
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9c828f092 | ||
|
|
4490e7053b | ||
|
|
ad65d4833e | ||
|
|
7d2f65ad42 | ||
|
|
225c52710a | ||
|
|
56e6713292 | ||
|
|
ec7f5c4e57 | ||
|
|
1d63e882bc | ||
|
|
cf1b693e85 | ||
|
|
454b77215f | ||
|
|
253a0aa56e | ||
|
|
ba75718ac3 | ||
|
|
e3394655b3 | ||
|
|
595e34b2d1 | ||
|
|
8f325de979 | ||
|
|
515b3206b9 | ||
|
|
fce9459e42 | ||
|
|
47fe24e07c | ||
|
|
6359d65999 | ||
|
|
179473e5d5 | ||
|
|
ec378e82c9 | ||
|
|
0e1e2e87de | ||
|
|
342a9a4edf | ||
|
|
5f6065344f | ||
|
|
a47a4683f3 | ||
|
|
8b829647f4 | ||
|
|
0fc162e8cd | ||
|
|
a6fb39ba9f | ||
|
|
9410913da9 | ||
|
|
bd61ef532f | ||
|
|
015075077c | ||
|
|
263a72483a | ||
|
|
5cd72d2df3 | ||
|
|
e4c7c8fe9b | ||
|
|
9c21fa4e4f | ||
|
|
50e2998a20 | ||
|
|
4243da507c | ||
|
|
29cc4fd904 | ||
|
|
04e32212f7 | ||
|
|
bb496fde78 | ||
|
|
0461a632eb | ||
|
|
0454fe968b | ||
|
|
1614af5b67 | ||
|
|
7cac6f613e | ||
|
|
8f64174b6b | ||
|
|
ede9c62be3 | ||
|
|
a907cc5294 | ||
|
|
d115bf7156 | ||
|
|
c747374aa6 | ||
|
|
8c9a68cf1c | ||
|
|
e594d2b650 | ||
|
|
fa93cc5622 | ||
|
|
3a2219454e | ||
|
|
b8a30d5b93 | ||
|
|
ead490bddc | ||
|
|
dc886ecd6b | ||
|
|
0d5f10a546 | ||
|
|
84af709bb3 | ||
|
|
feccfd5e03 | ||
|
|
ff8b381520 | ||
|
|
38ea4f3b8e | ||
|
|
b0eccc5301 | ||
|
|
3ce5d5fdc1 | ||
|
|
cffe279eb0 | ||
|
|
6a37c0019b | ||
|
|
6a4e385a78 | ||
|
|
d17cd80bd9 | ||
|
|
7803d47026 | ||
|
|
463817ee34 | ||
|
|
614ab1420f | ||
|
|
ac5f1154c7 | ||
|
|
14d7a46181 | ||
|
|
4d49abb371 | ||
|
|
1d7b7dab58 | ||
|
|
cb2c84ccd7 | ||
|
|
0e05b1b677 | ||
|
|
cb5f439cce |
No files matched your search
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261025T023502Z-d6f876",
|
||||
"base_sha": "0461a632eb667c7988fd33be92e9a4b36151f22a",
|
||||
"head_sha": "bb496fde785133ad1dbec288aa7431766efb6eb5",
|
||||
"tree_sha": "15a72c9a1933c64018a2f2d9aa9707348905bb4d",
|
||||
"review_commit": "bb496fde785133ad1dbec288aa7431766efb6eb5",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "05af786c",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-25T03:40:44Z"
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
# PR #70 (Issue #66) 리뷰 — head bb496fd
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (macOS, bash 3.2, /usr/bin/python3 3.9.6)
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1`
|
||||
- identity 확인: HEAD bb496fde785133ad1dbec288aa7431766efb6eb5, tree 15a72c9a1933c64018a2f2d9aa9707348905bb4d, `git status --porcelain --ignored` 비어 있음, `.mam` 없음 (시작·종료 시 모두). main HEAD 0461a63 그대로.
|
||||
- 범위: base 0461a63..bb496fd (커밋 1개). 바뀐 파일은 세 개다: `.agents/skills/lib_py/stall_watch.py`, `tests/conftest.py`, `tests/test_issue66_stall_watch_sanitized_name.py`.
|
||||
|
||||
## 1. 확인한 것과 근거
|
||||
|
||||
### 1) 완료 조건
|
||||
|
||||
herdr 를 부르는 곳은 모두 변환된 이름을 쓴다.
|
||||
- `HerdrSource.__init__` 이 `self.name = sanitize_herdr_agent_name(target)` 를 만든다 (`stall_watch.py:162`).
|
||||
- 이 이름을 쓰는 곳:
|
||||
- `agent get` (:183)
|
||||
- `agent read` (:206)
|
||||
- `agent prompt` (:211)
|
||||
- `watcher.wait_once(self.herdr_name, …)` (:331, 값은 :229)
|
||||
- `pane process-info` 와 `pane read` (:187, :193)는 `agent get` 이 돌려준 pane_id 를 쓰므로 이름과 상관이 없다.
|
||||
- `--herdr-session` 은 서버 이름으로만 쓰인다. `self.session` (:159) 과 `session=self.args.herdr_session` (:331) 이고, 변환되지 않는다. 둘을 섞어 쓴 곳은 없다.
|
||||
|
||||
로그와 알림은 원래 이름을 쓴다.
|
||||
- 로그 :373 (blocked), :397 (입력창 비어 있지 않음), :406 (idle) 은 모두 `self.args.target` 이다.
|
||||
- 알림 상세(`stalled:idle`, `stalled:blocked …`)에는 이름이 들어가지 않는다.
|
||||
- 회귀 테스트는 `agent {target} idle for` 와 `agent {target} is blocked` 가 원래 이름으로 나오는지 확인한다 (test :85, :106).
|
||||
|
||||
변환이 필요 없는 이름(`creator-claude-t`)은 그대로이고, 그 경우의 테스트도 통과한다 (test :68 의 PLAIN).
|
||||
|
||||
### 2) 회귀 테스트: base 에서 실패, head 에서 통과
|
||||
|
||||
- head: 7 passed.
|
||||
- base(0461a63) 사본에 head 의 새 테스트와 conftest 를 넣고 돌리면 5 failed, 2 passed 다.
|
||||
- 실패: LONG, UPPER 두 이름 각각의 idle nudge 와 blocked 보고 (4개), 긴 codex 이름의 종류 판정 (1개).
|
||||
- 통과: strict mock 자체를 확인하는 테스트와, 변환이 필요 없는 PLAIN.
|
||||
|
||||
### 3) 변환 함수가 lib.sh 와 같은 결과를 내는가
|
||||
|
||||
lib.sh `_sanitize_herdr_agent_name` (:293-316) 과 `lib_py.agents.sanitize.sanitize_herdr_agent_name` 에 같은 이름 10개를 넣어 비교했다.
|
||||
- 비교한 이름: 그대로 둘 이름, 대문자, 32자 초과(루프 세션 이름 포함), 정확히 32자, 33자, 숫자로 시작, `.`·공백·`@`, 비 ASCII `Ü`, `_` 로 시작.
|
||||
- 결과: 10개 모두 같고, 두 번 변환해도 바뀌지 않는다.
|
||||
- 그래서 호출자가 이미 변환된 이름을 넘겨도 안전하다.
|
||||
- 호출자는 원래 세션 이름을 넘긴다: `multi-agent-mux-delegate-job:431`, `:615` → `start_stall_watch "$AGENT_SESSION"` 와 `"$current_session"`.
|
||||
|
||||
### 4) resolve_agent 를 원래 이름으로 하는 것이 맞는가
|
||||
|
||||
맞다.
|
||||
- 긴 이름은 변환되면 끝이 해시로 바뀐다. 예: `mam-wt-issue-66-very-long-creator-codex` → `…-<sha8>`. 그래서 종류는 원래 접미사에서 읽어야 한다.
|
||||
- `HerdrSource` (:163) 과 `StallWatch` (:230) 둘 다 원래 이름을 쓴다.
|
||||
- 테스트 :111-129 가 `--agent ""` 일 때 codex 로 정해져 nudge 가 가는 것을 확인한다.
|
||||
- 종류를 변환된 이름에서 읽도록 바꾼 변이(m3)는 이 테스트에 잡힌다.
|
||||
|
||||
### 5) conftest `strict_agent_names`
|
||||
|
||||
- 기본(키 없음)일 때 `_match_agent` 는 예전과 똑같이 네 가지 비교를 한다 (`conftest.py` 수정분 :473-476). get/read 의 실패 출력도 예전 문구 그대로다. 그래서 기존 테스트의 동작은 바뀌지 않는다.
|
||||
- 켜면 등록된 이름과 정확히 같을 때만 답하고, 아니면 `{"error":{"code":"agent_not_found"}}` 를 stderr 에 쓰고 exit 1 한다. 실제 herdr 처럼 등록 이름(변환된 이름)으로만 찾는다.
|
||||
- 테스트 :51-65 가 get, read, wait, prompt 네 명령 모두 원래 이름에는 agent_not_found 를, 변환된 이름에는 rc 0 을 내는지 확인한다.
|
||||
|
||||
### 6) macOS 호환
|
||||
|
||||
- `/usr/bin/python3` 3.9.6 으로 `lib_py.stall_watch` import 와 `resolve_agent` 호출이 된다.
|
||||
- 새 코드에 `X | Y`, `match`, tomllib 가 없다. 셸은 바뀌지 않았다.
|
||||
|
||||
### 7) test_issue17_item4_decisions::test_c_finalize_ends_with_an_unreaped_wrapper[broker_unavailable-3-0]
|
||||
|
||||
이 변경과 무관하다.
|
||||
- 이 테스트는 래퍼의 finalize 를 자체 shim(`PY=<shim>`)으로 돌린다. stall watcher 는 argv 를 기록하는 대역이다 (`tests/test_issue17_item4_decisions.py:218`).
|
||||
- conftest 의 mock herdr 와 `lib_py.stall_watch` 를 쓰지 않는다.
|
||||
- 판정 조건은 `took < 4.5` 와 terminate 뒤 레코드가 그대로인지 (:598-601) 로, 시간과 정리 단계에 의존한다.
|
||||
- 이 PR 이 바꾼 것(stall_watch 의 이름 변환, 기본 꺼짐인 mock 옵션)은 이 경로에 닿지 않는다.
|
||||
- 이 호스트에서도 통과했다(아래 표).
|
||||
|
||||
## 2. 실행한 테스트
|
||||
|
||||
모두 `env -i`, 임시 HOME/XDG, `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider --basetemp=<scratchpad>` 로 돌렸다. .mam.env 를 export 하지 않았다.
|
||||
|
||||
| 대상 | 결과 |
|
||||
|---|---|
|
||||
| 검증 경로 `tests/test_issue66_stall_watch_sanitized_name.py` | 7 passed |
|
||||
| base 사본 + 새 테스트·conftest | 5 failed, 2 passed |
|
||||
| 검증 경로: test_issue30_stall_watch, test_issue30_stall_watch_stop, test_issue17_item4_decisions, test_tier2_component, test_issue63_same_cwd_ids, test_issue50_dispatch_herdr, test_c1_tui_readiness (mock herdr 를 쓰는 인접 테스트) | 457 passed, 1 skipped, 0 failed |
|
||||
|
||||
변이 테스트 (head 사본, issue66 테스트):
|
||||
|
||||
| # | 변이 | 결과 |
|
||||
|---|---|---|
|
||||
| m1 | `wait_once` 를 원래 이름으로 | 잡힘 (5 failed) |
|
||||
| m2 | `agent prompt` 를 원래 이름으로 | 잡힘 (3 failed) |
|
||||
| m3 | 종류를 변환된 이름에서 판정 | 잡힘 (1 failed) |
|
||||
| m4 | blocked 로그를 변환된 이름으로 | 잡힘 (2 failed) |
|
||||
| m5 | `agent read` 를 원래 이름으로 | 잡힘 (3 failed) |
|
||||
|
||||
실제 herdr 서버(pid 19169 `herdr server`, 23492 `herdr --session multi-agent-mux server`)는 테스트 전후로 같다.
|
||||
|
||||
## 3. 차단 결함
|
||||
|
||||
없음.
|
||||
|
||||
## 4. 비차단
|
||||
|
||||
- **N1**: 변환된 이름을 두 곳에서 따로 만든다(`HerdrSource.name` :162, `StallWatch.herdr_name` :229). 주입된 source 를 쓰는 테스트 때문에 둘 다 필요해 보인다. 결과가 같으므로 동작에는 문제가 없다.
|
||||
- **N2** (기존, 이 PR 과 무관): `--agent` 가 비어 있고 이름이 `…-claude-01` 처럼 숫자로 끝나면 `resolve_agent` 는 종류를 정하지 못한다(None). 실제 호출자는 항상 `--agent "$AGENT"` 를 넘기므로 지금은 영향이 없다.
|
||||
- **N3** (기존, 이 PR 과 무관): lib.sh 에 `_sanitize_herdr_agent_name` 이 두 벌 있다(:36, :293). sha 도구가 없을 때의 대처가 서로 다르다(:36 은 32자로 자르고, :293 은 python3 로 해시). 이 호스트처럼 shasum 이 있으면 두 벌 모두 Python 판과 같다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261027T201245Z-63c71b",
|
||||
"base_sha": "1d63e882bc1c46e9af8484647d5091b296d8fa3f",
|
||||
"head_sha": "ec7f5c4e57286fb3c4238b8220f39094f4640391",
|
||||
"tree_sha": "fbadb24de60ccdae684b23e2e0a41cdf23ab81eb",
|
||||
"review_commit": "ec7f5c4e57286fb3c4238b8220f39094f4640391",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "1d2c21dc",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-27T21:06:27Z"
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
# PR #75 후속 라운드 리뷰 — 1d63e88..ec7f5c4 (review round 5)
|
||||
|
||||
리뷰어: planner-reviewer-claude-01 (claude) · job 1d2c21dc
|
||||
|
||||
## 0. 리뷰 경로 확인
|
||||
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261027T201245Z-63c71b/1`
|
||||
- HEAD `ec7f5c4e57286fb3c4238b8220f39094f4640391`, tree `fbadb24de60ccdae684b23e2e0a41cdf23ab81eb` 로 [리뷰 identity] 와 같다.
|
||||
- `git status --porcelain --ignored` 는 비어 있다. `.mam` 은 없다.
|
||||
- 리뷰 전후로 확인했다. 본 체크아웃 main 은 `0e1e2e8` 그대로다.
|
||||
- 범위: 1d63e88..HEAD 의 커밋 `ec7f5c4` 하나다.
|
||||
- 바뀐 파일: `.agents/skills/lib.sh`(+7/−1), `tests/test_issue72_update.py`(+32/−3)
|
||||
- 브리프가 정한 범위(R2 와 테스트의 `pkill` 제거) 밖은 건드리지 않았다.
|
||||
|
||||
## 1. R2 — legacy `start_watchdog` 의 프로세스 그룹 분리
|
||||
|
||||
### 1.1 코드 (`lib.sh:2297-2310`)
|
||||
|
||||
- 모니터를 띄우는 `nohup bash … &` 직전에 `set -m` 을 켠다.
|
||||
- 켜기 전에 호출자의 `$-` 에 `m` 이 있었는지 `had_m` 에 기억한다.
|
||||
- `pid=$!` 를 읽은 뒤, 원래 꺼져 있었으면 `set +m` 으로 되돌린다.
|
||||
- job control 이 켜진 상태에서 띄운 백그라운드 잡은 자기 프로세스 그룹의 리더가 된다(PGID = PID). 그래서 다음 효과가 있다.
|
||||
- `$!` 는 그대로 그 PID 다. nohup 과 bash 가 exec 해도 PID 는 바뀌지 않는다.
|
||||
- 따라서 정확한 PID 보고(`echo "$pid"`, `:2314-2316`)와 `mam_monitor_started` 확인이 바뀌지 않는다.
|
||||
- 다음 기존 동작은 바뀌지 않았다.
|
||||
- 중복 방지(`_monitor_pids`, `:2291`)
|
||||
- `MAM_NO_SUBSCRIBER_DAEMON=1` 분기(`:2295-2296`)
|
||||
- stdin `/dev/null`, 로그 append(`>> "$log_file"`), 작업 공간 cwd, `--idle-timeout 0`
|
||||
- 바뀐 것은 분기 안의 세 줄뿐이다.
|
||||
- 호출하는 곳은 모두 `$(…)` 안이다. 그래서 `set -m` 은 그 서브셸 안에서만 잠깐 켜진다.
|
||||
- `update.sh:106`, `post_update.sh:41`, `install_phase.sh:64`
|
||||
- `create_session.sh:748`, `resume_session.sh:162`
|
||||
|
||||
### 1.2 이 Mac 에서 직접 확인 (`/bin/bash` 3.2, tty 없음, `set -euo pipefail`)
|
||||
|
||||
- 같은 모양의 함수를 스크래치 스크립트로 만들어 돌렸다.
|
||||
- 결과 1: `$(f)` 안에서 띄운 자식의 PGID 는 자기 PID 와 같다(53727 = 53727). 호출자의 PGID(53714)와 다르다.
|
||||
- 결과 2: 직접 호출한 경우도 마찬가지다.
|
||||
- 결과 3: 호출 뒤 `$-` 는 `ehuB` 로 그대로다. 곧 `m` 이 남지 않는다.
|
||||
- 결과 4: "no job control", "[1] PID", "Done" 같은 잡 메시지가 stdout 에도 stderr 에도 나오지 않았다. 그래서 `$(…)` 로 받는 PID 출력이 오염되지 않는다.
|
||||
- **고치기 전 코드에서 실패하는지도 확인했다.**
|
||||
- 방법: `git archive HEAD` 로 TMPDIR 에 사본을 풀었다. 그 사본의 `lib.sh` 만 1d63e88 판으로 바꿨다.
|
||||
- 그 사본에서 `-k test_every_restart` 를 실행했다.
|
||||
- 결과: **mode=0 의 3개(installed, killed, rolled-back)가 `left in the caller's group` 로 실패했다. mode=1 의 3개는 통과했다(3 failed, 3 passed).**
|
||||
- 즉 새 assertion(`tests/test_issue72_update.py:723-724`)이 R2 를 실제로 잡는다. 수정 뒤 6개가 모두 통과한다(§3).
|
||||
- 사본은 확인 뒤 지웠고, 남은 프로세스도 없다.
|
||||
- 세 경로와 두 모드: 정상 설치, 정지 시간 초과 뒤 복원, rollback 뒤 복원을 mode 0·1 로 돌리는 6 조합이 모두 같은 함수를 지나고, 모두 통과한다.
|
||||
- rollback 경로는 되돌린 트리의 `lib.sh` 를 쓴다. 테스트 상자에서는 그 트리가 이 버전이다.
|
||||
|
||||
### 1.3 python 경로와의 차이 (비차단, 참고)
|
||||
|
||||
- 두 경로가 분리하는 정도가 다르다.
|
||||
- python 경로(`daemon.py:95`): `start_new_session=True` 로 새 **세션**을 만든다. 그래서 제어 터미널에서도 떨어진다.
|
||||
- legacy 경로: 새 **프로세스 그룹**만 만든다. 세션과 제어 터미널은 호출자와 같다.
|
||||
- 결정 2 의 요구("부른 쪽 프로세스 그룹에서 분리")는 이것으로 충족된다.
|
||||
- 프로세스 그룹 신호(테스트 러너·timeout 래퍼의 killpg, 터미널의 SIGINT)는 이제 모니터에 닿지 않는다. 터미널 신호는 전경 그룹에만 가는데, 모니터는 배경 그룹이다.
|
||||
- 터미널이 닫힐 때의 SIGHUP 은 nohup 이 무시한다.
|
||||
- 배경 그룹이 tty 에 읽고 쓸 때 생기는 SIGTTIN/SIGTTOU 도 없다. stdin 은 `/dev/null` 이고 출력은 로그 파일로 간다.
|
||||
- `lib.sh:246-253`(shim 서버 기동)은 같은 문제에 `start_new_session=True` 를 쓰는 관례를 적어 두었다. legacy 경로만 `set -m` 을 쓴다.
|
||||
- 브리프가 "가장 작은 수정" 을 요구했고, legacy 경로는 opt-in 이므로 결함으로 보지 않는다.
|
||||
|
||||
## 2. 테스트의 `pkill` 제거 (`tests/test_issue72_update.py:184-194`, `:218-240`)
|
||||
|
||||
### 2.1 새 teardown 이 정리하는 것
|
||||
|
||||
- 패턴 kill 이 없어졌다. `tests/test_issue72_*.py` 전체에 `pkill`·`killall` 이 남아 있지 않다(grep 으로 확인).
|
||||
- 대신 다음 순서로 정리한다.
|
||||
1. 테스트가 `Box.helper`(`:132-135`, `start_new_session=True`)로 띄우고 `b.helpers` 에 기록한 프로세스의 그룹을 `killpg` 하고 `wait` 한다.
|
||||
2. 이 테스트의 작업 공간 `ws` 에 대해 `bash <ws>/…/reconcile.sh --subscribe` 형태로 정확히 맞는 모니터(`_exact_monitor_pids`)와 그 자손(`_descendants`, `pgrep -P` 재귀)만 SIGKILL 한다.
|
||||
- 남은 프로세스 확인: 명령줄에 `ws` 가 들어 있는 살아 있는 프로세스가 10초 안에 없어지지 않으면 이름을 적어 실패한다(`_processes_naming`, `:236-240`).
|
||||
- `ws` 는 basetemp 아래의 테스트별 경로다. 그래서 다른 작업 공간의 프로세스와 겹치지 않는다.
|
||||
|
||||
### 2.2 남은 프로세스 확인이 약해지지 않았는지
|
||||
|
||||
- 예전 `pkill -KILL -f <ws>` 는 `ws` 를 명령줄에 가진 것을 **조용히 죽였을** 뿐이다. 남은 프로세스를 확인하지는 않았다.
|
||||
- 새 방식은 정확히 고른 것만 죽인다. 나머지 가운데 `ws` 를 가진 프로세스가 남으면 **실패로 드러낸다**.
|
||||
- 그래서 확인은 더 강해졌다.
|
||||
- 대가가 하나 있다. 그런 프로세스가 생기면 정리되지 않고 남는다. 다만 이름이 실패 메시지에 나오므로 사람이 정리할 수 있다.
|
||||
- `ws` 를 명령줄에 갖지 않은 손자(예: stub 모니터의 `sleep 120`)는 두 방식 모두 패턴으로는 못 찾는다.
|
||||
- 새 방식은 모니터의 자손으로 찾아서 정리한다. 따라서 이 부분도 약해지지 않았다.
|
||||
- 이번 실행에서 teardown 실패는 없었다. 실행 뒤 `ps` 로 봐도 테스트가 남긴 프로세스가 없다(§3).
|
||||
|
||||
## 3. 실행한 테스트 (이 Mac: macOS, `/bin/bash` 3.2, `/usr/bin/python3` 3.9.6)
|
||||
|
||||
```
|
||||
cd <검증 경로>
|
||||
env -u HERDR_SOCKET_PATH -u HERDR_PANE_ID -u HERDR_TAB_ID -u HERDR_WORKSPACE_ID -u HERDR_SESSION_NAME \
|
||||
-u HERDR_SESSION -u WORKSPACE_ROOT -u MAM_ENV_FILE -u MAM_JOBS_DIR -u DELEGATE_JOB_LOGS_DIR \
|
||||
PYTHONDONTWRITEBYTECODE=1 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python \
|
||||
-m pytest -p no:cacheprovider --basetemp="$TMPDIR/claude-j1d2c-bt" -q -rfs \
|
||||
tests/test_issue72_update.py tests/test_issue19_subscriber_daemon_lifecycle.py \
|
||||
tests/test_issue5_phase1_delegation.py
|
||||
```
|
||||
|
||||
- 결과: **132 passed, 0 failed, 0 skipped** (191 s)
|
||||
- `test_every_restart_starts_a_real_monitor_in_both_delegation_modes` 의 6 조합이 이 안에 들어 있다.
|
||||
- `test_issue19`·`test_issue5_phase1` 의 watchdog 시험(중복 방지, `MAM_NO_SUBSCRIBER_DAEMON`, PID 보고, legacy·python 동등성)이 모두 통과한다.
|
||||
- 고치기 전 대조 실행: TMPDIR 사본, `-k test_every_restart` → 3 failed(mode=0), 3 passed(mode=1). 자세한 것은 §1.2.
|
||||
- 테스트 뒤 남은 프로세스: 테스트가 띄운 `reconcile.sh`, stub monitor, mock herdr, 구독자는 없다. 남은 것은 원래 있던 셋이다.
|
||||
- 본 체크아웃 모니터 3714
|
||||
- 이번 루프 7552
|
||||
- 이번 잡을 디스패치할 때 띄운 job_subscriber 53086
|
||||
|
||||
## 4. 발견 사항
|
||||
|
||||
### 차단
|
||||
|
||||
- 없음.
|
||||
|
||||
### 비차단
|
||||
|
||||
- **N8 (이월, R2 에도 해당).** rollback 뒤 재기동은 결정 2 에 따라 **되돌린 옛 트리의** `start_watchdog` 을 쓴다.
|
||||
- 그래서 실제로 이 PR 이전 버전으로 되돌리면, `MAM_DELEGATION_PY=0` 에서는 옛 `nohup … &` 그대로라 호출자 그룹에 남는다.
|
||||
- 이 PR 이후 버전 사이의 rollback 에서는 해결된다.
|
||||
- 결정 2 가 정한 방식("되돌린 트리의 함수")에서 생기는 한계라 결함으로 보지 않는다.
|
||||
- **1.3 의 세션 차이.** legacy 경로는 프로세스 그룹만 분리하고, python 경로는 세션까지 분리한다. 결정 요구는 충족한다.
|
||||
- N5·N6·N1·N3·N4 는 이번 범위 밖이라 그대로다.
|
||||
|
||||
## 5. 사용자 확인 필요
|
||||
|
||||
- N8: rollback 대상이 이 PR 이전 버전이고 `MAM_DELEGATION_PY=0` 인 경우를 받아들일지.
|
||||
- 받아들이지 않는다면 결정 2 의 "되돌린 트리의 start_watchdog" 대신 `install_phase.sh` 복사본 쪽에서 분리해 띄우도록 결정을 바꿔야 한다.
|
||||
- 이전 라운드에서 넘어온 N2, 그리고 결정 8 문구 보강 건은 그대로다.
|
||||
|
||||
## 6. 결론
|
||||
|
||||
- R2 는 정상 설치, 시간 초과 복원, rollback 복원의 세 경로와 두 모드 모두에서 해결됐다.
|
||||
- 고치기 전 코드에서 mode=0 이 실패하고, 고친 뒤 6개가 모두 통과하는 것을 이 Mac 에서 확인했다.
|
||||
- 분리 방식(`set -m`)은 중복 방지, opt-out, PID 보고, 로그를 바꾸지 않는다. bash 3.2 에서 잡 메시지 없이 동작한다.
|
||||
- 테스트의 패턴 kill 은 기록한 PID 와 정확히 맞는 모니터·자손 정리로 바뀌었다. 남은 프로세스 확인은 오히려 강해졌다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261019T015801Z-05463c",
|
||||
"base_sha": "1d7b7dab58a10fe86fd747b7290d47674ca93230",
|
||||
"head_sha": "6a37c0019baa31ee28b32a086e57b4c8239413c4",
|
||||
"tree_sha": "87013e7ef7f5239f1bf6ea38a2cbda214257be3c",
|
||||
"review_commit": "6a37c0019baa31ee28b32a086e57b4c8239413c4",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "1fb2cefa",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-19T02:37:22Z"
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# PR #62 리뷰 2회차 — Issue #60 Linux 확인 후속 (job 1fb2cefa)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T015801Z-05463c/1`
|
||||
- identity: HEAD `6a37c0019baa31ee28b32a086e57b4c8239413c4`, tree `87013e7ef7f5239f1bf6ea38a2cbda214257be3c` 가 일치한다. 작업 트리는 clean 이고 `.mam` 은 없다.
|
||||
- 범위: base `1d7b7da`..head 9커밋. 판정은 base..head 전체에 대한 것이다. 1회차 이후 바뀐 것은 6a37c00 하나이고, `opencode.py`·`status.sh`·`reconcile.sh`·`test_issue60_linux_fixes.py` 4개 파일만 바뀌었다.
|
||||
- 1회차(@6a4e385)의 확인은 6a37c00 이 건드리지 않은 코드라 그대로 유효하다. 1회차에 확인한 것: 임시 DB 에서 `_ro_connect` 세 갈래, `_DB_PATHS` 키, `auth-ok` CLI, stop 문구.
|
||||
- 환경: macOS, Python 3.9.6, sqlite 3.51.0. 실제 opencode 데이터는 `ls`·`grep -c`·`ps`·`lsof` 로만 봤다.
|
||||
|
||||
## 결론
|
||||
|
||||
1회차의 공통 차단(B1)과 codex·agy 지적이 모두 해결됐고, 새 회귀는 찾지 못했다. base..head 전체를 병합 가능으로 판단한다.
|
||||
|
||||
## 1회차 지적별 확인
|
||||
|
||||
**B1 (clean 상태 5개가 macOS 에서 실패) — 해결.**
|
||||
- `make("clean")` 이 close 뒤 남은 `-wal`·`-shm` 을 지운다(`tests/test_issue60_linux_fixes.py:158-162`). 1회차 사본에서 내가 검증한 수정과 같다.
|
||||
- 검증 경로에서 이 모듈이 모두 통과한다(아래 실행 결과).
|
||||
- 뮤테이션 i1(immutable 분기를 `mode=ro` 로)이 이제 이 Mac 에서 5 failed 로 잡힌다. 1회차에는 fixture 가 먼저 실패해서 이 뮤테이션이 가려졌었다.
|
||||
|
||||
**안내 문구 패턴이 사용자가 친 `Ask anything…` 를 지움(codex) — 해결.**
|
||||
- 패턴이 `Ask anything(?:…|\.\.\.) "[^"]*"?` 가 되어 따옴표 예시가 필수다(`opencode.py:80`).
|
||||
- 두 fixture 에 넣어 직접 확인한 결과:
|
||||
- `Ask anything…`, `Ask anything...` 는 친 그대로 읽힌다.
|
||||
- 홈 화면 원본, 잘린 예시, `Ask anything… "` 는 빈 입력으로 읽힌다.
|
||||
- 회귀 테스트 2개가 추가됐다(`:84-85`). 뮤테이션 p1(예시를 다시 선택 사항으로)은 4 failed 로 잡힌다.
|
||||
|
||||
**auth_ok 가 실행 불가한 `~/.opencode/bin/opencode` 도 인정(codex) — 해결.**
|
||||
- 이제 `isfile and access(X_OK)` 로 본다(`opencode.py:322-323`).
|
||||
- 권한 644 파일과 디렉터리는 모두 False 로 고정됐다(`test_a_home_install_that_cannot_run_is_not_enough`). 뮤테이션 a1(`exists` 로 되돌림)은 1 failed 로 잡힌다.
|
||||
- `shutil.which` 가 이미 실행 가능 여부를 보므로 PATH 쪽은 그대로다.
|
||||
|
||||
**status.sh·reconcile.sh 의 `sqlite3.connect(odb)`(agy) — 해결.**
|
||||
- status 의 opencode 두 블록(`status.sh:115-117`, `:316-318`)과 reconcile drift C(`reconcile.sh:953-955`)가 `_ro_connect` 를 쓴다.
|
||||
- 두 스크립트는 lib_py 를 이미 import 하는 경로에서 돌기 때문에 import 가 된다(status 는 `env_python`/PYTHONPATH, reconcile 은 `env_python`).
|
||||
- status 블록에서 `import sqlite3` 를 지웠지만 영향이 없다. 같은 함수의 다른 블록(hermes `:90`, codex `:132`)은 각자 `import sqlite3` 를 한다.
|
||||
- 행동 확인: git archive 사본에 임시 e2e 를 넣었다. 순서는 이렇다.
|
||||
1. mam_sandbox+mock 에서 create → 자식 세션 추가 → `journal_mode=wal` → 사이드카 삭제(Linux 의 닫힌 상태)
|
||||
2. `reconcile.sh --once --emit-diff` → `status.sh --json` 과 plain status
|
||||
- 결과(head): rc 는 모두 0. 부모 id 로 고정됐고 `resume_state == "yes"` 이다. DB 디렉터리는 실행 전후 모두 `['opencode.db']` 였다.
|
||||
- 대조(두 스크립트를 `sqlite3.connect(odb)` 로 되돌린 사본): reconcile 뒤 `opencode.db-shm`·`opencode.db-wal` 이 생겨 같은 테스트가 실패했다.
|
||||
- 참고: mock opencode 가 만드는 DB 는 WAL 모드가 아니다. 그래서 WAL 로 바꾸지 않으면 이 차이가 드러나지 않는다.
|
||||
|
||||
## 테스트 실행 (검증 경로)
|
||||
|
||||
- 조건: `env -i`, 임시 HOME, `XDG_DATA_HOME=<scratchpad>`, basetemp 는 scratchpad, `-p no:cacheprovider`, `PYTHONDONTWRITEBYTECODE=1`.
|
||||
- 대상 12개 모듈: issue60_linux_fixes, issue60_opencode_v2, a4, tier1, tier2, issue31, issue55, issue57, issue21_worktree_prereqs, issue50_dispatch, issue30_stall_watch, c1_tui_readiness.
|
||||
- 결과: **463 passed, 2 skipped, 실패 0**, 16분.
|
||||
- skip 1: `/proc` 전용 테스트.
|
||||
- skip 2: PATH 에 opencode 가 없어 건너뛴 실바이너리 계약 테스트(1회차·이전 이슈에서 따로 확인함).
|
||||
- 부작용 확인:
|
||||
- scratchpad XDG 디렉터리는 실행 뒤에도 비어 있었다.
|
||||
- 실제 `~/.local/share/opencode` 의 목록은 전후가 같았다.
|
||||
- 실제 log 의 `debug` 줄 수는 2 → 2 였다.
|
||||
- 남은 opencode 프로세스는 사용자 서비스 하나뿐이다(pid 23630, 시작 08:13, 이 잡 이전).
|
||||
- #55 가드 오탐은 없었다.
|
||||
- 린트:
|
||||
- `git diff --check 1d7b7da..HEAD` 는 깨끗하다.
|
||||
- 바뀐 Python 은 3.9 로 파싱된다.
|
||||
- `status.sh`·`reconcile.sh` 는 `/bin/bash -n`(3.2)을 통과한다.
|
||||
|
||||
## 비차단
|
||||
|
||||
- **N1.** 새 `test_status_and_reconcile_open_the_database_read_only` 는 소스 문자열만 센다. `sqlite3.connect(odb, timeout=…)` 같은 변형은 지나간다. 위의 임시 e2e(WAL DB, 사이드카 없음 → reconcile·status → 목록 불변)를 tier2 opencode e2e 에 붙이면 행동으로 고정할 수 있다.
|
||||
- **N2.** 1회차 비차단은 의도된 완화로 그대로 남아 있다(브리프에도 기록됨):
|
||||
- immutable 경쟁 창(ms 수준)
|
||||
- `_DB_PATHS` 의 프로세스 수명 캐시
|
||||
- create 사전 검사의 완화
|
||||
- **N3.** 아주 좁은 화면에서 예시의 여는 따옴표까지 잘리면 `Ask anything…` 만 남는다. 이때는 친 초안으로 읽힌다(안전한 쪽). 실제 캡처로는 확인하지 않았다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261020T133532Z-a1684a",
|
||||
"base_sha": "ff8b381520ae04e80c73a9e18bfad73d47e853c9",
|
||||
"head_sha": "feccfd5e0356c8acf0438bc68bce7ea36ce2588e",
|
||||
"tree_sha": "0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a",
|
||||
"review_commit": "feccfd5e0356c8acf0438bc68bce7ea36ce2588e",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "5bdb3a81",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-20T15:51:16Z"
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
# Issue #40 리뷰 — Class A 세션 ID 자동 확정, agy 다른 작업 공간 ID 오등록 방지 (job 5bdb3a81)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261020T133532Z-a1684a/1`
|
||||
- identity: HEAD `feccfd5e0356c8acf0438bc68bce7ea36ce2588e`, tree `0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a` 가 일치한다. 작업 트리는 clean 이고 `.mam` 은 없다.
|
||||
- 범위: base `ff8b381`..head 1커밋, 19개 파일. 판정은 base..head 전체에 대한 것이다.
|
||||
- 기준 문서: 이슈 본문과 댓글 전체(`issue40.txt`)를 끝까지 읽고 합의 사항과 대조했다.
|
||||
- 환경: macOS, Python 3.9.6.
|
||||
- 실제 에이전트 홈은 `ls` 와 `lsof -p <agy pid>`(프로세스 테이블 조회)로만 봤다. 파일 자체를 연 적은 없다.
|
||||
- 재현과 테스트는 모두 scratchpad 의 임시 HOME, mock herdr, git archive 사본에서 했다.
|
||||
|
||||
## 결론
|
||||
|
||||
합의된 설계(PR #41 리뷰·이슈 댓글)대로 구현됐고, 완료 조건 1-4 가 테스트로 고정됐다. 각 조건의 테스트는 base 에서 실패하고 head 에서 통과한다. 이 변경이 만든 회귀는 찾지 못했다. 남은 것은 테스트 보강 권고와 운영 위험 기록이다.
|
||||
|
||||
## 설계 확인 (합의 대비)
|
||||
|
||||
**어댑터는 사실과 판정만 제공한다.**
|
||||
- `id_lifecycle`: 기본값은 `POST_DISCOVERED`. claude·grok 만 `PRE_ASSIGNED` 이다(`base.py:378-381`, `claude.py:188`, `grok.py:18`).
|
||||
- `capture_active_session_id`(`base.py:383-401`): `discover()` 후보 가운데 `verify_session_uuid` 를 통과하고 다른 행이 기록하지 않은 것이 정확히 하나일 때만 `captured` 를 돌려준다. 그 밖에는 이유가 담긴 결과(`capture_result`)를 돌려준다. 읽기 전용이다.
|
||||
- 설정 추가나 리팩터링은 없다.
|
||||
|
||||
**agy 경로 (`agy.py`)**
|
||||
- `verify_artifact` discover 모드(`:97-106`): `last_conversations.json` 이 그 id 를 다른 cwd 에 매핑하면 거절한다(workspace_key 로 비교). 다른 행이 기록한 id 는 캐시가 일치해도 항상 거절한다. 이제 캐시 일치가 이 검사를 건너뛰지 못한다.
|
||||
- `capture_active_session_id`(`:129-152`): 후보는 `last_conversations.json[cwd]` 하나뿐이다. 그 대화의 presence lock 이나 DB 를 그 행의 pane 프로세스 또는 그 자손이 열고 있어야 확정한다(`_owner_check`, `:154-183`, lsof/ps). 증명하지 못하면 확정하지 않는다.
|
||||
- 이 근거를 실제로 확인했다. 이 Mac 에서 실행 중인 agy 3개(`--conversation <id>`)는 모두 자기 `presence/<id>.lock` 을 열고 있다(`lsof -p`).
|
||||
- `_holder_pids`·`_process_parents` 를 macOS 에서 직접 확인했다. 아무도 안 연 파일은 `[]`, 연 파일은 보유 pid 를 돌려준다(0.13s). ps 표에는 자기 pid 가 들어 있다.
|
||||
|
||||
**reconcile drift C (agy·hermes·opencode·codex) 가 하나로 합쳐졌다** (`reconcile.sh:778-811`).
|
||||
- 네 에이전트가 모두 `autopin.capture` 를 거쳐 같은 어댑터 판정을 쓴다. agy 의 `*.db` 전체 glob 은 사라졌다.
|
||||
- 기존 안전장치는 그대로다: 후보가 둘 이상이면 C-ambiguous, 뷰포트가 다르면(rc 1) C-warn 으로 pin 안 함, 뷰포트 확인이 불가하면(그 밖의 rc) degraded pin.
|
||||
- 다른 행이 가진 id 배제는 더 엄격해졌다. 예전에는 같은 cwd 의, 멈추지 않은, 같은 에이전트 행만 봤다. 지금은 다른 모든 행의 모든 own key 를 본다(`autopin.claimed_elsewhere`).
|
||||
- 지운 hermes·opencode·codex SQL 은 각 어댑터의 `discover()` 와 같은 범위(cwd·epoch·`parent_id IS NULL`)다. codex 는 `CODEX_HOME` 을 따른다(`codex.py:65`).
|
||||
|
||||
**수신 측 처리기 (`lib_py/agents/autopin.py`)**
|
||||
- 호출 지점:
|
||||
- 래퍼: `finalize_unfinished` 뒤 `completed` 일 때(`multi-agent-mux-delegate-job:454-458`). stderr 로만 출력하므로 마지막 stdout 줄(audit-log 경로) 계약은 그대로다.
|
||||
- `create --onboard` 의 completed 분기(`create_session.sh:754-756`)
|
||||
- 루프 `wait_for_job` 직후의 서브프로세스(`runner.py:679, 690-705`, G9 경계를 지킴)
|
||||
- 공통 헬퍼 `delegate_autopin_completed`(`lib.sh:2470-2480`)는 `|| true` 라 잡 결과와 종료 코드를 바꾸지 않는다. `set -u` 에서 쓰는 변수(`AGENT_SESSIONS_YAML`, `_disp_tok`, `DELEGATE_JOB_TOKEN`, `task_reg_dir`)는 모두 정의돼 있다.
|
||||
- 중복 방지:
|
||||
- 키는 `(job_id, dispatch_token, attempt_id)` 이다. 잡 디렉터리의 `autopin.lock`(flock)과 `autopin.json` 마커를 쓴다. 마커는 원자 교체로 쓴다.
|
||||
- 쓰기는 `atomic_dump_yaml` 의 mutation 으로 한다. 이 모듈의 `_MUTATION` 을 `sys.modules[__name__]` 로 찾아 `python -m` 에서도 같은 객체를 쓴다. 쓸 것이 없으면 `SystemExit(0)` 으로 쓰지 않고 끝나며, `atomic_yaml.py:195` 의 finally 가 정리한다.
|
||||
- 건너뛰는 잡: remote, human, completed 가 아닌 잡, 오래된 token. pre-assigned 와 이미 id 가 있는 행은 쓰기 없이 끝난다.
|
||||
- 메타데이터: pin 은 `session_id_source=discovered`, `session_id_verified=true` 를 함께 기록한다. reconcile 의 `_pin_and_verify_resume` 도 같은 `pin_row` 를 쓴다. 이슈 댓글이 짚은 불일치(선택 항목)가 해결됐다.
|
||||
- publish_event.py 는 바뀌지 않았다. 에이전트는 제어 평면에 쓰지 않는다(`test_call_sites_and_publisher_boundary`).
|
||||
- 잠금 안에서 뷰포트 확인과 resume `--dry-run` 을 서브프로세스로 실행한다. 예전 reconcile 도 mutation 안에서 똑같이 했다. `load_state_json` 은 YAML flock 없이 SQLite 를 읽으므로 교착은 없다.
|
||||
|
||||
## 완료 조건별 테스트 (base 실패 / head 통과)
|
||||
|
||||
`tests/test_issue40_auto_pin.py`(27개)를 base 사본에 넣어 실행하면 **16 failed, 13 errors**(errors 는 base 에 없는 `agy._holder_pids` 를 패치하는 fixture 때문), **41 passed**(issue64 포함) 이다. head 에서는 issue40+issue64 **70 passed**.
|
||||
|
||||
| 조건 | 테스트 | base 결과 |
|
||||
|---|---|---|
|
||||
| 1 | `test_agy_verify_rejects_id_cached_for_another_workspace` | `True is False` |
|
||||
| 1 | `test_reconcile_does_not_pin_another_workspaces_agy_conversation` | `'b4000002…' is None` — 실제로 B 의 id 를 A 행에 기록함 |
|
||||
| 1 | `test_autopin_does_not_pin_…` | — |
|
||||
| 1 | issue64 `:407` 단언 | `is False` 로 바뀜 |
|
||||
| 2 | `test_completed_onboarding_pins_agy_without_daemon`(MAM_NO_SUBSCRIBER_DAEMON=1, B 가 더 늦게 갱신) | — |
|
||||
| 2 | `test_completed_first_job_pins_opencode_codex_hermes`[3] | 실패 |
|
||||
| 2 | `test_reconcile_pins_agy_owned_by_the_pane`, `test_lib_sh_helper_pins_through_the_cli`, `test_create_onboard_pins_without_daemon` | 실패 |
|
||||
| 3 | `test_wrapper_and_loop_recovery_pin_once`(capture 1회, YAML mtime 불변), `test_loop_recovery_pins_when_the_wrapper_did_not` | 실패 |
|
||||
| 3 | `test_ambiguous_candidates_leave_reason_without_pin`(행에 `autopin ambiguous (job amb): 2 candidates`) | 실패 |
|
||||
| 4 | `test_preassigned_and_existing_ids_are_untouched`, `test_ineligible_jobs_pin_nothing`, `test_stale_dispatch_token_pins_nothing` | — |
|
||||
|
||||
- reconcile 테스트는 세션을 mock herdr state 에 running 으로 등록한다(`_mock_agent`). 그래서 drift A 에서 멈추지 않고 실제로 drift C 까지 간다. base 에서 B 의 id 가 기록되는 것이 그 증거다.
|
||||
|
||||
뮤테이션: git archive 사본에서 issue40 모듈만 돌렸다.
|
||||
|
||||
| 뮤테이션 | 결과 |
|
||||
|---|---|
|
||||
| 다른 cwd 거절 제거 | 1 failed |
|
||||
| 중복 마커 무시 | 2 failed |
|
||||
| 모호 판정 제거 | 1 failed |
|
||||
| agy 소유 확인 제거 | 4 failed(같은 cwd 형제 교차 pin 포함) |
|
||||
| 이미 id 가 있는 행 보호 제거 | 1 failed |
|
||||
| remote·human 제외 제거 | 2 failed |
|
||||
| 래퍼 호출 제거 | 1 failed(정적 검사만 잡음) |
|
||||
| **pre-assigned 보호 제거** | **실패 0** (아래 T1) |
|
||||
|
||||
## 관련 테스트 묶음
|
||||
|
||||
- 조건: `env -i`, 임시 HOME, `XDG_DATA_HOME=<scratchpad>`, basetemp 는 scratchpad.
|
||||
- 대상 18개 모듈: issue40, issue64, a4, issue10_13, issue31, issue50_dispatch, issue55, issue57, issue60_linux_fixes, issue60_opencode_v2, tier1, tier2, uuid_target, orc_onboard, issue21_phase2_loop_a, issue5_phase3_loop, issue21_worktree_prereqs, issue17_workspace_root_isolation.
|
||||
- 결과: **577 passed, 1 skipped, 3 failed**, 18분.
|
||||
- 실패 3개(`test_issue21_phase2_loop_a::test_w10_…[install.sh|install_mam.sh]`, `test_w11_…`)는 환경 탓이다.
|
||||
- 설치 스크립트가 시스템 python3 의 PyYAML 을 찾는데, 이 Mac 의 PyYAML 은 사용자 site(`~/Library/Python/3.9/...`)에만 있다. 내가 HOME 을 임시로 바꿔서 보이지 않았다.
|
||||
- base 사본에서도 같은 3개가 같은 이유로 실패하고, 설치 스크립트는 이 diff 에 없다. 판정 근거로 쓰지 않았다.
|
||||
- skip 1개: PATH 에 opencode 가 없는 실바이너리 계약 테스트.
|
||||
- 부작용 확인:
|
||||
- 실제 `~/.gemini/antigravity-cli/conversations`·`~/.hermes`·`~/.local/share/opencode` 목록은 전후가 같았다.
|
||||
- scratchpad XDG 디렉터리는 실행 뒤에도 비어 있었다.
|
||||
- #55 가드 오탐은 없었다.
|
||||
- 린트:
|
||||
- `git diff --check` 는 깨끗하다.
|
||||
- 바뀐 셸 4개(lib.sh, create_session.sh, 래퍼, reconcile.sh)가 `/bin/bash -n`(3.2)을 통과한다.
|
||||
- 바뀐 Python 은 3.9 로 파싱된다. `/usr/bin/python3` 로 `lib_py.agents.autopin`·`lib_py.loop.runner` 를 import 할 수 있다.
|
||||
|
||||
## 비차단
|
||||
|
||||
- **T1. pre-assigned 보호(`autopin.capture` 의 `id_lifecycle != POST_DISCOVERED`)는 테스트로 고정되지 않았다.**
|
||||
- 테스트의 claude·grok 행은 이미 id 가 있어서 `already-set` 에서 먼저 끝난다.
|
||||
- id 가 비어 있는 claude 행이 생기면(예: 행을 손으로 고침), 이 보호 없이는 기본 capture 가 claude `discover()` 로 남의 transcript 를 pin 할 수 있다.
|
||||
- 권고: `claude_session_id_own: None` 인 claude 행에서 `pre-assigned` 가 나오는지 확인하는 한 줄을 추가한다.
|
||||
- **T2.** 래퍼 진입점은 정적 검사(`test_call_sites_…`)로만 고정됐다. 헬퍼(`lib.sh`)와 CLI, create, 루프는 실행으로 검증됐다.
|
||||
- **R1. agy 의 pin 은 이제 세 가지에 기댄다**(reconcile drift C 도 같다):
|
||||
- (a) lsof·ps 가 있어야 한다
|
||||
- (b) 행에 pane pid 가 있어야 한다
|
||||
- (c) agy 가 presence lock 을 열고 있어야 한다
|
||||
- (c) 는 resume 된 agy 3개에서 확인했다. 새로 띄운 agy 의 첫 턴 직후는 실제 agy 를 띄울 수 없어 확인하지 못했다.
|
||||
- lsof 가 없는 Linux 에서는 agy 가 영영 pin 되지 않는다. 예전 glob 경로는 pin 했다(오등록 위험을 감수하고). autopin 경로는 행에 `autopin unverifiable-owner … lsof is unavailable` 을 남기지만, reconcile 은 아무것도 남기지 않는다.
|
||||
- 설계상 의도된 안전 쪽 선택이다. 그래도 Linux 의존성으로 문서에 한 줄 적어 두기를 권한다.
|
||||
- **R2.** 중복 마커는 실패 결과도 기록한다. 래퍼 시점에 `no-candidate` 였던 잡은 루프 복구 경로에서 다시 시도되지 않는다. 다음 잡의 completed 나 reconcile 이 다시 시도한다. 합의("job/token/attempt 단위로 중복 금지")와 맞다.
|
||||
- **범위 밖(보고만).** stop 의 캡처 경로는 정리하지 않았다(이슈가 "크면 하지 말고 보고만"이라고 함).
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -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,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261023T035928Z-2ccc02",
|
||||
"base_sha": "dc886ecd6b38c01032a55a2d1c230c73362ae630",
|
||||
"head_sha": "1614af5b673d7519d3a38f63e3636055592b4f14",
|
||||
"tree_sha": "f1af6fdd548ee5d6f6b81e2202833edfaca98188",
|
||||
"review_commit": "1614af5b673d7519d3a38f63e3636055592b4f14",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "af549b13",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-23T05:16:58Z"
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
# PR #69 (Issue #63) 리뷰 — head 1614af5
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (macOS, bash 3.2, /usr/bin/python3 3.9.6)
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261023T035928Z-2ccc02/1`
|
||||
- identity 확인: HEAD 1614af5b673d7519d3a38f63e3636055592b4f14, tree f1af6fdd548ee5d6f6b81e2202833edfaca98188, `git status --porcelain --ignored` 비어 있음, `.mam` 없음 (시작·종료 시 모두). main HEAD dc886ec 그대로.
|
||||
- 범위: base dc886ec..1614af5 전체(커밋 13개).
|
||||
- ede9c62 까지는 이전 리뷰(잡 8445af7f, 55f86b49)에서, 8f64174·7cac6f6 은 잡 1c6eca2b 에서 봤다. 그 결론을 다시 확인했다.
|
||||
- 이번에는 1614af5 와 세 수정 사이의 정합성을 중점으로 봤다.
|
||||
- 참고: 같은 head 를 맡은 잡 906427d1 은 관련 스위트 도중 검증 경로(`…a646cb/3`)가 사라져 끝내지 못했다. 그 잡에서 얻은 head 결과(issue63 27 passed, 변이 결과)는 같은 tree 의 스크래치 사본에서 나온 것이다. `diff -rq` 로 이번 검증 경로와 같음을 확인했다.
|
||||
|
||||
## 1. 1614af5 가 바꾼 것
|
||||
|
||||
- `.agents/skills/lib_py/agents/id_order.py:223`: `elif sess == me or not sess:` 를 `elif not sess:` 로 바꿨다.
|
||||
- 그래서 호출자 자신의 세션 이름에 남은 죽은 owner 마커도 고아 규칙을 따른다. 이번 시도의 행이 등록되면 지우고(:225), herdr 가 정상 목록으로 "없음"이라 답하면 지우고(:228-229), 그 밖에는 남긴다.
|
||||
- `_read_and_prune_markers` 의 `me` 인자를 지웠다(:183, :308).
|
||||
- `blockers` 는 ede9c62 부터 `m_sess == me` 를 건너뛰지 않는다(:136-144). 그래서 남은 자기 이름 마커가 실제로 막는다.
|
||||
|
||||
## 2. 세 수정이 각 지적을 닫는가, 서로 어긋나지 않는가
|
||||
|
||||
1. **8f64174 — 조회 실패를 "없음"으로 읽던 문제**
|
||||
- `herdr_session_state` (:58-86) 는 정상 파싱한 `agent list` 에 이름이 없을 때만 False 를 돌려준다. 그 밖에는 None 이다.
|
||||
- prune 은 `drop = live is False` (:228) 다.
|
||||
- 변이 m1(`not live`)과 m2(rc≠0 → False)가 테스트에 잡혔다(잡 1c6eca2b).
|
||||
2. **7cac6f6 — 상한 0 에서 30 초 조회, server_not_running**
|
||||
- 조회 시간은 `deadline - now` 다(:288). timeout≤0 이면 None 을 돌려준다(:64-65).
|
||||
- 변이 m4(고정 30 s)와 m7(timeout≤0 에도 조회)이 잡혔다.
|
||||
- 상한 0 테스트는 0.3 s 안에 끝나는 것을 확인한다(test :1222-1224).
|
||||
3. **1614af5 — 같은 이름의 죽은 owner 마커를 확인 없이 지우던 문제**
|
||||
- 옛 규칙(`sess == me` 이면 drop)을 되돌린 변이 사본에서는 두 테스트가 실패한다: `test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_is_alive`, `test_id_order_real_probe_against_mock_herdr_fails_closed`.
|
||||
- 커밋 메시지가 말한 "재시도의 실패 정리가 살아 있는 pane 을 죽임"도 막힌다. create 의 대기(`create_session.sh:287`)는 롤백 trap 설치(:367)보다 앞서 있어서, 대기에서 거부(exit 1)되면 `cleanup_herdr_on_error` 가 돌지 않는다.
|
||||
4. **정합성**: 세 수정은 모두 `_read_and_prune_markers` 의 "죽은 owner 마커" 분기 하나를 쓰고, 조회는 `bounded_probe` 하나를 거친다. 자기 이름 마커만 다른 규칙을 쓰던 예외가 사라져서 규칙이 하나로 모였다. 어긋나는 곳은 찾지 못했다.
|
||||
|
||||
## 3. 흔한 사용을 600 초 대기로 막지 않는가
|
||||
|
||||
스크래치에서 `id_order.wait` 를 프로세스 안에서 직접 불렀다. 조회 함수를 주입하고, cap 2 s(일부 0 s)로 돌렸다.
|
||||
|
||||
**정상 사용 (실패 없음)**: 막지 않는다.
|
||||
- create 마커가 남은 채로, 그 세션이 다음 상태일 때 같은 이름으로 resume 했다:
|
||||
- stopped 이고 own id 가 기록됨
|
||||
- stopped 이고 id 없음 (herdr-dead stop)
|
||||
- reconcile 이 terminated 로 바꿈
|
||||
- 세 경우 모두 herdr 가 **답하지 못해도**(None), cap 0 에서도 rc=0 이었다.
|
||||
- `blockers` 의 `pinned_for_attempt` 와 `ended_after_attempt` (:158-171) 가 이 경우들을 걸러낸다.
|
||||
- 정상 create/resume 이 끝난 뒤의 마커는 행이 running 인 동안에는 `_registered_for_attempt` (:177-180) 로 지워진다.
|
||||
|
||||
**실패 뒤 재시도**:
|
||||
|
||||
| 경우 | herdr 가 "없음" 응답 (서버 살아 있음) | herdr 무응답 (서버 꺼짐 등) |
|
||||
|---|---|---|
|
||||
| create X 가 행 쓰기 전에 실패(exit 42 롤백) → create X 재시도 | rc=0, 0.0 s | rc=1, cap 만큼 대기 |
|
||||
| resume X 가 spawn 에서 실패(행 그대로) → resume X 재시도 | rc=0, 0.0 s | rc=1, cap 만큼 대기 |
|
||||
| 위 resume 실패 → 같은 cwd 에서 create Y | rc=0, 0.0 s | rc=1, cap 만큼 대기 |
|
||||
|
||||
- 7cac6f6 사본으로 같은 시뮬레이션을 돌리면 무응답 열의 위 두 줄(같은 이름 재시도)이 rc=0 이다.
|
||||
- 즉 1614af5 는 서버가 살아 있는 흔한 재시도는 그대로 두고, herdr 가 답하지 못하는 재시도만 막는다. 이는 아래 R1 이 같은 이름까지 넓어진 것이다.
|
||||
|
||||
## 4. 그 밖의 확인
|
||||
|
||||
브리프 항목 1·2·4·5·6·8(완료 조건 테스트, autopin 재사용, resume·stop, 지운 심볼, issue34 무관)은 1614af5 가 `session_ids.py`, `lib.sh`, 셸 스크립트, 문서를 건드리지 않으므로 이전 리뷰의 결론 그대로다.
|
||||
|
||||
**macOS**:
|
||||
- `/usr/bin/python3` 3.9.6 으로 `lib_py.agents.id_order` import 가 성공한다(잡 1c6eca2b; 1614af5 는 문법 요소를 더하지 않는다).
|
||||
- 잠금은 `fcntl.flock` 이다(:294-295).
|
||||
|
||||
**실제 herdr 를 건드리지 않았는가**:
|
||||
- 기존 서버 두 개(pid 3407 `herdr server`, 8323 `herdr --session multi-agent-mux server`)는 스위트 전후로 같다(시작 시각 08:21).
|
||||
- 스위트 중 13:22 에 `herdr --session mam-pylecturenotes server` 가 새로 보였다. 하지만 이 프로세스는 실제 HOME 과 `WORKSPACE_ROOT=/Users/godopu16/PuKi/lac/curriculum/01_python_getting_started_lecturenotes` 로 떠 있고, 사용자의 attach 클라이언트(13:27)가 붙어 있다. 테스트는 HOME 을 스크래치로 바꿔 돌므로 다른 작업 공간의 것이다.
|
||||
|
||||
## 5. 실행한 테스트
|
||||
|
||||
모두 `env -i`, 임시 HOME/XDG, `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider --basetemp=<scratchpad>` 로 돌렸다. 테스트에서 .mam.env 를 export 하지 않았다.
|
||||
|
||||
| 대상 | 결과 |
|
||||
|---|---|
|
||||
| 검증 경로: `tests/test_issue63_same_cwd_ids.py` + 관련 19개 모듈 (issue40_auto_pin, issue64_ro_connect, issue10_13_purge, issue31_purge_residual, issue42_skill_blocks, tier1_unit, tier2_component, b4_session_created, issue24_resume_dialogs, issue30_stall_watch_stop, issue23_24_startup_path, issue45_restore, issue50_pane_lock, c1_tui_readiness, issue6_fail_closed, issue60_opencode_v2, issue60_linux_fixes, uuid_target, issue17_workspace_root_isolation) | **597 passed, 2 skipped, 0 failed** (32 분) |
|
||||
| 같은 tree 스크래치 사본: issue63 단독 | 27 passed |
|
||||
| base 사본 + head 테스트 | 27 errors (`ImportError: id_order`). 조건별 base 실패는 1차에서 22 failed + 1 passed 로 확인했다 |
|
||||
| 변이: 1614af5 를 되돌림(자기 이름 마커를 drop) | 2 failed (위 두 테스트) |
|
||||
|
||||
## 6. 차단 결함
|
||||
|
||||
없음.
|
||||
|
||||
## 7. 비차단
|
||||
|
||||
### R1 (잡 1c6eca2b 의 R1, 범위가 넓어짐): herdr 가 답하지 못하는 고아 마커는 같은 폴더·같은 종류의 create/resume 을 매번 cap 동안 붙잡는다
|
||||
|
||||
- 이제 같은 이름 재시도도 포함된다(위 표).
|
||||
- 서버가 꺼진 상태(server_not_running)가 대표적이다.
|
||||
- 조회는 서버를 띄우지 않는다(:67).
|
||||
- create/resume 은 대기를 통과해야 spawn 으로 서버를 띄운다(`create_session.sh:287`→:289, `resume_session.sh:243`→:260).
|
||||
- 그래서 다른 무언가가 서버를 켜기 전까지는 기본 600 s 를 기다린 뒤 거부가 반복된다.
|
||||
- 회복 경로는 있다: 메시지의 `HERDR_SESSION_NAME=<srv> herdr kill-session -t <s>` 를 shim 으로 실행하면 서버가 기동된다(잡 1c6eca2b 의 가짜 herdr 재현에서 `--session srv-down server` 1회).
|
||||
- 생기는 조건은 "행 등록 전 실패나 kill"에 "서버가 꺼짐"이 겹치는 경우다. 정상 사용과 서버가 살아 있는 재시도는 막지 않는다(3절).
|
||||
- 권고 (둘 중 하나):
|
||||
- (a) server_not_running 만은 "없음"으로 읽는다. MAM 서버는 restore 가 꺼져 있어서 꺼진 서버 위에는 살아 있는 pane 이 없다. `lib.sh:204-207` 의 ISSUE-9 규약과도 맞는다.
|
||||
- (b) 그 경우는 cap 을 기다리지 말고 바로 거부한다. 메시지는 "서버가 꺼져 있음 → 위 명령으로 정리"처럼 사실대로 쓴다.
|
||||
- fail-closed 를 택한 것은 루프에서 정한 설계 선택이라 차단으로 두지 않는다.
|
||||
|
||||
### 그 밖의 비차단 (잡 1c6eca2b 와 같음)
|
||||
|
||||
- **N1**: 조회가 `MAM_NO_AUTOSTART=1` 을 쓴다는 것(:67)을 고정하는 테스트가 없다.
|
||||
- **N2**: sanitize 한 이름 비교(:85)를 고정하는 테스트가 없다.
|
||||
- **N3**: `flock(LOCK_EX)` 대기(:295)는 시간 제한이 없다. 다른 호출자가 앞 대기의 남은 시간만큼 더 기다릴 수 있다. 상한은 있다.
|
||||
- **N4**: create·resume SKILL.md 의 exit 1 설명이 고아 마커로 거부되는 경우와 그 정리 방법을 말하지 않는다.
|
||||
- **N5**: 이전 라운드의 비차단 항목이 남아 있다: resume `mam_id_order_wait` 제거가 테스트로 고정되지 않음, c6(b) 의 1.5 s 타이밍 flake, lib.sh `--herdr-server` 전달이 테스트되지 않음.
|
||||
|
||||
## 8. 결론
|
||||
|
||||
- 1614af5 는 codex 의 3차 지적(같은 이름의 죽은 owner 마커를 확인 없이 지움)을 닫는다. 8f64174·7cac6f6 과 같은 고아 규칙 하나로 모였다.
|
||||
- 정상 create/resume 과 서버가 살아 있는 재시도는 막지 않는다.
|
||||
- herdr 가 답하지 못할 때 cap 동안 기다리는 비용(R1)은 회복 경로가 있는 설계 선택이므로 비차단이다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261019T134017Z-d90c23",
|
||||
"base_sha": "3ce5d5fdc108b0435e933a251fe1c1256b5d7973",
|
||||
"head_sha": "b0eccc530149bc6e9211727e19921665c7b80492",
|
||||
"tree_sha": "f5a412a3ee96465db7c4b3223d2d76e95716ddfa",
|
||||
"review_commit": "b0eccc530149bc6e9211727e19921665c7b80492",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "b78af5ad",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-19T14:59:46Z"
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
# Issue #64 리뷰 — agy·codex·hermes DB 읽기 전용 열기 (job b78af5ad)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T134017Z-d90c23/1`
|
||||
- identity: HEAD `b0eccc530149bc6e9211727e19921665c7b80492`, tree `f5a412a3ee96465db7c4b3223d2d76e95716ddfa` 가 일치한다. 작업 트리는 clean 이고 `.mam` 은 없다.
|
||||
- 범위: base `3ce5d5f`(v4.9.7)..head 1커밋. 판정은 base..head 전체에 대한 것이다.
|
||||
- 참고 자료: creator(agy) 리포트 `.mam/jobs/c721e4f5/agy-reports/report-final.md`
|
||||
- 환경: macOS, Python 3.9.6, sqlite 3.51.0. 실제 에이전트 홈은 `ls`·`lsof`·`ps` 로만 봤고, sqlite 로 연 적은 없다.
|
||||
|
||||
## 결론
|
||||
|
||||
이슈 범위는 빠짐없이 바뀌었다. 조회 전용 열기는 모두 `_ro_connect` 로 바뀌었고, 쓰기와 MAM 자신의 DB 는 그대로다. 새 테스트는 base 에서 실패하고 head 에서 통과한다. 원인도 직접 재현해 확인했다. 차단 결함은 없다. 다만 새 런타임 테스트의 reconcile 단계가 실제로는 drift C 를 실행하지 않는다(N1). 또 기존부터 있던 workspace 간 오고정 문제를 새 테스트가 기대값으로 고정해 버렸다(N2). 둘 다 테스트를 보강하라는 권고다.
|
||||
|
||||
## 1. 원인 확인 — 직접 재현함
|
||||
|
||||
creator 의 결론: `reconcile.sh` drift C(agy, `:786-839`)는 고정되지 않은(unpinned) 실행 중 agy 행이 있으면 호스트 공용 `conversations/*.db` 를 전부 훑는다. 각 DB 에 `verify_session_uuid` 를 부르고, 이어서 `AgyAgentAdapter.verify_artifact` 가 `sqlite3.connect(path)` 로 연다. `last_conversations.json` 이 그 대화를 다른 workspace 의 것으로 적어 두어도 `cache_match=False` 는 `_sibling_claimed_uuids` 만 보고 거르지 않는다(`agy.py:60-72`). 그러면 macOS persistent WAL 때문에 `-wal`·`-shm` 이 남는다.
|
||||
|
||||
재현 방법:
|
||||
- git archive 사본에 임시 테스트를 넣었다(mam_sandbox, 임시 HOME, mock herdr).
|
||||
- 사이드카 없는 WAL agy DB 하나를 만들고, `last_conversations.json` 은 그 대화를 다른 workspace `ws_b` 에 매핑했다.
|
||||
- ws 의 고정되지 않은 running 행을 mock herdr 에 살아 있는 pane 으로 등록했다.
|
||||
- `reconcile.sh --once --emit-diff --dry-run` 을 실행했다.
|
||||
|
||||
| 사본 | DB 디렉터리 결과 |
|
||||
|---|---|
|
||||
| base | `<uuid>.db`, `.db-shm`, `.db-wal` — 사이드카가 생긴다 |
|
||||
| head | `<uuid>.db` 만 — 바뀌지 않는다 |
|
||||
|
||||
이로써 v4.9.7 리뷰 때 #55 가드가 다른 workspace 의 agy DB 옆에서 잡은 사이드카는 MAM 의 reconcile drift C(status·create 가 부르는 경로 포함)가 만든 것으로 설명된다. 다만 그때 그 시각에 고정되지 않은 agy 행이 실제로 있었는지는 기록이 없어 확인하지 못했다.
|
||||
|
||||
## 2. 바뀐 곳 확인
|
||||
|
||||
- **공용 helper.** `_ro_connect` 가 `base.py:111-135` 로 옮겨졌다. 본문은 그대로이고, 임시 복사본 이름만 `os.path.basename(path)` 를 쓴다.
|
||||
- opencode 는 base 의 것을 import 한다(`opencode._ro_connect is base._ro_connect`, 테스트로 고정).
|
||||
- opencode 에서 쓰지 않게 된 `quote`·`tempfile` import 는 지워졌다. `shutil`·`sqlite3` 는 아직 쓰고 있다(`which`, purge).
|
||||
- **`db_rows_naming` 기본 연결.** 기본값이 `_ro_connect` 가 됐다(`base.py:145`). 그래서 codex·opencode 의 residual 확인도 clean WAL DB 를 읽는다. base 에서는 `mode=ro` 가 Mac 에서 `unable to open` 으로 실패해 빈 결과를 냈다.
|
||||
- **조회 전용 열기를 바꾼 곳:**
|
||||
- agy `verify_artifact`(`agy.py:74`)
|
||||
- codex `_find_rollout_via_db`(`codex.py:80`), `discover`(`:195`)
|
||||
- hermes `verify_artifact`(`hermes.py:51`), `discover`(`:100`)
|
||||
- status.sh hermes·codex 두 모드(`:90-91`, `:131-132`, `:290-291`, `:331-332`)
|
||||
- reconcile.sh hermes drift(`:865`), codex drift(`:1048`)
|
||||
- **남은 `sqlite3.connect`(grep 으로 전수 확인).** 모두 대상이 아니라는 판단이 맞다.
|
||||
- 의도된 쓰기: codex·hermes·opencode purge 의 DELETE
|
||||
- MAM 자신의 DB: `lib.sh:1599,2068`, `atomic_yaml.py:61`, `state.py:17`, `verify_session.py:41`(agent-sessions.db), `loop/runner.py:416`
|
||||
- **import 정리.** agy 에서 `sqlite3` import 를 지웠고 다른 사용처는 없다. status.sh 의 opencode 블록은 이제 `lib_py.agents.base` 에서 import 한다.
|
||||
|
||||
## 3. 테스트 실행
|
||||
|
||||
조건: `env -i`, 임시 HOME, `XDG_DATA_HOME=<scratchpad>`, basetemp 는 scratchpad, `-p no:cacheprovider`, `PYTHONDONTWRITEBYTECODE=1`.
|
||||
|
||||
**새 모듈 `tests/test_issue64_ro_connect.py`**
|
||||
- head: 43 passed.
|
||||
- base 사본에 이 파일만 넣으면: **23 failed, 20 passed**. creator 의 수치와 같다.
|
||||
- base 에서 실패한 것은 모든 clean·walonly 경우, 정적 검사, workspace 간 테스트다. 실패 메시지는 사이드카 `-shm`·`-wal` 이 생겼다는 것이다.
|
||||
|
||||
**관련 13개 모듈**
|
||||
- 대상: issue64, issue60_linux_fixes, issue60_opencode_v2, a4, issue10_13_purge, issue31, orc_onboard, tier1, tier2, uuid_target, issue55, issue57, issue21_worktree_prereqs.
|
||||
- 결과: **461 passed, 1 skipped, 실패 0**, 15분. skip 은 PATH 에 opencode 가 없는 실바이너리 계약 테스트다. #55 가드 오탐은 없었다.
|
||||
|
||||
**뮤테이션** (git archive 사본, issue64 모듈)
|
||||
|
||||
| 뮤테이션 | 결과 |
|
||||
|---|---|
|
||||
| agy `verify_artifact` 를 `sqlite3.connect` 로 | 5 failed(workspace 간 테스트 포함) |
|
||||
| `db_rows_naming` 기본값을 옛 `mode=ro` URI 로 | 4 failed |
|
||||
| hermes `discover` 를 `sqlite3.connect` 로 | 2 failed |
|
||||
| reconcile hermes drift 를 `sqlite3.connect` 로 | 1 failed — 정적 검사만 잡음 |
|
||||
| reconcile codex drift 를 `sqlite3.connect` 로 | 1 failed — 정적 검사만 잡음 |
|
||||
|
||||
**린트**
|
||||
- `git diff --check` 는 깨끗하다.
|
||||
- 바뀐 Python 6개 파일이 3.9 로 파싱된다.
|
||||
- status.sh·reconcile.sh 는 `/bin/bash -n`(3.2)을 통과한다.
|
||||
|
||||
## 비차단
|
||||
|
||||
- **N1. 런타임 테스트의 reconcile 단계는 drift C 를 실행하지 않는다.**
|
||||
- 위치: `test_status_and_reconcile_runtime_leave_sidecars_alone`(`:241-...`).
|
||||
- 원인: 세 행을 mock herdr 에 등록하지 않는다. 그래서 drift A 가 먼저 `herdr gone … Marked terminated` 로 처리하고 drift C 를 건너뛴다. base 에서 같은 상황을 실행해 drift 결과로 확인했다.
|
||||
- 결과: reconcile 의 hermes·codex 변경은 정적 문자열 검사로만 고정된다(위 뮤테이션 2개).
|
||||
- 권고: 이슈 64 요구는 "실제 실행 또는 최소한 정적 검사"라 충족은 된다. 그래도 mock herdr state 에 세 세션을 `running` 으로 넣으면 실제 drift C 경로가 검증된다(내 재현처럼 `state["agents"][name] = {...}`). status 단계는 실제로 열린다. base 에서 hermes `state.db` 사이드카로 실패한다.
|
||||
- **N2. 기존 결함: drift C(agy)가 다른 workspace 의 대화를 고정한다.**
|
||||
- 내 재현에서 `last_conversations.json` 이 `ws_b` 의 것으로 적은 대화가 epoch 뒤 유일한 후보였다. 그러자 base·head 모두 ws 행에 `updated conversation id: <uuid>` 로 고정했다(dry-run 기준).
|
||||
- 이 PR 이 만든 문제는 아니고 #64 범위 밖이다. 다만 새 테스트 `test_unpinned_agy_reconcile_…`(`:288`)가 `verify_session_uuid(ws_a, …, uuid_b) is True` 를 단언해 이 동작을 기대값으로 고정한다.
|
||||
- 권고: 이 단언은 빼고 디렉터리 목록만 본다. 오고정은 별도 이슈로 다룬다. 예를 들어 `cache_match` 가 아니면서 lc 가 그 uuid 를 다른 cwd 에 매핑하면 거절한다.
|
||||
- **N3.** `_ro_connect` 의 immutable 경쟁 창(ms 수준)은 #60 에서 남긴 것이 공용 helper 를 통해 agy·codex·hermes 로도 넓어졌다. 세 에이전트의 실제 DB 는 모두 WAL 형태다(`ls`: hermes·codex 는 사이드카 쌍이 있고, agy 대화 72개 중 7개에 사이드카가 있음). 실행 중에는 사이드카가 있으므로 `mode=ro` 갈래를 탄다.
|
||||
|
||||
## 관찰·공개
|
||||
|
||||
- **실제 codex DB 의 사이드카.** 관련 모듈 실행 전후로 `~/.codex/` 목록을 비교했더니 `state_5.sqlite-shm`·`-wal` 이 다시 나타나 있었다(mtime 23:29/23:35, 지금은 보유 프로세스 없음).
|
||||
- 이 두 파일은 잡 시작 직후(23:04)에도 있었다가 사라졌던 것이다.
|
||||
- 같은 시간대에 실제 `~/.codex` 를 쓰는 codex 프로세스들이 떠 있다: `codex resume`(이 루프의 리뷰어), app-server daemon, IDE 확장.
|
||||
- 내 실행은 `env -i`·임시 HOME 이었고, head 코드는 사이드카를 만들지 않으며, #55 가드도 아무것도 잡지 않았다. 그래서 테스트 산출물이 아니라 실제 codex 활동으로 본다. 판정 근거로는 쓰지 않았다.
|
||||
- **검증 트리에 임시 파일이 들어갔다 지워짐.** 재현 중 내 셸 명령 실수로 임시 테스트 파일(`tests/test_zz_review_agy.py`)이 검증 트리에 몇 초간 복사됐다. 그 파일로 아무것도 실행하지 않았고 즉시 지웠다. 이후 `git status --porcelain --ignored` 는 비어 있고 HEAD·tree 는 그대로다. 모든 재현은 scratchpad 사본에서 했다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,93 @@
|
||||
# PR #62 리뷰 — Issue #60 Linux 확인 후속 (job dd5c8c62)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T000805Z-975c4c/1`
|
||||
- identity: HEAD `6a4e385a78c8a561d5cf3644336de6a352ea9271`, tree `afa2c55925f93820337b69699c670c0b0ca90980` 일치. 작업 트리 clean, `.mam` 없음. base `1d7b7da`..head 8커밋. 판정은 base..head 전체에 대한 것이다.
|
||||
- 환경: macOS, `/usr/bin/python3` 3.9.6 와 `.venv` python 3.9.6, 둘 다 sqlite 3.51.0. 실제 opencode 데이터는 `ls`·`grep -c` 로만 봤다. 실제 DB 는 sqlite 로 열지 않았다.
|
||||
|
||||
## 결론
|
||||
|
||||
제품 코드에서는 차단 결함을 찾지 못했다. 다만 새 테스트 5개가 이 Mac 에서 항상 실패한다(B1). 이 리뷰의 중점이 macOS 정합성이고, 오케스트레이터의 전체 스위트도 이 Mac 에서 돈다. 그래서 NOT PASS 로 둔다. 고칠 곳은 fixture 몇 줄이고 재설계는 필요 없다.
|
||||
|
||||
## 차단
|
||||
|
||||
**B1. `test_a_query_reads_right_and_leaves_the_sidecars_alone[clean-*]` 5개가 macOS 에서 실패한다.**
|
||||
- 위치: `tests/test_issue60_linux_fixes.py:153-154`(`make("clean")`)과 `:175-181`.
|
||||
- 원인: macOS 시스템 SQLite 는 persistent WAL 로 동작해서, 마지막 연결을 닫아도 `-wal`·`-shm` 을 지우지 않는다. 임시 DB 에서 쓰기 연결을 닫은 뒤의 목록이 `['o.db', 'o.db-shm', 'o.db-wal']` 였다. 그래서 `_wal_writer(db, ws).close()` 뒤의 목록이 기대값 `["opencode.db"]` 와 다르고, 첫 assert(`make(state) == listing`)에서 실패한다. 제품 코드까지 가지도 않는다.
|
||||
- 재현: 검증 경로에서 `env -i …` 로 `tests/test_issue60_linux_fixes.py` 를 단독 실행하면 5 failed, 40 passed. 관련 18개 모듈 실행(아래)에서도 같은 5개만 실패했다.
|
||||
- 제품 코드는 맞다. 이 테스트의 `clean` 상태는 Linux 에서 닫힌 DB 의 모습(사이드카 없음)이다. git archive 사본에서 이 상태를 만들도록 close 뒤에 `-wal`·`-shm` 을 지워 보았다. 그러자 해당 서브셋 33개가 모두 통과했고, 아래 뮤테이션 6개도 모두 잡혔다.
|
||||
- 권고: `clean` 분기에서 close 뒤 남은 `-wal`·`-shm` 을 지운다. close 가 이미 checkpoint 하므로 행은 본 파일에 있다. 또는 Mac 의 실제 상태인 "쓰는 쪽 없이 사이드카 둘 다"는 이미 `leftover` 가 다루므로, 그 사실을 주석으로 남긴다.
|
||||
- 주의: 지금 상태의 Mac 에서는 `clean` 이 첫 assert 에서 멈춘다. 그래서 immutable 분기를 없애는 뮤테이션(M1)이 이 Mac 에서는 드러나지 않는다.
|
||||
|
||||
## 중점 항목 확인
|
||||
|
||||
**1. `_ro_connect` 세 갈래 (`opencode.py:13-36`).** 검증 경로 코드를 3.9 로 import 해 임시 DB 에서 확인했다. 사이드카를 직접 지워 Linux 와 같은 상태를 만들었다.
|
||||
|
||||
| 상태 | 결과 |
|
||||
|---|---|
|
||||
| 사이드카 없음 | 같은 상태에서 base 의 평범한 `mode=ro` 는 `unable to open database file` 로 실패한다. 그래서 base 의 `_descendants`·`db_rows_naming` 은 Mac 에서도 빈 결과였다. `_ro_connect`(immutable)는 행을 읽고 아무 파일도 만들지 않는다. |
|
||||
| writer 가 살아 있음(둘 다 있음) | WAL 에만 있는 행까지 읽는다. 목록은 그대로다. |
|
||||
| `-shm` 없는 `-wal` | 커밋된 행을 읽는다. 원본 두 파일의 mtime 은 그대로였고 `-shm` 도 생기지 않았다. 같은 상태에서 평범한 `mode=ro` 는 `-shm` 을 만들었다. 디렉터리가 읽기 전용(0555)이어도 읽힌다. |
|
||||
| DB 없음 | `OperationalError`. 파일은 생기지 않는다. base 의 `sqlite3.connect(db)` 는 빈 DB 를 만들었으므로 개선이다. |
|
||||
|
||||
- 실제 Mac 의 상태: 실제 디렉터리에는 서비스가 떠 있어 `opencode.db`·`-shm`·`-wal` 이 모두 있다(`ls`). persistent WAL 이 기본인 Mac 에서는 "둘 다 있음 → mode=ro" 갈래가 대부분일 것이다. 다만 bun 이 시스템 SQLite 를 쓰는지는 확인하지 않았다.
|
||||
|
||||
**2. `_DB_PATHS` 캐시 (`opencode.py:39-40, 139-161`).**
|
||||
- 키는 `(bin, ctx.home_dir, _data_home(ctx))` 다. `_run` 이 넘기는 HOME·XDG_DATA_HOME 과 같은 값이라, 다른 HOME 이나 XDG 에 다른 경로를 돌려주지 않는다.
|
||||
- 실패한 `debug paths` 는 저장되지 않는다. 바이너리가 없으면(`bin_path` None) 캐시를 쓰지 않는다.
|
||||
- 테스트 간 누출: 이 모듈은 autouse fixture 로 캐시를 비운다. 다른 모듈은 tmp_path 별 HOME 이라 키가 겹치지 않는다. 18개 모듈을 한 프로세스에서 실행했을 때 B1 외의 실패는 없었다.
|
||||
|
||||
**3. auth_ok (`opencode.py:317-321`, `__main__.py:120-127`, `create_session.sh:145-151`).**
|
||||
- 다른 에이전트의 분기와 adapter 는 바뀌지 않았다. `auth-ok` 를 부르는 곳은 opencode 분기뿐이다.
|
||||
- `python -m lib_py.agents auth-ok opencode` 를 `/usr/bin/python3` 3.9 로 실행했다. PATH 에 opencode 가 없으면 rc 1, `~/.opencode/bin` 이 있으면 rc 0, 인자가 없으면 rc 1(`unknown agent ''`).
|
||||
- PYTHONPATH 는 lib.sh 가 export 한다(`lib.sh:25`). 같은 파일의 `facts`·`spawn-spec` 호출과 같은 방식이다.
|
||||
- create 의 사전 검사로 충분하다. 바로 위 `command -v "$AGENT"`(`:117`)가 이미 PATH 의 바이너리를 요구한다.
|
||||
|
||||
**4. `input_placeholder_pattern` (`opencode.py:75-79`, `input_region.py:83-85`).**
|
||||
- 홈 화면 fixture 두 개(140열, 잘린 예시가 있는 60열)는 모두 빈 입력으로 읽힌다. 1.x 캡처도 그대로 빈 입력이다. 다른 adapter 는 None 이다(테스트로 고정).
|
||||
- fullmatch 라 `Ask anything… "x" and fix it` 처럼 뒤에 글이 붙은 입력은 그대로 읽힌다.
|
||||
|
||||
**5. stop 문구 (`stop_session.sh:242`).** 메시지만 바뀌었고 동작은 같다. 테스트 2개(exit 됨 / 살아 있어 kill)가 통과했다.
|
||||
|
||||
**6. Python 3.9 와 셸.**
|
||||
- 바뀐 Python 5개 파일이 3.9 에서 파싱되고, 제품 모듈은 3.9 로 import 해 실행했다. 새 테스트는 `from __future__ import annotations` 를 쓴다.
|
||||
- `create_session.sh`·`stop_session.sh` 가 `/bin/bash -n`(3.2)을 통과한다. `git diff --check 1d7b7da..HEAD` 는 깨끗하다.
|
||||
|
||||
## 테스트 실행 (검증 경로)
|
||||
|
||||
조건: `env -i`, 임시 HOME, `XDG_DATA_HOME=<scratchpad>`, basetemp 는 scratchpad, `-p no:cacheprovider`, `PYTHONDONTWRITEBYTECODE=1`.
|
||||
|
||||
대상 18개 모듈: issue60_linux_fixes, issue60_opencode_v2, a4, issue50_dispatch, issue30_stall_watch(+_stop), issue31, tier1, tier2, c1_tui_readiness, issue5_phase0/phase2b, issue24, issue6, uuid_target, issue55, issue57, issue21_worktree_prereqs.
|
||||
|
||||
- 결과: **5 failed(B1 의 5개뿐), 623 passed, 2 skipped**, 29분.
|
||||
- skip 2개: `/proc` 전용 테스트와, PATH 에 opencode 가 없어 건너뛴 실바이너리 계약 테스트.
|
||||
- 부작용 확인:
|
||||
- scratchpad XDG 디렉터리는 실행 뒤에도 비어 있었다.
|
||||
- 실제 `~/.local/share/opencode` 목록은 실행 전후가 같았다.
|
||||
- 실제 log 의 `debug` 줄 수는 2 → 2.
|
||||
- 남은 opencode 프로세스는 없었다(사용자 서비스 제외).
|
||||
- #55 가드 오탐도 없었다.
|
||||
|
||||
뮤테이션: git archive 사본에 B1 fixture 수정을 넣고 진행했다. 대상은 issue60_linux_fixes 와 a4 이며, 실제 프로세스를 띄우는 stop·create 2개는 뺐다. 모두 잡혔다.
|
||||
|
||||
| 뮤테이션 | 결과 |
|
||||
|---|---|
|
||||
| M1 immutable 분기를 `mode=ro` 로 | 5 failed |
|
||||
| M2 `wal and shm` 를 `wal` 로 | 5 failed |
|
||||
| M3 캐시 사용 끔 | 1 failed |
|
||||
| M4 잘린 예시 허용(`"?`) 제거 | 2 failed |
|
||||
| M5 fullmatch 를 match 로 | 4 failed |
|
||||
| M6 auth_ok 를 항상 True 로 | 3 failed |
|
||||
|
||||
## 비차단
|
||||
|
||||
- **N1. immutable 경쟁.** `-wal` 없음을 판단한 뒤 연결이 쓰이는 동안 writer 가 생기면 결과가 틀릴 수 있다. 임시 DB 에서 재현했다.
|
||||
- (a) 판단 직후 writer 가 한 행을 커밋하면, immutable 연결은 1/2 만 본다.
|
||||
- (b) 열린 immutable 커서 아래에서 writer 가 지우고 다시 쓰고 checkpoint 하면, 예외 없이 옛 행과 새 행이 섞인 20119행이 나왔다(옛 20000행, 새 30000행).
|
||||
- 쿼리가 짧아(LIMIT 20, 단건 조회) 실제 창은 ms 수준이다. persistent WAL 인 Mac 에서는 이 갈래 자체가 드물다.
|
||||
- 원하면 이렇게 줄일 수 있다: 쿼리 뒤 `-wal` 이 여전히 없고 본 파일의 mtime·size 가 그대로인지 확인하고, 아니면 `mode=ro` 로 한 번 다시 읽는다.
|
||||
- **N2. 캐시는 프로세스 수명 동안 유지된다.** 같은 경로에서 바이너리를 업그레이드한 경우, 오래 도는 `reconcile --subscribe` 는 예전 `debug paths` 결과를 계속 쓴다. 경로 규칙이 바뀌는 업그레이드에서만 문제가 된다.
|
||||
- **N3. 사전 검사가 약해졌다.** create 의 opencode 검사는 사실상 `command -v` 와 같아졌다. 깨진 바이너리도 통과하고, 실패는 이후 준비 대기 단계에서 드러난다. 2.x 에 로그인이 없다는 전제와 맞는 완화로 본다.
|
||||
- **N4. 안내 문구와 같은 모양의 초안.** 사용자가 정확히 그 모양으로 친 입력(`Ask anything...`, `Ask anything… "fix the tests"`)은 빈 입력으로 읽힌다. 확인한 결과 둘 다 `''` 였다. 실사용에서 생길 가능성은 매우 낮다.
|
||||
|
||||
[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,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261026T133440Z-c8961b",
|
||||
"base_sha": "29cc4fd90478936f15387a7665e2c95d13a3f14c",
|
||||
"head_sha": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
|
||||
"tree_sha": "98d57bdc60920238c115ece90c90cc694f2744bc",
|
||||
"review_commit": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "f39ae4ba",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-26T17:32:11Z"
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
# PR #73 (Issue #71) 리뷰 — head 5f60653
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (macOS Darwin 25.6, `/bin/bash` 3.2.57, `/usr/bin/python3` 3.9.6, BSD sed/tr)
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2`
|
||||
- identity 확인 (시작·종료 시 모두):
|
||||
- HEAD 5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26, tree 98d57bdc60920238c115ece90c90cc694f2744bc
|
||||
- `git status --porcelain --ignored` 비어 있음
|
||||
- `.mam` 없음. 추적 파일 `.mam.env.example` 만 있다.
|
||||
- main HEAD 29cc4fd 그대로.
|
||||
- 범위: 29cc4fd..5f60653, 커밋 14개, 파일 28개 (+953/−176).
|
||||
- 읽은 것: 이슈 #71 본문과 댓글("범위 정리" 1–10항, #72 분리), PR #73 본문.
|
||||
|
||||
## 0. 이번 잡(f39ae4ba)에 대해
|
||||
|
||||
- 이 잡은 같은 루프의 검증 경로 `/2` 다. head 5f60653, tree 98d57bdc 는 앞 잡 0698ffc5(검증 경로 `/1`) 와 같다. 브리프의 확인 항목도 잡 ID와 경로를 빼면 글자까지 같다.
|
||||
- 그래서 아래 1장의 분석(파일:줄 근거, 리뷰어 시험 p1–p12, 변이 m1–m12, base 비교)은 같은 tree 의 `git archive` 사본에서 얻은 결과를 그대로 쓴다.
|
||||
- 이번 경로 `/2` 에서 새로 확인한 것:
|
||||
- 브리프에 붙은 diff(1920줄)가 `git diff 29cc4fd 5f60653` 와 바이트까지 같다. 커밋은 14개다.
|
||||
- `/2` 의 `lib.sh` 에서 `_sanitize_herdr_agent_name` 을 다시 뽑았다. 정의는 하나이고, 앞서 비교한 정의와 같다.
|
||||
- `/2` 의 `lib_py` 를 기준으로 bash↔Python 비교를 다시 돌렸다. 408건에 불일치는 0이다. python3 와 해시 도구가 모두 없는 5건도 기대대로 동작했다.
|
||||
- 테스트 세 묶음을 `/2` 에서 다시 돌렸다. 결과는 아래 2장 표와 같다.
|
||||
- issue71+issue66: 77 passed
|
||||
- A: 384 passed, 1 skipped
|
||||
- B: 543 passed, 3 skipped
|
||||
- 실제 herdr 서버 두 개(pid 11655, 69309)는 실행 전후로 같다.
|
||||
- 시작과 끝에 `/2` 의 HEAD·tree 를 확인했다. `status --ignored` 는 비어 있고 `.mam` 은 없다. main HEAD 는 29cc4fd 그대로다.
|
||||
|
||||
## 1. 확인한 것과 근거
|
||||
|
||||
### 1) 완료 조건과 범위
|
||||
|
||||
| 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 1 attach 형태 | 충족 | 아래 3) |
|
||||
| 2 `default` 도 적음 | 충족 | 아래 3) |
|
||||
| 3 래퍼 attach | 충족 | 아래 3) |
|
||||
| 4 두 필드 삭제 | 충족 | 아래 4) |
|
||||
| 5 원칙(스킬로 시작·종료), 행 없는 stop | 충족 | 아래 4), 5) |
|
||||
| 6 attach 는 예외 | 충족 | attach 만 herdr 명령을 직접 쓴다 |
|
||||
| 7 변환 규칙 하나 | 충족 | 아래 6) |
|
||||
| 8 해시 불가 시 오류 | 충족 | 아래 6) |
|
||||
| 9 마이그레이션 | 충족 | 아래 7) |
|
||||
| 10 stall_watch 한 곳 | 충족 | 아래 8) |
|
||||
|
||||
- 보강 완료 조건도 모두 충족한다.
|
||||
- 새 행에 두 필드가 없다.
|
||||
- 해시 도구가 없으면 python3 로 같은 결과를 내고, python3 도 없으면 오류다.
|
||||
- 옛 행의 마이그레이션은 멱등이고, YAML 과 DB 가 같다.
|
||||
- 행 없는 stop 은 레지스트리를 바꾸지 않는다.
|
||||
- 정리 안내는 stop 스킬 명령이다.
|
||||
- #72(update 스킬)는 끌어들이지 않았다.
|
||||
- 진입점 `python3 -m lib_py.registry_format` 만 두었다.
|
||||
- 그 진입점을 부르는 코드는 없다.
|
||||
- 본문 완료 조건 6(실제 에이전트를 32자 초과 이름으로 띄워 attach)은 수동 확인 항목이다. 브리프 제약(실제 에이전트·실제 herdr 금지)에 따라 하지 않았다.
|
||||
- 대신 형태가 맞는지는 확인했다. 실제 herdr 0.9.5 의 `agent attach --help` 를 격리 HOME 에서 보면 사용법이 `herdr agent attach <TARGET> [OPTIONS]` 이고, 옵션은 `--takeover` 다.
|
||||
|
||||
### 2) 회귀 테스트 tests/test_issue71_herdr_names.py (70개)
|
||||
|
||||
- head (검증 경로): issue71 70개와 issue66 7개를 함께 돌려 77 passed.
|
||||
- base: 29cc4fd 사본에 head 의 테스트 파일을 넣었다. conftest 는 이 PR 에서 바뀌지 않았다.
|
||||
- 결과: **68 failed, 2 passed**. PR 이 말한 숫자와 같다.
|
||||
- 통과하는 2개:
|
||||
- `test_the_names_need_converting_and_the_plain_one_does_not`: 변환 전제를 확인한다.
|
||||
- `test_wrapper_attach_calls_herdr_with_the_herdr_name[PLAIN]`: 변환이 필요 없는 이름이라 base 에서도 맞다.
|
||||
- reconcile 경로 (`test_reconcile_registers_with_the_attach_command`, test :126-139) 는 행 등록 경로에 실제로 닿는다.
|
||||
- mock herdr 에 `cwd=mam_sandbox`, `pane_id` 가 있는 agent 를 넣는다.
|
||||
- 실행 전에 행이 없음을 확인한다 (:132).
|
||||
- 실행 뒤 `last_visible_note == "auto-registered by monitor"` 인 행이 하나인지 확인한다 (:137-138).
|
||||
- 변이 m10 (reconcile 을 옛 형태로) 이 이 테스트 3개를 실패시킨다.
|
||||
|
||||
### 3) attach_command (`lib_py/agents/sanitize.py:34-43`)
|
||||
|
||||
- 형태: `herdr --session <shlex.quote(server or "default")> agent attach <shlex.quote(sanitize(name))>`.
|
||||
- 서버가 None 이나 빈 값이어도 `--session default` 가 된다.
|
||||
- 변환된 이름은 `[a-z0-9_-]` 이므로 quote 를 해도 그대로다.
|
||||
- 서버 `srv x` 는 `'srv x'` 로 감싸진다. 내 시험과 test :51 에서 확인했다.
|
||||
- 쓰는 곳은 네 곳이고, 모두 이 함수를 쓴다:
|
||||
- create: `create_session.sh:566`
|
||||
- resume 의 새 행: `update_yaml_resumed.sh:151`
|
||||
- resume 의 서버 변경: `update_yaml_resumed.sh:161`
|
||||
- reconcile: `reconcile.sh:676`
|
||||
- 마이그레이션도 같은 함수를 쓴다 (`registry_format.py:62`).
|
||||
- resume 의 else 분기는 서버가 바뀔 때만 attach_command 를 다시 쓴다 (:159-161).
|
||||
- 서버가 그대로인 기존 행은 첫 쓰기의 마이그레이션이 이미 새 형태로 바꿔 둔다. 그래서 낡은 값이 남지 않는다.
|
||||
- lib.sh 래퍼 (`lib.sh:605-613`):
|
||||
- `agent attach <t> ...` 의 첫 인자를 `_resolve_herdr_target` 으로 변환한다. get/read/prompt 와 같은 함수다.
|
||||
- 나머지 `"$@"` 는 그대로 넘긴다.
|
||||
- 테스트 :151-159 가 strict mock 에서 `["agent","attach",<herdr 이름>,"--takeover"]` 를 확인한다.
|
||||
- 변이 m4 (원래 이름을 넘김) 는 LONG·UPPER 에서 잡힌다.
|
||||
|
||||
### 4) kill_command·start_command 삭제
|
||||
|
||||
- 코드·문서에는 이 두 키가 남아 있지 않다. 리뷰 트리 전체를 grep 했다.
|
||||
- 남은 곳: 마이그레이션 상수 (`registry_format.py:37`), 테스트의 "옛 행" 픽스처와 부재 단언, 과거 리뷰 보관본 `.agents/reports/*`.
|
||||
- `status --json` 의 `start_command` 키는 빠졌다 (`status.sh:224-228`).
|
||||
- 이 키를 읽는 코드나 테스트는 없다.
|
||||
- status SKILL.md 의 예시도 같이 고쳤다.
|
||||
- 래퍼의 `new-session`/`kill-session` 가짜 명령은 남겨 두는 것이 맞다. 아직 쓰는 곳:
|
||||
- create (`create_session.sh:266-276`, :346)
|
||||
- stop (`stop_session.sh:157`, :307, :321)
|
||||
- reconcile
|
||||
- 사용자에게 보이던 kill-session 안내 두 곳은 stop 스킬 명령으로 바뀌었다:
|
||||
- create exit 43 (`create_session.sh:364`)
|
||||
- id_order (`id_order.py:343-345`)
|
||||
- 바뀐 명령 `stop_session.sh --session X --herdr-session S` 는 행이 없어도 동작한다 (아래 5).
|
||||
- exit 43 은 `_mark_row_rolled_back "running" "rollback-failed"` 로 행을 남기므로, 그 경우는 행 있는 stop 경로를 탄다.
|
||||
|
||||
### 5) 행이 없는 stop (`stop_session.sh:117-191`)
|
||||
|
||||
- **행이 없을 때만 그 경로로 간다.**
|
||||
- 행을 찾는 Python 은 행이 없을 때만 `sys.exit(7)` 을 낸다.
|
||||
- `[ "${MAPPED_RC:-0}" -ne 7 ] || stop_rowless` (:189) 로 7 일 때만 row-less 로 간다.
|
||||
- 다른 실패는 `cannot read the row` 로 exit 1 이다 (:190-193).
|
||||
- 변이 m6 (0 이 아니면 모두 row-less) 은 `test_stop_does_not_take_an_unreadable_row_for_no_row` 에 잡힌다.
|
||||
- **레지스트리를 읽지 못할 때**는 `load_state_json strict` (:170, lib.sh:1598-1631) 가 exit 1 로 끝난다.
|
||||
- 해당 경우: YAML 깨짐(DB 없음), DB 손상, sessions 테이블 없음, 테이블 전부 없음.
|
||||
- 테스트 :514-541 의 4개 매개변수가 이를 확인한다.
|
||||
- 인자 없는 기존 호출은 동작이 같다. strict 일 때만 raise 하거나 exit 한다.
|
||||
- **작업 공간 판정** (:142-151) 은 `realpath` 두 개를 비교한다: `cwd == root or cwd.startswith(root + os.sep)`. 내 추가 시험 (head 사본, `tests/test_zz_review71.py`) 결과:
|
||||
|
||||
| 경우 | 결과 |
|
||||
|---|---|
|
||||
| p1 접두어 혼동 (`<ws>-other`) | exit 5, 닫지 않음 |
|
||||
| p2 밖에서 작업 공간 안으로 가는 심볼릭 링크 | 0, 닫음 |
|
||||
| p3 작업 공간 안에서 밖으로 가는 심볼릭 링크 | exit 5 |
|
||||
| p4 후행 슬래시 | 0 |
|
||||
| p5 WORKSPACE_ROOT 를 심볼릭 링크로 지정 | 0 |
|
||||
| p7 32자 초과 이름 (strict mock) | 0 |
|
||||
|
||||
- **서버 결정 순서** (:107-113, :128-130) 는 `--herdr-session` → 환경의 `HERDR_SESSION_NAME` → 작업 공간 서버다.
|
||||
- 환경 값이 비었거나 `default` 이면 작업 공간 서버로 간다. create 의 :185, :253 과 같은 규칙이다.
|
||||
- p6 (환경 값 `default`) 에서 모든 `agent get` 이 작업 공간 서버로 간 것을 확인했다.
|
||||
- 테스트 :487-499 는 환경 서버를 확인한다.
|
||||
- **레지스트리를 바꾸지 않는다.** 모든 row-less 테스트가 YAML/DB 해시를 비교한다 (`_registry_bytes`).
|
||||
- **`--purge-conversation`** 은 행이 없으면 exit 1 이다 (:124-126, test :544-550).
|
||||
- **종료 코드:**
|
||||
- 0 / 1 / 5 / 6 은 SKILL.md 표 (:58-67) 와 헤더 주석 (:30-36), 코드 (:134, :154, :161, :163) 가 같다.
|
||||
- `test_literal_exit_rule_on_base_scripts` 는 `{0..6}` 이다.
|
||||
- skill_code_blocks 의 `entry_points` 도 5·6 을 더했다.
|
||||
|
||||
### 6) 이름 변환 (`lib.sh:44-82`)
|
||||
|
||||
- 정의는 하나다 (`lib.sh:44`). test :171 이 확인한다.
|
||||
- shim 은 `{ printf shebang; declare -f _sanitize_herdr_agent_name; cat <<'EOF' … } > "$tmp_file"` (:179-1393) 으로 같은 정의를 받는다.
|
||||
- bash 3.2 의 `declare -f` 출력을 다시 source 한 사본도 아래 비교에 넣었다.
|
||||
- 이 호스트의 `bash` 는 `/bin/bash` 3.2 하나뿐이라, 테스트의 shim 도 3.2 로 돈다.
|
||||
- has-session 의 `lambda s: s.lower()[:32]` 대체와 `os.path.abspath(".agents/skills")` import 는 없어졌다.
|
||||
- 대신 lib.sh 가 계산한 `TARGET_SAN` 을 넘긴다 (:633-641).
|
||||
- **macOS 실측 비교:** 이름 34개를 Python `sanitize_herdr_agent_name` 과 비교했다.
|
||||
- 이름: ASCII, 대문자, 32/33/40/200자, 숫자·`_`·`-n`·`-e`·`--help` 로 시작, `\n`(가운데·앞·뒤·연속), `\t`, `\r`, `*?[a]`, `\`, `%s`, `Ü`, `İstanbul…`, `日本語`, 이모지, `K`(U+212A), `ß`, `Dž`, `Ω…` 긴 이름, NBSP 등.
|
||||
- 원본 정의와 `declare -f` 사본 2 벌 × 환경 6 개 (`LANG=en_US.UTF-8`, `LC_ALL=C`, 로캘 없음, `LC_ALL=UTF-8`+`LC_CTYPE=C`, 해시 도구 없이 python3 만 + UTF-8 또는 C) = **408 건, 불일치 0**.
|
||||
- ASCII 가 아닌 이름은 바이트 단위 치환을 쓰지 않고 python3 의 문자 단위 `str.lower()`+치환을 쓰므로 Python 과 같다 (:51-56).
|
||||
- **해시 도구별:** `/sbin/sha1sum` 만, openssl(LibreSSL) 만, 깨진 shasum(빈 출력)에서 openssl 로 넘어가는 경우 모두 Python 과 같았다 (`mam-wt-issue-71-very-lo-54e536a7`).
|
||||
- **python3 도 해시 도구도 없을 때:**
|
||||
- 짧은 ASCII 이름(대문자, 끝의 `\n` 포함)은 정상이다.
|
||||
- 32자 초과는 `no shasum, sha1sum, openssl or python3` 오류로 rc 1 이다.
|
||||
- ASCII 가 아닌 이름은 `not ASCII and there is no python3` 오류로 rc 1 이다.
|
||||
- `00000000` 은 어디에도 없다.
|
||||
- 호출하는 곳은 실패를 넘긴다: `|| return 1`, `|| exit 1`, `wait_for_agent_idle` 은 `|| return 3`, delegate-job 은 진단만 건너뛴다.
|
||||
- spawn 의 `agent_name` 계산은 루프 앞 (:688) 으로 옮겨졌다. 그래서 이름을 못 구하면 spawn 하지 않는다. test :267 이 확인한다.
|
||||
|
||||
### 7) 레지스트리 형식 2 (`registry_format.py`, `atomic_yaml.py:118-137, :192`)
|
||||
|
||||
- **순서:** 잠금 → `BEGIN IMMEDIATE` (:79-87) → DB 또는 YAML 읽기 → 마이그레이션 (:121-126) → 호출자 변경 (:144-149) → 검증 → DB 쓰기 → commit → YAML 쓰기.
|
||||
- 마이그레이션은 잠금 안에서, 호출자 변경 전에 돈다.
|
||||
- **모든 행을 바꾼다.** `migrate` 는 이름이 있는 모든 행에서 두 키를 지우고 attach_command 를 다시 만든다.
|
||||
- stopped·terminated 도 포함한다. 변이 m8 (running 만) 은 6 failed 로 잡힌다.
|
||||
- **YAML 도 반드시 다시 쓴다.** `force_yaml = True` (:126) 이고, 변이 m2 는 6 failed 로 잡힌다.
|
||||
- **멱등이다.** test :327-338 이 확인하고, 진입점을 두 번 부르면 `2 -> 2, rows changed: 0` 에 바이트도 같다 (test :342-362).
|
||||
- **YAML 교체가 실패한 뒤의 복구:** DB 가 2 이고 YAML 이 2 미만이면 다음 쓰기에서 `force_yaml` 이 된다 (:127-137).
|
||||
- 진입점으로 하든 아무 쓰기로 하든 복구된다 (test :379-411).
|
||||
- 변이 m3 은 2 failed 로 잡힌다.
|
||||
- **다른 쓰기 경로:** 레지스트리 YAML/DB 를 쓰는 코드는 `atomic_yaml` 하나뿐이다.
|
||||
- grep 결과 `safe_dump`, `INTO state/sessions` 는 이 파일에만 있다. hermes.py 의 DELETE 는 에이전트 자신의 DB 다.
|
||||
- orc_onboard 와 autopin 도 `atomic_dump_yaml(_main)` 을 거친다.
|
||||
- 호출자 변경 가운데 `d` 를 통째로 새로 만드는 것은 없다. 만들어도 다음 쓰기가 다시 마이그레이션하므로 형식 키를 잃지 않는다.
|
||||
- **읽기 경로:** `load_state_json` 과 `lib_py.state` 는 바꾸지 않았다. strict 인자만 더했다.
|
||||
- **미래 형식 (p8):** `registry_format: 3` 이면 진입점은 `3 -> 3, rows changed: 0` 이다. 쓰기 뒤에도 키는 3 으로 남고, 행(옛 키 포함)도 그대로다. 내려 쓰지 않는다.
|
||||
- **비정상 값 (p9):** `"2"`, `True`, `None` 은 1 로 보고 2 로 바꾼다. `0` 은 `0 -> 2` 로 출력된다.
|
||||
- **진입점 계약:**
|
||||
- 성공은 stdout 한 줄 `registry-format: <from> -> <to>, rows changed: <n>` 에 exit 0 이다.
|
||||
- 레지스트리가 없으면 exit 1 이다 (test :423).
|
||||
- DB 손상 (p10) 은 stderr `registry-format: error: file is not a database` 에 exit 1 이고 stdout 은 비어 있다.
|
||||
- 검증 거부 (p11) 는 `registry-format: error: VALIDATE: … bad status` 에 exit 1 이다.
|
||||
- 모두 docstring 과 같다.
|
||||
- **macOS persistent WAL:** YAML/DB 바이트 비교 테스트(진입점 재실행, row-less stop)가 이 호스트에서 모두 통과했다.
|
||||
|
||||
### 8) stall_watch
|
||||
|
||||
- `StallWatch.__init__` 이 `self.herdr_name` 을 한 번 구한다 (:229).
|
||||
- 그 값을 `HerdrSource(..., name=self.herdr_name)` (:230) 와 `wait_once` (:332) 에 같이 쓴다.
|
||||
- `HerdrSource` 는 keyword-only `name` 이 필수다. 원래 이름으로 대신 쓰는 경로는 없다.
|
||||
- 다른 호출자는 `test_issue60_linux_fixes.py:99` 하나이고, `name="t"` 를 넘기도록 바뀌었다.
|
||||
- `tests/test_issue66_stall_watch_sanitized_name.py` 는 diff 가 없고, 7 passed 다.
|
||||
|
||||
### 9) 바뀐 기존 테스트
|
||||
|
||||
- `test_tier2_component`: `HERDR_SESSION_NAME=` 포함 여부 대신 새 attach 형태 전체가 같은지와 두 키가 없는지를 본다.
|
||||
- 서버 전파와 라벨이 새지 않는지를 보는 원래 의도는 유지되고, 단언은 더 엄격해졌다.
|
||||
- `test_issue50_pane_lock:173`: create 가 쓴 행이라는 증거를 `start_command` 대신 `session_id_source` 로 본다.
|
||||
- reconcile 의 행 (`reconcile.sh:666-680`) 에는 이 키가 없다. 그래서 증거로서 같은 구분을 한다.
|
||||
- `test_issue21_worktree_prereqs:584`: 제어 루트 경로가 새지 않는지를 attach_command 에서 본다. 의도가 같다.
|
||||
- `test_tier1_unit` / `test_tier3_integration`:
|
||||
- "agent 를 추론할 수 없으면 exit 2" 를 이제 **행이 있는** 세션으로 확인한다. 행이 없으면 row-less 경로로 가기 때문이다.
|
||||
- 의도가 유지된다.
|
||||
- `test_issue63`, `test_issue23_24` S9: 안내 문구를 새 명령으로 바꿨다.
|
||||
- `test_layout`, `test_issue42`: heredoc 시작 위치와 종료 코드 집합을 따라 고쳤다.
|
||||
- `tests/data/skill_code_blocks.json`:
|
||||
- 블록 33개 가운데 sha 가 바뀐 것은 문서 내용이 바뀐 두 블록이다: create :134 (YAML 행 예시), status :74 (JSON 예시).
|
||||
- 나머지는 줄 이동과 evidence 줄 번호다.
|
||||
- `entry_points` 의 stop 종료 코드에 5·6 을 더했다.
|
||||
- 문서 diff 와 맞다.
|
||||
- **S11** (`test_issue23_24_startup_path.py:294-316`):
|
||||
- 비교 기준을 `0fc162e` 로 두었다. `0fc162e..HEAD` 사이 lib.sh 변경은 5f60653 의 `load_state_json` 하나뿐이고, shim heredoc 과 sanitize 함수는 바뀌지 않았다.
|
||||
- merge commit 으로 병합하면 0fc162e 가 main 이력에 남으므로 계속 맞다.
|
||||
- 기준을 찾지 못하면 `pytest.skip` 한다. 이는 base(525df2b) 때부터의 동작이다. git 이 없는 설치본이나 squash 병합에서 건너뛰는 것은 기존 설계 그대로라고 본다.
|
||||
- 검증 경로(git worktree)에서는 **skip 이 아니라 통과**했다 (`-k s11`: 1 passed).
|
||||
- squash 하면 이 확인이 사라지므로 PR 본문의 "merge commit 필수" 를 지켜야 한다.
|
||||
|
||||
### 10) macOS 호환
|
||||
|
||||
- 바뀐 Python 6개 파일이 `/usr/bin/python3` 3.9.6 에서 compile 된다.
|
||||
- `attach_command` 와 `migrate` 가 3.9 에서 실행된다.
|
||||
- 바뀐 셸 줄에 `declare -A`, `mapfile`, `${x,,}`, `sed -i`, `stat -c`, `readlink -f`, `local -n` 이 없다.
|
||||
- 이름 변환은 BSD tr/sed 와 bash 3.2 에서 위 6) 처럼 확인했다.
|
||||
|
||||
## 2. 실행한 테스트
|
||||
|
||||
- 실행 조건:
|
||||
- 모두 `env -i PATH=/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin`, 임시 HOME/XDG_DATA_HOME 에서 돌렸다.
|
||||
- `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider --basetemp=<스크래치>` 를 썼다.
|
||||
- `.mam.env` 는 export 하지 않았다.
|
||||
- 실제 herdr 서버 두 개는 테스트 전후로 같다: pid 11655 `herdr server`, 69309 `herdr --session multi-agent-mux server`, 시작 시각도 같다.
|
||||
|
||||
| 대상 | 결과 |
|
||||
|---|---|
|
||||
| 검증 경로: test_issue71_herdr_names + test_issue66_stall_watch_sanitized_name | **77 passed** (`/1`, `/2` 모두) |
|
||||
| base 29cc4fd 사본 + head 의 issue71 테스트 | **68 failed, 2 passed** |
|
||||
| 검증 경로 A: tier1_unit, tier2_component, tier3_integration, issue23_24_startup_path, issue50_pane_lock, issue21_worktree_prereqs, issue63_same_cwd_ids, issue60_linux_fixes, layout, issue42_skill_blocks | **384 passed, 1 skipped** (`/1`, `/2` 모두; skip: issue42 의 `.claude/skills is untracked; absent in a worktree`, 환경 요인) |
|
||||
| 검증 경로 B (stop·reconcile·create·resume 인접): b4_session_created, b19_headless_reconcile_fixes, c1_tui_readiness, issue15_registry_isolation, issue17_workspace_root_isolation, issue31_purge_residual, issue24_resume_dialogs, issue40_auto_pin, issue45_restore, issue6_fail_closed, issue9_server_env, issue64_ro_connect, uuid_target, workspace_scope, issue57_logs_follow_registry, issue30_stall_watch, issue30_stall_watch_stop, issue50_dispatch_herdr, issue60_opencode_v2, issue36_prompt_transport, a4_adapter_contract, issue26_ints | **543 passed, 3 skipped** (`/1`, `/2` 모두) |
|
||||
| S11 단독 (검증 경로) | 1 passed (skip 아님) |
|
||||
| 리뷰어 추가 시험 (head 사본, p1–p12) | 14 passed. 1개는 내 기대값 오류다 (형식 0 → "0 -> 2" 출력, 동작은 정상) |
|
||||
| bash↔Python 이름 변환 비교 | 408 건 불일치 0, 도구 없음 5 건 모두 기대대로 (`/2` 의 lib.sh·lib_py 로 다시 실행) |
|
||||
|
||||
변이 (head 사본, issue71 70개):
|
||||
|
||||
| # | 변이 | 결과 |
|
||||
|---|---|---|
|
||||
| m1 | 작업 공간 판정을 `startswith(root)` 로 (접두어 혼동) | **살아남음** (리뷰어 p1 은 잡음) |
|
||||
| m2 | 마이그레이션 뒤 YAML 강제 쓰기 끔 | 6 failed |
|
||||
| m3 | YAML 복구 분기 끔 | 2 failed |
|
||||
| m4 | 래퍼 attach 에 원래 이름 | 2 failed |
|
||||
| m5 | 줄바꿈 → `-` 제거 | 6 failed |
|
||||
| m6 | rc≠0 이면 모두 row-less | 1 failed |
|
||||
| m7 | strict 에서 state 테이블 오류만 무시 | **살아남음** (아래 N3) |
|
||||
| m8 | running 행만 마이그레이션 | 6 failed |
|
||||
| m9 | 서버 없으면 `--session ''` | 3 failed |
|
||||
| m10 | reconcile 을 옛 attach 형태로 | 3 failed |
|
||||
| m11 | 행 없는 stop 이 환경 서버를 무시 | 1 failed |
|
||||
| m12 | 해시 실패 시 `00000000` | 4 failed |
|
||||
|
||||
## 3. 차단 결함
|
||||
|
||||
없음.
|
||||
|
||||
## 4. 비차단
|
||||
|
||||
### N1: 행 없는 stop 이 제어 루트 밖 워크트리의 세션은 닫지 못한다
|
||||
|
||||
- 상황:
|
||||
- `worktree_prepare.sh:229-236` 은 creator 를 `WORKSPACE_ROOT=$MAIN` 과 `--workspace $WT` 로 만든다.
|
||||
- `$WT` 는 `--path` 로 받은 임의의 경로이고, 보통 제어 루트 밖이다. 루프의 `mam-wt-*` 나 `$TMPDIR/mam-review/…` 가 그 예다.
|
||||
- 결과:
|
||||
- 그런 create 가 행을 등록하기 전에 죽으면, id_order 의 안내 (`id_order.py:343-345`) 는 stop 명령을 준다.
|
||||
- 그 stop 은 `realpath(cwd)` 가 `WORKSPACE_ROOT` 밖이므로 exit 5 로 거부한다 (`stop_session.sh:151-154`). 다른 작업 공간 테스트 (test :467) 와 같은 경로다.
|
||||
- 거부는 안전하다(아무것도 바꾸지 않음). 하지만 이 경우에는 안내된 스킬 명령으로 정리할 수 없다.
|
||||
- 권고: 둘 중 하나.
|
||||
- (a) row-less 판정에 "이 제어 루트에 묶인 워크트리(`git worktree list`)" 도 넣는다.
|
||||
- (b) exit 5 메시지에 그 경우의 정리 방법을 적는다.
|
||||
|
||||
### N2: 접두어 혼동을 고정하는 테스트가 없다
|
||||
|
||||
- m1 이 PR 테스트에서 살아남는다. `<ws>-other` 를 cwd 로 하는 한 줄짜리 시험을 더하기를 권한다. 내 p1 이 그 형태다.
|
||||
|
||||
### N3: strict 읽기에서 state 테이블만 없는 DB 는 걸리지 않는다
|
||||
|
||||
- m7 이 살아남은 이유는 테스트의 `no-tables` 가 sessions 테이블 오류로도 잡히기 때문이다.
|
||||
- state 만 없는 DB 에서도 stop 은 sessions 테이블로 행을 정확히 찾으므로 영향은 없다.
|
||||
|
||||
### N4: 이 PR 이 건드리지 않은 attach 안내가 남아 있다 (모두 base 부터 있던 것)
|
||||
|
||||
- `create_session.sh:770`: `Attach: herdr session attach $SESSION_NAME`. 서버를 적지 않고, 이름도 변환하지 않는다. 행의 `attach_command` 를 출력하기를 권한다.
|
||||
- `multi-agent-mux-delegate-job:1021`: `HERDR_SESSION_NAME=… herdr agent attach $sess (lib.sh를 source한 셸에서)`. 래퍼가 이제 이름을 변환하므로 동작은 하지만, 새 형태와 다르다.
|
||||
- `deploy/install_mam.sh:330`: `HERDR_SERVER_NAME=… herdr agent attach`. 없는 환경 변수 이름이다.
|
||||
|
||||
### N5: 래퍼 attach 는 대상이 첫 인자라고 가정한다
|
||||
|
||||
- `herdr agent attach --takeover <name>` 순서로 쓰면 래퍼는 `--takeover` 를 대상으로 찾다가 exit 1 을 낸다.
|
||||
- herdr 의 사용법(`<TARGET> [OPTIONS]`)과 문서의 순서대로라면 문제가 없다.
|
||||
|
||||
### N6: YAML 이 아직 없는 작업 공간에서는 행 없는 stop 이 동작하지 않는다
|
||||
|
||||
- 처음 만든 작업 공간에서 첫 create 가 행 등록 전에 죽어 YAML 이 아직 없으면, stop 은 행 없는 경로 전에 `YAML not found` 로 exit 1 한다 (:101).
|
||||
- 드문 경우다.
|
||||
|
||||
### N7: 정리할 만한 작은 것들
|
||||
|
||||
- `create_session.sh:519` 의 `server_opt` 는 쓰는 곳이 없다. base 에서도 그랬다.
|
||||
- shim 내용이 바뀌었으므로, 같은 제어 루트를 쓰는 옛 버전 체크아웃과 새 버전 체크아웃이 섞이면 `.mam/shim/herdr` 를 서로 덮어쓴다. #45 때와 같은 전환기 비용이다.
|
||||
|
||||
[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,84 @@
|
||||
# Issue #40 구현 계획 정교화 — agy 동일 cwd 오등록 방지
|
||||
|
||||
## 반영한 이의제기와 결론
|
||||
|
||||
Creator의 이의는 타당합니다. `last_conversations.json`은 cwd를 키로 UUID 하나만 저장하므로 그 값을 후보로 삼는 것만으로는 agy 후보가 최대 하나가 됩니다. 따라서 일반적인 `candidate_count > 1` 검사는 이 경우 작동하지 않습니다. 이 캐시는 대화를 연 시점에 갱신되며 현재 대화의 소유자를 증명하지도 않습니다. 특히 `agy.verify_artifact()`의 형제 UUID 검사가 `cache_match`가 아닐 때만 실행된다면, cache hit만 사용하는 자동 pin 경로에서 형제 배제는 무력화됩니다. 같은 cwd의 동시 세션은 실제 지원 시나리오이므로 epoch와 viewport 확인도 대화 소유권을 대신할 수 없습니다.
|
||||
|
||||
이에 따라 agy는 **캐시 일치만으로 자동 pin하지 않습니다.** 캐시는 검사할 UUID를 제안하는 데만 쓰고, 해당 UUID의 열린 presence lock을 보유한 프로세스가 현재 행의 pane 프로세스이거나 그 자손인지 확인해야 합니다. 확인할 수 없거나 소유자가 불일치하면 fail closed로 미확정 처리합니다. 같은 cwd에 다른 non-stopped agy 세션이 있으면 이 소유권 증명이 필수이며, 증명 없이 pin하지 않습니다. 형제 UUID 배제는 cache hit 여부와 무관하게 항상 적용합니다.
|
||||
|
||||
## 현재 기준선과 범위
|
||||
|
||||
대상 워크트리 `/Users/godopu16/PuKi/laa/canary_projects/mam-wt-issue40`, 브랜치 `fix/issue-40-auto-pin`은 base/head 모두 `ff8b381520ae04e80c73a9e18bfad73d47e853c9`이며 변경 사항이 없습니다. 본 작업은 계획서 갱신이며 코드·브랜치를 수정하지 않습니다. 구현은 이 워크트리에서 진행합니다.
|
||||
|
||||
목표는 Class A 어댑터의 첫 로컬 `completed` 작업을 통해 미등록 세션 ID를 안전하게 확정하는 것입니다. 어댑터는 수명주기와 읽기 전용 capture 사실을 제공하고, completed를 검증한 로컬 수신 측만 YAML/SQLite를 갱신합니다. `publish_event.py`는 계속 이벤트 발행만 맡습니다. wrapper subscriber 종결 처리와 `loop.runner.wait_for_job()` 복구 경로는 같은 handler를 호출하고 `(job_id, dispatch_token, attempt_id)`로 중복을 제거합니다. remote/human job 및 pre-assigned ID는 제외합니다.
|
||||
|
||||
## 정교화한 설계
|
||||
|
||||
1. **Capture 결과와 lifecycle 계약**
|
||||
|
||||
`BaseAgentAdapter`에 `id_lifecycle` 및 `capture_active_session_id(ctx)` 계약을 추가합니다. 결과는 ID와 구체적 상태/사유를 포함해 `captured`, `no-candidate`, `ambiguous(shared-cwd)`, `unverifiable-owner`, `owner-mismatch`, `sibling-claimed`, `stale`, `wrong-workspace` 등을 구분합니다. Claude/Grok은 `pre_assigned`로 선언하고 자동 capture하지 않습니다. agy·opencode·codex·hermes는 `post_discovered` 대상입니다. 추가/기존 어댑터가 결과를 명확히 전달하도록 하며, `None` 하나로 모든 실패 원인을 뭉개지 않습니다.
|
||||
|
||||
2. **공용 handler와 저장 경계**
|
||||
|
||||
`lib_py/agents/autopin.py`와 같은 공용 경로에 판정·pin handler를 둡니다. 검증된 local `completed` job, 세션 row, transport, agent, dispatch token 및 attempt를 확인하고, 기존 ID/lifecycle을 존중한 뒤 adapter capture, workspace/epoch 검증, sibling 배제, viewport 확인을 수행합니다. 판정 코드는 저장소를 직접 우회하지 않고 기존 `atomic_dump_yaml` 경로에서 row와 관련 SQLite 상태를 갱신합니다. 성공 때 own ID, `session_id_source='discovered'`, `session_id_verified=True`, 표시 상태를 일관되게 기록하고, 실패 시 기존 own ID를 변경하지 않은 채 구체적 사유를 보존합니다. capture 실패는 이미 성공한 작업의 terminal 상태를 실패로 뒤집지 않습니다.
|
||||
|
||||
3. **agy는 캐시 후보와 실행 중 소유권을 모두 증명**
|
||||
|
||||
- `last_conversations.json[normalized_cwd]`는 후보 UUID 검색의 시작점일 뿐, 단독 pin 근거로 사용하지 않습니다. 해당 map이 다른 cwd를 가리키는 UUID는 즉시 거절합니다.
|
||||
- 후보 UUID의 `presence/<uuid>.lock` (필요하면 기존 artifact 경로 계약에 따른 DB/sidecar)을 열고 있는 PID를 `lsof`로 조회합니다. 소유 PID는 해당 행의 `pane.pid` 또는 그 자손 프로세스여야 합니다. PID/조상 관계를 확인하는 동안 프로세스 종료·PID 재사용 가능성을 고려해 실제 프로세스 존재와 계보 확인에 실패하면 성공으로 추정하지 않습니다.
|
||||
- lsof 부재/오류, lock 미점유, pane PID 누락, 계보 불일치 등의 경우 pin하지 않고 각각 재현 가능한 fail-closed 사유를 기록합니다. 실제 CLI 실행 상태를 확인할 수 없는 환경에서 cache-only fallback은 두지 않습니다.
|
||||
- 동일 normalized cwd를 가진 다른 non-stopped agy row가 있으면 owner 검사가 통과해야만 그 소유 행에 pin할 수 있습니다. 서로 다른 행이 같은 캐시 UUID를 갖는 동시 처리에서도 pane 소유권을 통과한 행만 후보가 됩니다. owner가 불확실하면 둘 다 미확정으로 남을 수 있으며 이는 안전한 결과입니다.
|
||||
- `verify_artifact()`의 sibling claim 검사는 cache match와 무관하게 먼저 적용합니다. 다른 활성/등록 행이 이미 보유한 UUID를 거절합니다. own row의 재검증은 자기 claim을 sibling으로 오인하지 않도록 제외합니다.
|
||||
- reconcile drift C와 completed capture는 같은 agy 판정기를 호출합니다. 대화 폴더 `*.db` 전체를 훑어 얻은 단독 후보를 자동 pin하지 않습니다. 다른 workspace의 DB를 읽지 않도록 막고, DB 조회는 `base._ro_connect`를 사용합니다.
|
||||
|
||||
4. **나머지 Class A 어댑터**
|
||||
|
||||
OpenCode는 세션 DB의 directory/time, Codex는 state DB의 cwd와 rollout metadata, Hermes는 sessions의 cwd/started_at을 사용합니다. 각 후보는 workspace와 세션 epoch(`herdr_session_epoch`) 조건을 모두 만족해야 하며 검증된 후보가 정확히 하나일 때만 확정합니다. 다수 후보는 `ambiguous`로 남깁니다. agy의 presence owner 규칙은 다른 어댑터에 억지로 일반화하지 않습니다.
|
||||
|
||||
5. **동시 completed 처리와 재수신**
|
||||
|
||||
job/token/attempt 키 아래 직렬화하고 처리 결과를 남깁니다. wrapper와 loop 복구 경로가 연속 또는 동시 호출되어도 하나의 capture만 수행합니다. handler가 상태 저장 전에 중단된 경우 재시도는 기존 own ID와 artifact를 재검증해 멱등이어야 합니다. agy의 owner 판별은 각 처리 시점에 수행하며, 캐시가 바뀌어도 UUID 소유 pane을 잘못 선택하지 않습니다.
|
||||
|
||||
6. **호출 지점**
|
||||
|
||||
subscriber가 검증한 completed, subscriber timeout 뒤 disk fallback에서 확정한 completed, loop `wait_for_job()`의 completed 반환 직전 모두 동일 handler로 이어집니다. 기존 wrapper가 순수 상태 매핑을 맡고 있다면 얇은 Python 호출 경계를 추가합니다. subscriber와 `publish_event.py`에 registry/YAML/SQLite 쓰기를 추가하지 않습니다.
|
||||
|
||||
## 단계별 실행 및 검증 기준
|
||||
|
||||
### 1단계 — 실패 재현과 회귀 테스트
|
||||
|
||||
- Creator의 같은 cwd 재현을 테스트 fixture로 옮깁니다. A와 B가 동일 cwd이고 cache는 UUID U 하나를 가리키며, U의 presence lock 소유자는 A의 pane PID인 상태에서 B는 discover/capture/verify 모두 U를 얻지 못해야 합니다.
|
||||
- 동일 cwd 동시 onboard 완료 두 건을 모의합니다. cache UUID는 하나여도 결과는 소유 pane 행만 pin할 수 있고, PID 확인이 불가능하거나 불일치하면 어느 행도 pin하지 않습니다.
|
||||
- `test_agy_capture_requires_presence_owned_by_pane_pid`: lsof 결과를 모의해 일치 PID/자손, 다른 PID, lock 미점유, lsof 오류를 검증합니다.
|
||||
- `test_agy_capture_rejects_cache_uuid_owned_by_sibling_in_same_cwd` 및 `test_agy_concurrent_onboard_same_cwd_no_cross_pin`을 추가합니다. sibling 배제 검사는 cache-hit에서도 실행되어야 합니다.
|
||||
- Issue #64의 `verify_session_uuid(...uuid_b...) is True` 기대를 `False`로 바꾸고, 외부 DB에 sidecar가 생기지 않는 확인은 유지합니다.
|
||||
|
||||
### 2단계 — adapter 및 agy 판정
|
||||
|
||||
- lifecycle 선언, capture 결과 타입, epoch/workspace 단일 후보 규칙을 테스트합니다.
|
||||
- A/B 캐시 전환, 타 workspace mapping, 같은 cwd sibling claim, cache match여도 sibling UUID 거부, presence 소유자가 pane 또는 자손인지 확인합니다.
|
||||
- lsof를 쓸 수 없는 상황에서는 `unverifiable-owner` 등 사유와 함께 미확정임을 확인합니다. 어떤 예외 경로도 cache-only 자동 pin으로 되돌아가면 안 됩니다.
|
||||
|
||||
### 3단계 — 공용 completed handler
|
||||
|
||||
- daemon 없는 onboard 완료, OpenCode/Codex/Hermes의 첫 completed pin, 기존 ID/pre-assigned skip, remote/human/stale token/non-completed 제외를 확인합니다.
|
||||
- wrapper completed와 loop 복구 완료를 같은 키로 호출해 capture 및 저장이 한 번뿐인지 검증합니다.
|
||||
- 모호성·검증 실패의 사유가 보존되고 자체 ID는 그대로인지 확인합니다.
|
||||
|
||||
### 4단계 — reconcile 통합 및 회귀
|
||||
|
||||
- drift C가 공용 판정기를 사용하고 agy의 전체 DB glob 후보를 자동 pin에 쓰지 않는지 테스트합니다.
|
||||
- same-cwd A/B에서 B의 최신 cache 값과 A의 더 최근 epoch가 있어도 lock 소유 pane이 A라면 A만 pin될 수 있고, B는 pin되지 않는지 검증합니다.
|
||||
- subscriber, retained/disk fallback, daemon 없는 실행, loop 복구 경로에서 동일 dedupe key를 확인합니다. publisher가 제어-plane 상태를 쓰지 않는 것도 확인합니다.
|
||||
|
||||
### 5단계 — 검증과 범위 마무리
|
||||
|
||||
- 지정 Python 환경에서 신규 Issue #40 테스트, 갱신된 Issue #64, UUID/tier2/dispatch 및 관련 adapter/reconcile 테스트를 실행합니다. 전체 테스트는 상위 오케스트레이션에 맡깁니다.
|
||||
- 테스트는 임시 HOME/DB와 source 바깥 basetemp를 쓰고 실제 agent home, `.mam.env`, monitor/herdr 서비스에 접근하지 않습니다. 변경한 shell은 `bash -n`, diff는 `git diff --check`로 확인합니다.
|
||||
- 작업 전후 branch/status를 확인하고 요청 범위 파일만 stage합니다. 버전 파일 및 `.venv`는 추가하지 않습니다.
|
||||
|
||||
## 위험과 명시적 한계
|
||||
|
||||
가장 큰 위험은 잘못 pin된 ID가 잘못된 resume 또는 purge로 이어지는 것입니다. 따라서 agy에서 lock 소유 프로세스와 pane의 관계를 확인할 수 없으면 자동 pin을 포기합니다. 이는 일부 환경에서 정상 대화도 자동 등록되지 않는 false negative를 허용하는 fail-closed 선택입니다. 추후 에이전트가 presence lock을 열지 않는 지원 모드가 확인되면 별도의 식별 가능한 프로세스 귀속 신호를 설계해야 하며, 기존 cwd 캐시를 대체 증거로 승격하지 않습니다.
|
||||
|
||||
같은 cwd의 활성 agy 세션 수가 많을수록 cache-only 판정은 신뢰할 수 없습니다. viewport 검사, epoch, 최신 mtime은 owner 증명이 아닙니다. stop의 기존 capture와 source/verified 메타데이터 정합성은 별도 후속 범위로 남기되, completed pin이 이를 덮어써 기존 ID를 변경하지 않도록 회귀 테스트합니다.
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261027T201245Z-63c71b",
|
||||
"base_sha": "1d63e882bc1c46e9af8484647d5091b296d8fa3f",
|
||||
"head_sha": "ec7f5c4e57286fb3c4238b8220f39094f4640391",
|
||||
"tree_sha": "fbadb24de60ccdae684b23e2e0a41cdf23ab81eb",
|
||||
"review_commit": "ec7f5c4e57286fb3c4238b8220f39094f4640391",
|
||||
"reviewer": "planner-reviewer-codex-01",
|
||||
"job_id": "028e98cb",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-27T21:06:27Z"
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# PR #75 후속 리뷰 — job 028e98cb
|
||||
|
||||
## 판정과 검증 대상
|
||||
|
||||
이번 범위의 차단 사항을 발견하지 않았다. R2의 PGID 분리와 테스트 teardown 수정이 요구 사항을 충족한다.
|
||||
|
||||
- base: 1d63e882bc1c46e9af8484647d5091b296d8fa3f
|
||||
- head: ec7f5c4e57286fb3c4238b8220f39094f4640391
|
||||
- tree: fbadb24de60ccdae684b23e2e0a41cdf23ab81eb
|
||||
- 검증 경로: /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261027T201245Z-63c71b/1
|
||||
- HEAD/tree 일치, clean status, `git diff --check 1d63e88..HEAD` 통과. 실제 diff는 lib.sh와 test_issue72_update.py 두 파일이다.
|
||||
- 새 브리프의 좁혀진 범위인 1d63e88..HEAD를 검토했다. 이전 비차단 권고에 해당하는 파일/기능을 추가 변경하지 않았음을 확인했다.
|
||||
- `tea issues 72 --comments`, `tea pulls 75`를 다시 읽었다. 기준 이슈 내용은 앞선 검토와 같고, 이번 수정은 결정 2 및 기존 중복 방지/opt-out/PID/로그 계약과 정합적이다.
|
||||
|
||||
## 확인한 것
|
||||
|
||||
| 항목 | 파일:줄 근거 | 확인 결과 |
|
||||
|---|---|---|
|
||||
| R2: 호출자 PGID에서 분리 | `.agents/skills/lib.sh:2303–2309` | 기존 monitor 옵션을 기록하고 spawn 때만 `set -m` 사용. 비동기 monitor가 별도 PGID에서 뜨며, 기존 옵션은 복원된다. macOS에 없는 setsid(1) 없이 동작. |
|
||||
| 기존 기동 계약 | `lib.sh:2289–2315` | 정확한 PID 검색과 중복 방지, opt-out 분기는 유지. cwd를 작업 공간으로 두고 원래 cwd로 복원, stdin /dev/null, 로그 append, --idle-timeout 0, nohup의 SIGHUP 처리와 PID stdout 계약 유지. |
|
||||
| 0/1 × 세 복원 경로 회귀 | `tests/test_issue72_update.py:699–725`, 특히 `:724` | 정상 설치·정지 timeout·rollback의 두 모드 모두 실제 새 monitor와 PGID 분리를 검증한다. decoy 미신호 및 기동 횟수 검증도 유지. 여섯 조합 모두 통과. |
|
||||
| pkill 제거·소유 프로세스 정리 | `test_issue72_update.py:184–193,220–240` | 기록된 helper 그룹을 정리하고 wait한다. monitor는 이 테스트의 정확한 절대 스크립트 명령행으로 고른 뒤, 미리 수집한 자손 PID와 함께 정리한다. 전역 패턴 kill은 없다. |
|
||||
| 잔여 프로세스 검출 유지 | `test_issue72_update.py:237–240` | ws를 명령행에 남긴 프로세스가 있으면 최대 10초 기다린 뒤 실패한다. 이 문자열 검색은 보고/검증용이며 kill 대상으로 사용되지 않는다. 원본 테스트를 외부 정리 플러그인 없이 실행해 통과. |
|
||||
|
||||
## R2의 전후 재현 결과
|
||||
|
||||
base 1d63e88의 부정 대조는 직전 d14ae099 리뷰에서 실제로 실행한 동일 외부 탐침 로그를 사용했다. 당시 mode=0의 정상·timeout·rollback 세 경우는 PGID가 호출자와 같아 실패했고, mode=1의 세 대조군은 통과했다. 이번에도 같은 탐침 코드를 변경 없이 지정된 새 snapshot에서 실행했다.
|
||||
|
||||
| 경로 | mode=0의 새 monitor PGID / caller PGID | mode=1의 새 monitor PGID / caller PGID |
|
||||
|---|---|---|
|
||||
| 정상 설치 | 93359 / 88049 — 분리 | 96481 / 88049 — 분리 |
|
||||
| 정지 timeout 복원 | 91992 / 88631 — 분리 | 94916 / 88631 — 분리 |
|
||||
| rollback 복원 | 93376 / 88631 — 분리 | 96166 / 88631 — 분리 |
|
||||
|
||||
- 기존 `probe_monitor_group.py`: **2 passed / 11.95s** (base에서는 1 failed, 1 passed).
|
||||
- 기존 `probe_monitor_restore_group.py`: **4 passed / 10.40s** (base에서는 2 failed, 2 passed).
|
||||
- 별도 직접 호출 탐침으로 monitor 옵션 초기 off→off 및 on→on, cwd 복원을 확인: **2 passed / 3.24s**. 새 monitor의 PGID 분리도 확인했다.
|
||||
- PGID 검증은 조회로 했으며 호출자의 프로세스 그룹에 신호를 보내지 않았다.
|
||||
|
||||
base 로그와 동일 탐침 코드, 이번 새 결과를 evidence/에 보존했다. base를 이번 허용 경로 밖에서 다시 실행한 것은 아니다.
|
||||
|
||||
## Mac에서 실행한 테스트
|
||||
|
||||
/bin/bash 3.2.57, Python 3.9.6에서 실행했다. 스냅샷에 .venv가 없어 브리프 앞부분에 명시된 제어 루트 .venv/bin/python을 사용했다.
|
||||
|
||||
작업 디렉터리는 위 snapshot이며, 주요 실행 명령은 다음과 같다.
|
||||
|
||||
```bash
|
||||
env -u HERDR_SOCKET_PATH -u HERDR_PANE_ID -u HERDR_TAB_ID \
|
||||
-u HERDR_WORKSPACE_ID -u HERDR_SESSION_NAME -u HERDR_SESSION \
|
||||
-u WORKSPACE_ROOT -u MAM_ENV_FILE -u MAM_JOBS_DIR \
|
||||
-u DELEGATE_JOB_LOGS_DIR -u AGENT_SESSIONS_YAML \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python \
|
||||
-m pytest -p no:cacheprovider \
|
||||
--basetemp=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review-028e98cb-pytest -ra \
|
||||
tests/test_issue72_update.py \
|
||||
tests/test_issue5_phase1_delegation.py::test_c07_watchdog_spawns_daemon_with_workdir_and_log \
|
||||
tests/test_issue5_phase1_delegation.py::test_c08_watchdog_second_call_reuses_running_daemon \
|
||||
tests/test_issue5_phase1_delegation.py::test_c09_start_watchdog_and_ensure_subscriber_daemon_agree \
|
||||
tests/test_issue5_phase1_delegation.py::test_daemon_missing_monitor_raises_with_the_legacy_path \
|
||||
tests/test_issue5_phase1_delegation.py::test_daemon_spawn_dedup_and_survives_parent_exit \
|
||||
tests/test_issue19_subscriber_daemon_lifecycle.py::test_opt_out_still_reports_a_daemon_that_already_runs
|
||||
```
|
||||
|
||||
**59 passed / 133.12s / exit 0**. update 파일 50건, phase1 watchdog 8건, 기존 daemon의 opt-out 보고 1건이다.
|
||||
|
||||
같은 환경 제거·Python·no-cache 설정으로 실행한 추가 검증:
|
||||
|
||||
| 선택 | 결과 |
|
||||
|---|---|
|
||||
| `test_issue19_subscriber_daemon_lifecycle.py::test_sandbox_default_never_spawns_a_daemon` (0/1) | 2 passed / 0.53s |
|
||||
| `-p probe_monitor_group ... tests/test_issue72_update.py` | 2 passed / 11.95s |
|
||||
| `-p probe_monitor_restore_group ... tests/test_issue72_update.py` | 4 passed / 10.40s |
|
||||
| `-p probe_monitor_options ... tests/test_issue72_update.py` | 2 passed / 3.24s |
|
||||
|
||||
외부 탐침은 기존 Module의 Box fixture를 사용하고 항목만 외부 plugin으로 선택했다. source는 변경하지 않았다. 각 basetemp는 TMPDIR 아래 서로 다른 mam-review-028e98cb-{pytest,group,restore-group,options,optout}이었다. 이번에는 safe_cleanup 등의 외부 teardown 대체 없이 수정된 원본 cleanup을 실행했다.
|
||||
|
||||
- `/bin/bash -n .agents/skills/lib.sh`: 통과.
|
||||
- `ast.parse(test_issue72_update.py, feature_version=(3,9))`: 통과.
|
||||
- diff --check: 통과.
|
||||
- ShellCheck는 PATH에 없어 실행하지 못했다. bash -n을 ShellCheck 통과로 표기하지 않는다.
|
||||
- 이번 새 브리프가 정한 수정 파일·watchdog 범위로 실행했고 전체 스위트는 실행하지 않았다.
|
||||
|
||||
## 발견 사항 및 사용자 확인
|
||||
|
||||
차단/비차단 신규 지적 없음. R2와 pkill 제거 요구 모두 해결됐다. 새 정책 결정이나 재계획이 필요하지 않다.
|
||||
|
||||
## 자원 안전
|
||||
|
||||
- 검증은 지정된 snapshot에서만 했고 creator 작업 트리에서 실행·수정하지 않았다. installer/update는 TMPDIR 가짜 설치 및 로컬 source/file:// installer만 사용했다.
|
||||
- 실제 herdr 서버·설정·에이전트 홈 DB를 조작하지 않았고, 실제 .mam.env 값을 테스트에 출력하거나 export하지 않았다. 이슈/PR 댓글·상태 변경, push/merge 없음.
|
||||
- 다섯 basetemp의 종료 후 프로세스 점검 결과 `remaining_test_processes=[]`.
|
||||
- 원래 제어 루트 monitor PID 3714는 실행 전후 같은 명령으로 살아 있었으며 신호를 보내지 않았다.
|
||||
- 종료 시 HEAD/tree와 clean status가 그대로이며 diff --check도 다시 통과했다.
|
||||
- 승인된 잡 이벤트·리포트 외에 실제 control-plane 상태를 수정하지 않았다. 원본 로그와 외부 탐침 코드는 이 리포트 옆 evidence/에 보존했다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261023T035928Z-2ccc02",
|
||||
"base_sha": "dc886ecd6b38c01032a55a2d1c230c73362ae630",
|
||||
"head_sha": "1614af5b673d7519d3a38f63e3636055592b4f14",
|
||||
"tree_sha": "f1af6fdd548ee5d6f6b81e2202833edfaca98188",
|
||||
"review_commit": "1614af5b673d7519d3a38f63e3636055592b4f14",
|
||||
"reviewer": "planner-reviewer-codex-01",
|
||||
"job_id": "12669b52",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-23T05:16:58Z"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Reviewer report — PR #69 / Issue #63
|
||||
|
||||
Reviewed the required snapshot at `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261023T035928Z-2ccc02/1`, head `1614af5b673d7519d3a38f63e3636055592b4f14`, against base `dc886ecd6b38c01032a55a2d1c230c73362ae630`. The snapshot matches the expected tree `f1af6fdd548ee5d6f6b81e2202833edfaca98188` and is clean. I read Issue #63 and PR #69. The latest delta is limited to `id_order.py` and its regression tests.
|
||||
|
||||
## Previously blocking finding is closed
|
||||
|
||||
At the previous head (`7cac6f6`), `_read_and_prune_markers` dropped a dead-owner marker when its session name matched the caller, without checking herdr. The new code removes that exception: `.agents/skills/lib_py/agents/id_order.py:219-230` now keeps a dead-owner marker until its row is registered for that attempt or herdr returns an authoritative `False`; live and unknown answers both retain the orphan. `blockers()` includes a marker without a row even when its name is the caller's (`id_order.py:145-148`), so a retry is refused rather than replacing its marker.
|
||||
|
||||
The tests now cover same-name retries with a live owner, a dead owner and live herdr session, unknown herdr state, and a confirmed-gone session (`tests/test_issue63_same_cwd_ids.py:1117-1148`). The real-probe integration test also retries using the orphan's own name and checks that the marker is retained (`tests/test_issue63_same_cwd_ids.py:1287-1290`) and then safely replaced once the session is confirmed gone (`1312-1315`). The test fixture uses a mocked herdr and in-process probes; it does not contact a real herdr server.
|
||||
|
||||
## Verification
|
||||
|
||||
- `PYTHONDONTWRITEBYTECODE=1 .../python -m pytest -p no:cacheprovider --basetemp=/tmp/mam-review-12669b52 tests/test_issue63_same_cwd_ids.py` — **27 passed** on macOS, Python 3.9.6 (230.59 s). This includes the orphan liveness, unknown-result, zero-wait-cap, and same-name retry cases.
|
||||
- Changed shell scripts checked with `/bin/bash -n` — passed.
|
||||
- `git diff --check dc886ecd6b38c01032a55a2d1c230c73362ae630..HEAD` — passed.
|
||||
- The earlier review covered the broader unchanged stop/purge/resume implementation and adjacent compatibility checks. This re-review did not repeat the base checkout or all adjacent test groups because the new delta is confined to dead-owner marker pruning and its regression tests.
|
||||
- No real agent-home databases or real herdr sessions were accessed.
|
||||
|
||||
The latest change fixes the remaining blocking same-name orphan path. The earlier probe-failure and wait-cap fixes remain covered by the passing Issue #63 tests. I found no blocking issue in this revision.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,19 @@
|
||||
# PR #62 / Issue #60 Linux follow-up review
|
||||
|
||||
**Scope.** Reviewed base `1d7b7dab58a10fe86fd747b7290d47674ca93230` through head `6a4e385a78c8a561d5cf3644336de6a352ea9271` in the assigned review-only worktree. HEAD and tree matched the brief; the worktree was clean. No source files, branches, services, or user data were changed.
|
||||
|
||||
**NOT PASS findings**
|
||||
|
||||
1. **`auth_ok()` accepts a non-executable home install.** At `.agents/skills/lib_py/agents/adapters/opencode.py:317-321`, the `PATH` candidate is checked by `shutil.which`, but the home candidate is accepted by `os.path.exists` alone. Reproduction: with a temporary HOME containing `.opencode/bin/opencode` mode `0644`, an empty PATH, and no XDG variables, `adapter.auth_ok()` returned `True`. `create_session.sh` now relies on this result as its preflight, so it can report the installation as functional and proceed although the CLI cannot be executed. Check that the home candidate is a regular executable file (e.g. `os.path.isfile` and `os.access(..., os.X_OK)`), and cover a non-executable file and directory in the auth tests.
|
||||
|
||||
2. **The placeholder regex can discard a real draft.** `.agents/skills/lib_py/agents/adapters/opencode.py:76-79` makes the example suffix optional, so the pattern matches the exact user text `Ask anything…`. Replaced the captured prompt line in the 2.x home fixture with that text and `extract_input_region()` returned `''`. Thus the existing input-empty signal loses this exact draft and can treat a non-empty prompt as idle. The observed 2.x placeholder fixtures include an opening quote and example, including the truncated narrow capture; constrain the pattern to that observed shape and add the exact-short-text draft as a regression case. Other tested similar strings are retained.
|
||||
|
||||
3. **Five newly added Mac tests fail before exercising `_ro_connect`.** In `tests/test_issue60_linux_fixes.py:153-155`, the `clean` fixture closes a WAL-mode connection and assumes macOS SQLite removes both sidecars. On this Mac the files remain after close, so `make("clean")` returns `opencode.db`, `opencode.db-shm`, and `opencode.db-wal` and the assertion at `:181` fails before the query runs. This affects the five `_descendants`, `_has_session`, `discover`, `residual_artifacts`, and `verify_artifact` parameterizations. Normalize the fixture to a genuine no-WAL state after closing/checkpointing the writer (or construct the clean database in rollback-journal mode) before asserting the directory contents.
|
||||
|
||||
**Validation.** On macOS / Python 3.9.6, ran the focused suite with temporary HOME/TMPDIR, XDG variables unset, the required environment scrub, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and an external temporary basetemp:
|
||||
|
||||
`tests/test_issue60_linux_fixes.py tests/test_a4_adapter_contract.py tests/test_issue31_purge_residual.py tests/test_issue50_dispatch.py`
|
||||
|
||||
Result: **189 passed, 5 failed** (136.27s). All five failures share the clean-fixture setup issue above; the other WAL states and remaining focused tests passed. A separate no-WAL temporary DB probe exercised `verify_artifact`, `discover`, `_has_session`, `_descendants`, and `residual_artifacts`; each returned the expected result and left only `opencode.db`. The non-executable-file probe reproduced finding 1; the captured-pane probe reproduced finding 2. `git diff --check` and `bash -n` on the changed create/stop scripts passed. No actual OpenCode service or real data was accessed. Linux was not independently rerun in this turn.
|
||||
|
||||
[VERDICT: NOT 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,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261025T023502Z-d6f876",
|
||||
"base_sha": "0461a632eb667c7988fd33be92e9a4b36151f22a",
|
||||
"head_sha": "bb496fde785133ad1dbec288aa7431766efb6eb5",
|
||||
"tree_sha": "15a72c9a1933c64018a2f2d9aa9707348905bb4d",
|
||||
"review_commit": "bb496fde785133ad1dbec288aa7431766efb6eb5",
|
||||
"reviewer": "planner-reviewer-codex-01",
|
||||
"job_id": "65396b40",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-25T03:40:44Z"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Reviewer report — PR #70 / Issue #66
|
||||
|
||||
Reviewed the required snapshot at `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1`, head `bb496fde785133ad1dbec288aa7431766efb6eb5`, against base `0461a632eb667c7988fd33be92e9a4b36151f22a`. The snapshot matches expected tree `15a72c9a1933c64018a2f2d9aa9707348905bb4d` and is clean. I read Issue #66 and PR #70; the implementation matches the requested scope.
|
||||
|
||||
## Findings
|
||||
|
||||
No blocking findings.
|
||||
|
||||
`HerdrSource` derives `self.name` from `sanitize_herdr_agent_name(target)` while resolving the adapter from the original name (`.agents/skills/lib_py/stall_watch.py:140-164`). Its agent `get`, `read`, and `prompt` calls use the sanitized name (`182-212`); the pane operations use pane IDs, so they do not need the session name. `StallWatch` separately derives the sanitized value for `watcher.wait_once` at line 229 and calls it at line 331, while notices/logs continue to use `args.target` (for example lines 391-398). `args.herdr_session` remains the herdr server selector passed as `session=`, separate from the sanitized agent name.
|
||||
|
||||
The transformation in `lib_py/agents/sanitize.py` follows the same lowercase, allowed-character replacement, leading-letter prefix, 32-character cap and SHA-1 suffix rules as the active `_sanitize_herdr_agent_name` in `lib.sh:293-318`. Agent kind detection uses the original target, which is necessary for long names whose sanitized suffix is a hash. The codex long-name test verifies this using a name whose original `-codex` suffix is absent from the sanitized form (`tests/test_issue66_stall_watch_sanitized_name.py:113-128`).
|
||||
|
||||
The mock's `strict_agent_names` check is opt-in (`tests/conftest.py:470-477`), so its default path retains the prior matching behavior. In strict mode, the tests verify that unconverted names receive `agent_not_found` for get/read/wait/prompt and sanitized names resolve (`test_issue66_stall_watch_sanitized_name.py:52-67`). Tests also verify transformed get/read/prompt/wait calls, idle and blocked notifications, preservation of the original name in log output, and unchanged plain-name behavior (`69-110`).
|
||||
|
||||
## Verification
|
||||
|
||||
- On the head snapshot, `PYTHONDONTWRITEBYTECODE=1 .../python -m pytest -p no:cacheprovider --basetemp=/tmp/mam-review-65396b40-head-pytest tests/test_issue66_stall_watch_sanitized_name.py tests/test_issue30_stall_watch.py tests/test_issue30_stall_watch_stop.py` — **66 passed, 1 skipped** on macOS, Python 3.9.6 (357.58 s). The skip is the existing Linux-only `/proc` test.
|
||||
- For the base comparison, I checked out base into `/tmp/mam-review-65396b40-base`, copied in the new regression test and its strict-mock fixture change, and ran the same test file — **5 failed, 2 passed** (82.04 s). The failures are the expected unconverted-name calls for long and uppercase targets; the plain-name and strict-mock contract tests pass.
|
||||
- `git diff --check` — passed. No Bash files changed; the integration tests execute the watcher and mock herdr through the host Bash/Python environment.
|
||||
- The reported `test_issue17_item4_decisions.py::test_c_finalize_ends_with_an_unreaped_wrapper[broker_unavailable-3-0]` cleanup race is unrelated to this diff: the changed paths are `stall_watch.py`, the test-only mock in `tests/conftest.py`, and the new Issue #66 regression file; no delegation or process-cleanup implementation changed.
|
||||
- No real agent-home databases, actual `.mam` state, or real herdr servers were accessed.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261019T015801Z-05463c",
|
||||
"base_sha": "1d7b7dab58a10fe86fd747b7290d47674ca93230",
|
||||
"head_sha": "6a37c0019baa31ee28b32a086e57b4c8239413c4",
|
||||
"tree_sha": "87013e7ef7f5239f1bf6ea38a2cbda214257be3c",
|
||||
"review_commit": "6a37c0019baa31ee28b32a086e57b4c8239413c4",
|
||||
"reviewer": "planner-reviewer-codex-01",
|
||||
"job_id": "68ebbff1",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-19T02:37:22Z"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# PR #62 / Issue #60 Linux follow-up review, round 2
|
||||
|
||||
**Scope.** Reviewed base `1d7b7dab58a10fe86fd747b7290d47674ca93230` through head `6a37c0019baa31ee28b32a086e57b4c8239413c4` in the assigned review-only worktree. HEAD and tree matched the brief, and `git status --short` was empty. No source changes, commits, branch operations, or real service/data access were performed.
|
||||
|
||||
The three findings from round 1 are addressed:
|
||||
|
||||
- `.agents/skills/lib_py/agents/adapters/opencode.py:76-80` now requires the quote/example portion of the observed placeholder. `tests/test_issue60_linux_fixes.py:79-91` verifies that both the bare `Ask anything…` and `Ask anything...` typed drafts are retained, while the wide and truncated-narrow UI placeholders still read as empty.
|
||||
- `opencode.py:318-324` accepts the home install only when it is a regular executable file; the test at `tests/test_issue60_linux_fixes.py:334-341` rejects both a non-executable file and a directory.
|
||||
- `tests/test_issue60_linux_fixes.py:156-164` removes macOS-persistent WAL sidecars only after closing the last writer, to normalize the no-WAL fixture. The clean-state queries then pass, confirming committed rows remain available after fixture setup. The other three sidecar states still exercise the intended code paths.
|
||||
|
||||
The follow-up also replaces direct writable SQLite opens in the two status branches (`.agents/skills/multi-agent-mux-status/scripts/status.sh:115-119,316-320`) and reconciler (`.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh:953-959`) with the adapter's `_ro_connect`, avoiding sidecar creation/removal in these readers. Their scripts receive the skill directory through the existing `env_python` bootstrap, so the new adapter import resolves in the normal execution path. The change preserves the prior status result mapping and query semantics.
|
||||
|
||||
No new blocking issue found. The `_DB_PATHS` cache remains scoped to binary path and effective HOME/data-home, with its test fixture clearing the cache between tests; the previously noted narrow immutable/WAL race remains a transient-read caveat rather than a new data mutation path. No live OpenCode command, service, or user database was accessed.
|
||||
|
||||
**Validation.** On macOS / Python 3.9.6, ran the related suite from the review worktree with temporary HOME/TMPDIR, XDG variables unset, the required environment scrub, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and an external temporary basetemp:
|
||||
|
||||
`tests/test_issue60_linux_fixes.py tests/test_a4_adapter_contract.py tests/test_issue31_purge_residual.py tests/test_issue50_dispatch.py`
|
||||
|
||||
Result: **201 passed** (136.01s). `git diff --check` and `bash -n` on the changed status, reconcile, create, and stop scripts also passed. Linux was not independently rerun in this reviewer turn; the brief records the author's Linux run.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261020T133532Z-a1684a",
|
||||
"base_sha": "ff8b381520ae04e80c73a9e18bfad73d47e853c9",
|
||||
"head_sha": "feccfd5e0356c8acf0438bc68bce7ea36ce2588e",
|
||||
"tree_sha": "0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a",
|
||||
"review_commit": "feccfd5e0356c8acf0438bc68bce7ea36ce2588e",
|
||||
"reviewer": "planner-reviewer-codex-01",
|
||||
"job_id": "75b29d78",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-20T15:51:16Z"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# Issue #40 교차 코드 리뷰
|
||||
|
||||
## 검토 범위와 기준선
|
||||
|
||||
리뷰는 브리프가 지정한 immutable 검증 경로 `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261020T133532Z-a1684a/1`에서만 했습니다. HEAD는 `feccfd5e0356c8acf0438bc68bce7ea36ce2588e`, tree는 브리프의 `0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a`와 일치하고, `git status`는 깨끗합니다. diff는 기준선 `ff8b381520ae04e80c73a9e18bfad73d47e853c9` 대비 19개 파일, 1128 insertions / 338 deletions입니다.
|
||||
|
||||
검토한 경계는 `autopin.py`와 adapter capture/verify, agy presence-lock 소유 PID 확인, wrapper/create/loop 호출 경로, reconcile drift C 통합, atomic YAML/SQLite 기록, Issue #40/64 회귀 및 기존 tier 테스트입니다. 소스 작업 트리에서는 실행하거나 수정하지 않았습니다. 기준선 checkout으로 테스트를 재실행하지 않았습니다. 리뷰 지침상 지정된 immutable head 검증 경로에서만 실행할 수 있었고 별도 baseline worktree를 만들지 않았기 때문입니다.
|
||||
|
||||
## 코드 리뷰 결과
|
||||
|
||||
- **agy cross-workspace 및 same-cwd 격리:** cache는 workspace 키에 해당하는 UUID 후보만 제공하며, 후보가 다른 cwd에도 매핑되면 `verify_artifact()`가 거절합니다. sibling UUID 검사는 cache hit와 무관하게 적용됩니다. `capture_active_session_id()`는 pane PID 또는 그 자손이 presence lock/DB 경로를 점유함을 확인하기 전에는 ID를 반환하지 않습니다. lsof/ps, pane PID, 점유자 확인 불능은 fail-closed이고, 다른 행이 이미 UUID를 가졌으면 거절합니다. 이 경로가 `autopin.capture()`와 reconcile drift C에서 공유됩니다.
|
||||
- **completed 수신과 중복 처리:** wrapper/create 및 loop 복구가 동일 handler를 호출합니다. handler는 terminal status, local transport, session row와 dispatch token을 검사하고 job-scoped flock와 `(job_id, dispatch_token, attempt_id)` marker로 중복 호출을 억제합니다. publisher는 제어 평면에 쓰지 않습니다. pin 성공은 atomic mutation 안에서 ID와 `session_id_source`/`session_id_verified`를 함께 기록합니다.
|
||||
- **기존 안전장치:** epoch/workspace 검증과 sibling exclusion 이후 단일 후보만 선택하고, viewport mismatch에서는 pin을 막습니다. 모호성은 pin하지 않고 행에 사유를 남깁니다. 실패한 pin은 job의 성공 terminal 상태를 덮어쓰지 않습니다. Claude/Grok 및 이미 ID가 있는 행은 capture 경로에서 보존됩니다.
|
||||
- **변경 범위:** reconcile의 agent별 후보 검색을 공용 Python capture로 줄이고, create/delegate/loop 호출과 문서·테스트를 더했습니다. stop/purge 구현과 publisher 소유권 경계를 건드리지 않았습니다. 기존 reconcile의 비-0/1 viewport 결과를 degraded pin으로 처리하는 동작은 유지했습니다.
|
||||
|
||||
차단성 결함은 발견하지 못했습니다. 비차단 관찰로, reconcile drift C는 `ambiguous`일 때만 구체적 capture 사유를 `last_visible_status`/drifts에 남기고 `unverifiable-owner`, `owner-mismatch`, `rejected`는 조용히 건너뜁니다. completed handler 경로는 이 사유를 기록하므로 안전성 문제는 아니지만, completed 처리 뒤 재시도/운영 진단을 돕기 위해 reconcile에서도 이 세 상태를 warning drift로 노출하면 추적성이 좋아집니다.
|
||||
|
||||
## 검증
|
||||
|
||||
- `tests/test_issue40_auto_pin.py tests/test_issue64_ro_connect.py`: **70 passed** (42.64s). same-cwd A/B 소유 pane test, presence 소유자 일치/자손/불일치/lsof 불가, 다른 workspace 거절, completed dedupe, ambiguous 및 publisher 경계 포함.
|
||||
- `tests/test_issue60_linux_fixes.py tests/test_issue60_opencode_v2.py tests/test_tier1_unit.py tests/test_tier2_component.py`: **176 passed** (390.99s).
|
||||
- 변경 shell 파일(`lib.sh`, create script, delegate wrapper, reconcile): `bash -n` 통과.
|
||||
- `git diff --check ff8b381..HEAD`: 통과.
|
||||
|
||||
기준선에서 새 테스트가 실패하고 HEAD에서 통과한다는 A/B 재실행은 이 리뷰의 격리 경로 제한상 수행하지 않았습니다. HEAD 테스트와 코드 경로 검토만으로 verdict를 냈습니다. 전체 테스트 스위트는 브리프대로 상위 오케스트레이터 검증에 남깁니다.
|
||||
|
||||
## 남은 위험
|
||||
|
||||
agy 자동 pin은 `lsof`와 `ps`의 소유자 계보 확인에 의존하므로 제한된 권한 환경에서는 정상 대화도 자동 등록되지 않을 수 있습니다. 현재 동작은 오등록보다 false negative를 택해 안전하게 실패합니다. process snapshot과 completed event 처리 사이의 race는 이미 종료된 후보를 보수적으로 거절할 수 있지만, 다른 pane의 ID를 승인하게 하지는 않습니다. 위 reconcile 진단 개선 외에 설계 재계획이 필요한 문제는 발견되지 않았습니다.
|
||||
|
||||
리뷰 가능한 변경분에서 자동 pin의 핵심 안전 경로를 확인했고, 관련 회귀 묶음이 모두 통과했습니다. 다른 workspace 또는 동일 cwd sibling의 agy 대화를 잘못 pin할 차단성 우회는 발견하지 못했습니다.
|
||||
|
||||
[VERDICT: 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,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261019T134017Z-d90c23",
|
||||
"base_sha": "3ce5d5fdc108b0435e933a251fe1c1256b5d7973",
|
||||
"head_sha": "b0eccc530149bc6e9211727e19921665c7b80492",
|
||||
"tree_sha": "f5a412a3ee96465db7c4b3223d2d76e95716ddfa",
|
||||
"review_commit": "b0eccc530149bc6e9211727e19921665c7b80492",
|
||||
"reviewer": "planner-reviewer-codex-01",
|
||||
"job_id": "d22e5336",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-19T14:59:46Z"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# Issue #64 코드 리뷰 — 읽기 전용 에이전트 DB 조회
|
||||
|
||||
## 판정 요약
|
||||
|
||||
head `b0eccc530149bc6e9211727e19921665c7b80492`의 변경은 조회 전용 SQLite 경로를 공용 `_ro_connect`로 일관되게 바꾸며, 의도된 DELETE 경로와 MAM 자체 DB 쓰기는 보존합니다. 다른 워크스페이스 agy DB에 WAL/SHM이 생긴 원인 경로도 코드와 격리 재현 테스트로 확인했습니다.
|
||||
|
||||
검증은 브리프의 검증 트리 `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T134017Z-d90c23/1`에서만 수행했습니다. identity는 base `3ce5d5fdc108b0435e933a251fe1c1256b5d7973`, head `b0eccc530149bc6e9211727e19921665c7b80492`, tree `f5a412a3ee96465db7c4b3223d2d76e95716ddfa`이며, `git status --short`는 비어 있습니다. 소스 워크트리는 실행하거나 수정하지 않았습니다.
|
||||
|
||||
## 원인
|
||||
|
||||
원인은 `reconcile.sh`의 agy drift C 경로입니다. [reconcile.sh:786](/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T134017Z-d90c23/1/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh:786)에서 대화 DB 디렉터리를 설정하고, [reconcile.sh:800](/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T134017Z-d90c23/1/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh:800)에서 해당 홈의 모든 `*.db`를 훑은 뒤 [reconcile.sh:816](/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T134017Z-d90c23/1/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh:816)에서 각 UUID에 `verify_session_uuid(..., mode="discover")`를 호출합니다. 이는 adapter의 `verify_artifact`로 이어집니다. agy의 [agy.py:74-76](/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T134017Z-d90c23/1/.agents/skills/lib_py/agents/adapters/agy.py:74)는 이전에 DB를 쓰기 가능 모드로 열어 `steps`를 조회했습니다. 캐시에 다른 workspace가 기록된 UUID도 sibling claim에 막히지 않으면 조회 대상이 될 수 있었고, macOS SQLite가 새 `-wal` 및 `-shm`을 남겨 #55 홈 가드에 걸릴 수 있었습니다. 연결이 이미 닫혀 프로세스가 없던 관찰과도 맞습니다.
|
||||
|
||||
신규 테스트 `test_unpinned_agy_reconcile_leaves_other_workspace_clean_db_without_sidecars`는 workspace A의 미고정 agy 행과 workspace B에 매핑된 DB를 임시 HOME에 만들고 `verify_session_uuid`가 그 DB를 조회하는 상황을 재현합니다. head에서는 조회 결과를 유지하면서 디렉터리 항목이 바뀌지 않습니다. 이번 변경은 다른 workspace 후보를 선택할 수 있는 기존 정책 자체는 바꾸지 않습니다.
|
||||
|
||||
## 변경 범위와 코드 리뷰
|
||||
|
||||
- [base.py:111-134](/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T134017Z-d90c23/1/.agents/skills/lib_py/agents/base.py:111)에 기존 opencode `_ro_connect`를 공용화했습니다. WAL/SHM 쌍은 `mode=ro`, WAL이 없으면 `mode=ro&immutable=1`, WAL만 있으면 DB와 WAL의 임시 사본을 메모리에 backup합니다. [base.py:137-145](/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T134017Z-d90c23/1/.agents/skills/lib_py/agents/base.py:137)의 `db_rows_naming` 기본 연결도 helper를 사용하며 사용자 지정 `connect` 인자는 유지됩니다.
|
||||
- [agy.py:74](/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T134017Z-d90c23/1/.agents/skills/lib_py/agents/adapters/agy.py:74): `steps` 검증 조회. `discover`도 이 검증을 거칩니다.
|
||||
- [codex.py:80,195](/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T134017Z-d90c23/1/.agents/skills/lib_py/agents/adapters/codex.py:80): rollout 조회와 state DB 검색. residual 조회는 `db_rows_naming`의 기본 helper를 사용합니다.
|
||||
- [hermes.py:51,100](/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T134017Z-d90c23/1/.agents/skills/lib_py/agents/adapters/hermes.py:51): artifact 검증과 discover 조회.
|
||||
- [status.sh:90-132,290-332](/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T134017Z-d90c23/1/.agents/skills/multi-agent-mux-status/scripts/status.sh:90): JSON/text 양쪽 hermes·codex 및 opencode 분기를 전환했습니다. agy status는 기존처럼 DB를 열지 않고 파일 존재만 확인합니다.
|
||||
- [reconcile.sh:423,865,954,1048](/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T134017Z-d90c23/1/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh:423): 공용 helper를 import하고 hermes, opencode, codex drift 조회에 사용합니다. agy 경로의 `verify_artifact` 전환도 포함됩니다.
|
||||
- 신규 `tests/test_issue64_ro_connect.py`: helper 공유, agy/codex/hermes 조회, 4가지 WAL 상태, status/reconcile 정적·런타임 동작, 다른 workspace agy 재현을 확인합니다.
|
||||
|
||||
제외한 쓰기 연결은 그대로입니다: [codex.py:152-155](/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T134017Z-d90c23/1/.agents/skills/lib_py/agents/adapters/codex.py:152)의 purge DELETE, [hermes.py:74-78](/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T134017Z-d90c23/1/.agents/skills/lib_py/agents/adapters/hermes.py:74)의 purge DELETE, [opencode.py:193-199](/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T134017Z-d90c23/1/.agents/skills/lib_py/agents/adapters/opencode.py:193)의 legacy-row DELETE. `lib.sh`, `lib_py/state.py`, `atomic_yaml.py`, `verify_session.py`, loop runner의 연결은 MAM 자체 상태/트랜잭션 용도입니다. Claude/grok adapter에는 대화 SQLite 조회가 없습니다. helper 내부의 read-only URI와 메모리 DB, 임시 사본 연결도 의도된 구현입니다.
|
||||
|
||||
변경된 조회 호출은 기존 쿼리·정렬·필터 조건을 보존하면서 helper를 경유합니다. `git diff --check base..head`와 `bash -n`으로 두 셸 스크립트 문법도 확인했습니다. 커밋은 8개 파일, 461 insertions / 56 deletions이고 검증 트리는 clean입니다. 버전 파일과 문서는 바뀌지 않았습니다.
|
||||
|
||||
## 테스트 결과
|
||||
|
||||
- Python 3.9.6 / pytest 8.4.2, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`; basetemp는 검증 트리 밖을 사용했습니다. 임시 경로와 fixture로만 DB를 만들었으며 실제 agent home DB는 열지 않았습니다.
|
||||
- `tests/test_issue64_ro_connect.py`: **43 passed** (11.59초).
|
||||
- 관련 회귀 묶음 `test_issue60_linux_fixes.py`, `test_issue60_opencode_v2.py`, `test_a4_adapter_contract.py`, `test_issue31_purge_residual.py`, `test_issue50_dispatch.py`: **216 passed** (148.47초).
|
||||
- 이번 리뷰에서 base 별도 checkout을 만들지는 않았습니다. 동일 identity/head/base를 사용한 독립 리뷰 `.mam/jobs/366a4d58/grok-reports/report-final.md`에 기록된 base 실행은 새 테스트 **23 failed, 20 passed**였습니다. 실패에는 helper 이동, clean/WAL-only 조회 뒤 sidecar 생성, status/reconcile helper 부재, 다른 workspace agy 재현이 포함되며, 남은 20개는 sidecar가 이미 있던 상태입니다. 이 수치는 그 독립 리뷰의 결과이지 이번 실행에서 재현한 수치는 아닙니다.
|
||||
|
||||
## 남은 위험
|
||||
|
||||
1. helper가 `-wal` 존재 여부를 확인한 뒤 immutable 연결을 여는 사이 writer가 WAL을 만들면 해당 연결은 새 WAL을 보지 못할 수 있습니다. 기존 구현에서 온 경쟁 조건입니다.
|
||||
2. stale WAL/SHM을 동반한 파일은 `mode=ro` 분기로 처리됩니다. 파일명 생성·삭제는 막지만 SQLite가 기존 공유 메모리 영역을 업데이트할 가능성은 남습니다.
|
||||
3. reconcile의 agy drift C가 다른 workspace DB를 후보로 보는 정책은 그대로입니다. 수정은 해당 검사가 원본 sidecar를 만들지 않게 하며 후보 귀속 규칙은 변경하지 않습니다.
|
||||
4. 브리프가 제외한 purge 쓰기는 WAL/SHM을 만들거나 변경할 수 있습니다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261026T133440Z-c8961b",
|
||||
"base_sha": "29cc4fd90478936f15387a7665e2c95d13a3f14c",
|
||||
"head_sha": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
|
||||
"tree_sha": "98d57bdc60920238c115ece90c90cc694f2744bc",
|
||||
"review_commit": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
|
||||
"reviewer": "planner-reviewer-codex-01",
|
||||
"job_id": "eb60f37d",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-26T17:32:11Z"
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# Review report — PR #73 / Issue #71
|
||||
|
||||
Reviewed validation snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2`: head `5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26`, tree `98d57bdc60920238c115ece90c90cc694f2744bc`, base `29cc4fd90478936f15387a7665e2c95d13a3f14c`. The snapshot was clean; `git diff --check` reported no whitespace errors. No source files were changed.
|
||||
|
||||
## Findings
|
||||
|
||||
No blocking or non-blocking defects found in this pass.
|
||||
|
||||
The earlier rowless-stop finding about `HERDR_SESSION_NAME=default` was re-adjudicated in job `f40aa3bd` and withdrawn. That environment value is treated as unset by create: `create_session.sh:179-187,251-256` chooses the workspace server unless the caller supplies `--herdr-session`. Rowless stop follows the same resolution (`stop_session.sh:109-130`; `lib.sh:1681-1712`). The prior probe put an agent on `default` without the explicit option, a state that invocation of create would not produce. Deliberate default-server selection remains possible with `--herdr-session default` and the stop command accepts the same explicit option.
|
||||
|
||||
## Review checks
|
||||
|
||||
- `attach_command` is centralized in `lib_py/agents/sanitize.py` and used by create (`create_session.sh:512,566`), resume (`update_yaml_resumed.sh:119,151,161`), and reconcile (`reconcile.sh:569,676`). Coverage checks default server rendering, shell quoting, normalized names, and attach argument forwarding.
|
||||
- Runtime references to removed `start_command` and `kill_command` fields are gone; only migration compatibility docs/code and assertions remain. The shim’s `new-session` and `kill-session` routes are still needed by the lifecycle scripts. Cleanup guidance now directs to the stop skill (`create_session.sh:364`, `lib_py/agents/id_order.py:342-345`).
|
||||
- Rowless stop validates strict registry readability (`stop_session.sh:169-173`), refuses purge without a row (`124-126`), resolves cwd/root canonically before checking containment (`145-154`), and leaves registry bytes untouched (`156-164`). The safety and exit-code cases are covered by the new tests and the stop skill table (`SKILL.md:58-67`).
|
||||
- The bash sanitizer is defined once (`lib.sh:44-82`), uses verified hash fallbacks, and errors when no conversion/hash tool is available. On macOS Bash 3.2, names containing `İ`, `-n`, and a trailing newline matched Python under `LC_ALL=C` and `LC_ALL=C.UTF-8`.
|
||||
- Registry migration updates all named rows and format metadata (`registry_format.py:35-66`) before caller mutation under `BEGIN IMMEDIATE` (`atomic_yaml.py:78-88`); stale YAML is repaired from DB state (`127-138`). `atomic_dump_yaml` is the only sanctioned registry YAML writer. Future versions are not downgraded. The migration entry point’s output/error contract is documented and implemented in `registry_format.py:17-25,69-88`.
|
||||
- StallWatch computes the normalized herdr name once and passes it to its source (`stall_watch.py:229`); its unchanged test passes.
|
||||
- S11’s baseline `0fc162e` exists and is an ancestor of this head. Python 3.9.6 and Bash 3.2.57 are the host versions used. The scope excludes Issue #72’s update skill.
|
||||
|
||||
## Verification
|
||||
|
||||
Commands used the required `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp` outside the validation checkout.
|
||||
|
||||
- Head combined review run (all requested focused and adjacent suites): **461 passed, 1 skipped** in 1295.97s. This included `test_issue71_herdr_names.py`, `test_issue66_stall_watch_sanitized_name.py`, tier 1/2/3, Issues 23/24, 50, 21, 63, 60, layout, and skill blocks.
|
||||
- Base Issue #71 regression run from the temporary base snapshot: **68 failed, 2 passed** in 121.85s, matching the expected base behavior.
|
||||
- Unicode/name parity probe under C and C.UTF-8: outputs matched Python.
|
||||
- `git diff --check`: clean.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261026T133440Z-c8961b",
|
||||
"base_sha": "29cc4fd90478936f15387a7665e2c95d13a3f14c",
|
||||
"head_sha": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
|
||||
"tree_sha": "98d57bdc60920238c115ece90c90cc694f2744bc",
|
||||
"review_commit": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
|
||||
"reviewer": "reviewer-agy-01",
|
||||
"job_id": "0a1bb7e7",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-26T17:32:11Z"
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
# PR #73 (`fix/issue-71`) Review Report — `agy` (`herdr:reviewer-agy-01`)
|
||||
|
||||
- **Job ID**: `0a1bb7e7`
|
||||
- **Loop ID**: `loop-20261026T133440Z-c8961b` (iteration 2)
|
||||
- **Issue / PR**: Issue #71 / PR #73 (`fix/issue-71`, 14 commits)
|
||||
- **Base Commit**: `29cc4fd90478936f15387a7665e2c95d13a3f14c` (`main`, `v4.10.1`)
|
||||
- **Head Commit**: `5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26` (tree `98d57bdc60920238c115ece90c90cc694f2744bc`)
|
||||
- **Review Worktree**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2`
|
||||
- **Host Environment**: macOS Darwin arm64 (`Darwin 25.3.0`), `/bin/bash 3.2.57(1)-release`, `/usr/bin/python3 3.9.6`
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
PR #73 resolves Issue #71 across all 10 scope items and review refinements (`A`–`F`):
|
||||
1. Unifies `attach_command` construction in [`lib_py/agents/sanitize.py::attach_command`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib_py/agents/sanitize.py#L34-L42) (`herdr --session <server> agent attach <sanitized_name>`) across `create_session.sh`, `update_yaml_resumed.sh` (both branches), and `reconcile.sh`, and teaches the `lib.sh` shim's `herdr agent attach` branch to resolve its target via `_resolve_herdr_target`.
|
||||
2. Removes `kill_command` and `start_command` from newly written rows, `status.sh --json`, and `SKILL.md` examples, and replaces direct `herdr kill-session` cleanup hints in `create_session.sh` (exit 43) and `id_order.py` with `stop_session.sh --session <name> --herdr-session <srv>`.
|
||||
3. Adds row-less session closing (`stop_rowless`) to [`stop_session.sh`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh#L123-L165) with strict registry readability gating (`load_state_json strict`), workspace boundary enforcement (`os.path.realpath` prefix-safe check, exit 5 on foreign workspace), server selection parity with `create_session.sh`, and registry immutability.
|
||||
4. Consolidates `_sanitize_herdr_agent_name` into a single definition in [`lib.sh`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib.sh#L40-L85) injected into the generated shim via `declare -f`, achieving exact parity with Python's `sanitize_herdr_agent_name` across ASCII, non-ASCII/multibyte, option-like (`-n`, `-e`), and newline-containing names, while failing closed (nonzero, stderr) when no hash tool or `python3` is available instead of emitting `00000000` or truncated names.
|
||||
5. Introduces registry format version 2 (`registry_format: 2`) in [`lib_py/registry_format.py`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib_py/registry_format.py#L1-L92) and [`lib_py/atomic_yaml.py`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib_py/atomic_yaml.py#L121-L138), migrating under the writer lock (`BEGIN IMMEDIATE`) and repairing stale YAML copies on retry.
|
||||
6. Computes `herdr_name` once in `StallWatch.__init__` and passes it as a required keyword-only argument `name` to `HerdrSource`.
|
||||
|
||||
All 10 review checklist items, macOS `/bin/bash 3.2.57` and `/usr/bin/python3 3.9.6` compatibility checks, negative regression on `base` (`68 failed, 2 passed`), and 461 functional/integration tests on `head` (`461 passed, 1 skipped, 0 failed`) pass on macOS Darwin arm64.
|
||||
|
||||
---
|
||||
|
||||
## 2. Checklist Verification (Items 1–10)
|
||||
|
||||
### 1. Completion Criteria & Scope Discipline (Issue #71 & Scope Comment Items 1–10)
|
||||
- **Verified (`PASS`)**:
|
||||
- Every requirement in Issue #71 and the "범위 정리" comment (items 1–10) is implemented and covered by regression tests.
|
||||
- Out-of-scope Issue #72 (`multi-agent-mux-update` skill) is not pulled into this PR; only the `python3 -m lib_py.registry_format --yaml <path>` entry point specified in item 9 is provided for #72 to call. `MAM_VERSION` (`4.10.1`) and `VERSIONS.md` are untouched as intended.
|
||||
|
||||
### 2. Regression Suite `tests/test_issue71_herdr_names.py` (Base vs Head & Reconcile Pane Precondition)
|
||||
- **Verified (`PASS`)**:
|
||||
- **Base (`29cc4fd90478936f15387a7665e2c95d13a3f14c`)**: Checked out into an isolated temporary directory outside the review worktree and ran `tests/test_issue71_herdr_names.py` (70 tests): **68 failed, 2 passed in 104.17s** (the only 2 passing tests on `base` are `test_the_names_need_converting_and_the_plain_one_does_not` and `test_wrapper_attach_calls_herdr_with_the_herdr_name[lab-plain-creator-claude]`, matching the PR description).
|
||||
- **Head (`5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26`)**: All **70 passed** (including all 4 `hashers` parameterizations: `shasum`, `sha1sum`, `openssl`, `python3`).
|
||||
- **Reconcile test precondition**: [`test_reconcile_registers_with_the_attach_command`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/tests/test_issue71_herdr_names.py#L128-L141) seeds `mock_herdr` with `agents={name: {"agent": "claude", "status": "running", "cwd": str(mam_sandbox), "workspace_id": "w1", "pid": 9999, "pane_id": "w1:p71", "command": "claude", "buffer": "Ready"}}` and asserts `len(rows) == 1` and `rows[0]["last_visible_note"] == "auto-registered by monitor"` before checking `_assert_new_form(rows[0], rows[0]["herdr_session"])`.
|
||||
|
||||
### 3. `attach_command` (`lib_py/agents/sanitize.py`) & `lib.sh` Wrapper `agent attach`
|
||||
- **Verified (`PASS`)**:
|
||||
- [`lib_py/agents/sanitize.py:34-42`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib_py/agents/sanitize.py#L34-L42) defines:
|
||||
```python
|
||||
def attach_command(server, name):
|
||||
return "herdr --session %s agent attach %s" % (
|
||||
shlex.quote(server or "default"), shlex.quote(sanitize_herdr_agent_name(name)))
|
||||
```
|
||||
- Always writes `--session default` when `server` is `"default"` or empty/falsy, and applies `shlex.quote` to both the server and sanitized agent name.
|
||||
- Used by all three writers:
|
||||
- `create`: [`create_session.sh:512, 566`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-create/scripts/create_session.sh#L512-L566)
|
||||
- `resume` (new row and existing row): [`update_yaml_resumed.sh:119, 150, 160`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh#L119-L160)
|
||||
- `reconcile`: [`reconcile.sh:569, 676`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh#L569-L676)
|
||||
- In [`lib.sh:605-613`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib.sh#L605-L613), the shim's `agent attach` branch shifts `<target>` (`t="$1"; shift`), resolves `tgt=$(_resolve_herdr_target "$t" || true)`, fails with exit 1 if empty, and invokes `_real_herdr agent attach "$tgt" "$@"`, preserving trailing flags such as `--takeover`.
|
||||
|
||||
### 4. Removal of `kill_command` & `start_command` and Updated Cleanup Hints
|
||||
- **Verified (`PASS`)**:
|
||||
- `git grep -E 'kill_command|start_command'` across the repository confirms no production code, skill doc, or active schema assertion reads or writes `kill_command` or `start_command` (the only remaining mentions are in `registry_format.py` to drop them during v1 -> v2 migration and in `test_issue71_herdr_names.py` / `test_tier2_component.py` asserting their absence).
|
||||
- [`status.sh:221-231`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-status/scripts/status.sh#L221-L231) and [`multi-agent-mux-status/SKILL.md:94-101`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-status/SKILL.md#L94-L101) no longer include `start_command`, and nothing in the codebase reads `start_command` from `status.sh --json`.
|
||||
- The shim's `new-session` and `kill-session` translation branches in `lib.sh` are properly retained because `create_session.sh`, `resume_session.sh`, `stop_session.sh`, and `reconcile.sh` call them internally.
|
||||
- Both user-facing cleanup hints now point to `stop_session.sh`:
|
||||
- [`create_session.sh:364`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-create/scripts/create_session.sh#L364): `bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session $SESSION_NAME --herdr-session ${HERDR_SESSION_NAME:-default}`
|
||||
- [`lib_py/agents/id_order.py:359-360`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib_py/agents/id_order.py#L359-L360): `bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session {m['session']} --herdr-session {srv}`
|
||||
|
||||
### 5. Row-less `stop` (`stop_session.sh`) & Strict Registry Read (`load_state_json strict`)
|
||||
- **Verified (`PASS`)**:
|
||||
- **Strict registry read gate**: [`stop_session.sh:170-193`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh#L170-L193) calls `STATE_JSON=$(load_state_json strict)` first. In [`lib.sh:1597-1631`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib.sh#L1597-L1631), `load_state_json strict` re-raises `sqlite3.OperationalError` on missing `state` or `sessions` tables as well as corrupt DB / malformed YAML, printing to `stderr` and exiting `1`. Furthermore, `MAPPED_DATA` exits `7` **only** when `d.get('herdr_sessions', [])` was validly iterated and had no matching dict row; non-dict rows (`herdr_sessions: [- 42]`) exit `1` (`cannot read the row`).
|
||||
- **Workspace boundary check**: [`stop_rowless` (`stop_session.sh:145-155`)](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh#L145-L155) resolves both `PANE_CWD` and `WORKSPACE_ROOT` via `os.path.realpath` and checks `cwd == root or cwd.startswith(root + os.sep)`. Verified on macOS that this prevents prefix confusion (`/tmp/ws` vs `/tmp/ws-other`), handles symlinks (`/var` -> `/private/var`), and normalizes trailing slashes; out-of-workspace sessions are refused with exit code `5` before any `kill-session`.
|
||||
- **Server selection order**: Matches `create_session.sh:184-188`: `--herdr-session` (`HERDR_SERVER_OPT`) -> ambient `HERDR_SESSION_NAME` (when non-empty and `!= "default"`) -> workspace server via `resolve_herdr_session`.
|
||||
- **`--purge-conversation` and exit codes**: Refuses `--purge-conversation` on a row-less session with exit `1`, exits `6` if `herdr has-session` still succeeds after `kill-session`, never mutates `agent-sessions.yaml` or `agent-sessions.db`, and matches [`multi-agent-mux-stop/SKILL.md:59-67`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-stop/SKILL.md#L59-L67).
|
||||
|
||||
### 6. Single Sanitize Rule in `lib.sh` & macOS BSD / Locale Parity
|
||||
- **Verified (`PASS`)**:
|
||||
- [`lib.sh:40-85`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib.sh#L40-L85) has the single definition of `_sanitize_herdr_agent_name`. [`_init_herdr_isolation` (`lib.sh:179-182`)](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib.sh#L179-L182) writes `declare -f _sanitize_herdr_agent_name` into the generated shim (`$wrapper_dir/herdr`).
|
||||
- Removed the duplicate definition in the shim heredoc, removed `"00000000"` and `${s:0:32}` fallbacks, and removed `has-session`'s relative-path `sys.path.insert(0, os.path.abspath(".agents/skills"))` + `lambda s: s.lower()[:32]` fallback (`lib.sh:633-647` now computes `ssess=$(_sanitize_herdr_agent_name "$sess") || exit 1` in bash and passes `TARGET_SAN="$ssess"`).
|
||||
- **macOS `/bin/bash 3.2.57`, BSD `tr`/`sed`, and locale (`LC_ALL=C` and `LC_ALL=en_US.UTF-8`) verification**:
|
||||
- Pure-ASCII check uses `LC_ALL=C tr -d '\000-\177'`. For ASCII inputs, `printf '%s' "$n" | tr '\n' '-' | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g'` avoids `echo` flag parsing (`-n`, `-e`, `-En`) and converts `\n` to `-` before `sed` or command substitution `$(...)` can strip trailing newlines.
|
||||
- For non-ASCII inputs (`İ`, `é`, `ü`, `K`, CJK/Hangul, emoji), it delegates lowercasing and `re.sub(r"[^a-z0-9_-]", "-", ...)` to `python3` so character-by-character replacement and Unicode case folding (`\u0130`.lower() -> `'i\u0307'` -> `'i-'`) match `lib_py.agents.sanitize.sanitize_herdr_agent_name` byte-for-byte, and fails closed (exit 1, stderr) if `python3` is unavailable.
|
||||
|
||||
### 7. Registry Format Version 2 (`lib_py/registry_format.py`, `lib_py/atomic_yaml.py`)
|
||||
- **Verified (`PASS`)**:
|
||||
- Inside [`atomic_dump_yaml_main` (`lib_py/atomic_yaml.py:121-138`)](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib_py/atomic_yaml.py#L121-L138), after acquiring the flock and `BEGIN IMMEDIATE` and loading `state` + `sessions`, if `from_version < registry_format.CURRENT` (`2`), `registry_format.migrate(d)` runs before the caller's mutation and sets `force_yaml = True`.
|
||||
- Migrates all rows in `herdr_sessions` (`running`, `stopped`, `terminated`, `archived`), drops `kill_command` and `start_command`, recomputes `attach_command`, and sets `d["registry_format"] = 2`.
|
||||
- If the DB was already at format 2 (`elif row:`) but an earlier YAML `os.replace` failed after `conn.commit()`, `force_yaml = registry_format.stored_version(y) < registry_format.CURRENT` detects the stale YAML and rewrites it on the next write.
|
||||
- **Future format preservation**: Verified that when `registry_format` > 2 (e.g., `3`), `stored_version(d)` returns `3`, `from_version < CURRENT` is `False`, and `force_yaml` is `False` — the writer neither downgrades `registry_format` nor clobbers future fields.
|
||||
- **Other registry paths**: Checked all references to `agent-sessions.yaml` across `.agents/skills/`; `atomic_dump_yaml` is the sole writer in production (`create_session.sh`, `update_yaml_resumed.sh`, `stop_session.sh`, `reconcile.sh`, `orc_onboard.sh`), while `load_state_json` (`lib.sh` / `lib_py/state.py`) is strictly read-only.
|
||||
- **CLI entry point**: `PYTHONPATH=.agents/skills python3 -m lib_py.registry_format --yaml <path>` prints `registry-format: <from> -> <to>, rows changed: <n>` and exits `0` (or nonzero with `registry-format: error: ...` on stderr when missing/failing), matching its module docstring contract.
|
||||
|
||||
### 8. `stall_watch.py` Single Sanitization Point
|
||||
- **Verified (`PASS`)**:
|
||||
- In [`.agents/skills/lib_py/stall_watch.py:158-163, 229-230`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib_py/stall_watch.py#L158-L230), `StallWatch.__init__` computes `self.herdr_name = sanitize_herdr_agent_name(args.target)` once and passes `name=self.herdr_name` (required keyword-only parameter) to `HerdrSource`.
|
||||
- `tests/test_issue66_stall_watch_sanitized_name.py` is unmodified in `git diff` and all 7 of its tests pass.
|
||||
|
||||
### 9. Test Updates, `skill_code_blocks.json`, and `test_s11` Anchor (`0fc162e`)
|
||||
- **Verified (`PASS`)**:
|
||||
- `tests/test_issue50_pane_lock.py`, `tests/test_issue21_worktree_prereqs.py`, `tests/test_tier1_unit.py`, `tests/test_tier2_component.py`, and `tests/test_tier3_integration.py` preserve their original test intent after replacing assertions on removed `start_command`/`kill_command` fields.
|
||||
- `tests/data/skill_code_blocks.json` and `tests/test_issue42_skill_blocks.py` pass all 96 skill-block verification tests.
|
||||
- In [`tests/test_issue23_24_startup_path.py:306-335`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/tests/test_issue23_24_startup_path.py#L306-L335), `test_s11_this_branch_does_not_change_the_generated_shim` pins `base = os.environ.get("MAM_BASE_SHA", "0fc162e")` and extracts both `_sanitize_herdr_agent_name() { ... }` and the `cat <<'EOF'` heredoc body in `_heredoc_body`. Commit `0fc162e` is an ancestor of `HEAD` (`5f60653`) on `fix/issue-71`; when merged into `main` via merge commit (as documented in PR #73), `0fc162e` remains reachable in git history so `test_s11` actively verifies the shim rather than skipping. (The `if show.returncode != 0: pytest.skip(...)` guard is pre-existing for shallow/archive checkouts without history.)
|
||||
|
||||
### 10. macOS Compatibility (`/usr/bin/python3 3.9.6`, `/bin/bash 3.2.57`, BSD Userland, SQLite WAL)
|
||||
- **Verified (`PASS`)**:
|
||||
- Parsed all 16 modified/added Python files with `/usr/bin/python3` (`3.9.6`) `ast.parse(..., feature_version=(3, 9))` and imported `lib_py.agents.sanitize`, `lib_py.registry_format`, `lib_py.atomic_yaml`, and `lib_py.stall_watch` under system Python 3.9.6.
|
||||
- Checked all 7 modified bash scripts and the generated `.mam/shim/herdr` with `/bin/bash -n` (`GNU bash 3.2.57(1)-release`); no bash 4+ constructs (`declare -A`, `${var,,}`, `mapfile`, `readarray`) are used.
|
||||
- In `tests/test_issue71_herdr_names.py:514-527`, `test_stop_does_not_take_an_unreadable_registry_for_no_row` unlinks `agent-sessions.db*` / `agent-sessions.db-*` before writing corrupt DB/YAML fixtures, properly accounting for macOS SQLite's persistent `-wal`/`-shm` sidecars.
|
||||
|
||||
---
|
||||
|
||||
## 3. Test Execution Summary on macOS
|
||||
|
||||
| Suite / Command | Target | Result |
|
||||
|---|---|---|
|
||||
| `git diff --check 29cc4fd..5f60653` | Review worktree `/2` | Clean (exit 0) |
|
||||
| `/bin/bash -n` on all 7 modified `.sh` scripts | `/bin/bash 3.2.57` | All 7 OK (exit 0) |
|
||||
| `/usr/bin/python3` (`3.9.6`) AST + import + `LC_ALL=C`/`en_US.UTF-8` sanitize & path checks | System Python 3.9.6 + `/bin/bash 3.2.57` | `ALL_LOCALE_AND_MULTIBYTE_PARITY_OK`, `FUTURE_FORMAT_PRESERVED_OK`, `STOP_ROWLESS_PATH_BOUNDARY_CHECKS_OK` (exit 0) |
|
||||
| `pytest tests/test_issue71_herdr_names.py` | **Base** (`29cc4fd` in temp dir) | **68 failed, 2 passed** in 104.17s |
|
||||
| `pytest tests/test_issue71_herdr_names.py tests/test_issue66_stall_watch_sanitized_name.py tests/test_layout.py tests/test_issue42_skill_blocks.py` | **Head** (`5f60653`, worktree `/2`) | **152 passed, 1 skipped** in 150.63s (all 70 in `test_issue71` and all 7 in `test_issue66` passed) |
|
||||
| `pytest tests/test_issue60_linux_fixes.py tests/test_tier1_unit.py tests/test_tier2_component.py tests/test_tier3_integration.py tests/test_issue23_24_startup_path.py tests/test_issue50_pane_lock.py tests/test_issue21_worktree_prereqs.py tests/test_issue63_same_cwd_ids.py` | **Head** (`5f60653`) | **309 passed, 0 failed** |
|
||||
|
||||
---
|
||||
|
||||
## 4. Defects & Observations
|
||||
|
||||
### Blocking Defects
|
||||
- None.
|
||||
|
||||
### Non-Blocking Observations
|
||||
- In [`.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:1021`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job#L1021), the informational message after `send-keys` prints `(연결하려면: HERDR_SESSION_NAME=$HERDR_SESSION_NAME herdr agent attach $sess — lib.sh를 source한 셸에서 실행)`. Because PR #73 updated the `lib.sh` shim's `herdr agent attach` handler to resolve `$sess` via `_resolve_herdr_target`, this command works as stated when `lib.sh` is sourced, though a future cleanup could also print the native `herdr --session ... agent attach ...` form.
|
||||
|
||||
[VERDICT: 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,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261025T023502Z-d6f876",
|
||||
"base_sha": "0461a632eb667c7988fd33be92e9a4b36151f22a",
|
||||
"head_sha": "bb496fde785133ad1dbec288aa7431766efb6eb5",
|
||||
"tree_sha": "15a72c9a1933c64018a2f2d9aa9707348905bb4d",
|
||||
"review_commit": "bb496fde785133ad1dbec288aa7431766efb6eb5",
|
||||
"reviewer": "reviewer-agy-01",
|
||||
"job_id": "927ed539",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-25T03:40:44Z"
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# PR #70 (`fix/issue-66`) Review Report — `agy` (`herdr:reviewer-agy-01`)
|
||||
|
||||
- **Job ID**: `927ed539`
|
||||
- **Issue / PR**: Issue #66 / PR #70 (`fix/issue-66`)
|
||||
- **Base Commit**: `0461a632eb667c7988fd33be92e9a4b36151f22a`
|
||||
- **Head Commit**: `bb496fde785133ad1dbec288aa7431766efb6eb5` (tree `15a72c9a1933c64018a2f2d9aa9707348905bb4d`)
|
||||
- **Review Worktree**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1`
|
||||
- **Host Environment**: macOS Darwin arm64 (`Darwin 25.3.0`), `/bin/bash 3.2.57(1)-release`, `/usr/bin/python3 3.9.6`
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
PR #70 fixes Issue #66, where `lib_py.stall_watch` passed the unsanitized `--target` session name to `herdr agent get|read|prompt|wait`, causing `stall_watch` to immediately fail with `stall_watch:agent_not_found` (exit code 2) whenever the session name had been transformed by `_sanitize_herdr_agent_name` / `sanitize_herdr_agent_name` at session creation time (e.g. names > 32 characters such as worktree-scoped loop agents, uppercase characters, punctuation, or non-letter leading characters).
|
||||
|
||||
All 7 review checklist items, static/Python 3.9 compatibility checks, negative regression checks on `base` (`0461a632eb667c7988fd33be92e9a4b36151f22a`), and functional/regression test runs on `head` (`bb496fde785133ad1dbec288aa7431766efb6eb5`) pass cleanly with zero blocking or non-blocking defects.
|
||||
|
||||
---
|
||||
|
||||
## 2. Detailed Checklist Verification
|
||||
|
||||
### 1. Does `stall_watch.py` use `sanitize_herdr_agent_name` for every `herdr` call (`agent get`, `agent read`, `agent prompt`, `agent wait`)?
|
||||
- **Verified (`PASS`)**:
|
||||
- In [`.agents/skills/lib_py/stall_watch.py`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1/.agents/skills/lib_py/stall_watch.py#L158-L212), `HerdrSource.__init__` computes `self.name = sanitize_herdr_agent_name(target)` while preserving `self.target = target`:
|
||||
- `HerdrSource.agent_get` (line 183): `self._herdr("agent", "get", self.name)`
|
||||
- `HerdrSource.input_empty` (line 206): `self._herdr("agent", "read", self.name)`
|
||||
- `HerdrSource.prompt` (line 211): `self._herdr("agent", "prompt", self.name, text)`
|
||||
- (`HerdrSource.agent_pid` at line 187 and `HerdrSource.blocked_text` at line 193 take `pane_id` (`wN:pM`) returned by `agent get`, not the agent name; `_herdr` at line 168 passes `self.session` to `--session`, which is the herdr server session `--herdr-session`.)
|
||||
- In [`StallWatch.__init__`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1/.agents/skills/lib_py/stall_watch.py#L225-L234) and [`StallWatch.run`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1/.agents/skills/lib_py/stall_watch.py#L331), `self.herdr_name = sanitize_herdr_agent_name(args.target)` is passed to `watcher.wait_once(self.herdr_name, until=until, timeout_ms=tmo, session=self.args.herdr_session)`.
|
||||
- Confirmed that `lib_py.agents.sanitize.sanitize_herdr_agent_name` and `lib.sh::_sanitize_herdr_agent_name` produce identical outputs across empty strings, lowercase/uppercase names, leading digits/dashes, >32-char names (`{s[:23]}-{sha1(s)[:8]}`), and special characters.
|
||||
|
||||
### 2. Are there any other places where `stall_watch.py` calls `herdr` with an unsanitized agent name?
|
||||
- **Verified (`PASS`)**:
|
||||
- Inspected all occurrences of `_herdr`, `herdr`, `wait_once`, `self.target`, and `self.args.target` across [`.agents/skills/lib_py/stall_watch.py`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1/.agents/skills/lib_py/stall_watch.py) and [`.agents/skills/lib_py/loop/watcher.py`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1/.agents/skills/lib_py/loop/watcher.py). No `herdr` invocation uses an unsanitized agent name.
|
||||
|
||||
### 3. Does `stall_watch.py` still keep the original `target` name for human-facing log lines and notifications?
|
||||
- **Verified (`PASS`)**:
|
||||
- All log lines in `StallWatch` (`stall_watch.py:373`, `397`, `405-406`) continue to format `self.args.target` (the original unsanitized session name):
|
||||
- `[stall-watch] idle detected for {self.args.target} (job {self.args.job_id}); sending nudge`
|
||||
- `[stall-watch] blocked detected for {self.args.target} (job {self.args.job_id}): {text}`
|
||||
- `[stall-watch] cannot judge input box for {self.args.target} (agent={self.kind}, job {self.args.job_id}); nudge withheld`
|
||||
- Progress notifications (`notify_progress`) continue to publish `stalled:idle`, `stalled:blocked ...`, `stalled:unverified_input ...`, and `stall_watch:...` keyed by `--job` (`self.args.job_id`).
|
||||
|
||||
### 4. Does `resolve_agent(agent, target)` still receive the original `target` name?
|
||||
- **Verified (`PASS`)**:
|
||||
- Both `HerdrSource.__init__` (`stall_watch.py:163`) and `StallWatch.__init__` (`stall_watch.py:230`) pass the original `target` (`args.target`) to `resolve_agent(...)`.
|
||||
- When `--agent` is omitted, `resolve_agent` infers the agent kind from the `-<agent>` suffix of `target` (e.g., `mam-wt-issue-66-very-long-creator-codex` -> `codex`). Passing the sanitized name (`mam-wt-issue-66-very-lo-823a0a24`) would have lost the `-codex` suffix due to 32-character truncation + SHA-1 suffixing. Keeping `resolve_agent` on the original `target` is verified by `test_the_kind_of_a_long_name_comes_from_its_original_suffix`.
|
||||
|
||||
### 5. In `tests/conftest.py`, is the `strict_agent_names` opt-in clean and backward-compatible with existing tests?
|
||||
- **Verified (`PASS`)**:
|
||||
- In [`tests/conftest.py`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1/tests/conftest.py#L473-L476), `_match_agent(k, target)` checks `if state.get("strict_agent_names"): return k == target` and otherwise falls back to the existing substring match (`k == target or target in k or k in target`).
|
||||
- When `strict_agent_names` is enabled and an agent is not found, `cmd2 == "get"` (`tests/conftest.py:1058-1061`) and `cmd2 == "read"` (`tests/conftest.py:1153-1156`) emit `{"error": {"code": "agent_not_found", "message": "agent " + name + " not found"}}` on `stderr` with exit code 1, matching `wait`, `prompt`, and real `herdr`.
|
||||
- Existing tests that do not set `strict_agent_names` retain identical behavior.
|
||||
|
||||
### 6. Are the new tests in `tests/test_issue66_stall_watch_sanitized_name.py` genuine behavioral tests that would fail without the fix in `stall_watch.py`?
|
||||
- **Verified (`PASS`)**:
|
||||
- Executed `tests/test_issue66_stall_watch_sanitized_name.py` + updated `tests/conftest.py` against `base` (`0461a632eb667c7988fd33be92e9a4b36151f22a`, without the `stall_watch.py` fix):
|
||||
- **5 failed, 2 passed**: Every test case where `sanitize_herdr_agent_name(target) != target` (`creator-claude-fix-issue-99-long-branch`, `Creator-Claude-T`, and `mam-wt-issue-66-very-long-creator-codex`) failed on `base` (`stall_watch:agent_not_found` instead of blocked notice, or empty prompt list instead of idle nudge), while the 2 control tests (`test_the_names_change_and_the_strict_mock_answers_only_the_sanitized_one` and `test_an_idle_agent_is_nudged_through_the_sanitized_name[creator-claude-t]`) passed.
|
||||
- On `head` (`bb496fde785133ad1dbec288aa7431766efb6eb5`), all **7 passed**.
|
||||
|
||||
### 7. Is the one-off failure in `test_issue17_item4_decisions.py::test_c_finalize_ends_with_an_unreaped_wrapper[broker_unavailable-3-0]` unrelated to this diff?
|
||||
- **Verified (`PASS`)**:
|
||||
- Inspected `tests/test_issue17_item4_decisions.py:204-221` and `565-601`: that test stubs `*lib_py.stall_watch*` in its `$PY` wrapper to `exit 0` immediately and tests `finalize_unfinished` re-observing a live `lib_py.delegation.wrapper` process across a 3-second `broker_unavailable` window (`touch ready` + `time.sleep(2)` in `_terminate_unreaped`). It never imports or executes `lib_py.stall_watch` or `strict_agent_names`.
|
||||
- Both `test_c_finalize_ends_with_an_unreaped_wrapper[broker_unavailable-3-0]` and `[timeout-2-10]` passed on `head` during our verification run.
|
||||
|
||||
---
|
||||
|
||||
## 3. Verification Summary
|
||||
|
||||
| Check | Command / Scope | Result |
|
||||
|---|---|---|
|
||||
| Whitespace / patch hygiene | `git diff --check 0461a632eb667c7988fd33be92e9a4b36151f22a..bb496fde785133ad1dbec288aa7431766efb6eb5` | Clean (exit 0) |
|
||||
| macOS system Python 3.9 syntax & import | `/usr/bin/python3` (`3.9.6`) `ast.parse(..., feature_version=(3, 9))` + `import lib_py.stall_watch` | `PY39_AND_SANITIZE_EQUIV_OK` (exit 0) |
|
||||
| Bash vs Python sanitizer equivalence | `lib.sh::_sanitize_herdr_agent_name` vs `sanitize_herdr_agent_name` across 8 edge cases | Exact match (exit 0) |
|
||||
| Negative regression on `base` (`0461a63`) | `pytest tests/test_issue66_stall_watch_sanitized_name.py` on `0461a63` + strict mock | `5 failed, 2 passed` (reproduces `stall_watch:agent_not_found`) |
|
||||
| Behavioral & regression suites on `head` (`bb496fd`) | `pytest tests/test_issue66_stall_watch_sanitized_name.py tests/test_issue30_stall_watch.py tests/test_issue30_stall_watch_stop.py tests/test_issue17_item4_decisions.py -k "..."` | `91 passed, 1 skipped` in 399.91s (exit 0) |
|
||||
| Worktree cleanliness | `git status --short` in review worktree | Clean (no untracked or modified files) |
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261020T133532Z-a1684a",
|
||||
"base_sha": "ff8b381520ae04e80c73a9e18bfad73d47e853c9",
|
||||
"head_sha": "feccfd5e0356c8acf0438bc68bce7ea36ce2588e",
|
||||
"tree_sha": "0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a",
|
||||
"review_commit": "feccfd5e0356c8acf0438bc68bce7ea36ce2588e",
|
||||
"reviewer": "reviewer-agy-01",
|
||||
"job_id": "99090752",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-20T15:51:16Z"
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# Code Review Report — Job `99090752` (Issue #40: Auto-Pin of Post-Discovered Session IDs on `completed`)
|
||||
|
||||
- **Reviewer**: `herdr:reviewer-agy-01` (`agy`)
|
||||
- **Iteration**: 1
|
||||
- **Verification Worktree**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261020T133532Z-a1684a/1`
|
||||
- **Branch / Commit Range**: `fix/issue-40-auto-pin` (`ff8b381520ae04e80c73a9e18bfad73d47e853c9..feccfd5e0356c8acf0438bc68bce7ea36ce2588e`, 1 commit)
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary of Changes Reviewed
|
||||
|
||||
The implementation resolves Issue #40 by unifying post-discovered session ID capture and pinning across the `completed`-receiving control-plane paths and `reconcile.sh` drift C, while eliminating `agy`'s cross-workspace / cross-session conversation ID contamination:
|
||||
|
||||
1. **Adapter Contract & Lifecycle Classification (`.agents/skills/lib_py/agents/base.py`, `claude.py`, `grok.py`, `agy.py`)**:
|
||||
- Introduces `PRE_ASSIGNED` (`claude`, `grok`) and `POST_DISCOVERED` (`agy`, `opencode`, `codex`, `hermes` via `BaseAgentAdapter.id_lifecycle` default).
|
||||
- Adds `BaseAgentAdapter.capture_active_session_id(ctx)` (read-only), returning `capture_result(status, session_id=..., reason=..., candidates=...)`. For `opencode`, `codex`, and `hermes`, a session ID is captured only when `discover(ctx)` yields **exactly one** candidate accepted by `verify_session_uuid(..., mode="discover")` and not claimed in `ctx.row["_sibling_claimed_uuids"]`.
|
||||
- `AgyAgentAdapter.verify_artifact` (`mode="discover"`) now:
|
||||
- Rejects any UUID mapped by `cache/last_conversations.json` to another workspace (`workspace_key(k) != workspace_key(ctx.cwd)`).
|
||||
- Rejects any UUID in `ctx.row.get("_sibling_claimed_uuids")` unconditionally (eliminating the previous `if not cache_match:` bypass that allowed two `agy` sessions in the same `cwd` to cross-pin the same cached conversation ID).
|
||||
- `AgyAgentAdapter.capture_active_session_id(ctx)` treats `last_conversations.json[cwd]` strictly as a proposal and requires `_owner_check(uuid, ctx)` (`lsof -t` on `presence/<uuid>.lock`, `<uuid>.db`, `-wal`, `-shm` held by `pane.pid` or a descendant in `ps -eo pid=,ppid=`).
|
||||
2. **Control-Plane Auto-Pin Handler (`.agents/skills/lib_py/agents/autopin.py`, `.agents/skills/lib.sh`)**:
|
||||
- Implements `claimed_elsewhere`, `capture`, `pin_row`, `resume_dry_run`, `tui_viewport`, `handle_completed`, and CLI `python -m lib_py.agents.autopin`.
|
||||
- Enforces security and trust boundaries (#15/#20/#21): only the receiving/verifying side mutates `.mam/agent-sessions.yaml` via `atomic_dump_yaml`; `publish_event.py` / `publisher.py` never touch the session registry.
|
||||
- Deduplicates per `(job_id, dispatch_token, attempt_id)` under `fcntl.flock` (`<job_dir>/autopin.lock`) + atomic `os.replace` marker (`<job_dir>/autopin.json`).
|
||||
- Skips remote (`transport == "remote"` / `remote:*`), `human`, non-`completed`, or stale-dispatch-token records; leaves `PRE_ASSIGNED` and already-pinned rows untouched; records failure/ambiguity reasons in `last_visible_status` without failing the completed job.
|
||||
3. **Call Sites & Reconcile Unification (`create_session.sh`, `multi-agent-mux-delegate-job`, `runner.py`, `reconcile.sh`)**:
|
||||
- `create_session.sh`: invokes `delegate_autopin_completed "$DELEGATE_JOB_ID" "$DELEGATE_JOB_TOKEN" "$task_reg_dir"` upon onboarding `completed`.
|
||||
- `multi-agent-mux-delegate-job`: invokes `delegate_autopin_completed "$JOB_ID" "$_disp_tok" "$REGISTRY_DIR"` when direct job finishes `completed`.
|
||||
- `lib_py/loop/runner.py`: invokes `self._autopin(job_id)` in `wait_for_job` when `status == "completed"` as an idempotent recovery path (preserving `G9` import isolation via subprocess invocation).
|
||||
- `reconcile.sh`: replaces the separate drift C blocks for `agy` (which previously globbed `conversations/*.db` across all workspaces), `hermes`, `opencode`, and `codex` with a single loop calling `autopin.capture(s, agent, d, home_dir=home)` and `autopin.pin_row` / `autopin.resume_dry_run`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Verification & Test Evidence
|
||||
|
||||
All verification was executed against the isolated worktree `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261020T133532Z-a1684a/1` with temporary `HOME` and `--basetemp` directories (no real user/agent state databases opened):
|
||||
|
||||
1. **Issue #40 Completion Conditions (`tests/test_issue40_auto_pin.py`)**:
|
||||
- **Condition 1 (No cross-workspace `agy` pinning)**: `test_agy_verify_rejects_id_cached_for_another_workspace`, `test_reconcile_does_not_pin_another_workspaces_agy_conversation`, `test_autopin_does_not_pin_another_workspaces_agy_conversation` — **PASSED**.
|
||||
- **Condition 2 (First completed job pins without daemon)**: `test_completed_onboarding_pins_agy_without_daemon`, `test_completed_first_job_pins_opencode_codex_hermes[opencode|codex|hermes]`, `test_reconcile_pins_agy_owned_by_the_pane`, `test_lib_sh_helper_pins_through_the_cli`, `test_create_onboard_pins_without_daemon` — **PASSED**.
|
||||
- **Condition 3 (Idempotency per job/token/attempt, ambiguity & ownership handling)**: `test_agy_same_cwd_sibling_never_cross_pins`, `test_agy_capture_requires_presence_owned_by_pane` (4 parametrized cases), `test_agy_holder_pids_reads_lsof`, `test_wrapper_and_loop_recovery_pin_once`, `test_loop_recovery_pins_when_the_wrapper_did_not`, `test_ambiguous_candidates_leave_reason_without_pin` — **PASSED**.
|
||||
- **Condition 4 (Pre-assigned & existing IDs untouched, ineligible/stale skipped, publisher boundary)**: `test_preassigned_and_existing_ids_are_untouched`, `test_ineligible_jobs_pin_nothing` (4 parametrized cases), `test_stale_dispatch_token_pins_nothing`, `test_call_sites_and_publisher_boundary` — **PASSED**.
|
||||
2. **Regression & Component Suites**:
|
||||
- `pytest tests/test_issue40_auto_pin.py tests/test_issue64_ro_connect.py tests/test_issue60_linux_fixes.py tests/test_issue60_opencode_v2.py tests/test_a4_adapter_contract.py tests/test_tier1_unit.py -v` → all 27 tests in `tests/test_issue40_auto_pin.py` and all unit/adapter/ro_connect tests **PASSED** (`237 passed, 0 failed`).
|
||||
- `pytest tests/test_issue40_auto_pin.py tests/test_issue64_ro_connect.py tests/test_issue60_linux_fixes.py tests/test_issue60_opencode_v2.py tests/test_a4_adapter_contract.py tests/test_tier1_unit.py tests/test_tier2_component.py -k "opencode or codex or agy or hermes or autopin or capture or lifecycle or ro_connect or issue40"` → **131 passed** (`0` failed).
|
||||
3. **Negative Regression Verification Against Base (`ff8b381520ae04e80c73a9e18bfad73d47e853c9`)**:
|
||||
- Exported `ff8b381520ae04e80c73a9e18bfad73d47e853c9` via `git archive` to a temporary directory and ran `tests/test_issue40_auto_pin.py` tests (`test_adapter_id_lifecycle_classification`, `test_agy_verify_rejects_id_cached_for_another_workspace`, `test_reconcile_does_not_pin_another_workspaces_agy_conversation`, `test_call_sites_and_publisher_boundary`) → **4 failed (`BASE_RC=1`)**, confirming the tests directly catch the pre-fix behavior (including `reconcile.sh` pinning `proj-B`'s `UB` onto `proj-A`'s row).
|
||||
4. **Static & Hygiene Checks**:
|
||||
- `git diff --check ff8b381520ae04e80c73a9e18bfad73d47e853c9..feccfd5e0356c8acf0438bc68bce7ea36ce2588e` → clean (exit `0`).
|
||||
- `bash -n` on `.agents/skills/lib.sh`, `create_session.sh`, `multi-agent-mux-delegate-job`, and `reconcile.sh` → clean (exit `0`).
|
||||
- `git status --short` in verification worktree → clean.
|
||||
|
||||
---
|
||||
|
||||
## 3. Findings
|
||||
|
||||
No blocking issues, regressions, or defects found.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261027T201245Z-63c71b",
|
||||
"base_sha": "1d63e882bc1c46e9af8484647d5091b296d8fa3f",
|
||||
"head_sha": "ec7f5c4e57286fb3c4238b8220f39094f4640391",
|
||||
"tree_sha": "fbadb24de60ccdae684b23e2e0a41cdf23ab81eb",
|
||||
"review_commit": "ec7f5c4e57286fb3c4238b8220f39094f4640391",
|
||||
"reviewer": "reviewer-agy-01",
|
||||
"job_id": "a967e100",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-27T21:06:27Z"
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
# Code Review Report — PR #75 Follow-up Round (`1d63e88..ec7f5c4`, Issue #72)
|
||||
|
||||
- **Job ID**: `a967e100`
|
||||
- **Reviewer**: `agy` (`herdr:reviewer-agy-01`)
|
||||
- **Loop ID**: `loop-20261027T201245Z-63c71b` (Round 1)
|
||||
- **Review Snapshot Path**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261027T201245Z-63c71b/1`
|
||||
- **Branch**: `fix/issue-72`
|
||||
- **Base Commit**: `1d63e882bc1c46e9af8484647d5091b296d8fa3f` (cumulative base: `0e1e2e87dea7fcaab69d6ac388121c35b02b7dab`)
|
||||
- **Head Commit**: `ec7f5c4e57286fb3c4238b8220f39094f4640391`
|
||||
- **Tree Hash**: `fbadb24de60ccdae684b23e2e0a41cdf23ab81eb`
|
||||
|
||||
---
|
||||
|
||||
## 1. 확인한 것 (Files & Line Ranges Inspected in Review Snapshot)
|
||||
|
||||
- **`1d63e88..ec7f5c4` 변경분 집중 검증**:
|
||||
- `.agents/skills/lib.sh:2278-2317` (`_start_watchdog_legacy`):
|
||||
- `MAM_DELEGATION_PY=0` 경로에서 `case $- in *m*) had_m=1 ;; esac`, `set -m`으로 `nohup bash "$monitor_script" --subscribe --idle-timeout 0 </dev/null >> "$log_file" 2>&1 &`를 실행한 뒤 `[ "$had_m" = 1 ] || set +m`으로 호출자의 job-control 옵션을 복원(`lib.sh:2303-2309`).
|
||||
- macOS(`/bin/bash` 3.2, `setsid(1)` 없음)에서도 `set -m` 아래 백그라운드 작업이 자체 프로세스 그룹(`pgid == pid`)으로 기동되어 호출자의 프로세스 그룹에서 분리됨을 확인.
|
||||
- 기존 동작(`_monitor_pids` 중복 방지, `MAM_NO_SUBSCRIBER_DAEMON=1` opt-out, `echo "$pid"` 보고, `</dev/null`, `>> "$log_file" 2>&1`)이 그대로 보존됨을 확인.
|
||||
- `tests/test_issue72_update.py:184-195, 221-241, 697-726`:
|
||||
- `_descendants(pid)`(`184-186`) 및 읽기 전용 `_processes_naming(path)`(`189-193`) 추가.
|
||||
- `box` fixture teardown(`221-241`)에서 패턴 기반 `pkill -KILL -f <ws>`를 완전히 제거하고, 테스트가 직접 띄운 `b.helpers` 프로세스 그룹(`os.killpg`)과 `_exact_monitor_pids(ws / MONITOR_REL)` + 그 자식(`_descendants(pid)`)만 `SIGKILL`로 정리한 뒤 `_until(lambda: not _processes_naming(ws), timeout=10)`으로 잔존 프로세스가 없음을 검증.
|
||||
- `test_every_restart_starts_a_real_monitor_in_both_delegation_modes`(`697-726`): 6개 조합(`mode` `0`/`1` × `installed`/`killed`/`rolled-back`) 모두에서 `assert all(os.getpgid(int(p)) != os.getpgrp() for p in box.monitor_pids())`로 모니터의 PGID가 호출자의 PGID와 다름을 검증.
|
||||
- **누적 변경분(`0e1e2e8..ec7f5c4`) 정합성 확인**:
|
||||
- `.agents/skills/lib.sh:24-35, 2238-2317, 3299-3379`
|
||||
- `.agents/skills/lib_py/delegation/daemon.py:40-79`
|
||||
- `.agents/skills/lib_py/pane_lock.py:1-130`
|
||||
- `.agents/skills/lib_py/update.py:1-428`
|
||||
- `.agents/skills/multi-agent-mux-update/SKILL.md:1-30`
|
||||
- `.agents/skills/multi-agent-mux-update/scripts/{update.sh,install_phase.sh,post_update.sh}`
|
||||
- `.agents/skills/multi-agent-mux-{create,resume,stop,loop,orc-onboard,delegate-job}` 및 `deploy/{install.sh,install_mam.sh,remove.sh}`
|
||||
|
||||
---
|
||||
|
||||
## 2. 결정 1–10, U1–U4, Attach 댓글 및 완료 조건 대조표
|
||||
|
||||
| 항목 | 요구사항 요약 | 구현 위치 | 테스트 커버리지 | 상태 |
|
||||
|---|---|---|---|---|
|
||||
| 결정 1 | 활성 세션(레지스트리 `running` + 모든 실행 중 herdr 서버의 워크스페이스 내 라이브 에이전트)·루프·열린 잡(`pending`/`dispatched`/`running`, 타 제어 평면은 본 레지스트리 대상 잡만) 있으면 거부 및 해제 명령 안내 | `lib_py/update.py:150-267`, `update.sh:124-162` | `tests/test_issue72_update.py` (`test_every_refusal_condition_blocks_and_leaves_the_workspace_untouched` 외 9개) | 충족 |
|
||||
| 결정 2 | 모니터 데몬(`reconcile.sh --subscribe`) 자동 정지(`SIGTERM`→`SIGKILL`)·설치/타임아웃/롤백 후 호출자 프로세스 그룹에서 분리하여 재기동(`MAM_DELEGATION_PY=0/1` 모두 정확 매칭 및 별도 PGID); `resume` 시 모니터 보장 | `update.sh:100-116,165-212`, `post_update.sh:37-46`, `install_phase.sh:57-69`, `resume_session.sh:215-225,250,299`, `daemon.py:40-79`, `lib.sh:2238-2317` | `tests/test_issue72_update.py` (`test_every_restart_starts_a_real_monitor_in_both_delegation_modes` 외), `tests/test_issue72_resume_monitor.py` | 충족 |
|
||||
| 결정 3 | 설치 실패 시 `.agents`, `.mam_deploy`, 루트 문서, `.venv`, 설치기 관리 `.mam` 항목, 레지스트리 전체 되돌리기 | `update.sh:240-262`, `install_phase.sh:27-44,104-115` | `tests/test_issue72_update.py` (`test_a_failing_install_rolls_back_the_entire_workspace_and_restarts_the_monitor` 외) | 충족 |
|
||||
| 결정 4 | 업데이트 완료 후 새 코드로 레지스트리 변환(`lib_py.registry_format`), 레지스트리 없으면 생략, 실패 시 설치 유지 + 0 아닌 종료 | `post_update.sh:27-35` | `tests/test_issue72_update.py` (`test_a_conversion_failure_keeps_the_new_install_and_exits_nonzero` 외) | 충족 |
|
||||
| 결정 5 | 2단계 분리: 워크스페이스 밖 임시 복사본(`install_phase.sh`)을 `exec`(같은 PID 유지)한 뒤 새 트리의 `post_update.sh`를 별도 프로세스로 실행 | `update.sh:214-215,264-267`, `install_phase.sh:118-129` | `tests/test_issue72_update.py` (`test_the_clean_update_runs_both_phases_converts_the_registry_and_restarts_the_monitor`) | 충족 |
|
||||
| 결정 6 | `deploy/update.sh` 제거, `multi-agent-mux-update` 스킬로 일원화, `deploy/install.sh` 제자리 재실행 정리(없어진 파일 삭제·수정 파일 보존, `install_state` 제자리 갱신) | `deploy/install.sh:191-196,403-448,632-653`, `deploy/update.sh`(삭제) | `tests/test_issue72_installer.py`, `tests/test_deploy_layout.py`, `tests/test_issue8_update_state.py` | 충족 |
|
||||
| 결정 7 | 호출 에이전트 자신(`(caller_server, HERDR_PANE_ID)`)과 그 `running` 행·실행 중인 잡 1개는 차단 대상에서 제외 | `lib_py/update.py:192-219,229-230,244-250`, `lib_py/pane_lock.py:25-39` | `tests/test_issue72_update.py` (`test_the_calling_agent_its_row_and_its_running_job_are_excluded` 외 3개) | 충족 |
|
||||
| 결정 8 | 업데이트 입장 잠금(`.mam/update-admission.lock` 공유/배타 + `.mam/update-in-progress` 표식): `create`·`resume`·`run_loop`·`delegate submit`·`stop`·`orc-onboard` 차단, 자식 프로세스 fd 누수 방지 | `lib.sh:3312-3379`, `lib_py/pane_lock.py:42-115`, `lib_py/update.py:277-322` | `tests/test_issue72_admission.py` (21개 케이스) | 충족 |
|
||||
| 결정 9 | `update.sh [-y|--yes]`: TTY면 프롬프트, 비-TTY에서 `-y` 없으면 거부(`exit 1`) | `update.sh:30-60` | `tests/test_issue72_update.py` (`test_confirmation_prompt_and_non_tty_refusal`) | 충족 |
|
||||
| 결정 10 | 되돌리기 범위에서 작업 기록(`jobs`, `delegate_job_logs`, `state`, `worktrees`, `locks`) 보존 및 레지스트리 백업(`sqlite3.backup` + YAML/bak) 복원 | `update.sh:241-256`, `lib_py/update.py:342-366`, `install_phase.sh:37-44` | `tests/test_issue72_update.py` (`test_rollback_restores_every_item_the_installer_can_touch_and_leaves_work_records`) | 충족 |
|
||||
| U1 | attach 안내 헬퍼는 추가하지 않고 기존 행의 `attach_command`(`ATTACH_CMD`)를 그대로 사용 | `create_session.sh:654`, `multi-agent-mux-delegate-job:680-687` | `tests/test_issue72_attach_hints.py` | 충족 |
|
||||
| U2 | `.mam` 없는 트리에서 입장 잠금 헬퍼가 `.mam`이나 잠금 파일을 만들지 않고 통과 | `lib.sh:3315` | `tests/test_issue72_admission.py` (`test_a_tree_without_mam_stays_clean`) | 충족 |
|
||||
| U3 | `MAM_UPDATE_MONITOR_STOP_S`를 `mam_int_knob`으로 읽고 `test_issue26_ints.py` 표에 등록 | `update.sh:189`, `tests/test_issue26_ints.py:67` | `tests/test_issue26_ints.py` | 충족 |
|
||||
| U4 | 이전 업데이트가 남긴 `.mam/update-backup/` 상태별 처리(`backing-up`/`installed`/빈 상태 삭제, `installing`은 `--skip-registry`로 파일만 복원, 알 수 없는 상태 거부) | `update.sh:218-237`, `install_phase.sh:36` | `tests/test_issue72_update.py` (`test_a_leftover_backup_from_a_killed_update_is_handled_by_state`) | 충족 |
|
||||
| 댓글 #926, #930 | `multi-agent-mux-delegate-job` 및 `create_session.sh`의 attach 안내가 `--session <server>`를 포함한 `attach_command`를 출력 | `create_session.sh:654`, `multi-agent-mux-delegate-job:680-687` | `tests/test_issue72_attach_hints.py` (8개 케이스) | 충족 |
|
||||
| 첫 전환 (완료 조건 5) | 옛 `update.sh`가 있는 설치본에서 첫 전환 시 새 `install.sh`를 거쳐 `multi-agent-mux-update` 스킬과 레지스트리 변환까지 도달 | `deploy/install.sh:191-196,403-405`, `tests/test_issue72_update.py:909-966` | `tests/test_issue72_update.py` (`test_the_first_transition_from_the_old_updater_reaches_the_skill_and_conversion`) | 충족 |
|
||||
|
||||
빠진 완료 조건: **없음**.
|
||||
|
||||
---
|
||||
|
||||
## 3. 실행한 테스트와 결과 (macOS 실측)
|
||||
|
||||
검증 경로(`/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261027T201245Z-63c71b/1`)에서 브리프의 격리 실행 명령으로 새·바뀐 테스트 파일과 watchdog 관련 테스트(`test_issue5_phase1_delegation.py`, `test_issue19_subscriber_daemon_lifecycle.py`)를 모두 실행했습니다.
|
||||
|
||||
### 명령 1 (`tests/test_issue72_update.py`, `tests/test_issue72_resume_monitor.py`, `tests/test_issue42_skill_blocks.py`)
|
||||
```bash
|
||||
BT="$(mktemp -d "${TMPDIR:-/tmp}/mam-rev-a967e100-1.XXXXXX")"
|
||||
trap 'rm -rf "$BT"' EXIT
|
||||
cd /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261027T201245Z-63c71b/1 && \
|
||||
env -u HERDR_SOCKET_PATH -u HERDR_PANE_ID -u HERDR_TAB_ID -u HERDR_WORKSPACE_ID -u HERDR_SESSION_NAME -u HERDR_SESSION -u WORKSPACE_ROOT -u MAM_ENV_FILE -u MAM_JOBS_DIR -u DELEGATE_JOB_LOGS_DIR \
|
||||
PYTHONDONTWRITEBYTECODE=1 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python -m pytest -p no:cacheprovider --basetemp="$BT/pytest" \
|
||||
tests/test_issue72_update.py \
|
||||
tests/test_issue72_resume_monitor.py \
|
||||
tests/test_issue42_skill_blocks.py
|
||||
```
|
||||
- **결과**: `95 passed, 1 skipped in 379.90s (0:06:19)` (exit code `0`)
|
||||
|
||||
### 명령 2 (watchdog 관련 스위트 2종 + 나머지 변경·신규 테스트 7종)
|
||||
```bash
|
||||
BT="$(mktemp -d "${TMPDIR:-/tmp}/mam-rev-a967e100-2.XXXXXX")"
|
||||
trap 'rm -rf "$BT"' EXIT
|
||||
cd /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261027T201245Z-63c71b/1 && \
|
||||
env -u HERDR_SOCKET_PATH -u HERDR_PANE_ID -u HERDR_TAB_ID -u HERDR_WORKSPACE_ID -u HERDR_SESSION_NAME -u HERDR_SESSION -u WORKSPACE_ROOT -u MAM_ENV_FILE -u MAM_JOBS_DIR -u DELEGATE_JOB_LOGS_DIR \
|
||||
PYTHONDONTWRITEBYTECODE=1 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python -m pytest -p no:cacheprovider --basetemp="$BT/pytest" \
|
||||
tests/test_issue5_phase1_delegation.py \
|
||||
tests/test_issue19_subscriber_daemon_lifecycle.py \
|
||||
tests/test_deploy_layout.py \
|
||||
tests/test_issue26_ints.py \
|
||||
tests/test_issue8_update_state.py \
|
||||
tests/test_version_consistency.py \
|
||||
tests/test_issue72_attach_hints.py \
|
||||
tests/test_issue72_installer.py \
|
||||
tests/test_issue72_admission.py
|
||||
```
|
||||
- **결과**: `219 passed in 509.96s (0:08:29)` (exit code `0`)
|
||||
|
||||
### 명령 3 (`/bin/bash -n` 구문 검사 및 프로세스·워크트리 무결성 확인)
|
||||
- 변경된 모든 셸 스크립트(13개) `/bin/bash -n` 통과 (`exit 0`).
|
||||
- `git status --short`: 출력 없음 (검증 스냅샷 깨끗함).
|
||||
- `ps -ef`: 본 체크아웃의 기존 `reconcile.sh --subscribe` 데몬(PID `3714`)만 정상 유지 중이며, 테스트가 남긴 자식·더미 프로세스 없음.
|
||||
|
||||
---
|
||||
|
||||
## 4. 발견 사항 (차단 / 비차단)
|
||||
|
||||
- **차단 (Blocking)**: 없음.
|
||||
- **비차단 (Non-Blocking)**: 없음.
|
||||
|
||||
---
|
||||
|
||||
## 5. 사용자 확인 필요
|
||||
|
||||
- 없음.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,93 @@
|
||||
# PR #62 Code Review Report — Issue #60 Linux Follow-up (`1d7b7da..6a4e385`)
|
||||
|
||||
- **Job ID**: `ba63f0b5`
|
||||
- **Reviewer**: `agy` (`herdr:reviewer-agy-01`)
|
||||
- **Branch**: `fix/issue-60-linux`
|
||||
- **Base .. Head**: `1d7b7dab58a10fe86fd747b7290d47674ca93230` .. `6a4e385a78c8a561d5cf3644336de6a352ea9271` (8 commits)
|
||||
- **Verification Worktree**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T000805Z-975c4c/1`
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
All production code changes in PR #62 (`_ro_connect`, `_DB_PATHS` cache, `input_placeholder_pattern`, `auth_ok` / `auth-ok`, `stop_session.sh` log text, and `test_u1e`) are well-designed and behave as intended on macOS (Python 3.9.6, Apple SQLite 3.51.0, opencode 2.0.32).
|
||||
|
||||
However, **5 test cases in `tests/test_issue60_linux_fixes.py` fail on macOS** (`test_a_query_reads_right_and_leaves_the_sidecars_alone[clean-listing0-*]`) due to a macOS vs. Linux SQLite difference in `wal_home`'s `make("clean")` fixture helper:
|
||||
- On Linux (upstream SQLite default `SQLITE_FCNTL_PERSIST_WAL = 0`), closing the last connection to a WAL database (`_wal_writer(db, ws).close()`) checkpoints the WAL into `opencode.db` and unlinks `opencode.db-wal` and `opencode.db-shm`.
|
||||
- On macOS (`/usr/bin/python3` linked against Apple's `/usr/lib/libsqlite3.dylib`, which sets `SQLITE_FCNTL_PERSIST_WAL = 1` by default), `_wal_writer(db, ws).close()` checkpoints the WAL into `opencode.db` and truncates `opencode.db-wal` to 0 bytes, **but leaves `opencode.db-wal` and `opencode.db-shm` on disk**.
|
||||
- Consequently, `assert make(state) == listing` at `tests/test_issue60_linux_fixes.py:181` fails with `AssertionError: assert ['opencode.db', 'opencode.db-shm', 'opencode.db-wal'] == ['opencode.db']` before `_ro_connect` is even called.
|
||||
|
||||
Unlinking `opencode.db-wal` and `opencode.db-shm` after `_wal_writer(db, ws).close()` in `make("clean")` makes all 20 `(query × state)` cases pass on macOS as well.
|
||||
|
||||
---
|
||||
|
||||
## 2. Findings
|
||||
|
||||
### `[P1]` Blocking — `wal_home` fixture `make("clean")` fails on macOS due to Apple SQLite `SQLITE_FCNTL_PERSIST_WAL=1`
|
||||
|
||||
- **Location**: `tests/test_issue60_linux_fixes.py:157-159, 181`
|
||||
- **Symptom**:
|
||||
Running `pytest tests/test_issue60_linux_fixes.py` on macOS (`/usr/bin/python3` 3.9.6, `sqlite3.sqlite_version == '3.51.0'`) fails 5 tests:
|
||||
- `FAILED tests/test_issue60_linux_fixes.py::test_a_query_reads_right_and_leaves_the_sidecars_alone[clean-listing0-_descendants]`
|
||||
- `FAILED tests/test_issue60_linux_fixes.py::test_a_query_reads_right_and_leaves_the_sidecars_alone[clean-listing0-_has_session]`
|
||||
- `FAILED tests/test_issue60_linux_fixes.py::test_a_query_reads_right_and_leaves_the_sidecars_alone[clean-listing0-discover]`
|
||||
- `FAILED tests/test_issue60_linux_fixes.py::test_a_query_reads_right_and_leaves_the_sidecars_alone[clean-listing0-residual_artifacts]`
|
||||
- `FAILED tests/test_issue60_linux_fixes.py::test_a_query_reads_right_and_leaves_the_sidecars_alone[clean-listing0-verify_artifact]`
|
||||
- **Root Cause**:
|
||||
Apple's system `libsqlite3.dylib` enables `SQLITE_FCNTL_PERSIST_WAL` by default. When `_wal_writer(db, ws).close()` runs on macOS, SQLite checkpoints the committed WAL frames into `opencode.db` (`8192` bytes) and truncates `opencode.db-wal` to `0` bytes, but keeps `opencode.db-wal` and `opencode.db-shm` on disk instead of unlinking them.
|
||||
- **Verification & Fix**:
|
||||
In `make(state)` (`tests/test_issue60_linux_fixes.py:157-159`), removing any remaining sidecars after `_wal_writer(db, ws).close()` checkpoints the DB:
|
||||
```python
|
||||
def make(state):
|
||||
if state == "clean":
|
||||
_wal_writer(db, ws).close()
|
||||
for suffix in ("-wal", "-shm"):
|
||||
if os.path.exists(str(db) + suffix):
|
||||
os.unlink(str(db) + suffix)
|
||||
```
|
||||
leaves `opencode.db` with its WAL header and checkpointed rows and no sidecar files (`["opencode.db"]`). Verified on macOS that all 20 `(query × state)` combinations pass with this change, and that `_ro_connect(db)`'s `mode=ro&immutable=1` branch is crucial on macOS (where a plain `mode=ro` open on a sidecar-free WAL-mode DB raises `sqlite3.OperationalError: unable to open database file`, and a plain read-write `sqlite3.connect(db)` creates and leaves `-wal` and `-shm`).
|
||||
|
||||
---
|
||||
|
||||
### `[P2]` Non-blocking — `status.sh` and `reconcile.sh` still open `opencode.db` with read-write `sqlite3.connect(odb)`
|
||||
|
||||
- **Locations**:
|
||||
- `.agents/skills/multi-agent-mux-status/scripts/status.sh:116, 316`
|
||||
- `.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh:953`
|
||||
- **Observation**:
|
||||
While `OpenCodeAgentAdapter` methods (`verify_artifact`, `discover`, `_has_session`, `_descendants`, `residual_artifacts`) now use `_ro_connect(db)`, `status.sh` (both `--json` and text mode) and `reconcile.sh` (drift C, which `status.sh` also runs via `--dry-run`) still call `sqlite3.connect(odb)` directly:
|
||||
- On macOS, calling `sqlite3.connect(odb)` on a sidecar-free WAL-mode `opencode.db` creates `opencode.db-wal` and `opencode.db-shm` and leaves them on disk after `.close()`.
|
||||
- On Linux, calling `sqlite3.connect(odb)` on a `leftover` WAL database checkpoints the WAL and deletes `opencode.db-wal` and `opencode.db-shm` on `.close()`.
|
||||
- **Suggestion**:
|
||||
In `status.sh:108-120, 308-320`, replace the raw `sqlite3.connect(odb)` block with `get_adapter('opencode')._has_session(odb, u)` (which already uses `_ro_connect`), and in `reconcile.sh:953`, use `_ro_connect(odb)` from `lib_py.agents.adapters.opencode`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Detailed Review by Checklist Item
|
||||
|
||||
1. **`_ro_connect` on macOS (`.agents/skills/lib_py/agents/adapters/opencode.py:13-36`)**:
|
||||
- **Both `-wal` and `-shm` present (`leftover` / `writer`)**: `sqlite3.connect(f"file:{quote(path)}?mode=ro", uri=True)` reads uncommitted-to-main-file (WAL-committed) rows through the existing WAL index without creating or removing sidecars.
|
||||
- **No `-wal` (`clean`)**: `sqlite3.connect(f"file:{quote(path)}?mode=ro&immutable=1", uri=True)` reads `opencode.db` directly without creating `-wal`/`-shm` (and avoids `sqlite3.OperationalError: unable to open database file` that plain `mode=ro` raises on macOS when opening a WAL-header DB without sidecars). Because every query opens, fetches, and immediately closes `conn`, any race with a writer starting right after `os.path.exists(path + "-wal")` only sees the pre-WAL snapshot for that single short read and never holds an immutable handle across writes.
|
||||
- **`-wal` without `-shm` (`walonly`)**: Copies `opencode.db` and `opencode.db-wal` into a `tempfile.TemporaryDirectory(prefix="mam-opencode-")`, opens the private copy so SQLite can rebuild `-shm` and recover the committed WAL frames, backs it up to `:memory:`, and cleans up the temp dir. Verified on macOS that committed rows in `-wal` are returned and the source directory remains `['opencode.db', 'opencode.db-wal']`.
|
||||
2. **`_DB_PATHS` process-local cache (`opencode.py:40, 144-160`)**:
|
||||
- Keyed by `(bin_path, ctx.home_dir, self._data_home(ctx))` (or `(bin_path, HOME, XDG_DATA_HOME)` when `ctx.home_dir` is unset).
|
||||
- Different `HOME`, `XDG_DATA_HOME`, or binary paths produce distinct cache keys; failed `debug paths` runs (`returncode != 0` or no `db` line) are not cached.
|
||||
3. **`auth_ok` & `create_session.sh` (`opencode.py:317-321`, `__main__.py:120-127`, `create_session.sh:146-151`)**:
|
||||
- Checks binary existence on `PATH` or `<home>/.opencode/bin/opencode` without spawning `opencode --version` (preventing log writes to the real opencode data dir). Other agents' branches in `create_session.sh` are untouched.
|
||||
4. **`input_placeholder_pattern` (`opencode.py:75-79`, `base.py:212-217`, `input_region.py:80-81`)**:
|
||||
- `re.fullmatch(r'Ask anything(?:…|\.\.\.)(?: "[^"]*"?)?', head.strip())` strips both full and narrow-truncated 2.x home screen placeholders while preserving user input starting with `Ask anything…` (such as `'Ask anything… "x" and fix it'`). Other adapters return `None`.
|
||||
5. **Python 3.9 & macOS compatibility**:
|
||||
- All syntax, type annotations, and shell scripts are compatible with Python 3.9.6 and macOS bash 3.2 (`set -euo pipefail`). `git diff --check` is clean.
|
||||
|
||||
---
|
||||
|
||||
## 4. Test Execution Summary
|
||||
|
||||
Executed in `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T000805Z-975c4c/1` with isolated `HOME`, external `--basetemp`, `PYTHONDONTWRITEBYTECODE=1`, and `-p no:cacheprovider`:
|
||||
- `tests/test_issue60_opencode_v2.py`: 15 passed
|
||||
- `tests/test_a4_adapter_contract.py`: 33 passed
|
||||
- `tests/test_issue50_dispatch.py`: 49 passed
|
||||
- `tests/test_issue55_agent_homes.py`: 11 passed
|
||||
- `tests/test_issue60_linux_fixes.py`: 40 passed, **5 failed** (`test_a_query_reads_right_and_leaves_the_sidecars_alone[clean-listing0-*]`)
|
||||
|
||||
[VERDICT: NOT 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,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261023T035928Z-2ccc02",
|
||||
"base_sha": "dc886ecd6b38c01032a55a2d1c230c73362ae630",
|
||||
"head_sha": "1614af5b673d7519d3a38f63e3636055592b4f14",
|
||||
"tree_sha": "f1af6fdd548ee5d6f6b81e2202833edfaca98188",
|
||||
"review_commit": "1614af5b673d7519d3a38f63e3636055592b4f14",
|
||||
"reviewer": "reviewer-agy-01",
|
||||
"job_id": "cae1ea73",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-23T05:16:58Z"
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# PR #69 (Issue #63) Round 3 Code Review Report — `agy` (`reviewer-agy-01`)
|
||||
|
||||
- **Job ID**: `cae1ea73`
|
||||
- **Branch / Range**: `fix/issue-63` (`dc886ecd6b38c01032a55a2d1c230c73362ae630..1614af5b673d7519d3a38f63e3636055592b4f14`, 13 commits)
|
||||
- **Tree**: `f1af6fdd548ee5d6f6b81e2202833edfaca98188`
|
||||
- **Verification Worktree**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261023T035928Z-2ccc02/1`
|
||||
- **Environment**: macOS (`Darwin arm64`), `/bin/bash 3.2.57(1)-release`, `/usr/bin/python3 3.9.6`, `.venv/bin/python 3.9.6`
|
||||
- **Summary**: **PASS** (`P0: 0`, `P1: 0`, `P2: 0`)
|
||||
|
||||
---
|
||||
|
||||
## 1. 2·3차 리뷰 후속 커밋 (`ede9c62`, `8f64174`, `7cac6f6`, `1614af5`) 집중 검증 결과
|
||||
|
||||
### 1.1 권위 있는 "세션 없음" 응답에서만 고아 마커 삭제 (`8f64174`, `id_order.py:58-90,225-227`)
|
||||
- `herdr_session_state(server, session, timeout)` (`id_order.py:58-90`)는 `MAM_NO_AUTOSTART="1"` 및 `HERDR_SESSION_NAME=server or "default"` 환경에서 `source "$1" && _herdr agent list`를 실행합니다.
|
||||
- `res.returncode == 0`이고 `json.loads(res.stdout)["result"]["agents"]`가 리스트이며, 그 안에 `session` 및 `sanitize_herdr_agent_name(session)`이 모두 없을 때에만 `False`를 반환합니다 (`id_order.py:74-83`).
|
||||
- `_read_and_prune_markers` (`id_order.py:225-227`)는 `drop = live is False`로 판정하여, 조회 실패·타임아웃·파싱 실패(`live is None`) 시에는 마커를 삭제하지 않고 `_orphan = "unknown"`으로 유지합니다.
|
||||
|
||||
### 1.2 `server_not_running` 불확실 처리 및 `MAM_ID_ORDER_WAIT_S=0` 무프로브 보장 (`7cac6f6`, `id_order.py:58-90,280-291,330-346`)
|
||||
- `server_not_running`을 포함한 모든 비정상 종료(`res.returncode != 0`)에서 `herdr_session_state`가 `None`을 반환하므로 서버 다운 상태에서도 고아 마커가 삭제되지 않고 fail-closed로 차단됩니다.
|
||||
- `bounded_probe` (`id_order.py:285-290`)가 `deadline - time.monotonic()`을 `timeout`으로 전달하고, `herdr_session_state` 첫 줄(`if timeout <= 0: return None`, `id_order.py:64-65`)에서 즉시 반환하므로:
|
||||
- 다수의 죽은 마커가 있더라도 전체 프로브 시간이 `MAM_ID_ORDER_WAIT_S` 상한 안에 엄격히 묶입니다 (`test_id_order_herdr_probes_stay_within_the_wait_cap`).
|
||||
- `MAM_ID_ORDER_WAIT_S=0`(`cap == 0`)일 때는 herdr 서브프로세스를 전혀 호출하지 않고 즉시 거부하며 `(MAM_ID_ORDER_WAIT_S=0 leaves no time to ask herdr)` 안내를 출력합니다 (`id_order.py:336-338`).
|
||||
- `answers: Dict[str, bool]` (`id_order.py:283,287-289,333`)가 이전 폴링에서 확인된 확정 응답을 보존하여 마지막 폴링 시점에 예산이 소진되더라도 `"its herdr session is alive"` 메시지를 정확히 유지합니다.
|
||||
|
||||
### 1.3 동일 세션 이름(`sess == me`)의 죽은 owner 마커에 동일한 고아 규칙 적용 (`1614af5`, `id_order.py:180-235,305`)
|
||||
- `_read_and_prune_markers`에서 `sess == me`일 때 무조건 삭제하던 분기를 제거하여(`id_order.py:217-227`), 이전 `create`/`resume`이 pane을 띄운 직후 죽어 고아 세션을 남긴 상태에서 **같은 세션 이름**으로 재시도하더라도:
|
||||
- 고아 herdr 세션이 살아 있거나 확인 불가(`True`/`None`)이면 마커를 유지하고 차단합니다 (`blockers()`는 `m_sess == me`를 건너뛰지 않음, `id_order.py:130-145`).
|
||||
- herdr가 해당 세션이 사라졌음을 확인(`False`)하면 죽은 마커를 삭제하고 새 시도의 마커로 교체(`owner_pid == my_pid`)하여 정상 진입합니다 (`test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_is_alive`, `test_id_order_real_probe_against_mock_herdr_fails_closed`).
|
||||
|
||||
### 1.4 세 수정 간 정합성 및 일반 사용(정상 create/resume, 재시도) 600초 대기 미발생 검증
|
||||
- **정상 `create` 후 진행 중**: 첫 세션이 YAML 행을 `status: "running"` 및 `herdr_session_epoch > prev_epoch`로 등록하면 `_registered_for_attempt` (`id_order.py:174-177`)가 `True`를 반환해 herdr 프로브 없이 마커가 즉시 삭제되고, 이후에는 YAML 행의 `own_id` 기록 여부에 따라 대기가 결정됩니다.
|
||||
- **정상 `stop` 후 후속 `create`/`resume`**: 세션이 정상 중지되면 `own_id`가 기록되거나(`pinned_for_attempt == True`) `stopped_at_epoch > max(m_at, prev_end_epoch)`(`ended_after_attempt == True`)가 성립하므로 `blockers()` (`id_order.py:150-163`)가 이를 차단 목록에서 즉시 제외하여 600초 대기가 발생하지 않습니다.
|
||||
- **`create` 실패·롤백(`exit 42`) 후 재시도**: `create_session.sh`의 `cleanup_herdr_on_error` 트랩(`create_session.sh:337-368`)이 실행 중인 워크스페이스 herdr 서버에서 세션을 즉시 종료하므로, 재시도 시 첫 폴링의 `_herdr agent list`가 빈 에이전트 목록(`False`)을 반환해 죽은 마커를 즉시 정리하고 대기 없이 통과합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 전체 체크리스트 (1~8) 점검 요약
|
||||
|
||||
| # | 점검 항목 | 결과 | 근거 파일 및 라인 |
|
||||
|---|---|---|---|
|
||||
| 1 | 완료 조건 1~8 및 테스트 커버리지 | **PASS** | `session_ids.py:116-297`, `id_order.py:1-382`, `tests/test_issue63_same_cwd_ids.py` (총 27개 테스트) |
|
||||
| 2 | `#40` `autopin.capture` 재사용 (판정 단일화) | **PASS** | `session_ids.py:19,176,272` (`from lib_py.agents.autopin import OWN_KEYS, capture, claimed_elsewhere`), `lib.sh` 구 `capture_conversation_id` 제거 |
|
||||
| 3 | `id_order.py` 잠금·만료·비차단 조건·`MAM_ID_ORDER_WAIT_S` | **PASS** | `fcntl.flock` (`id_order.py:293`), `wait_s` (`id_order.py:36-41`), `herdr_session_state` (`id_order.py:58-83`), `_read_and_prune_markers` (`id_order.py:180-235`) |
|
||||
| 4 | `resume`: `--drop-stale-id`, 사전 거부(exit 1), 이미 실행 중 분기, `SKILL.md` | **PASS** | `resume_session.sh:132-170,243,270-276`, `update_yaml_resumed.sh:21,66-197`, `multi-agent-mux-resume/SKILL.md:59-83,165-171` |
|
||||
| 5 | `stop`: kill 전 ID 판정, 죽은 herdr 미기록+이유 기록, purge 재검증 | **PASS** | `stop_session.sh:104-179,233-286,308-358`, `multi-agent-mux-stop/SKILL.md:38-55,98-115` |
|
||||
| 6 | 삭제된 심볼 잔존 참조 없음 (`capture_conversation_id`, `CAPTURE_ID`) | **PASS** | `git grep` 결과 코드·문서·테스트 내 잔존 참조 0건 |
|
||||
| 7 | macOS 호환성 (`/bin/bash 3.2.57`, `/usr/bin/python3 3.9.6`) | **PASS** | 수정된 셸 6개 `/bin/bash -n` 통과, Python 3.9 AST 파싱 및 모듈 import(`PY39_IMPORT_OK`) 통과 |
|
||||
| 8 | `test_issue34_job_tree.py::test_t5b_a_*[False]` 관련성 여부 | **PASS** | 본 브랜치는 루프·잡 트리(`lib_py/loop`, `delegate-job`)를 전혀 수정하지 않으며, 해당 테스트는 본 변경과 무관함 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 실행한 검증 명령 및 결과
|
||||
|
||||
1. **정적 검사 (`git diff --check`, `/bin/bash -n`, `/usr/bin/python3` 3.9.6 import)**:
|
||||
- Exit `0` (`PY39_IMPORT_OK`).
|
||||
2. **Issue #63 `id_order` 및 이식성 테스트 + 실제 `herdr` 호출 감시(Spy) 검증**:
|
||||
- `pytest tests/test_issue63_same_cwd_ids.py -k "test_id_order or test_c6_id_order_markers or test_portability" -v`
|
||||
- 결과: **`8 passed`**, `SPY_LOG` 비어 있음 (`0` calls).
|
||||
3. **Herdr 격리 및 Skill 블록 인벤토리 정합성 테스트**:
|
||||
- `pytest tests/test_herdr_real_server_leak.py tests/test_issue42_skill_blocks.py -v`
|
||||
- 결과: **`40 passed, 1 skipped in 1.52s`**.
|
||||
4. **Negative Regression 검증**:
|
||||
- Base(`dc886ec`): `test_issue63_same_cwd_ids.py` 12개 실패 확인.
|
||||
- `ede9c62` 대비: `test_id_order_real_probe_against_mock_herdr_fails_closed`가 `protocol_mismatch`에서 `assert 0 == 1`로 실패하고 `HEAD`에서 통과함을 확인.
|
||||
- `7cac6f6`(`HEAD~1`) 대비: `1614af5`의 동일 세션 이름 고아 마커 검증(`assert wait_same() == 1`)이 `HEAD~1`에서 `assert 0 == 1`로 실패하고 `1614af5`(`HEAD`)에서 통과함을 확인.
|
||||
|
||||
---
|
||||
|
||||
## 4. 발견 사항
|
||||
|
||||
- 차단 결함(P0/P1) 및 비차단 결함(P2) 없음.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261019T134017Z-d90c23",
|
||||
"base_sha": "3ce5d5fdc108b0435e933a251fe1c1256b5d7973",
|
||||
"head_sha": "b0eccc530149bc6e9211727e19921665c7b80492",
|
||||
"tree_sha": "f5a412a3ee96465db7c4b3223d2d76e95716ddfa",
|
||||
"review_commit": "b0eccc530149bc6e9211727e19921665c7b80492",
|
||||
"reviewer": "reviewer-agy-01",
|
||||
"job_id": "e95527ef",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-19T14:59:46Z"
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# Issue #64 Code Review Report (`3ce5d5f..b0eccc5`)
|
||||
|
||||
- **Job ID**: `e95527ef`
|
||||
- **Reviewer**: `agy` (`herdr:reviewer-agy-01`)
|
||||
- **Branch**: `fix/issue-64-ro-connect`
|
||||
- **Base .. Head**: `3ce5d5fdc108b0435e933a251fe1c1256b5d7973` (`v4.9.7`) .. `b0eccc530149bc6e9211727e19921665c7b80492` (1 commit)
|
||||
- **Verification Worktree**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T134017Z-d90c23/1`
|
||||
|
||||
---
|
||||
|
||||
## 1. Root Cause Analysis (Task 1)
|
||||
|
||||
**Why did the Issue #55 agent home guard detect newly created `.db-wal` and `.db-shm` sidecars beside another workspace's `agy` conversation DB during a macOS test run?**
|
||||
|
||||
1. **Global `agy` conversation directory scan in `reconcile.sh` drift C (`.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh:797-817`)**:
|
||||
- When an `agy` session is `running` and unpinned (`agy_conversation_id_own` is `None`), `reconcile.sh` globs every `*.db` in `${HOME_DIR}/.gemini/antigravity-cli/conversations/` (a global directory shared across all workspaces under that `HOME`) and invokes `verify_session_uuid(cwd, 'agy', uuid, s_eval, mode="discover")`.
|
||||
2. **`AgyAgentAdapter.verify_artifact` opened every candidate `.db` with read-write `sqlite3.connect(path)` (`.agents/skills/lib_py/agents/adapters/agy.py:60-80`)**:
|
||||
- In `discover` mode, `verify_artifact` reads `cache/last_conversations.json`. When `lc_data.get(ctx.cwd) == uuid` is `False` (for example, `uuid` belongs to another workspace), `verify_artifact` only filters out `uuid` if it is in `ctx.row.get("_sibling_claimed_uuids")` (which tracks siblings in the **same** workspace `cwd`) or if `os.path.getmtime(path) < ctx.epoch`.
|
||||
- For any conversation `.db` from another workspace whose `mtime >= ctx.epoch` (or when `ctx.epoch` is not set), `verify_artifact` proceeded to `conn = sqlite3.connect(path)` to run `SELECT count(*) FROM steps`.
|
||||
3. **macOS persistent WAL sidecar creation**:
|
||||
- On macOS, Apple's system `libsqlite3.dylib` sets `SQLITE_FCNTL_PERSIST_WAL = 1` by default. Opening a WAL-mode `.db` that currently has no `-wal`/`-shm` sidecars with read-write `sqlite3.connect(path)` and closing it creates `<uuid>.db-wal` and `<uuid>.db-shm` and leaves them on disk.
|
||||
- Similarly, if any `reconcile.sh` or `verify_artifact` call ran against the real `HOME` (or if another workspace's `reconcile.sh` / `agy` process scanned `conversations/*.db` while the test suite was running), opening a clean WAL `.db` with `sqlite3.connect(path)` created `<uuid>.db-wal` and `<uuid>.db-shm` in `~/.gemini/antigravity-cli/conversations/`, which `_agent_home_guard` flagged at session teardown.
|
||||
- Replacing `sqlite3.connect(path)` with `_ro_connect(path)` (`mode=ro&immutable=1` when no `-wal` exists) prevents MAM from ever creating or removing `-wal`/`-shm` sidecars when inspecting `agy` conversation databases (verified by `test_unpinned_agy_reconcile_leaves_other_workspace_clean_db_without_sidecars`, which fails on `base` `3ce5d5f` and passes on `head` `b0eccc5`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Summary of Changes (`3ce5d5f..b0eccc5`)
|
||||
|
||||
### Changed Files & Lines (Read-Only Agent DB Queries)
|
||||
1. **`.agents/skills/lib_py/agents/base.py:111-145`**:
|
||||
- Moved `_ro_connect(db: str)` to `lib_py.agents.base` as a shared helper (with `tempfile.TemporaryDirectory(prefix="mam-ro-")` and `os.path.basename(path) or "state.db"` for the `walonly` branch).
|
||||
- Updated `db_rows_naming` default `connect` to `_ro_connect` (`conn = (connect or _ro_connect)(db)`), so `codex.residual_artifacts` (`state_*.sqlite`, `thread_history_*.sqlite`) and any other `db_rows_naming` callers also use `_ro_connect` instead of plain `mode=ro`.
|
||||
2. **`.agents/skills/lib_py/agents/adapters/opencode.py:3, 161-185`**:
|
||||
- Imports and reuses `_ro_connect` from `lib_py.agents.base`.
|
||||
3. **`.agents/skills/lib_py/agents/adapters/agy.py:3, 74`**:
|
||||
- `verify_artifact` (and `discover`, which delegates to `verify_artifact`) opens `conversations/<uuid>.db` via `_ro_connect(path)`.
|
||||
4. **`.agents/skills/lib_py/agents/adapters/codex.py:3, 80, 195`**:
|
||||
- `_find_rollout_via_db` (used by `_find_rollout`, `artifact_path`, `verify_artifact`) and `discover` open `state_*.sqlite` via `_ro_connect(db)`.
|
||||
5. **`.agents/skills/lib_py/agents/adapters/hermes.py:3, 51, 100`**:
|
||||
- `verify_artifact` and `discover` open `~/.hermes/state.db` via `_ro_connect(hdb)`.
|
||||
6. **`.agents/skills/multi-agent-mux-status/scripts/status.sh:90-91, 103, 120-121, 290-291, 303, 320-321`**:
|
||||
- Both `--json` and text modes use `_ro_connect` from `lib_py.agents.base` for `hermes` (`hdb`), `opencode` (`odb`), and `codex` (`db`). Zero raw `sqlite3.connect` calls remain in `status.sh`.
|
||||
7. **`.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh:423, 865, 954, 1047`**:
|
||||
- Imports `_ro_connect` from `lib_py.agents.base` at top level and uses it for `hermes` drift C (`_ro_connect(hdb)`), `opencode` drift C (`_ro_connect(odb)`), and `codex` drift C (`_ro_connect(state_db)`). Zero raw `sqlite3.connect` calls remain in `reconcile.sh`.
|
||||
8. **`tests/test_issue64_ro_connect.py` (lines 1-408)**:
|
||||
- 43 new regression tests covering shared helper identity, all 4 WAL states (`clean`, `leftover`, `walonly`, `writer`) across `agy`, `codex`, `hermes`, and `db_rows_naming`, static and runtime checks of `status.sh` and `reconcile.sh`, and cross-workspace unpinned `agy` drift-C discovery.
|
||||
|
||||
### Intentionally Excluded `sqlite3.connect` Call Sites
|
||||
- **Agent DB write paths (`purge_artifacts`)**:
|
||||
- `lib_py/agents/adapters/codex.py:152` (`DELETE FROM threads WHERE id=?`)
|
||||
- `lib_py/agents/adapters/hermes.py:74` (`DELETE FROM sessions` / `DELETE FROM messages`)
|
||||
- `lib_py/agents/adapters/opencode.py:193` (`DELETE` from 1.x legacy tables)
|
||||
- **MAM's own state database (`agent-sessions.db`)**:
|
||||
- `lib_py/atomic_yaml.py:61`, `lib_py/state.py:17`, `lib_py/verify_session.py:41`, `lib_py/loop/runner.py:416`, `lib.sh:1599, 2068`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Test Results (Base vs. Head) & Remaining Risks
|
||||
|
||||
1. **Negative verification on `base` (`3ce5d5f`)**:
|
||||
- Running `tests/test_issue64_ro_connect.py` against an archive copy of `base` (`3ce5d5f`) on macOS: **`23 failed, 20 passed in 10.97s`** (every `clean` and `walonly` state across `agy`, `codex`, `hermes`, `db_rows_naming`, `status.sh`, `reconcile.sh`, plus `test_ro_connect_lives_in_base_and_opencode_reuses_it`, `test_status_and_reconcile_static_ro_connect_usage`, and `test_unpinned_agy_reconcile_leaves_other_workspace_clean_db_without_sidecars` failed as expected).
|
||||
2. **Verification on `head` (`b0eccc5`)**:
|
||||
- `tests/test_issue64_ro_connect.py`: **43 passed**
|
||||
- `tests/test_issue60_linux_fixes.py`: **52 passed**
|
||||
- `tests/test_issue60_opencode_v2.py`: **15 passed**
|
||||
- `tests/test_a4_adapter_contract.py`: **33 passed**
|
||||
- `tests/test_issue55_agent_homes.py`: **11 passed**
|
||||
- `tests/test_issue31_purge_residual.py`: **67 passed**
|
||||
- **Total**: **`221 passed in 119.66s`**
|
||||
- `git status -s` and `git diff --check` in the verification worktree are completely clean.
|
||||
3. **Remaining risks**:
|
||||
- External processes outside MAM (such as an active `agy` IDE/CLI session running in another workspace on the same host) can still create or remove their own conversation `.db-wal`/`.db-shm` sidecars in `~/.gemini/antigravity-cli/conversations/` while a pytest session is running, which `_agent_home_guard` (which only exempts live non-temp workspace directories for `claude`) can observe as a new directory entry. Within MAM itself, all read-only queries against `agy`, `codex`, `hermes`, and `opencode` databases are now sidecar-safe.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261019T015801Z-05463c",
|
||||
"base_sha": "1d7b7dab58a10fe86fd747b7290d47674ca93230",
|
||||
"head_sha": "6a37c0019baa31ee28b32a086e57b4c8239413c4",
|
||||
"tree_sha": "87013e7ef7f5239f1bf6ea38a2cbda214257be3c",
|
||||
"review_commit": "6a37c0019baa31ee28b32a086e57b4c8239413c4",
|
||||
"reviewer": "reviewer-agy-01",
|
||||
"job_id": "fe8fd3b9",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-19T02:37:22Z"
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# PR #62 Code Review Report — Round 2 (`1d7b7da..6a37c00`)
|
||||
|
||||
- **Job ID**: `fe8fd3b9`
|
||||
- **Reviewer**: `agy` (`herdr:reviewer-agy-01`)
|
||||
- **Branch**: `fix/issue-60-linux`
|
||||
- **Base .. Head**: `1d7b7dab58a10fe86fd747b7290d47674ca93230` .. `6a37c0019baa31ee28b32a086e57b4c8239413c4` (9 commits; Round 2 delta: `6a37c00`)
|
||||
- **Verification Worktree**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T015801Z-05463c/1`
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Commit `6a37c00` resolves all blocking and non-blocking findings from Round 1 (`6a4e385`):
|
||||
|
||||
1. **macOS SQLite persistent WAL in `wal_home` fixture (`tests/test_issue60_linux_fixes.py:155-162`)**:
|
||||
- After `_wal_writer(db, ws).close()` checkpoints the committed WAL rows into `opencode.db`, `make("clean")` removes `opencode.db-wal` and `opencode.db-shm` if present (which Apple's system `libsqlite3.dylib` keeps on disk due to `SQLITE_FCNTL_PERSIST_WAL=1`).
|
||||
- All 20 `(query × state)` combinations in `test_a_query_reads_right_and_leaves_the_sidecars_alone` now pass on macOS.
|
||||
2. **Quoted example required in `input_placeholder_pattern` (`.agents/skills/lib_py/agents/adapters/opencode.py:76-79`)**:
|
||||
- Pattern updated to `r'Ask anything(?:…|\.\.\.) "[^"]*"?'` so a bare `Ask anything…` or `Ask anything...` typed by a user is preserved as draft input (`test_typed_text_is_read_as_typed`), while the 2.x home screen placeholder (both full and narrow-truncated) is stripped.
|
||||
3. **Executable check for `~/.opencode/bin/opencode` in `auth_ok()` (`.agents/skills/lib_py/agents/adapters/opencode.py:321-323`)**:
|
||||
- `auth_ok()` now checks `os.path.isfile(local) and os.access(local, os.X_OK)` for `<home>/.opencode/bin/opencode` (`test_a_home_install_that_cannot_run_is_not_enough`).
|
||||
4. **Read-only `_ro_connect` in `status.sh` and `reconcile.sh` (`.agents/skills/multi-agent-mux-status/scripts/status.sh:115-117, 316-318`, `.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh:953-955`)**:
|
||||
- Both scripts now open `opencode.db` via `_ro_connect(odb)` instead of raw `sqlite3.connect(odb)`, pinned by `test_status_and_reconcile_open_the_database_read_only`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Verification of Cumulative Changes (`1d7b7da..6a37c00`)
|
||||
|
||||
| Area | Verification Result |
|
||||
| :--- | :--- |
|
||||
| **`_ro_connect` (`opencode.py:13-36`)** | Verified all 3 branches (`wal and shm` -> `mode=ro`, `not wal` -> `mode=ro&immutable=1`, `wal and not shm` -> temp copy + `.backup(:memory:)`) across all 5 adapter queries and 4 DB states (`clean`, `leftover`, `walonly`, `writer`) on macOS Python 3.9.6 / Apple SQLite 3.51.0. Sidecars are neither created nor deleted. |
|
||||
| **`_DB_PATHS` cache (`opencode.py:40, 145-166`)** | Process-local cache keyed by `(bin_path, HOME, XDG_DATA_HOME)` caches successful `debug paths` lookups and does not cache failures. |
|
||||
| **`input_placeholder_pattern` (`opencode.py:75-79`, `base.py:212-217`, `input_region.py:80-81`)** | Matches `Ask anything(?:…|\.\.\.) "[^"]*"?` via `re.fullmatch` on the first non-blank line of the input box only; preserves user drafts including bare `Ask anything…` / `Ask anything...`. |
|
||||
| **`auth_ok` & `auth-ok` CLI (`opencode.py:319-323`, `__main__.py:120-127`, `create_session.sh:146-151`)** | Verifies `shutil.which("opencode")` or executable `<home>/.opencode/bin/opencode` without running `opencode --version`. |
|
||||
| **`status.sh`, `reconcile.sh`, `stop_session.sh`** | `status.sh` and `reconcile.sh` use `_ro_connect(odb)`; `stop_session.sh` logs unconfirmed `/exit` delivery accurately without altering exit codes or kill-chain behavior. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Test Execution Summary
|
||||
|
||||
Executed in `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T015801Z-05463c/1` with isolated `HOME`, external `--basetemp`, `PYTHONDONTWRITEBYTECODE=1`, and `-p no:cacheprovider`:
|
||||
|
||||
- `tests/test_issue60_linux_fixes.py`: 52 passed
|
||||
- `tests/test_issue60_opencode_v2.py`: 15 passed
|
||||
- `tests/test_a4_adapter_contract.py`: 33 passed
|
||||
- `tests/test_issue50_dispatch.py`: 49 passed
|
||||
- `tests/test_issue55_agent_homes.py`: 11 passed
|
||||
- `tests/test_tier2_component.py`: 42 passed
|
||||
- **Total**: `202 passed in 377.34s`
|
||||
- **Post-check**: Verification worktree is clean (`git status -s` empty, `git diff --check` clean, no `.mam` directory), and no stray `opencode` test processes remain.
|
||||
|
||||
---
|
||||
|
||||
## 4. Findings
|
||||
|
||||
No blocking or non-blocking issues remaining. PR #62 (`fix/issue-60-linux`, `1d7b7da..6a37c00`) is ready to merge.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261019T134017Z-d90c23",
|
||||
"base_sha": "3ce5d5fdc108b0435e933a251fe1c1256b5d7973",
|
||||
"head_sha": "b0eccc530149bc6e9211727e19921665c7b80492",
|
||||
"tree_sha": "f5a412a3ee96465db7c4b3223d2d76e95716ddfa",
|
||||
"review_commit": "b0eccc530149bc6e9211727e19921665c7b80492",
|
||||
"reviewer": "reviewer-grok-01",
|
||||
"job_id": "366a4d58",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-19T14:59:46Z"
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
# Review 366a4d58 — Issue #64
|
||||
|
||||
PASS. Commit `b0eccc5` moves `_ro_connect` into `base.py` and uses it for every query-only open of an agy, codex, or hermes database found in the skills tree. The other-workspace agy sidecars come from reconcile drift C: it walks every conversation `*.db` and `verify_artifact` used to open each one read-write. On this host that open leaves `-wal` and `-shm` after close, which is what the #55 name guard counts. Head related tests: 95 passed. The new file on base: 23 failed, 20 passed.
|
||||
|
||||
Reviewed only the snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T134017Z-d90c23/1` (`loop-20261019T134017Z-d90c23`). Head `b0eccc530149bc6e9211727e19921665c7b80492`, tree `f5a412a3ee96465db7c4b3223d2d76e95716ddfa`, base `3ce5d5fdc108b0435e933a251fe1c1256b5d7973` is an ancestor and is the control-plane HEAD. One commit, 8 files, +461/−56. `git diff --check` is clean. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane porcelain is empty. No product file was edited. The source worktree was not run.
|
||||
|
||||
The commit is `b0eccc5` `fix(agents): open agy, codex and hermes databases read-only (Issue #64)`. Files: `agy.py`, `codex.py`, `hermes.py`, `opencode.py`, `base.py`, `reconcile.sh`, `status.sh`, and new `tests/test_issue64_ro_connect.py`.
|
||||
|
||||
Host tools: control-plane venv Python 3.9.6, sqlite 3.51.0, bash 3.2.57(1)-release. `bash -n` accepts `status.sh` and `reconcile.sh`. The changed modules imported and ran under pytest. The opencode binary was not launched. Linux was not executed here. The real database was not opened.
|
||||
|
||||
## Cause
|
||||
|
||||
Reconcile drift C for an unpinned running agy row (`reconcile.sh:786-817`) lists every `<HOME_DIR>/.gemini/antigravity-cli/conversations/*.db` (`:800`) and calls `verify_session_uuid(cwd, 'agy', uuid, s_eval, mode="discover")` (`:816`) for each basename. `home` in that script is `os.environ['HOME_DIR']` (`:437`). `verify_session_uuid` (`verify_session.py:83-108`) builds a `DiscoveryContext` and returns `adapter.verify_artifact`. `DiscoveryContext` fills a missing `home_dir` through `resolve_home` (`paths.py:11`): the argument, then `HOME_DIR`, then `HOME`, then `~`. The reconcile call omits `home_dir`, so the open uses the same home as the glob.
|
||||
|
||||
On base, `AgyAgentAdapter.verify_artifact` did `sqlite3.connect(path)` and `SELECT count(*) FROM steps` once the file existed and was new enough for the epoch. A discover-mode cache miss returns false only when the uuid is in `_sibling_claimed_uuids` (siblings on the same pane cwd). `last_conversations.json` mapping that uuid to a different workspace does not skip the open. A read-write open of a WAL database on macOS sqlite 3.51.0 creates `-wal` and `-shm` when they are absent and leaves those names after close. The #55 guard snapshots entry names at session start and fails when a new name appears. At check time no process held the database because the connection had already closed. That matches the v4.9.7 timing: the review loop and the suite were running reconcile against the real home.
|
||||
|
||||
`status.sh` agy (`:78-81` and `:278-281`) returns from `os.path.exists` of the `.db`. It does not open sqlite.
|
||||
|
||||
Head `agy.py:74` uses `_ro_connect(path)` for the same `SELECT count(*) FROM steps`. With no `-wal`, that open is `mode=ro&immutable=1` and does not create sidecars.
|
||||
|
||||
`test_unpinned_agy_reconcile_leaves_other_workspace_clean_db_without_sidecars` reproduces this on a temporary home. `last_conversations.json` maps the conversation to workspace B, the row is an unpinned agy session whose pane cwd is workspace A, and the test calls `verify_session_uuid` (the function drift C calls). The function returns true and the directory stays one file. On base the same test failed because the listing gained `-wal` and `-shm`. The runtime test also spawns `reconcile.sh --once --emit-diff --dry-run` for a same-workspace unpinned agy session, with `HOME` and `HOME_DIR` pointed at the sandbox, and that listing stays unchanged.
|
||||
|
||||
A cache miss still accepts the other workspace's database when it has at least one step and is not sibling-claimed. The new test asserts that true return. Drift C can still treat that file as a candidate. The open no longer creates names.
|
||||
|
||||
## Changed opens
|
||||
|
||||
`_ro_connect` is `base.py:111-134`. Both sidecars use `file:<abspath>?mode=ro`. No `-wal` uses `mode=ro&immutable=1`. A `-wal` without `-shm` is copied into `tempfile.TemporaryDirectory(prefix="mam-ro-")`, opened read-write on that copy only, backed into a memory connection, and the temp directory is removed. The copy name is `os.path.basename(path)` or `state.db`, so `<uuid>.db`, `state.db`, and `state_5.sqlite` keep their `-wal` sibling. The temp prefix was `mam-opencode-` while the helper lived in `opencode.py`.
|
||||
|
||||
`db_rows_naming` (`base.py:137-145`) now defaults to `_ro_connect`. A caller can still pass `connect`.
|
||||
|
||||
Query sites on the helper:
|
||||
|
||||
- `agy.py:74` `verify_artifact` (`SELECT count(*) FROM steps`). `discover` (`agy.py:165-175`) reads `last_conversations.json` and calls `verify_artifact`.
|
||||
- `codex.py:80` `_find_rollout_via_db`. `codex.py:195` `discover` (`PRAGMA table_info(threads)` and `SELECT id, cwd FROM threads`). `codex.py:167-169` `residual_artifacts` goes through `db_rows_naming` for `state_*.sqlite` and `thread_history_*.sqlite`.
|
||||
- `hermes.py:51` `verify_artifact`. `hermes.py:100` `discover`.
|
||||
- `opencode.py` imports `_ro_connect` from `base` (`:3`). Query sites stay `:149`, `:212`, `:234`, `:249`, `:316`, and `connect=_ro_connect` at `:249`. The identity test asserts `opencode_mod._ro_connect is base_mod._ro_connect`.
|
||||
- `status.sh`: `_ro_connect(hdb)` at `:91` and `:291`, `_ro_connect(odb)` at `:116` and `:316`, `_ro_connect(db)` at `:132` and `:332`. The static test requires each of those counts to be 2.
|
||||
- `reconcile.sh`: import at `:423`. Hermes drift `_ro_connect(hdb)` at `:865`, opencode `_ro_connect(odb)` at `:954`, codex drift `_ro_connect(state_db)` at `:1048`. The static test requires each count to be 1.
|
||||
|
||||
`status.sh` and `reconcile.sh` contain no `sqlite3.connect` literal.
|
||||
|
||||
## Left as read-write
|
||||
|
||||
These opens write, or they are MAM's own databases. They stay `sqlite3.connect`:
|
||||
|
||||
- `opencode.py:193` deletes 1.x rows after `session delete`.
|
||||
- `hermes.py:74` purge `DELETE`.
|
||||
- `codex.py:152` purge `DELETE FROM threads`.
|
||||
- `lib.sh:1599` and `lib.sh:2068` (timeout 60).
|
||||
- `lib_py/state.py:17`.
|
||||
- `lib_py/verify_session.py:41` opens the state database beside `AGENT_SESSIONS_YAML` or `YAML_PATH` (`<yaml stem>.db`, `SELECT data FROM state`, timeout 5). That is MAM's `agent-sessions` state, not an agent conversation database.
|
||||
- `lib_py/loop/runner.py:416`.
|
||||
- `lib_py/atomic_yaml.py:61`.
|
||||
|
||||
Agy purge removes files and does not open sqlite. Claude and grok adapters have no `sqlite3.connect`. The three `sqlite3.connect` calls inside `_ro_connect` are the helper: the `mode=ro` URI, the immutable URI, and the read-write open of the private copy.
|
||||
|
||||
`VERSIONS.md` and the skill guides are unchanged. Version bumps stay with the orchestrator.
|
||||
|
||||
## Tests
|
||||
|
||||
Pytest used the control-plane venv, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and a `--basetemp` outside the snapshot. The basetemp path contains neither `mam-review` nor `shim`. Inherited `HERDR_*`, `MAM_*`, `MQTT_*`, `SKS_*`, `DELEGATE_JOB_LOGS_DIR`, `HOME_DIR`, `CLAUDE_PROJECT_DIR`, `CLAUDE_CONFIG_DIR`, `CLAUDE_CODE_TMPDIR`, `CODEX_HOME`, `PYTHONPATH`, `WORKSPACE_ROOT`, `MAM_ENV_FILE`, `AGENT_SESSIONS_YAML`, `XDG_STATE_HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, and `XDG_DATA_HOME` were dropped. `WORKSPACE_ROOT` was not pointed at the snapshot.
|
||||
|
||||
Head, cwd the snapshot, basetemp `/tmp/mam366-366a4d58-pybase`, files `tests/test_issue64_ro_connect.py` and `tests/test_issue60_linux_fixes.py`: 95 passed in 72.05 s. Exit 0. No skip and no error. The new file is 43 of those (helper identity, agy, codex including `db_rows_naming`, hermes, one static script check, four runtime script states, and the cross-workspace case). The issue #60 file is the other 52, so the move still passes the existing opencode read-only tests.
|
||||
|
||||
Base proof used a throwaway `git clone --shared` of the snapshot, checked out at `3ce5d5f`, with only `tests/test_issue64_ro_connect.py` taken from `b0eccc5`. Cwd was the clone. Basetemp was `/tmp/mam366-366a4d58-pybase-base`. Result: 23 failed, 20 passed in 10.84 s. Exit 1. The failures are the helper-location test, every clean and wal-only adapter query, the static script count (`_ro_connect(hdb)` was 0), runtime clean and wal-only, and the cross-workspace clean listing. Clean `db_rows_naming` returned `[]` and clean `residual_artifacts` returned only the rollout file; on base that helper's default open is a `mode=ro` URI (`base.py` at `3ce5d5f`). The other clean and wal-only failures are listings that gained `-wal` and `-shm` after a query that still returned the row. The 20 passes are the leftover and live-writer states, where those names were already present, so a read-write open did not change the listing.
|
||||
|
||||
The four states in the new file are clean (close, then unlink `-wal` and `-shm` when they exist), leftover sidecars, `-wal` without `-shm`, and a connection left open. Each query asserts the right row and an unchanged directory listing. Runtime covers `reconcile.sh --once --emit-diff --dry-run` and `status.sh --json` plus text mode. Agy, hermes, and codex `resume_state` are `yes`.
|
||||
|
||||
## Remaining risk
|
||||
|
||||
The check for `-wal` and the immutable connect are two steps. A wal created in that gap is invisible to the immutable open. A writer that already has both sidecars is read with `mode=ro`.
|
||||
|
||||
`mode=ro` with both sidecars rewrites the existing `-shm` bytes (the reader index). The `-wal` bytes and the file names stay. That rewrite was measured on the Issue #60 review and left in place.
|
||||
|
||||
Purge `DELETE` opens stay read-write, so a purge can still create or remove sidecars. That is the write the brief left out.
|
||||
|
||||
Drift C can still accept another workspace's agy database when the file has steps and the uuid is not sibling-claimed. The candidate decision is unchanged. The open no longer adds names.
|
||||
|
||||
`verify_session.py:41` still opens MAM's own state database read-write.
|
||||
|
||||
The user's background service stayed up. Its pid is 23630 and the command is `/Users/godopu16/.opencode/bin/opencode serve --service`. The real database mtime is 1792420196 and its size is 36712448. `~/.config/opencode/service.json` mtime is 1792246838. `~/.local/state/opencode/service.json` mtime is 1792365227. The real `opencode.log` mtime is 1792420391 and its size is 2699345. The herdr config mtime is 1784456394.
|
||||
|
||||
After the report, snapshot porcelain is still empty, the snapshot still has no `.mam`, and control-plane porcelain is still empty at `3ce5d5fdc108b0435e933a251fe1c1256b5d7973`. The herdr config mtime is still 1784456394. The opencode database mtime is still 1792420196 and its size is still 36712448. The opencode log mtime is still 1792420391 and its size is still 2699345. Service pid 23630 is still `opencode serve --service`.
|
||||
|
||||
[VERDICT: 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,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261025T023502Z-d6f876",
|
||||
"base_sha": "0461a632eb667c7988fd33be92e9a4b36151f22a",
|
||||
"head_sha": "bb496fde785133ad1dbec288aa7431766efb6eb5",
|
||||
"tree_sha": "15a72c9a1933c64018a2f2d9aa9707348905bb4d",
|
||||
"review_commit": "bb496fde785133ad1dbec288aa7431766efb6eb5",
|
||||
"reviewer": "reviewer-grok-01",
|
||||
"job_id": "7b602181",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-25T03:40:44Z"
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
# Review 7b602181 — PR #70 / Issue #66, Mac pass
|
||||
|
||||
PASS. Head `bb496fd` calls herdr with `sanitize_herdr_agent_name` of the session. `agent get`, `agent read`, `agent prompt`, and `agent wait` all use that name. The agent kind is still taken from `--agent` or from the original session suffix. Log lines keep the original session name. A name that is already legal is left as it is.
|
||||
|
||||
Reviewed only the snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1` (`loop-20261025T023502Z-d6f876`). Head `bb496fde785133ad1dbec288aa7431766efb6eb5`, tree `15a72c9a1933c64018a2f2d9aa9707348905bb4d`. Base `0461a632eb667c7988fd33be92e9a4b36151f22a` (main, v4.10.0) is an ancestor and is the control-plane HEAD. One commit, 3 files, +150/−5. No uncommitted changes. `git diff --check` is clean. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane porcelain is empty. No product file was edited. The source worktree `mam-wt-pr70` was not run.
|
||||
|
||||
The commit is `bb496fd` `fix(stall_watch): call herdr with the sanitized agent name`.
|
||||
|
||||
Host tools: control-plane venv Python 3.9.6, `/bin/bash` 3.2.57(1)-release. The real agent databases were not opened.
|
||||
|
||||
## Herdr name and kind
|
||||
|
||||
`HerdrSource` keeps the original string on `self.target` and stores `self.name = sanitize_herdr_agent_name(target)` (`stall_watch.py:158-165`). `agent_get`, `agent read`, and `agent prompt` pass `self.name` (`182-184`, `206`, `210-211`). `StallWatch.herdr_name` is the same function of `args.target` (`229`). `watcher.wait_once` receives that name, and `args.herdr_session` stays the server (`331`).
|
||||
|
||||
`build_argv` puts a non-default server on `herdr --session` and the target on `agent wait <target>` (`loop/watcher.py:131-139`). `HerdrSource._herdr` does the same split (`stall_watch.py:167-168`). The server argument is not run through the agent-name sanitizer.
|
||||
|
||||
`resolve_agent` still reads `--agent`, then its first hyphen piece, then `agent_of_row` on the original session name (`stall_watch.py:140-146`, `registry.py:30-49`, `base.py:326-330`). Measured on this snapshot: `resolve_agent("", "mam-wt-issue-66-very-long-creator-codex")` is `codex`. The sanitized form `mam-wt-issue-66-very-lo-d55dfa83` does not end in `-codex`, and `resolve_agent` of that string with an empty `--agent` is `None`. `resolve_agent("claude", <39-character name>)` is `claude`. `resolve_agent("claude-code", ...)` is `claude`. The long-name test passes `--agent ""` and still nudges, which is the codex input-box path (`test_issue66_stall_watch_sanitized_name.py:111-127`).
|
||||
|
||||
No remaining herdr agent-name argument in `stall_watch.py` uses `self.target` or `args.target`. Those strings appear in the log lines (`373`, `397`, `406`). Pane calls use the pane id (`186-193`).
|
||||
|
||||
`_notify` publishes `progress` with the detail it is given (`237-244`). Idle publishes `stalled:idle` (`408`). Blocked publishes `stalled:blocked` plus the dialog line (`374`). Those details contain the stall reason. The original session name is in the matching log line (`agent <original> idle for`, `agent <original> is blocked`). The tests lock both (`79-85`, `101-106`).
|
||||
|
||||
## Sanitizer
|
||||
|
||||
`sanitize_herdr_agent_name` (`lib_py/agents/sanitize.py:4-30`) lowercases, replaces characters outside `[a-z0-9_-]` with `-`, prefixes `x-` when the result does not start with a letter, and when the result is longer than 32 characters keeps 23 characters, a hyphen, and 8 hex digits of SHA-1.
|
||||
|
||||
The function `lib.sh` runs is the definition at `lib.sh:293-316` (an earlier copy at `36-61` is replaced when the file is sourced; every call site is below 316). It was executed on its own, without sourcing `lib.sh`. On this Mac, with `shasum` present, it returned the same strings as the Python function:
|
||||
|
||||
| input | result |
|
||||
| --- | --- |
|
||||
| `creator-claude-fix-issue-99-long-branch` (39) | `creator-claude-fix-issu-e6fc18ae` |
|
||||
| `Creator-Claude-T` | `creator-claude-t` |
|
||||
| `creator-claude-t` | `creator-claude-t` |
|
||||
| `mam-wt-issue-66-very-long-creator-codex` | `mam-wt-issue-66-very-lo-d55dfa83` |
|
||||
| empty | `agent` |
|
||||
| `1abc` | `x-1abc` |
|
||||
| `A B` | `a-b` |
|
||||
| 33 `x` | `xxxxxxxxxxxxxxxxxxxxxxx-60bbb3c8` |
|
||||
| `planner-agy` | `planner-agy` |
|
||||
|
||||
The earlier shell copy produced the same three long, codex, and `1abc` results. Python 3.9.6 imports `stall_watch`. The new code adds no `match` statement and no evaluated `X | Y` annotation. The module already has `from __future__ import annotations`.
|
||||
|
||||
## Mock
|
||||
|
||||
`strict_agent_names` is off unless a test sets it. `_match_agent` then keeps the previous comparison (`conftest.py:470-476`). With the flag set, the mock answers only an exact registered name. `agent get` and `agent read` then print `agent_not_found` JSON (`1057-1062`, `1152-1158`). `agent wait` and `agent prompt` already did (`1089-1096`, `1187-1194`). Existing tests that leave the flag unset keep the old not-found text on get and read.
|
||||
|
||||
## Tests
|
||||
|
||||
`test_the_names_change_and_the_strict_mock_answers_only_the_sanitized_one` registers the sanitized name and checks that get, read, wait, and prompt reject the original name with `agent_not_found` and accept the sanitized name (`51-65`).
|
||||
|
||||
`test_an_idle_agent_is_nudged_through_the_sanitized_name` covers the 39-character name, `Creator-Claude-T`, and `creator-claude-t`. Every get, read, prompt, and wait uses the sanitized name, one nudge is sent, the progress detail is `stalled:idle`, and the log contains `agent <original> idle for` (`68-85`).
|
||||
|
||||
`test_a_blocked_agent_is_reported_with_the_original_name` uses the sanitized name on the herdr calls, sends no prompt, publishes `stalled:blocked` plus the dialog line, and logs `agent <original> is blocked` (`90-106`).
|
||||
|
||||
`test_the_kind_of_a_long_name_comes_from_its_original_suffix` passes an empty `--agent`. The sanitized name does not end in `-codex`. The watcher still nudges and every herdr call uses the sanitized name (`111-127`).
|
||||
|
||||
## Results
|
||||
|
||||
Pytest used the control-plane venv, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp` `/tmp/mam7b6-7b602181-pytest` (head) and `/tmp/mam7b6-7b602181-base-pytest` (base). Neither path contains `mam-review` or `shim`. Inherited `HERDR_*`, `MAM_*`, `MQTT_*`, `SKS_*`, `DELEGATE_JOB_LOGS_DIR`, `HOME_DIR`, `CLAUDE_*`, `CODEX_HOME`, `PYTHONPATH`, `WORKSPACE_ROOT`, `MAM_ENV_FILE`, `MAM_JOBS_DIR`, `AGENT_SESSIONS_YAML`, the four `XDG_*` homes, and `AGENT_PYTHON_BIN` were dropped.
|
||||
|
||||
Head, cwd the snapshot: `tests/test_issue66_stall_watch_sanitized_name.py`, `test_issue30_stall_watch.py`, `test_issue30_stall_watch_stop.py`. **66 passed, 1 skipped** in 356.72 s. Exit 0. The log closed at 2026-10-25 12:31:50. The skip is `test_s6_the_heartbeat_timer_is_not_a_background_child`, marked for Linux because it reads `/proc` (`test_issue30_stall_watch.py:39-40`, `340-341`). That test is outside this delta. The seven Issue #66 tests passed inside the 66.
|
||||
|
||||
Base, this turn: a throwaway `git clone --shared` checked out at `0461a632eb667c7988fd33be92e9a4b36151f22a`, with only `tests/test_issue66_stall_watch_sanitized_name.py` copied from head. Base `stall_watch.py` has no `sanitize_herdr` use. **6 failed, 1 passed** in 37.05 s. Exit 1. The pass is `test_an_idle_agent_is_nudged_through_the_sanitized_name[creator-claude-t]`, the name that needs no conversion. The six failures are the long name, the uppercase name, and the long codex name. On that tree get, read, and wait are invoked with `creator-claude-fix-issue-99-long-branch` and `Creator-Claude-T`. The snapshot was not checked out. Snapshot porcelain stayed empty.
|
||||
|
||||
## Issue #17 timing failure
|
||||
|
||||
`tests/test_issue17_item4_decisions.py::test_c_finalize_ends_with_an_unreaped_wrapper[broker_unavailable-3-0]` is outside this diff. The test sends `SIGTERM` to a delegate wrapper during finalize and expects exit `-15` within 4.5 seconds, an empty call log, and an unchanged job record (`583-601`). The diff names only `stall_watch.py`, `tests/conftest.py`, and the new Issue #66 test. It does not touch the wrapper, `finalize_unfinished`, or that test. A miss on that timing assertion is a cleanup race in the wrapper test, separate from the herdr agent name.
|
||||
|
||||
## Blocking defects
|
||||
|
||||
None.
|
||||
|
||||
## Non-blocking
|
||||
|
||||
- `lib.sh` contains two copies of `_sanitize_herdr_agent_name` (`36-61` and `293-316`). Sourcing the file uses the second. On this host, with `shasum` present, both copies matched Python for the names checked above. The first copy's missing-hasher path truncates to 32 characters with no hash. This commit does not change `lib.sh`.
|
||||
- Progress details stay `stalled:idle` and `stalled:blocked <dialog>`. They carry the stall reason. The original session name is on the log line beside that notice.
|
||||
|
||||
## Host check
|
||||
|
||||
No process has a first command token of `opencode` or a path ending in `/opencode`. `lsof` on `~/.local/share/opencode/opencode.db` exited 1. That database was not opened. Its mtime is 1792641027 and its size is 36712448. `opencode.log` is absent. `~/.config/opencode/service.json` mtime is 1792246838 and its size is 64. `~/.local/state/opencode/service.json` is absent. The herdr config mtime is 1784456394. `~/.local/state/mam` mtime is 1792846843. `~/.local/state/mam/id-order` is a directory, mtime 1792846886. These stats match the check taken before the suite.
|
||||
|
||||
Five herdr processes were running immediately before the suite. After the suite log closed at 2026-10-25 12:31:50, the 12:31:59 check still found five. One pid from the before-set was gone. The replacement is `/opt/homebrew/bin/herdr` with `--session multi-agent-mux`, elapsed time about 15 seconds at 12:31:59, so it started near 12:31:44, during the last seconds of the suite. No herdr command line contained the review basetemp, the snapshot path, or `d6f876`. This review did not start or stop a herdr server.
|
||||
|
||||
After this report file was written, the same check was repeated at 2026-10-25 12:33:25. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane porcelain is empty at `0461a632eb667c7988fd33be92e9a4b36151f22a`. No process has a first command token of `opencode` or a path ending in `/opencode`, and `lsof` still reports no database holder. The database mtime is still 1792641027 and its size is still 36712448. `opencode.log` is still absent. The config service file mtime is still 1792246838. The state service file is still absent. The herdr config mtime is still 1784456394. `~/.local/state/mam` mtime is still 1792846843, and `id-order` is still the same directory (mtime 1792846886). Five herdr processes were present. The pid set differs from the 12:31:59 set by one homebrew herdr process whose arguments include `--session multi-agent-mux`; its elapsed time was about 41 seconds, so that process had turned over again. No herdr command line contained the review basetemp, the snapshot path, or `d6f876`. This review did not start or stop it.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,59 @@
|
||||
# Review 85982c2d — PR #62, Issue #60 Linux follow-up
|
||||
|
||||
NOT PASS. Five new tests fail on this Mac before they call the query. The read-only open itself, measured on temporary databases, returns the committed rows and leaves the directory names as they were.
|
||||
|
||||
Reviewed only the snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T000805Z-975c4c/1` (`loop-20261019T000805Z-975c4c`). Head `6a4e385a78c8a561d5cf3644336de6a352ea9271`, tree `afa2c55925f93820337b69699c670c0b0ca90980`, base `1d7b7dab58a10fe86fd747b7290d47674ca93230` is an ancestor and is the control-plane HEAD. Eight commits, 11 files, +515/−31. `git diff --check` is clean. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane porcelain is empty. No product file was edited. The source worktree was not run.
|
||||
|
||||
Commits, newest first: `6a4e385` (narrow fixture ends at its last drawn line), `d17cd80` (auth is the binary; create uses `auth-ok`), `7803d47` (a `-wal` without its `-shm` is read from a private copy), `463817e` (unconfirmed `/exit` wording), `614ab14` (`debug paths` cached per binary and home), `ac5f115` (queries go through `_ro_connect`), `14d7a46` (2.x home placeholder), `4d49abb` (`DELEGATE_JOB_PYTHON` for the u1e wrapper).
|
||||
|
||||
Host tools: control-plane venv Python 3.9.6, sqlite 3.51.0. `bash -n` accepts `create_session.sh` and `stop_session.sh`. `py_compile` accepts `opencode.py`, `base.py`, `input_region.py`, `__main__.py`, and `tests/test_issue60_linux_fixes.py`. The opencode binary was not launched. Linux was not executed here.
|
||||
|
||||
## The five failures
|
||||
|
||||
`tests/test_issue60_linux_fixes.py::test_a_query_reads_right_and_leaves_the_sidecars_alone` with state `clean` fails for all five queries (`verify_artifact`, `discover`, `_has_session`, `_descendants`, `residual_artifacts`).
|
||||
|
||||
`make("clean")` closes a WAL connection and requires the directory to be exactly `["opencode.db"]` (`test_issue60_linux_fixes.py:154` and `:173-181`). The assertion is that listing, and it runs before the query. On sqlite 3.51.0 here, closing the last connection leaves `opencode.db-wal` at 0 bytes and `opencode.db-shm` at 32768 bytes. `PRAGMA wal_checkpoint(TRUNCATE)` then close leaves the same two names. The main file grows from 4096 to 8192 bytes on that close, which is the checkpoint into the main file. The pytest failure is `['opencode.db', 'opencode.db-shm', 'opencode.db-wal']` against `['opencode.db']`.
|
||||
|
||||
That is the state a just-closed WAL database has on this host. `_ro_connect` then sees both sidecars and takes the `mode=ro` branch. A temporary database in that state returned the inserted row, and the three names and their sizes were the same after the connection closed.
|
||||
|
||||
The no-wal branch was measured by removing the sidecars after the checkpoint. `_ro_connect` (`opencode.py:13-36`) opened `file:<path>?mode=ro&immutable=1`, returned the row, and the directory stayed a single file with the same size and hash. A path containing a space behaved the same way.
|
||||
|
||||
The fixture has to remove the sidecars this sqlite keeps before it can assert a one-file listing and exercise that branch. As written, the five clean cases are red on the review host, so the related file is red here.
|
||||
|
||||
The other three states of the same test passed: both sidecars left by a writer that is gone, a `-wal` copied without its `-shm`, and a live writer. Each returned the expected rows (`ses_main`, the child id, or the residual `session_v2` count the test asks for) and the directory names after the query matched the names before it.
|
||||
|
||||
## The three open modes, on temporary databases
|
||||
|
||||
Both `-wal` and `-shm` present: `mode=ro`. The live writer's uncheckpointed row was visible. The `-wal` size and hash stayed the same. The `-shm` hash changed while the reader was open and was still the new hash after the reader closed (32768 bytes, same inode). The directory names stayed the three sidecar names. In a second sample the writer then inserted another row; `wal_checkpoint(TRUNCATE)` returned `(0, 0, 0)`.
|
||||
|
||||
No `-wal`: `immutable=1`. Covered above. An existing `-shm` with no `-wal` was left in place, and the row in the main file was visible.
|
||||
|
||||
`-wal` present and `-shm` absent: the function copies the db and the wal into a `mam-opencode-` temporary directory, backups that copy into memory, and drops the temporary directory before returning the memory connection. Both rows that lived in the wal were visible. The original db and wal kept their size and hash. No `-shm` appeared beside the original.
|
||||
|
||||
`db_rows_naming` takes the optional `connect` and the opencode residual path passes `_ro_connect` (`base.py:111-119`, `opencode.py:273-274`). `verify_artifact`, `_descendants`, `_has_session`, and `discover` call `_ro_connect`. `purge_artifacts` still uses `sqlite3.connect(db)` for the 1.x `DELETE` statements (`opencode.py:218`). That line is unchanged in this range.
|
||||
|
||||
An immutable open that is issued after the no-wal check has already been recorded, and after a writer has then committed a new row into a new `-wal`, returns the rows that were in the main file. `_ro_connect` called once both sidecars exist returns the new row as well. The gap is between `os.path.exists` (`opencode.py:21`) and `sqlite3.connect` (`opencode.py:25`). A writer that already has both sidecars is read with `mode=ro`.
|
||||
|
||||
## The other five items
|
||||
|
||||
The 2.x placeholder pattern is `Ask anything(?:…|\.\.\.)(?: "[^"]*"?)?` (`opencode.py:76-79`). `extract_input_region` applies it with `re.fullmatch` to the first non-blank line of the box only (`input_region.py:76-86`). Both new fixtures, wide and narrow, read as an empty box. The narrow fixture's example line contains a quote and does not end with a quote. A first line that is exactly `Ask anything…` or `Ask anything… "<example>"` (ellipsis or three dots) reads as empty. A first line with text after the closing quote is kept, and a later line with the placeholder shape is kept. `input_placeholder` stays `''`, which is what `lib.sh` greps. The other adapters' `input_placeholder_pattern` is `None`. Those tests passed.
|
||||
|
||||
`auth_ok` for opencode is `shutil.which("opencode")` or `<HOME_DIR or HOME>/.opencode/bin/opencode` (`opencode.py:317-321`). Keys and `auth.json` are outside that check. `__main__.py` `auth-ok` exits 0 or 1 from the adapter, and exits 1 for an unknown agent. `create_session.sh` calls `auth-ok opencode` through `_delegate_py_bin`. The diff among adapters is only `opencode.py`. Claude, agy, grok, codex, and hermes keep their previous `auth_ok` bodies. `test_adapter_auth_ok`, the binary-only cases, and `test_create_decides_opencode_auth_without_running_it` passed. The create run records no `--version` call.
|
||||
|
||||
`_DB_PATHS` keys are `(binary, home, data home)` (`opencode.py:139-161`). A hit returns the stored path. A failed `debug paths` is not stored. The new tests replace the dict for each test (`test_issue60_linux_fixes.py:48-50`). `test_debug_paths_runs_once_per_home` and `test_a_failed_debug_paths_is_not_remembered` passed. The same key keeps its path for the life of the process, including after the binary at that path is replaced.
|
||||
|
||||
`stop_session.sh` still sends the exit key, waits, and runs `kill-session` when the session is still there (`stop_session.sh:242-250`). The unconfirmed-delivery line now says it is waiting for the session to exit. `test_the_graceful_stop_reports_what_happened` passed for a pane that exits on `/exit` and for one that stays up. The staying-up case still contains `still alive → kill-session`.
|
||||
|
||||
`test_u1e_a_dry_run_record_is_not_an_open_job` passes `DELEGATE_JOB_PYTHON=sys.executable`. That test passed.
|
||||
|
||||
## Suite
|
||||
|
||||
Pytest used the control-plane venv, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp` `/tmp/mam859-85982c2d-pybase`. Inherited `HERDR_*`, `MAM_*`, `MQTT_*`, `SKS_*`, `DELEGATE_JOB_LOGS_DIR`, `HOME_DIR`, `CLAUDE_PROJECT_DIR`, `CLAUDE_CONFIG_DIR`, `CLAUDE_CODE_TMPDIR`, `CODEX_HOME`, `PYTHONPATH`, `WORKSPACE_ROOT`, `XDG_STATE_HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, and `XDG_DATA_HOME` were dropped.
|
||||
|
||||
5 failed, 42 passed in 59.38 s. The failures are the five `clean` cases named above. The rest of `tests/test_issue60_linux_fixes.py`, `tests/test_a4_adapter_contract.py::test_adapter_auth_ok`, and `tests/test_issue50_dispatch.py::test_u1e_a_dry_run_record_is_not_an_open_job` passed. No error from the session guards.
|
||||
|
||||
The user's background service stayed up. Its pid is 23630 and the command is `opencode serve --service`. The real database was not opened. Its mtime stayed 1792367390. `~/.config/opencode/service.json` stayed 1792246838 and `~/.local/state/opencode/service.json` stayed 1792365227. The real `opencode.log` mtime stayed 1792368889 and its size stayed 2636652. The herdr config mtime stayed 1784456394.
|
||||
|
||||
After the report, snapshot porcelain is still empty, the snapshot still has no `.mam`, and control-plane porcelain is still empty at `1d7b7dab`. The herdr config mtime is still 1784456394. The opencode database mtime is still 1792367390, the opencode log mtime is still 1792368889 and its size is still 2636652, and service pid 23630 is still `opencode serve --service`.
|
||||
|
||||
[VERDICT: NOT PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261019T015801Z-05463c",
|
||||
"base_sha": "1d7b7dab58a10fe86fd747b7290d47674ca93230",
|
||||
"head_sha": "6a37c0019baa31ee28b32a086e57b4c8239413c4",
|
||||
"tree_sha": "87013e7ef7f5239f1bf6ea38a2cbda214257be3c",
|
||||
"review_commit": "6a37c0019baa31ee28b32a086e57b4c8239413c4",
|
||||
"reviewer": "reviewer-grok-01",
|
||||
"job_id": "8f95cc77",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-19T02:37:22Z"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# Review 8f95cc77 — PR #62, second pass
|
||||
|
||||
PASS. Commit `6a37c00` fixes the five Mac failures from `6a4e385` and the two extra findings from that round. Related tests: 53 passed.
|
||||
|
||||
Reviewed only the snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T015801Z-05463c/1` (`loop-20261019T015801Z-05463c`). Head `6a37c0019baa31ee28b32a086e57b4c8239413c4`, tree `87013e7ef7f5239f1bf6ea38a2cbda214257be3c`, base `1d7b7dab58a10fe86fd747b7290d47674ca93230` is an ancestor and is the control-plane HEAD. Nine commits, 13 files, +553/−36. `git diff --check` is clean. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane porcelain is empty. No product file was edited. The source worktree was not run.
|
||||
|
||||
The new commit on top of `6a4e385` is `6a37c00`. It changes four files (+40/−7): `opencode.py` (placeholder pattern and `auth_ok`), `status.sh`, `reconcile.sh`, and `tests/test_issue60_linux_fixes.py`.
|
||||
|
||||
Host tools: control-plane venv Python 3.9.6, sqlite 3.51.0. `bash -n` accepts `status.sh`, `reconcile.sh`, `create_session.sh`, and `stop_session.sh`. The changed Python modules parse. The opencode binary was not launched. Linux was not executed here. The real database was not opened.
|
||||
|
||||
## The round-1 blocker
|
||||
|
||||
On this host, closing the last WAL connection leaves `opencode.db-wal` at 0 bytes and `opencode.db-shm` at 32768 bytes, and the checkpoint has already written the rows into the main file. `make("clean")` now removes those two sidecars when they exist (`test_issue60_linux_fixes.py:158-162`). The five `clean` cases of `test_a_query_reads_right_and_leaves_the_sidecars_alone` then see a directory of exactly `opencode.db`, read the expected rows through `mode=ro&immutable=1`, and the directory is still that one file afterward. Those five cases passed. The leftover, wal-only, and live-writer cases passed again.
|
||||
|
||||
On Linux the close already removes the sidecars, so the new `os.path.exists` unlink is a no-op there. The same rows are what the immutable open reads.
|
||||
|
||||
## Placeholder and auth
|
||||
|
||||
The pattern is now `Ask anything(?:…|\.\.\.) "[^"]*"?` (`opencode.py:79`). The quoted example is required, so a first line that is only `Ask anything…` or `Ask anything...` stays typed text. The wide and narrow home fixtures still read as an empty box, including the narrow line whose example has no closing quote. A first line with text after the closing quote stays, and so does a later line. `test_typed_text_is_read_as_typed` covers the two bare lines on both fixtures and passed. `input_placeholder` stays `''`. The other adapters' pattern property stays `None`.
|
||||
|
||||
A first line that is exactly `Ask anything… "<example>"` is still cleared. That is the home-screen shape.
|
||||
|
||||
`auth_ok` accepts `shutil.which("opencode")`, or `<home>/.opencode/bin/opencode` when that path is a file and `os.access(..., os.X_OK)` is true (`opencode.py:321-323`). A mode `0644` file and a directory named `opencode` both fail. `test_a_home_install_that_cannot_run_is_not_enough` and `test_adapter_auth_ok` passed. Keys and `auth.json` still do not count. Create still asks `auth-ok` and does not run `--version`. The other agents' `auth_ok` bodies are outside this commit.
|
||||
|
||||
## status and reconcile
|
||||
|
||||
Both copies of `resume_on_disk` in `status.sh` (`:117` and `:318`) and the opencode drift-C open in `reconcile.sh` (`:955`) call `_ro_connect(odb)`. `sqlite3.connect(odb)` is gone from both scripts. `test_status_and_reconcile_open_the_database_read_only` checks those counts and passed. `env_python` puts the skill directory on `PYTHONPATH`, which is the path `get_adapter` already uses a few lines above each open. A failed open still returns `MISSING` in status and is swallowed in reconcile. Hermes and codex in those scripts still open their own databases with `sqlite3.connect`.
|
||||
|
||||
## Suite
|
||||
|
||||
Pytest used the control-plane venv, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp` `/tmp/mam8f95-8f95cc77-pybase`. Inherited `HERDR_*`, `MAM_*`, `MQTT_*`, `SKS_*`, `DELEGATE_JOB_LOGS_DIR`, `HOME_DIR`, `CLAUDE_PROJECT_DIR`, `CLAUDE_CONFIG_DIR`, `CLAUDE_CODE_TMPDIR`, `CODEX_HOME`, `PYTHONPATH`, `WORKSPACE_ROOT`, `XDG_STATE_HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, and `XDG_DATA_HOME` were dropped.
|
||||
|
||||
53 passed in 59.23 s: `tests/test_issue60_linux_fixes.py` and `tests/test_a4_adapter_contract.py::test_adapter_auth_ok`. No skip and no error.
|
||||
|
||||
The earlier commits are unchanged in this pass: `_ro_connect` still picks `mode=ro` when both sidecars exist, `immutable=1` when there is no `-wal`, and a private memory copy when a `-wal` has no `-shm`. The gap between the `exists` check and the immutable open is still there. `_DB_PATHS` still keeps a path for the life of the process, and each test replaces that dict. Create still treats an executable binary as configured. Those three were left in place on purpose.
|
||||
|
||||
The user's background service stayed up. Its pid is 23630 and the command is `opencode serve --service`. The real database mtime stayed 1792367390. `~/.config/opencode/service.json` stayed 1792246838 and `~/.local/state/opencode/service.json` stayed 1792365227. The real `opencode.log` mtime stayed 1792368889 and its size stayed 2636652. The herdr config mtime stayed 1784456394.
|
||||
|
||||
After the report, snapshot porcelain is still empty, the snapshot still has no `.mam`, and control-plane porcelain is still empty at `1d7b7dab`. The herdr config mtime is still 1784456394. The opencode database mtime is still 1792367390, the opencode log mtime is still 1792368889 and its size is still 2636652, and service pid 23630 is still `opencode serve --service`.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261027T201245Z-63c71b",
|
||||
"base_sha": "1d63e882bc1c46e9af8484647d5091b296d8fa3f",
|
||||
"head_sha": "ec7f5c4e57286fb3c4238b8220f39094f4640391",
|
||||
"tree_sha": "fbadb24de60ccdae684b23e2e0a41cdf23ab81eb",
|
||||
"review_commit": "ec7f5c4e57286fb3c4238b8220f39094f4640391",
|
||||
"reviewer": "reviewer-grok-01",
|
||||
"job_id": "a43bca93",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-27T21:06:27Z"
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# PR #75 (Issue #72) 리뷰 — 라운드 5
|
||||
|
||||
검증 경로 `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261027T201245Z-63c71b/1` 만 봤다. HEAD `ec7f5c4e57286fb3c4238b8220f39094f4640391`, tree `fbadb24de60ccdae684b23e2e0a41cdf23ab81eb`, base `1d63e882bc1c46e9af8484647d5091b296d8fa3f` (HEAD 의 부모이자 조상), porcelain 0. 스냅샷에는 `.venv` 가 없다. 테스트 뒤에도 HEAD·tree·porcelain 은 같았다. 제어 평면의 입장 잠금과 업데이트 표식은 없었고, 체크아웃 모니터 pid 3714 (`reconcile.sh --subscribe --idle-timeout 0`) 는 그대로였다.
|
||||
|
||||
이번 커밋은 `ec7f5c4` 하나다. `1d63e88..ec7f5c4` 는 `lib.sh` 와 `tests/test_issue72_update.py` 이고 `git diff --check` 는 깨끗했다. diff 에 `mapfile`, `declare -A`, `local -n`, `[[ -v`, `sed -i`, `stat -c`, `readlink -f`, `date -d` 는 없다. `setsid(1)` 도 호출하지 않는다.
|
||||
|
||||
## 확인한 것
|
||||
|
||||
R2. `MAM_DELEGATION_PY=0` 의 기동도 호출자 프로세스 그룹 밖에 둔다.
|
||||
|
||||
- `lib.sh:2297-2310`. 이미 떠 있는 모니터가 없을 때만, 호출자가 이미 `-m` 이었는지 기억하고 `set -m` 으로 잡은 뒤 `nohup bash <reconcile.sh> --subscribe --idle-timeout 0` 을 백그라운드로 띄운다. stdin 은 `/dev/null`, 로그는 append, 그 다음 호출자의 `-m` 을 되돌린다. 중복 확인(`lib.sh:2291` `_monitor_pids`)과 `MAM_NO_SUBSCRIBER_DAEMON=1` (`lib.sh:2295-2296`) 은 그 앞에 그대로다. PID 출력은 `lib.sh:2314-2315` 이다.
|
||||
- 이 함수를 쓰는 재시작은 세 곳이다. 설치 성공은 `post_update.sh:41`, 정지 시간 초과는 `update.sh:106`, 롤백과 상태 기록 실패는 `install_phase.sh:64` 의 `bash -c` 가 복구된 트리의 `start_watchdog` 를 부른다. `lib.sh:2567-2573` 에서 `MAM_DELEGATION_PY=0` 이면 레거시, 아니면 Python `start_new_session` 이다.
|
||||
- tty 없이 `set -euo pipefail` 인 bash 3.2 에서 `set -m` 으로 띄운 `sleep` 의 PGID 는 그 PID 와 같고 부모 PGID 와 달랐다. 부모가 끝난 뒤에도 그 프로세스는 남아 있었고, 확인 뒤에 그 PID 만 종료했다.
|
||||
- `test_issue72_update.py:697-725`. `MAM_DELEGATION_PY` 0 과 1, 설치 성공·TERM 무시 후 KILL·롤백 여섯 조합에서 새 모니터의 PGID 가 이 테스트 프로세스 그룹과 다른지 본다. `box.update` 는 프로세스 그룹을 바꾸지 않으므로 그 그룹이 호출자 그룹이다.
|
||||
|
||||
정리. 테스트는 패턴으로 죽이지 않는다.
|
||||
|
||||
- `tests/test_issue72_update.py` 에 `pkill` 과 `killall` 은 없다.
|
||||
- `test_issue72_update.py:221-240`. 이 테스트가 `helper` 로 띄운 프로세스 그룹만 `os.killpg` 하고, 이 임시 작업공간의 `reconcile.sh --subscribe` 명령줄과 그 `pgrep -P` 자식만 `os.kill` 한다. 그 다음 10초 동안 그 작업공간 경로를 명령줄에 가진 프로세스가 남아 있으면 실패한다 (`_processes_naming`, `184-193`). 읽는 용도이고, 다른 작업공간의 모니터는 경로가 다르므로 대상이 아니다.
|
||||
|
||||
이전 라운드에서 맞던 중복 방지, opt-out, PID 보고, 로그 append 는 이 diff 가 그 순서를 바꾸지 않았다. Python 쪽 세션 분리는 `tests/test_issue5_phase1_delegation.py:697-699` 가 그대로 본다.
|
||||
|
||||
## 실행한 테스트
|
||||
|
||||
작업 디렉터리는 스냅샷이다. 인터프리터는 제어 평면 `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python` (Python 3.9.6). `--basetemp=/tmp/mam-i72-a43bca93`.
|
||||
|
||||
```
|
||||
env -u HERDR_SOCKET_PATH -u HERDR_PANE_ID -u HERDR_TAB_ID -u HERDR_WORKSPACE_ID \
|
||||
-u HERDR_SESSION_NAME -u HERDR_SESSION -u WORKSPACE_ROOT -u MAM_ENV_FILE \
|
||||
-u MAM_JOBS_DIR -u DELEGATE_JOB_LOGS_DIR \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python -m pytest \
|
||||
-p no:cacheprovider --basetemp=/tmp/mam-i72-a43bca93 \
|
||||
tests/test_issue72_admission.py tests/test_issue72_attach_hints.py \
|
||||
tests/test_issue72_installer.py tests/test_issue72_resume_monitor.py \
|
||||
tests/test_issue72_update.py tests/test_issue8_update_state.py \
|
||||
tests/test_deploy_layout.py tests/test_issue42_skill_blocks.py \
|
||||
tests/test_issue26_ints.py tests/test_version_consistency.py \
|
||||
tests/test_issue5_phase1_delegation.py \
|
||||
tests/test_issue19_subscriber_daemon_lifecycle.py \
|
||||
-q --tb=line
|
||||
```
|
||||
|
||||
결과: **314 passed, 1 skipped**, 888.47s, 종료 코드 0. PGID 여섯 조합과 watchdog 수명 주기가 이 안에 들어 있다.
|
||||
|
||||
건너뛴 한 건은 `tests/test_issue42_skill_blocks.py:266` (`.claude/skills` 가 이 체크아웃에 없음). 같은 파일만 `--basetemp=/tmp/mam-i72-a43bca93-skip -rs` 로 다시 돌리면 39 passed, 1 skipped, 0.07s 이다.
|
||||
|
||||
basetemp 경로를 가진 프로세스는 종료 뒤에 남아 있지 않았다.
|
||||
|
||||
## 발견 사항
|
||||
|
||||
차단: 없음. 설계를 다시 잡아야 하는 결함도 없다.
|
||||
|
||||
비차단:
|
||||
|
||||
- 레거시 경로는 새 프로세스 그룹이다 (`set -m`). Python 경로는 `start_new_session` 으로 새 세션이다. 이번 라운드가 요구한 것은 호출자 프로세스 그룹에서 벗어나는 것이고, 둘 다 그렇게 한다. `nohup` 이 SIGHUP 을 무시하는 것은 그대로다.
|
||||
- `lib.sh:2310`. 모니터를 띄운 뒤 원래 디렉터리로 돌아가지 못하면 함수는 1 을 반환한다. 그 시점에는 이미 `set +m` 이 실행됐고 데몬은 떠 있다. 호출자는 PID 를 받지 못한다. 디렉터리가 사라진 경우에만 해당한다.
|
||||
- 앞 라운드의 비차단 항목(롤백 재시작이 `mam_monitor_started` 를 호출하지 않음, SIGKILL 자식 PID 재확인, 이미 떠 있는 resume 의 잠금 범위, 호출자의 실행 중 잡이 둘일 때)은 이 커밋이 건드리지 않았고 이번 수정 범위 밖이다.
|
||||
|
||||
전용 테스트가 없는 조건:
|
||||
|
||||
| 조건 | 코드 | 테스트 |
|
||||
| --- | --- | --- |
|
||||
| 레거시 모니터가 호출자와 다른 세션인지 | `lib.sh:2306-2308` 은 프로세스 그룹만 분리 | 이번 테스트는 PGID 만 비교 (`test_issue72_update.py:724`) |
|
||||
| 앞 라운드 표의 세 조건 | 변경 없음 | 이번 범위 밖 |
|
||||
|
||||
## 사용자 확인 필요
|
||||
|
||||
없음.
|
||||
|
||||
[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]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261023T035928Z-2ccc02",
|
||||
"base_sha": "dc886ecd6b38c01032a55a2d1c230c73362ae630",
|
||||
"head_sha": "1614af5b673d7519d3a38f63e3636055592b4f14",
|
||||
"tree_sha": "f1af6fdd548ee5d6f6b81e2202833edfaca98188",
|
||||
"review_commit": "1614af5b673d7519d3a38f63e3636055592b4f14",
|
||||
"reviewer": "reviewer-grok-01",
|
||||
"job_id": "dee00bc0",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-23T05:16:58Z"
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
# Review dee00bc0 — PR #69 / Issue #63, fourth Mac pass
|
||||
|
||||
PASS. Head `1614af5` keeps a dead-owner marker whose session name is the caller's own until herdr returns a parsed agent list that omits that name, or until the row is registered for the attempt. A live owner of that name is kept. An empty session name is still dropped. A failed lookup, `server_not_running` included, and a wait cap of 0 leave the marker in place. A normal create or resume whose `has-session` already sees the name returns before this wait. A retry whose probe says the session is gone returns on that probe.
|
||||
|
||||
Reviewed only the snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261023T035928Z-2ccc02/1` (`loop-20261023T035928Z-2ccc02`). Head `1614af5b673d7519d3a38f63e3636055592b4f14`, tree `f1af6fdd548ee5d6f6b81e2202833edfaca98188`. Base `dc886ecd6b38c01032a55a2d1c230c73362ae630` (main, v4.9.9) is an ancestor and is the control-plane HEAD. Thirteen commits, 14 files, +2234/−102. Against the previous review head `7cac6f6` the delta is 2 files, +42/−16: `.agents/skills/lib_py/agents/id_order.py` and `tests/test_issue63_same_cwd_ids.py`. No uncommitted changes. `git diff --check` is clean. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane porcelain is empty. No product file was edited. The source worktree `mam-wt-pr69` was not run.
|
||||
|
||||
The new commit is `1614af5` `fix(id_order): a dead-owner marker on the caller's own session follows the orphan rules`. It closes the residual recorded on `7cac6f6`, where a dead owner of the waiter's own session name was dropped before herdr was asked. `8f64174` and `7cac6f6` stay underneath it. HEAD still prunes only on an authoritative gone, still treats `server_not_running` as unknown, and still asks herdr nothing at cap 0. The same-name case now uses that branch.
|
||||
|
||||
Host tools: control-plane venv Python 3.9.6, sqlite 3.51.0, `/bin/bash` 3.2.57(1)-release. The real agent databases were not opened.
|
||||
|
||||
## Same-name orphan
|
||||
|
||||
`_read_and_prune_markers` no longer takes the waiter session name (`id_order.py:183-186`, call at `308-309`). A dead owner's marker (`id_order.py:217-229`):
|
||||
|
||||
- A missing owner pid, or an owner pid equal to this waiter, drops the marker (`219-220`).
|
||||
- A live owner keeps it, including a live attempt on the caller's session (`221-222`).
|
||||
- An empty session name drops it (`223-224`).
|
||||
- A row registered for the attempt drops it without a probe. Registered means status `running` and `herdr_session_epoch` greater than the marker's `prev_epoch` (`177-180`, `225-226`).
|
||||
- Otherwise the probe runs. `drop` is true only when it returns false. True and `None` keep the marker, and `_orphan` is `"alive"` or `"unknown"` (`227-229`). A marker left by an earlier attempt on the caller's own session name takes this path.
|
||||
|
||||
`herdr_session_state` (`id_order.py:58-86`) is unchanged in this commit. False is only a successful, parsed `_herdr agent list` whose agents omit the session name and `sanitize_herdr_agent_name` of that name (`84-86`). A non-zero exit, a timeout, an `OSError`, or JSON that has no `result.agents` list returns `None` (`75-83`). `timeout <= 0` returns `None` before any subprocess (`64-65`). Measured on this snapshot: `herdr_session_state(..., 0)` and `herdr_session_state(..., -1)` are both `None`. `wait_s({})` is 600, `wait_s` of `"0"` is 0, and `wait_s` of `"abc"` is 600 (`36-41`).
|
||||
|
||||
The child environment sets `MAM_NO_AUTOSTART=1` and `HERDR_SESSION_NAME` to the marker's server, or `default` when that field is empty (`67`). The shim skips autostart when that variable is `1` (`lib.sh:203-208`). `bounded_probe` passes the time left on the deadline (`287-291`). The call sits inside `fcntl.flock(LOCK_EX)`. macOS has no `flock(1)`. The ordering tests passed with `fcntl`.
|
||||
|
||||
`blockers` includes a kept marker of the caller's own session when that row is absent (`133-147`). The waiter then holds until the marker is pruned or the cap expires (`325-347`). On cap expiry the message uses the last definite answer from this wait (`333-344`). An unknown orphan at cap 0 adds `MAM_ID_ORDER_WAIT_S=0 leaves no time to ask herdr`.
|
||||
|
||||
## Ordinary create, resume, and retry
|
||||
|
||||
Create calls `has-session` and exits 3 when the name is already present (`create_session.sh:168-170`). The id-order wait is later (`287`). Resume sets `HERDR_ALIVE` from `has-session` (`resume_session.sh:125-128`) and, when the session is alive, updates the row and exits (`158-169`) before the wait (`243`). The mock `has-session` exits 0 when the agent name is in its state (`conftest.py:1345-1355`). A retry that herdr still lists under that name therefore stops at `has-session` and does not enter the 600-second cap.
|
||||
|
||||
The wait itself returns 0 on the first lock hold when `blockers` is empty (`310-323`). That is a create or resume with no live same-cwd marker and no running row that still lacks an id. The default 600 is the cap for a marker that stays, and the function does not sleep it out on an empty blocker list.
|
||||
|
||||
When the caller has already passed `has-session` and a dead-owner marker of the same name remains:
|
||||
|
||||
- A parsed list that omits the name removes the marker and returns 0 on that pass (`228-229`, then `310-323`). The real-probe test does this with cap 2 on the marker's own session name and checks that the replacement marker's owner pid is the waiter (`test_issue63_same_cwd_ids.py:1315-1317`).
|
||||
- A parsed list that contains the name keeps the dead owner's pid and returns 1 inside the cap (`1290-1292`). The unit test at `wait_s_val=0` with an injected `pane_state` prints `its herdr session is alive` and `kill-session -t same-agy` (`1133-1140`).
|
||||
- `None`, which covers a failed lookup, `server_not_running`, and a real cap of 0, keeps the marker and the waiter uses the cap. That is the same rule `7cac6f6` already applies to every other orphan. The real-probe cap-0 assertion is on the other session name in the same test (`1306-1310`); the same-name branch calls the same `herdr_session_state`.
|
||||
|
||||
The head suite finished in 1301.73 s, in line with the previous pass on `7cac6f6` (1263.90 s). A common create or resume that now slept out 600 seconds would have stretched that run. It did not.
|
||||
|
||||
## Tests for this commit
|
||||
|
||||
`no_herdr_probe` (`test_issue63_same_cwd_ids.py:32-36`) makes an in-process `herdr_session_state` return false unless the test passes `pane_state`. The shell tests start a child, and that child runs the real function.
|
||||
|
||||
`test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_is_alive` (`1047`) still covers another session's orphan: alive keeps and prints the kill line, unknown keeps, a registered running row drops the marker with an empty probe list, and a false answer admits the next session. The same-name block (`1120-1148`) keeps a live owner's pid, keeps a dead owner's pid while the injected probe returns true or `None`, and on false replaces the owner pid with the waiter's pid.
|
||||
|
||||
`test_id_order_real_probe_against_mock_herdr_fails_closed` (`1250`) sources the sandbox `lib.sh` and calls `mam_id_order_wait`. A listed agent returns 1 for both the other session name and the marker's own name, and the dead owner's pid stays (`1287-1292`). `protocol_mismatch` and `server_not_running` return 1 with `herdr could not tell whether its session is gone` (`1294-1304`). The mock exits 1 for `force_error_code` before the subcommand (`conftest.py:486-488`). Cap 0 returns 1 with `MAM_ID_ORDER_WAIT_S=0 leaves no time to ask herdr` and keeps the marker (`1306-1310`). An unlisted agent returns 0 for the other name, and a following wait on the marker's own name returns 0 with a new owner pid (`1312-1317`). The mock `agent list` prints `{"result": {"agents": [...]}}` with each agent's `name` (`conftest.py:794-806`). A parsed list that contains the name is what prints `its herdr session is alive`.
|
||||
|
||||
`mock_herdr` prepends its bin (`conftest.py:1411`). These shell tests passed in the head run below. The unlisted own-name case returning 0, and the listed own-name case returning 1, are the evidence the probe reached the mock.
|
||||
|
||||
`test_id_order_create_waits_for_orphan_herdr_session_of_a_killed_create` (`1160`) and `test_id_order_herdr_probes_stay_within_the_wait_cap` (`1207`) are unchanged by this commit and passed. The create test uses a different session name and cap 3. The cap test's cap 0 finishes with every budget `<= 0` and both markers still on disk.
|
||||
|
||||
## Rest of base..head
|
||||
|
||||
The twelve commits through `7cac6f6` are the tree reviewed in job 737239ab. This commit does not change `session_ids.py`, `lib.sh`, the stop, resume, or create scripts, or the skill exit tables. `session_ids.py` still uses `#40`'s `capture` and `claimed_elsewhere`. Stop still decides the id before kill. Resume still resolves the id and refuses a claimed id before spawn. `git grep` for `capture_conversation_id` and `CAPTURE_ID`, excluding `.agents/reports` and `IMPROVEMENTS.md`, finds no caller. The diff names no loop or job-tree file, so the author's one-off `tests/test_issue34_job_tree.py::test_t5b_a_*[False]` failure is outside this change. `/bin/bash -n` accepts the snapshot `lib.sh`. Python 3.9.6 imports `id_order`. The annotations sit behind `from __future__ import annotations`.
|
||||
|
||||
## Results
|
||||
|
||||
Pytest used the control-plane venv, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp` `/tmp/mamdee-dee00bc0-pytest` (head) and `/tmp/mamdee-dee00bc0-base-pytest` (base). Neither path contains `mam-review` or `shim`. Inherited `HERDR_*`, `MAM_*`, `MQTT_*`, `SKS_*`, `DELEGATE_JOB_LOGS_DIR`, `HOME_DIR`, `CLAUDE_*`, `CODEX_HOME`, `PYTHONPATH`, `WORKSPACE_ROOT`, `MAM_ENV_FILE`, `MAM_JOBS_DIR`, `AGENT_SESSIONS_YAML`, the four `XDG_*` homes, and `AGENT_PYTHON_BIN` were dropped.
|
||||
|
||||
Head, cwd the snapshot: `tests/test_issue63_same_cwd_ids.py`, `test_issue40_auto_pin.py`, `test_issue64_ro_connect.py`, `test_issue10_13_purge.py`, `test_issue31_purge_residual.py`, `test_issue42_skill_blocks.py`, `test_tier1_unit.py`, `test_tier2_component.py`, `test_uuid_target.py`, `test_issue24_resume_dialogs.py`. **359 passed, 1 skipped** in 1301.73 s. Exit 0. The log closed at 2026-10-23 14:09:03. `.claude/skills` is absent in this snapshot, and `test_claude_skills_link_resolves_to_agents_skills` skips in that case (`test_issue42_skill_blocks.py:266`). That file is outside this delta. The same-name assertions are inside the two existing probe tests, so the pass count matches the previous head.
|
||||
|
||||
Base, this turn: a throwaway `git clone --shared` checked out at `dc886ecd6b38c01032a55a2d1c230c73362ae630`, with only `tests/test_issue63_same_cwd_ids.py` copied from head. `id_order.py` is absent there. **27 errors** in 0.11 s. Exit 1. Each error is `ImportError: cannot import name 'id_order' from 'lib_py.agents'` at setup of `no_herdr_probe`, including `test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_is_alive`, `test_id_order_real_probe_against_mock_herdr_fails_closed`, and `test_c4`. The snapshot was not checked out. Snapshot porcelain stayed empty.
|
||||
|
||||
The behavioral base result for this same base, on the test file from before that fixture imported `id_order`, remains the one recorded by job ba3eeb7b: **22 failed, 1 passed** in 181.15 s. The pass was `test_c4_pinned_post_discovered_and_preassigned_agents_unchanged`. On that run the second stop row received A's id, purge printed `purged:`, the unpinned resume carried A's id, and cap 0 still spawned. Those tests passed on this head inside the 359.
|
||||
|
||||
1. Stop order A→B and B→A, including herdr already dead, records only a verified own id. `test_c1_stop_same_cwd_agy_sessions_records_only_verified_owner` passed on head.
|
||||
2. Purge of B leaves A's files. `test_c2_purge_never_deletes_another_rows_conversation` passed on head.
|
||||
3. Resume of an unpinned B stays off A's conversation, and the already-running branch keeps the epoch. `test_c3_resume_unpinned_second_agy_row_starts_fresh_and_running_branch_keeps_epoch` passed on head.
|
||||
4. A row that already has an own id, and claude/grok, stay on the previous path. `test_c4_pinned_post_discovered_and_preassigned_agents_unchanged` passed on head, along with `test_issue40_auto_pin.py`, `test_issue64_ro_connect.py`, and `test_issue10_13_purge.py`.
|
||||
5. A same-cwd create waits while another post-discovered row of the same agent has no id. `test_c6_create_waits_or_refuses_while_same_cwd_post_discovered_row_has_no_id` passed on head. The orphan tests cover a dead owner, including one on the caller's own session name, whose herdr session is alive, unknown, or gone.
|
||||
6. Resume refuses before spawn when the resolved id belongs to another row. `test_c7_resume_refuses_when_recorded_id_is_claimed_by_another_row` passed on head.
|
||||
|
||||
## Blocking defects
|
||||
|
||||
None.
|
||||
|
||||
## Non-blocking
|
||||
|
||||
- While herdr keeps failing the lookup, the orphan marker stays, and every later create or resume that reaches the wait — a retry of the marker's own session name included — waits out `MAM_ID_ORDER_WAIT_S` again (`id_order.py:75-76`, `227-229`). The wait is bounded. Create and resume still return at `has-session` when that command already sees the name (`create_session.sh:168-170`, `resume_session.sh:158-169`). The cap message names `herdr kill-session`.
|
||||
- The probe holds the id-order lock until it returns. The budget is the time left on the deadline, and at most 30 seconds (`id_order.py:73`, `287-309`).
|
||||
- An empty `herdr_server` is probed on the `default` server (`id_order.py:67`). Create and resume export `HERDR_SESSION_NAME` before they write a new marker (`create_session.sh:185-186`, `resume_session.sh:111-114`). The cap line prints `default` for an empty server and leaves the server and session unquoted (`id_order.py:335-344`).
|
||||
- A successful agent list that is briefly empty while a pane is still registering would prune the marker. The tests cover a list that already contains the name, and a list that does not.
|
||||
- One remaining same-type row with herdr dead still uses `find_workspace_uuid` (`session_ids.py:274-275`).
|
||||
- A running row with an empty own id and a missing pane pid blocks until the cap (`id_order.py:128-131`). The unit test asserts that return of 1 (`test_issue63_same_cwd_ids.py:1150-1154`).
|
||||
- A marker whose owner pid has been reused by an unrelated live process stays until that process exits (`id_order.py:221-222`).
|
||||
- With herdr alive, a recorded id that fails revalidation is still returned (`session_ids.py:240-241`).
|
||||
- An unparsable `MAM_ID_ORDER_WAIT_S` becomes 600 (`id_order.py:36-41`).
|
||||
- `stop_session.sh:220` uses `eval "$(mam_session_ids stop ...)" || true`. A normal decision returns 0. A crashed helper leaves the captured fields empty and the stop continues.
|
||||
- `--no-onboard` waits out the cap. Presence-lock id discovery is not in this PR. Both are the deferrals named by the issue and the PR body.
|
||||
- `resolve_session_id.sh:4-5` still describes an empty line and exit 0. The script calls `mam_session_ids` under `set -euo pipefail` (line 10). The resume skill exit table documents exit 1 when another row claims the id.
|
||||
- Read-only connect residuals from #64 (immutable race, `mode=ro` sidecar rewrite, purge DELETE staying read-write) are outside this diff. `_ro_connect` is unchanged.
|
||||
|
||||
## Host check
|
||||
|
||||
No process has a first command token of `opencode` or a path ending in `/opencode`. `lsof` on `~/.local/share/opencode/opencode.db` exited 1. That database was not opened. Its mtime is 1792641027 and its size is 36712448. `opencode.log` is absent. `~/.config/opencode/service.json` mtime is 1792246838 and its size is 64. `~/.local/state/opencode/service.json` is absent. The herdr config mtime is 1784456394. `~/.local/state/mam` mtime is 1791810241. `~/.local/state/mam/id-order` is absent. These stats match the check taken before the suite.
|
||||
|
||||
Eight herdr processes were running immediately before the suite. After the suite log closed at 2026-10-23 14:09:03, the 14:09:37 check found seven. Two pids from the before-set were gone: one `/opt/homebrew/bin/herdr` whose session argument was `multi-agent-mux` (about 40 seconds old at suite start) and one whose session argument was `mam-pylecturenotes` (about 3 seconds old at suite start). The new process is `/opt/homebrew/bin/herdr` with `--session multi-agent-mux`, elapsed time about 54 seconds at 14:09:37, so it started near 14:08:43, during the last half-minute of the suite. No herdr command line contained the review basetemp, the snapshot path, or the test session names `probe-ws` and `orphan-ws`. This review did not start or stop a herdr server.
|
||||
|
||||
After this report file was written, the same check was repeated at 2026-10-23 14:11:38. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane porcelain is empty at `dc886ecd6b38c01032a55a2d1c230c73362ae630`. No process has a first command token of `opencode` or a path ending in `/opencode`, and `lsof` still reports no database holder. The database mtime is still 1792641027 and its size is still 36712448. `opencode.log` is still absent. The config service file mtime is still 1792246838. The state service file is still absent. The herdr config mtime is still 1784456394. `~/.local/state/mam` mtime is still 1791810241, and `id-order` is still absent. Seven herdr processes were present. The pid set differs from the 14:09:37 set by one homebrew herdr process whose arguments include `--session multi-agent-mux`; its elapsed time was about 54 seconds, so that process had turned over again. No herdr command line contained the review basetemp, the snapshot path, or `probe-ws` or `orphan-ws`. This review did not start or stop it.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261020T133532Z-a1684a",
|
||||
"base_sha": "ff8b381520ae04e80c73a9e18bfad73d47e853c9",
|
||||
"head_sha": "feccfd5e0356c8acf0438bc68bce7ea36ce2588e",
|
||||
"tree_sha": "0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a",
|
||||
"review_commit": "feccfd5e0356c8acf0438bc68bce7ea36ce2588e",
|
||||
"reviewer": "reviewer-grok-01",
|
||||
"job_id": "f02af611",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-20T15:51:16Z"
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# Review f02af611 — Issue #40
|
||||
|
||||
PASS. Commit `feccfd5` pins a post-discovered session id when the receiving side has verified `completed`, and agy no longer records another workspace's conversation. The four completion conditions fail on base `ff8b381` and pass on this head. Related tests: 76 passed.
|
||||
|
||||
Reviewed only the snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261020T133532Z-a1684a/1` (`loop-20261020T133532Z-a1684a`). Head `feccfd5e0356c8acf0438bc68bce7ea36ce2588e`, tree `0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a`, base `ff8b381520ae04e80c73a9e18bfad73d47e853c9` is an ancestor and is the control-plane HEAD. One commit, 19 files, +1128/−338. `git diff --check` is clean. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane porcelain is empty. No product file was edited. The source worktree was not run.
|
||||
|
||||
The commit is `feccfd5` `fix(agents): pin post-discovered session ids on a verified completed; agy never takes another workspace's or session's conversation (Issue #40)`.
|
||||
|
||||
Host tools: control-plane venv Python 3.9.6, sqlite 3.51.0, bash 3.2.57(1)-release. `bash -n` accepts `lib.sh`, `create_session.sh`, `reconcile.sh`, and `multi-agent-mux-delegate-job`. Linux was not executed here. The real agent databases were not opened.
|
||||
|
||||
## When a pin happens
|
||||
|
||||
Class A adapters (`agy`, `opencode`, `codex`, `hermes`) keep `id_lifecycle` `post_discovered`. Class B (`claude`, `grok`) return `pre_assigned`. The default on `BaseAgentAdapter` is `post_discovered`.
|
||||
|
||||
`capture_active_session_id` is read-only. The default (`base.py:384-400`) keeps a `discover()` candidate only when `verify_session_uuid` accepts it and no other row already records that id. Zero verified candidates is `no-candidate`. Two or more is `ambiguous`. Agy replaces that method (`agy.py:129`): `last_conversations.json` may propose the one id mapped to this cwd, `verify_session_uuid` must accept it, and a holder of that conversation's presence lock or database must be the row's pane process or a descendant (`agy.py:202-231`). A cache entry alone is not enough, because one cwd has one cache slot.
|
||||
|
||||
The write lives in `lib_py.agents.autopin`, which the agent process does not run. `handle_completed` runs only for a local job record whose status is `completed`. Remote transport, a `remote:` session, and a human job return `skipped`. A dispatch token passed by the caller must match the record. The pin key is `job_id:dispatch_token:attempt_id`, stored in `<registry>/<job>/autopin.json` under a flock on `autopin.lock`. A second call with the same key returns the stored result and does not capture again.
|
||||
|
||||
Callers:
|
||||
|
||||
- `delegate_autopin_completed` in `lib.sh` (the shell only launches `python -m lib_py.agents.autopin`).
|
||||
- The delegate-job wrapper, after `finalize_unfinished`, when `job_status` is `completed` (`multi-agent-mux-delegate-job` beside the audit-log echo).
|
||||
- `create_session.sh`, in the onboarding `completed` branch.
|
||||
- `LoopRunner.wait_for_job`, immediately after it observes `completed` (`runner.py:676-679`, `_autopin` at `:690`). This call does not pass `--expect-dispatch-token`; the marker key still uses the token stored on the record.
|
||||
|
||||
`pin_row` (`autopin.py:73-78`) sets the agent's own key, `session_id_source` `discovered`, `session_id_verified` true, and `last_visible_status` `pinned`, then the caller replaces that status with the resume dry-run result. A captured id is pinned when `verify_tui_viewport` returns 0, and also when it returns something other than 1 (the old degraded pin). Return 1 leaves the id empty and records `viewport-mismatch`. Any other capture status except `pre-assigned`, `already-set`, and `skipped` writes `last_visible_status` `autopin <status> (job <id>): <reason>` and still stores the marker, so that job is not tried again. The function's exit status stays 0 (`|| true` in the shell helper; `main` returns 0).
|
||||
|
||||
Reconcile drift C for agy, hermes, opencode, and codex calls the same `autopin.capture` (`reconcile.sh:795`) and the same `pin_row` (`:728`). Ambiguous capture sets `ambiguous: N candidates` and does not pin. A single `captured` id still goes through `verify_tui_viewport`. Claude drift C is unchanged and still scans its own jsonl directory.
|
||||
|
||||
## What changed, and what stayed
|
||||
|
||||
| File | Why |
|
||||
| --- | --- |
|
||||
| `lib_py/agents/base.py` | `PRE_ASSIGNED`, `POST_DISCOVERED`, `capture_result`, default `id_lifecycle`, default `capture_active_session_id` |
|
||||
| `lib_py/agents/autopin.py` | Receiving-side capture, pin, dedup marker, CLI |
|
||||
| `adapters/agy.py` | Discover mode rejects an id the cache maps to another cwd, and rejects a sibling id even when the cache names this cwd. Capture adds the pane-owner check |
|
||||
| `adapters/claude.py`, `adapters/grok.py` | `id_lifecycle` is `pre_assigned` |
|
||||
| `lib.sh`, delegate-job wrapper, `create_session.sh` | Call the handler after a verified `completed` |
|
||||
| `loop/runner.py` | Same handler after `wait_for_job` sees `completed` |
|
||||
| `reconcile.sh` | Class A drift C is the shared capture. The agy `*.db` glob and the hermes, opencode, and codex SQL in the shell are gone |
|
||||
| create and monitor `SKILL.md`, `docs/NEW_AGENT_INTEGRATION_GUIDE.md` | The new adapter fields and who writes the registry |
|
||||
| `tests/test_issue40_auto_pin.py` | The four completion conditions |
|
||||
| `test_issue64_ro_connect.py`, `test_issue60_linux_fixes.py`, `test_issue60_opencode_v2.py`, `test_tier1_unit.py`, `test_tier2_component.py` | Assertions that drift C now goes through `autopin.capture`, and the Issue #64 cross-workspace assert is now false |
|
||||
|
||||
`publish_event.py` and `publisher.py` have no autopin and no session-registry write. `test_call_sites_and_publisher_boundary` checks that and passed. Purge deletes stay read-write. Status still opens hermes, opencode, and codex with `_ro_connect`. Adapter queries still use `_ro_connect`. `reconcile.sh` contains neither `_ro_connect` nor `sqlite3.connect`; the adapters open the databases. `VERSIONS.md` is unchanged.
|
||||
|
||||
Stop's own capture path is unchanged. A new pin from this handler records `session_id_source: discovered` and `session_id_verified: true`. An id that stop recorded earlier can still disagree with those two fields. The issue comment asked for that stop cleanup only if it stayed small, and it is outside this diff.
|
||||
|
||||
`_pin_and_verify_resume` now records source and verified for claude too, because it calls `pin_row`. Claude's candidate scan is the same as before.
|
||||
|
||||
## Completion conditions
|
||||
|
||||
Pytest used the control-plane venv, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp` `/tmp/mamf02-f02af611-pybase` (head) and `/tmp/mamf02-f02af611-pybase-base` (base). Neither path contains `mam-review` or `shim`. Inherited `HERDR_*`, `MAM_*`, `MQTT_*`, `SKS_*`, `DELEGATE_JOB_LOGS_DIR`, `HOME_DIR`, `CLAUDE_PROJECT_DIR`, `CLAUDE_CONFIG_DIR`, `CLAUDE_CODE_TMPDIR`, `CODEX_HOME`, `PYTHONPATH`, `WORKSPACE_ROOT`, `MAM_ENV_FILE`, `MAM_JOBS_DIR`, `AGENT_SESSIONS_YAML`, and the four `XDG_*` homes were dropped. Sandbox tests point `HOME` and `HOME_DIR` at the temporary tree.
|
||||
|
||||
Head, cwd the snapshot: `tests/test_issue40_auto_pin.py`, `tests/test_issue64_ro_connect.py`, `test_status_and_reconcile_open_the_database_read_only`, `test_status_and_reconcile_read_session_v2`, `test_opencode_shell_and_scripts_integration`, `test_comp_opencode_create_and_reconcile_and_stop`, and `test_comp_codex_create_and_reconcile_and_stop`. 76 passed in 130.63 s. Exit 0. No skip and no error.
|
||||
|
||||
Base proof is a throwaway `git clone --shared` checked out at `ff8b381`, with `tests/test_issue40_auto_pin.py` and `tests/test_issue64_ro_connect.py` taken from `feccfd5`. The snapshot was not checked out. Result: 15 failed, 13 errors in 19.80 s. Exit 1.
|
||||
|
||||
1. Another workspace's agy id is not recorded. On base, `test_agy_verify_rejects_id_cached_for_another_workspace` got `True` from `verify_session_uuid`. `test_reconcile_does_not_pin_another_workspaces_agy_conversation` found workspace B's id written on A's row. `test_unpinned_agy_reconcile_leaves_other_workspace_clean_db_without_sidecars` still asserted the old `True`. On head all three pass, and the directory stays the one database file.
|
||||
2. The first completed job pins the session's own id with `MAM_NO_SUBSCRIBER_DAEMON=1`. On base, `test_completed_onboarding_pins_agy_without_daemon` errors because `_holder_pids` is absent, the three `test_completed_first_job_pins_opencode_codex_hermes` cases cannot import `autopin`, `test_reconcile_pins_agy_owned_by_the_pane` leaves the id empty, `delegate_autopin_completed` is not a command, and `test_create_onboard_pins_without_daemon` finds no `autopin.json`. On head, agy pins the id the cache maps to A's cwd when A's pane holds it, and opencode, codex, and hermes pin the session in their own workspace. The lib.sh helper, the loop recovery, and `create --onboard` do the same.
|
||||
3. One pin per job, and ambiguity pins nothing. On base, `test_wrapper_and_loop_recovery_pin_once` and `test_ambiguous_candidates_leave_reason_without_pin` cannot import `autopin`, and `test_loop_recovery_pins_when_the_wrapper_did_not` leaves the id empty. On head the wrapper call and the loop call share one marker, the yaml mtime does not move the second time, and two opencode sessions in one cwd leave `autopin ambiguous (job amb): 2 candidates` with the own id empty.
|
||||
4. Pre-assigned rows and rows that already have an id stay as they were. On base, `test_preassigned_and_existing_ids_are_untouched` errors in the owner fixture, and `test_call_sites_and_publisher_boundary` does not find the wrapper call. On head the claude, grok, already-pinned agy, and already-set opencode rows compare equal before and after, and remote, human, error, and still-running jobs return `skipped` without a marker. A stale `--expect-dispatch-token` also writes no marker.
|
||||
|
||||
## Remaining risk
|
||||
|
||||
Agy will not pin from the cache unless `lsof` shows the presence lock or the database held by the pane process or a descendant. No holder, a missing `lsof`, or a pane pid of 0 or 1 is `unverifiable-owner`. That result is stored on the job marker, so the loop recovery does not try the same job again. Reconcile does not read the marker and will try on a later cycle. A worktree creator has no monitor, so a completed turn whose files are already closed stays unpinned until a later completed job. The tests stub `lsof` and `ps`; a live agy process was not used.
|
||||
|
||||
`claimed_elsewhere` treats every own id on every other row, including a stopped row, as taken. The old drift C list was the live same-cwd siblings. An id that still sits on a stopped row is not given to a new row.
|
||||
|
||||
Codex drift C now uses `discover()` plus `verify_artifact`, so the epoch check is the rollout file mtime. The removed SQL compared the state-db timestamp. Hermes and opencode still filter cwd and epoch in `discover()`, and opencode still requires `parent_id IS NULL`.
|
||||
|
||||
The resume dry-run and the viewport check run while `atomic_dump_yaml` holds the registry lock, which is the same arrangement the old `_pin_and_verify_resume` used. `resume_session.sh --dry-run` returns before `update_yaml_resumed.sh` when herdr already has the session. `verify_tui_viewport` reads the pane and does not take that lock.
|
||||
|
||||
A `mode=ro` open that already has both sidecars can still rewrite `-shm` bytes, and the immutable open can miss a wal created after the exists check. Those are the Issue #64 residuals. This commit removes the reconcile path that opened every agy database.
|
||||
|
||||
The user's background service stayed up. Its pid is 23630 and the command is `/Users/godopu16/.opencode/bin/opencode serve --service`. The real database mtime is 1792420196 and its size is 36712448. `~/.config/opencode/service.json` mtime is 1792246838. `~/.local/state/opencode/service.json` mtime is 1792365227. The real `opencode.log` mtime is 1792420391 and its size is 2699345. The herdr config mtime is 1784456394.
|
||||
|
||||
After the report, snapshot porcelain is still empty, the snapshot still has no `.mam`, and control-plane porcelain is still empty at `ff8b381520ae04e80c73a9e18bfad73d47e853c9`. The herdr config mtime is still 1784456394. The opencode database mtime is still 1792420196 and its size is still 36712448. The opencode log mtime is still 1792420391 and its size is still 2699345. Service pid 23630 is still `opencode serve --service`.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261026T133440Z-c8961b",
|
||||
"base_sha": "29cc4fd90478936f15387a7665e2c95d13a3f14c",
|
||||
"head_sha": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
|
||||
"tree_sha": "98d57bdc60920238c115ece90c90cc694f2744bc",
|
||||
"review_commit": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
|
||||
"reviewer": "reviewer-grok-01",
|
||||
"job_id": "fd90992c",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-26T17:32:11Z"
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
# Review: PR #73 / Issue #71 (macOS, snapshot 2)
|
||||
|
||||
Job fd90992c. Reviewer only. Checks ran on the review snapshot. The source worktree and the control-plane checkout were not edited.
|
||||
|
||||
## Identity
|
||||
|
||||
- Snapshot: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2`
|
||||
- Loop: `loop-20261026T133440Z-c8961b`
|
||||
- Base: `29cc4fd90478936f15387a7665e2c95d13a3f14c` (main, v4.10.1)
|
||||
- Head: `5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26`
|
||||
- Tree: `98d57bdc60920238c115ece90c90cc694f2744bc`
|
||||
- 14 commits, base is an ancestor, porcelain 0, no `.mam` in the snapshot, `git diff --check` clean
|
||||
- Diff: 28 files, +953/−176
|
||||
- Host: `/usr/bin/python3` 3.9.6, bash 3.2.57(1)-release, sqlite 3.51.0, `LANG=en_US.UTF-8`
|
||||
|
||||
`tests/test_issue66_stall_watch_sanitized_name.py` has an empty diff against `29cc4fd`. Commit `0fc162e` is an ancestor. The diff file list has no `multi-agent-mux-update` skill. `MAM_VERSION` stays `4.10.1`.
|
||||
|
||||
Issue #71, the "범위 정리" comment (items 1–10), and PR #73 were read. The registry entry point is documented for #72 and is not wired to an update skill.
|
||||
|
||||
## What was checked
|
||||
|
||||
### attach_command
|
||||
|
||||
`lib_py/agents/sanitize.py:34` builds `herdr --session <server> agent attach <sanitized name>`. An empty server becomes `default`, and both the server and the name go through `shlex.quote`, so `default` is written as `--session default` and a server that needs quotes is quoted.
|
||||
|
||||
The three writers call it:
|
||||
|
||||
- create, `create_session.sh:566`
|
||||
- resume, `update_yaml_resumed.sh:151` (new row) and `:161` (existing row whose server is set)
|
||||
- reconcile, `reconcile.sh:676`, on the row whose note is `auto-registered by monitor`
|
||||
|
||||
The shim `agent attach` (`lib.sh:605-613`) resolves the target with `_resolve_herdr_target` and runs `_real_herdr agent attach "$tgt" "$@"`. Options after the name stay in `"$@"`.
|
||||
|
||||
### Removed fields and the stop hints
|
||||
|
||||
`kill_command` and `start_command` remain only as fields the migration pops and as assertions that they are absent. `status.sh` no longer emits `start_command`. The shim still has the fake `new-session` and `kill-session` verbs, which create, stop, and reconcile use. `kill-session` sanitizes the name and exits 1 when that fails.
|
||||
|
||||
Create exit 43 (`create_session.sh:364`) and `id_order.py:330` and `:344` tell the user to run `stop_session.sh --session <name> --herdr-session <server>`. The row-less tests below run that script.
|
||||
|
||||
### Row-less stop
|
||||
|
||||
`stop_rowless` (`stop_session.sh:123`) runs only when the row lookup exits 7 (`:189`). A strict read failure exits 1 before that (`:170`). `--purge-conversation` with no row exits 1 (`:124`). A cwd outside this workspace exits 5. A session that survives `kill-session` exits 6. Those codes match `multi-agent-mux-stop/SKILL.md:58-66`.
|
||||
|
||||
Server order: `--herdr-session`, else a non-default `HERDR_SESSION_NAME` captured before resolve (`:109`, `:128`), else the workspace server. `lib.sh` stamps an unset name as `default`, and the row-less path treats that stamp as unset.
|
||||
|
||||
The cwd check realpaths both sides and accepts the workspace itself or a child (`root + os.sep`). That rejects a sibling prefix and a symlink whose target is outside the workspace, and it treats a trailing slash as the same directory. `/tmp` and `/private/tmp` are the same path on this Mac. The row-less tests compare registry bytes before and after and passed.
|
||||
|
||||
`load_state_json strict` (`lib.sh:1594`) is the bash reader. A missing `state` or `sessions` table raises in strict mode and the function exits 1. The argument-free call still prints `{}`.
|
||||
|
||||
### One name rule
|
||||
|
||||
`lib.sh` has one `_sanitize_herdr_agent_name` (`:44`). `00000000` and `lower()[:32]` are absent. The shim receives the function through `declare -f` (`:181`).
|
||||
|
||||
The function was extracted with `sed` and run under `/bin/bash` 3.2 without sourcing `lib.sh`. Against `sanitize_herdr_agent_name` there were 0 mismatches under `LC_ALL=C` and `LC_ALL=en_US.UTF-8` for empty, `Upper-Case`, `-n`, `-e`, a 33-character name, the long branch name, `İstanbul-creator-claude`, a newline, `a\nb`, café, the kelvin sign, Japanese, Korean `한글`, and `A B`. Korean is two characters; the non-ASCII path uses `python3` `str.lower()` and the same regex, so the replacement follows characters, and the result matched Python.
|
||||
|
||||
With `PATH` limited to `tr`, `sed`, `awk`, and `cut`, the long name exits 1 with `no shasum, sha1sum, openssl or python3 to hash it` and prints no name. `한글` exits 1 with `not ASCII`.
|
||||
|
||||
`/usr/bin/python3` is 3.9.6. The diff adds no `mapfile`, `${var,,}`, `declare -A`, `match`, or `tomllib`. A throwaway SQLite database opened with `PRAGMA journal_mode=WAL`, written, and closed still had a `-wal` (0 bytes) and a `-shm` (32768 bytes).
|
||||
|
||||
### Registry format 2
|
||||
|
||||
`atomic_yaml.py:118-137` migrates under the lock, after `BEGIN IMMEDIATE` and before the caller mutation, when `stored_version < 2`. Migration sets `force_yaml`, so YAML is rewritten even if the mutation changes no session. If the DB is already current and the YAML copy is still older, the next write rewrites the YAML. `migrate` (`registry_format.py:50-66`) updates every named row, including stopped and terminated, and sets `registry_format` to 2. A version already greater than 2 is not less than `CURRENT`, so the writer does not call `migrate`.
|
||||
|
||||
The entry point `python -m lib_py.registry_format --yaml <path>` prints `registry-format: <from> -> <to>, rows changed: <n>` and exits 0, or `registry-format: error:` and a nonzero status when neither file exists. The only `yaml.safe_dump` under `.agents/skills` is the writer. Readers (`lib_py/state.py` and the argument-free bash loader) do not migrate.
|
||||
|
||||
### stall_watch and the edited tests
|
||||
|
||||
`stall_watch.py:229` computes `self.herdr_name` once and passes it into `HerdrSource`. The issue #66 test file is unchanged and is in the passing run.
|
||||
|
||||
Edited assertions keep the old intent: pane-lock uses `session_id_source` (create sets it at `create_session.sh:579`) to tell a create row from a monitor row; the worktree test looks for a leaked `WORKSPACE_ROOT=` in `attach_command`; tier 2 checks the new attach string and the absence of the two dropped keys; tier 1 and tier 3 plant a row with no agent so "cannot infer agent" still exits 2. `skill_code_blocks.json` moved with the docs, and `test_issue42_skill_blocks.py` expects stop exits `0` through `6`. That file passed aside from the skills-link skip.
|
||||
|
||||
S11 (`test_issue23_24_startup_path.py:306-310`) compares the shim with `0fc162e`. That commit is in this checkout, so the comparison ran as part of the passing file. If the object is missing, the test calls `pytest.skip` with a message that names the commit. That skip is intentional: the PR says a squash would drop `0fc162e`, and it asks for a merge commit.
|
||||
|
||||
The reconcile tests plant a mock pane (`test_issue71_herdr_names.py:127-140`) and require one new row. They passed, so registration ran.
|
||||
|
||||
## Tests
|
||||
|
||||
Pytest was `PYTHONDONTWRITEBYTECODE=1` with the control-plane venv, `-p no:cacheprovider`, and `--basetemp=/tmp/mamfd9-fd90992c-pytest`. Inherited `HERDR_*`, `MAM_*`, `MQTT_*`, `WORKSPACE_ROOT`, `DELEGATE_JOB_LOGS_DIR`, `PYTHONPATH`, the four `XDG_*` homes, and `AGENT_SESSIONS_YAML` were unset. `AGENT_PYTHON_BIN` pointed at the control-plane venv. The snapshot was the working directory and was not checked out.
|
||||
|
||||
Head, the files named in the brief plus `test_b19_headless_reconcile_fixes.py`, `test_b4_session_created.py`, and `test_issue30_stall_watch.py`: **546 passed, 2 skipped** in 1458.68s, exit 0. `tests/test_issue71_herdr_names.py` collects 70 tests and they are in that pass. The two skips, confirmed with `-rs` on those two tests, are `test_claude_skills_link_resolves_to_agents_skills` (`.claude/skills` absent, `test_issue42_skill_blocks.py:266`) and `test_s6_the_heartbeat_timer_is_not_a_background_child` (Linux `/proc`, `test_issue30_stall_watch.py:340`).
|
||||
|
||||
Base: a `--shared` clone at `/tmp/mamfd9-fd90992c-base`, detached at `29cc4fd`, with only this test file copied in. Same pytest flags, `--basetemp=/tmp/mamfd9-fd90992c-base-pytest`. **68 failed, 2 passed** in 98.42s, exit 1. The two passes are `test_the_names_need_converting_and_the_plain_one_does_not` and the plain-name `test_wrapper_attach_calls_herdr_with_the_herdr_name`. The long and uppercase wrapper cases failed.
|
||||
|
||||
## Residuals (non-blocking)
|
||||
|
||||
- Readers stay read-only. An old registry keeps a format-1 `attach_command` until the next writer, or until `python -m lib_py.registry_format` runs. The module docstring describes that window. The explicit entry point is left for #72.
|
||||
- `VERSIONS.md` is unchanged. The release sentence from the scope comment is in the PR body.
|
||||
- `migrate` skips a row with no `name` (`registry_format.py:57-58`), so that row can keep `kill_command` after the format key becomes 2.
|
||||
- `lib_py/state.py` `load_state_json` still returns `{}` on a read error. Stop uses the bash strict reader.
|
||||
- The SKILL.md exit-0 cell describes a stop or an already-stopped no-op. The pre-flight paragraph and the script header also describe the row-less close as exit 0. The pre-flight sentence names `--herdr-session` and `HERDR_SESSION_NAME`; the code also uses the workspace server when both are absent.
|
||||
- A direct call to `migrate` on a format already greater than 2 would write the key back to 2. The writer does not call it in that case.
|
||||
- S11 skips only when `0fc162e` cannot be resolved. A squash merge would take that path. The PR forbids squash for that reason.
|
||||
|
||||
## Host
|
||||
|
||||
Checked before the suite at 2026-10-27 01:43:34 and again after it at 02:08:54. No process whose first command token was `opencode`. `lsof` of the user opencode database returned exit 1. Database mtime 1792641027, size 36712448. `opencode.log` absent. Config `service.json` mtime 1792246838, size 64. State `service.json` absent. Herdr config mtime 1784456394, size 19. `~/.local/state/mam` directory mtime 1792846843, size 128. `id-order` is a directory, mtime 1792846886, size 128; its entries were not listed. The same two herdr servers were present before and after: pid 11655 (`/opt/homebrew/bin/herdr server`) and pid 69309 (`/opt/homebrew/bin/herdr --session multi-agent-mux server`). No server argv contained the scratch prefix, the snapshot path, or the loop id. The database was not opened.
|
||||
|
||||
After the suite the snapshot was still head `5f60653`, tree `98d57bdc`, porcelain 0, no `.mam`. The control-plane checkout was still `29cc4fd`, porcelain 0.
|
||||
|
||||
Rechecked after this report was written, at 2026-10-27 02:09:37. The snapshot was still head `5f60653`, tree `98d57bdc`, porcelain 0, with no `.mam`. The control plane was still `29cc4fd`, porcelain 0. Opencode first-token count 0, database `lsof` exit 1, and the database, log, service files, herdr config, `~/.local/state/mam`, and `id-order` mtimes and sizes were the same as the pre-suite reading. Herdr servers were still pid 11655 and pid 69309. No server argv contained the scratch prefix, the snapshot path, or the loop id.
|
||||
|
||||
[VERDICT: PASS]
|
||||
+254
-71
@@ -29,33 +29,54 @@ 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.12.0"
|
||||
export MAM_VERSION
|
||||
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
|
||||
|
||||
# The one bash form of lib_py.agents.sanitize.sanitize_herdr_agent_name
|
||||
# (Issue #71). The herdr shim gets this same definition: _init_herdr_isolation
|
||||
# writes it in with `declare -f`. A long name needs its SHA-1 suffix; with no
|
||||
# shasum, sha1sum, openssl or python3 to make it, this fails (stderr, nonzero)
|
||||
# rather than return a name herdr never registered. A name that is not ASCII
|
||||
# is lowercased and replaced by python3 (tr/sed differ from str.lower() there),
|
||||
# and fails the same way without it. Line breaks become '-' before sed (which
|
||||
# works line by line) and before $(...) could drop a trailing one.
|
||||
_sanitize_herdr_agent_name() {
|
||||
local n="$1"
|
||||
if [ -z "$n" ]; then
|
||||
printf 'agent\n'
|
||||
return 0
|
||||
fi
|
||||
local s
|
||||
s=$(echo "$n" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g')
|
||||
local s=""
|
||||
if [ -z "$(printf '%s' "$n" | LC_ALL=C tr -d '\000-\177')" ]; then
|
||||
s=$(printf '%s' "$n" | tr '\n' '-' | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g')
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
s=$(python3 -c 'import re,sys; sys.stdout.write(re.sub(r"[^a-z0-9_-]", "-", sys.argv[1].lower()))' "$n") || s=""
|
||||
fi
|
||||
if [ -z "$s" ]; then
|
||||
echo "ERROR: cannot make the herdr name of '$n': it is not ASCII and there is no python3 to convert it" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! "$s" =~ ^[a-z] ]]; then s="x-$s"; fi
|
||||
if [ "${#s}" -gt 32 ]; then
|
||||
local h=""
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
h=$(printf '%s' "$s" | shasum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
|
||||
elif command -v sha1sum >/dev/null 2>&1; then
|
||||
fi
|
||||
if [ "${#h}" -ne 8 ] && command -v sha1sum >/dev/null 2>&1; then
|
||||
h=$(printf '%s' "$s" | sha1sum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
fi
|
||||
if [ "${#h}" -ne 8 ] && command -v openssl >/dev/null 2>&1; then
|
||||
h=$(printf '%s' "$s" | openssl sha1 2>/dev/null | awk '{print $NF}' | cut -c 1-8)
|
||||
fi
|
||||
if [ -n "$h" ]; then
|
||||
s="${s:0:23}-$h"
|
||||
else
|
||||
s="${s:0:32}"
|
||||
if [ "${#h}" -ne 8 ] && command -v python3 >/dev/null 2>&1; then
|
||||
h=$(python3 -c 'import hashlib,sys; print(hashlib.sha1(sys.argv[1].encode("utf-8")).hexdigest()[:8])' "$s" 2>/dev/null || true)
|
||||
fi
|
||||
if [ "${#h}" -ne 8 ]; then
|
||||
echo "ERROR: cannot make the herdr name of '$n': no shasum, sha1sum, openssl or python3 to hash it" >&2
|
||||
return 1
|
||||
fi
|
||||
s="${s:0:23}-${h}"
|
||||
fi
|
||||
printf '%s\n' "$s"
|
||||
}
|
||||
@@ -155,8 +176,10 @@ _init_herdr_isolation() {
|
||||
fi
|
||||
|
||||
local tmp_file="$wrapper_dir/herdr.tmp.$$.$RANDOM"
|
||||
cat <<'EOF' > "$tmp_file"
|
||||
#!/usr/bin/env bash
|
||||
{
|
||||
printf '%s\n' '#!/usr/bin/env bash'
|
||||
declare -f _sanitize_herdr_agent_name
|
||||
cat <<'EOF'
|
||||
# Herdr-to-Herdr translation shim wrapper
|
||||
set -euo pipefail
|
||||
|
||||
@@ -290,31 +313,6 @@ _real_herdr() {
|
||||
fi
|
||||
}
|
||||
|
||||
_sanitize_herdr_agent_name() {
|
||||
local n="$1"
|
||||
if [ -z "$n" ]; then
|
||||
printf 'agent\n'
|
||||
return 0
|
||||
fi
|
||||
local s
|
||||
s=$(echo "$n" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g')
|
||||
if [[ ! "$s" =~ ^[a-z] ]]; then s="x-$s"; fi
|
||||
if [ "${#s}" -gt 32 ]; then
|
||||
local h=""
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
h=$(printf '%s' "$s" | shasum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
|
||||
elif command -v sha1sum >/dev/null 2>&1; then
|
||||
h=$(printf '%s' "$s" | sha1sum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
h=$(printf '%s' "$s" | openssl sha1 2>/dev/null | awk '{print $NF}' | cut -c 1-8)
|
||||
else
|
||||
h=$(python3 -c "import hashlib,sys; print(hashlib.sha1(sys.argv[1].encode()).hexdigest()[:8])" "$s" 2>/dev/null || echo "00000000")
|
||||
fi
|
||||
s="${s:0:23}-${h}"
|
||||
fi
|
||||
printf '%s\n' "${s:0:32}"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workspace scoping (ISSUE-3).
|
||||
#
|
||||
@@ -441,7 +439,7 @@ sys.exit(1)
|
||||
[ -n "$resolved_ws" ] && target_ws="$resolved_ws"
|
||||
fi
|
||||
local sat pid=""
|
||||
sat=$(_sanitize_herdr_agent_name "$target")
|
||||
sat=$(_sanitize_herdr_agent_name "$target") || return 1
|
||||
|
||||
local cand get_ws
|
||||
if [ "$#" -ge 2 ]; then
|
||||
@@ -534,7 +532,7 @@ case "$cmd" in
|
||||
_resolve_herdr_target() {
|
||||
local raw="$1"
|
||||
local sat
|
||||
sat=$(_sanitize_herdr_agent_name "$raw")
|
||||
sat=$(_sanitize_herdr_agent_name "$raw") || return 1
|
||||
if [ -n "$(_herdr_agent_get_scoped "$sat" 2>/dev/null || true)" ]; then
|
||||
echo "$sat"
|
||||
return 0
|
||||
@@ -604,6 +602,15 @@ sys.exit(1)
|
||||
exit 1
|
||||
fi
|
||||
_real_herdr agent read "$tgt" "$@"
|
||||
elif [ "$sub" = "attach" ] && [ $# -ge 1 ]; then
|
||||
t="$1"
|
||||
shift
|
||||
tgt=$(_resolve_herdr_target "$t" || true)
|
||||
if [ -z "$tgt" ]; then
|
||||
echo "Error: no in-scope herdr agent for '$t'" >&2
|
||||
exit 1
|
||||
fi
|
||||
_real_herdr agent attach "$tgt" "$@"
|
||||
else
|
||||
_real_herdr agent "$sub" "$@"
|
||||
fi
|
||||
@@ -623,19 +630,15 @@ sys.exit(1)
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$(_herdr_agent_get_scoped "$(_sanitize_herdr_agent_name "$sess")" 2>/dev/null || true)" ] \
|
||||
ssess=$(_sanitize_herdr_agent_name "$sess") || exit 1
|
||||
if [ -n "$(_herdr_agent_get_scoped "$ssess" 2>/dev/null || true)" ] \
|
||||
|| [ -n "$(_herdr_agent_get_scoped "$sess" 2>/dev/null || true)" ]; then
|
||||
exit 0
|
||||
fi
|
||||
if _real_herdr agent list 2>/dev/null | TARGET_NAME="$sess" TARGET_WS="$(_herdr_ws_scope)" python3 -c '
|
||||
if _real_herdr agent list 2>/dev/null | TARGET_NAME="$sess" TARGET_SAN="$ssess" TARGET_WS="$(_herdr_ws_scope)" python3 -c '
|
||||
import sys, json, os
|
||||
try:
|
||||
sys.path.insert(0, os.path.abspath(".agents/skills"))
|
||||
from lib_py.agents.sanitize import sanitize_herdr_agent_name
|
||||
except Exception:
|
||||
sanitize_herdr_agent_name = lambda s: s.lower()[:32] if s else "agent"
|
||||
tn = os.environ.get("TARGET_NAME", "")
|
||||
stn = sanitize_herdr_agent_name(tn)
|
||||
stn = os.environ.get("TARGET_SAN", "")
|
||||
tws = os.environ.get("TARGET_WS", "")
|
||||
try:
|
||||
d = json.loads(sys.stdin.read())
|
||||
@@ -682,6 +685,7 @@ sys.exit(1)
|
||||
*) run_cmd="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
agent_name=$(_sanitize_herdr_agent_name "$name") || exit 1
|
||||
if [ -z "$run_cmd" ]; then
|
||||
run_cmd="${SHELL:-/bin/bash}"
|
||||
fi
|
||||
@@ -896,7 +900,6 @@ except Exception:
|
||||
res=""
|
||||
success=0
|
||||
backoffs=(0.5 1 2)
|
||||
agent_name=$(_sanitize_herdr_agent_name "$name")
|
||||
for i in $(seq 0 2); do
|
||||
if [ -n "$final_cmd" ]; then
|
||||
res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"$kind\" --pane \"$target_pane\" -- $final_cmd" 2>&1 || true)
|
||||
@@ -967,7 +970,7 @@ except Exception:
|
||||
# `herdr session list`), not individual agent panes — `session stop/delete
|
||||
# "$sess"` with a MAM session name always fails (silently, via `|| true`).
|
||||
# Resolve the real pane_id via `agent get` and close just that pane instead.
|
||||
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
||||
agent_target=$(_sanitize_herdr_agent_name "$sess") || exit 1
|
||||
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
|
||||
if [ -n "$pane_id" ]; then
|
||||
_real_herdr pane close "$pane_id" >/dev/null 2>&1 || true
|
||||
@@ -1002,7 +1005,7 @@ except Exception:
|
||||
# there is no top-level "pane" key, and no "pid" field at all (only
|
||||
# "pane_id", herdr's own wN:pN identifier). The real OS pid requires a
|
||||
# second call to `pane process-info --pane <pane_id>`.
|
||||
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
||||
agent_target=$(_sanitize_herdr_agent_name "$sess") || exit 1
|
||||
info=$(_real_herdr agent get "$agent_target" 2>/dev/null || _real_herdr agent get "$sess" 2>/dev/null || true)
|
||||
pane_id="" cwd="" cmd=""
|
||||
if [ -n "$info" ]; then
|
||||
@@ -1072,7 +1075,7 @@ except Exception:
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
||||
agent_target=$(_sanitize_herdr_agent_name "$sess") || exit 1
|
||||
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
|
||||
if [ -n "$pane_id" ]; then
|
||||
_real_herdr pane read "$pane_id" --source visible --lines 100 2>/dev/null \
|
||||
@@ -1103,7 +1106,7 @@ except Exception:
|
||||
fi
|
||||
# `pane send-keys` requires a real pane_id ("wN:pN"), not an agent name —
|
||||
# resolve it via `agent get` first (agent-level commands accept names).
|
||||
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
||||
agent_target=$(_sanitize_herdr_agent_name "$sess") || exit 1
|
||||
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
|
||||
if [ -n "$pane_id" ]; then
|
||||
_real_herdr pane send-keys "$pane_id" "$key" >/dev/null 2>&1 || true
|
||||
@@ -1387,6 +1390,7 @@ for name, pid in rows:
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
} > "$tmp_file"
|
||||
chmod +x "$tmp_file" 2>/dev/null || true
|
||||
mv -f "$tmp_file" "$wrapper_dir/herdr" 2>/dev/null || rm -f "$tmp_file" 2>/dev/null || true
|
||||
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
|
||||
@@ -1587,10 +1591,14 @@ herdr() {
|
||||
# ---------------------------------------------------------------------------
|
||||
# NOTE: Retained as small inline PYEOF block (39 lines) per Plan Rev.2 (Job 98393a97).
|
||||
# Small blocks have minimal overhead and high local cohesion; extraction into lib_py is reserved for large blocks (>200 lines).
|
||||
# `load_state_json strict` (Issue #71) fails (stderr, exit 1) when the YAML or
|
||||
# DB cannot be read, a DB table missing included, instead of printing {} - for
|
||||
# a caller that acts on a session being absent.
|
||||
load_state_json() {
|
||||
env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
||||
env_python "$AGENT_SESSIONS_YAML" "MAM_STATE_READ=${1:-}" <<'PYEOF'
|
||||
import os, sys, sqlite3, json, yaml
|
||||
yaml_path = os.environ['YAML_PATH']
|
||||
strict = os.environ.get('MAM_STATE_READ') == 'strict'
|
||||
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
||||
d = {}
|
||||
db_sessions = []
|
||||
@@ -1603,20 +1611,24 @@ try:
|
||||
if row:
|
||||
d = json.loads(row[0])
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
if strict:
|
||||
raise
|
||||
try:
|
||||
cursor = conn.execute('SELECT data FROM sessions')
|
||||
for r in cursor.fetchall():
|
||||
db_sessions.append(json.loads(r[0]))
|
||||
d['herdr_sessions'] = db_sessions
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
if strict:
|
||||
raise
|
||||
conn.close()
|
||||
elif os.path.exists(yaml_path):
|
||||
with open(yaml_path) as f:
|
||||
d = yaml.safe_load(f) or {}
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
if strict:
|
||||
print('ERROR: cannot read the registry %s: %s' % (yaml_path, e), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
# Clean lone surrogates to prevent UnicodeEncodeError on stdout print
|
||||
def clean_surrogates(obj):
|
||||
if isinstance(obj, str):
|
||||
@@ -1659,6 +1671,22 @@ print(resolved)
|
||||
"
|
||||
}
|
||||
|
||||
# session_attach_command <session_name> [server]
|
||||
#
|
||||
# Issue #72: the attach hint a script prints - the session row's
|
||||
# attach_command, else the one lib_py.agents.sanitize.attach_command builds for
|
||||
# <server> (default: `default`).
|
||||
session_attach_command() {
|
||||
MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$1" ATTACH_SERVER="${2:-default}" python3 -c "
|
||||
import os, json
|
||||
from lib_py.agents.sanitize import attach_command
|
||||
name = os.environ['SESSION_NAME']
|
||||
d = json.loads(os.environ.get('MAM_STATE_JSON') or '{}')
|
||||
row = next((s for s in d.get('herdr_sessions', []) if s.get('name') == name), {})
|
||||
print(row.get('attach_command') or attach_command(os.environ['ATTACH_SERVER'], name))
|
||||
"
|
||||
}
|
||||
|
||||
# resolve_herdr_session <session_name> [workspace]
|
||||
#
|
||||
# returns the isolated herdr *session* name (socket/daemon) to use for this MAM session row,
|
||||
@@ -2024,18 +2052,31 @@ PYEOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# capture_conversation_id <agent> <workdir> [session_name]
|
||||
#
|
||||
# Thin wrapper over find_workspace_uuid: resolves THIS workspace's conversation
|
||||
# id (claude jsonl sessionId / agy db uuid) and prints it on stdout (empty line
|
||||
# if none). find_workspace_uuid is already a workspace-scoped, 2-tier, race-free
|
||||
# resolver (per-row own id -> workspace-scoped disk scan),
|
||||
# so recording its result into the row before kill guarantees tier-1 on the next
|
||||
# resume. Pass session_name to prefer that specific row's recorded id. Always exits 0.
|
||||
# mam_session_ids <stop|resume> [args...]
|
||||
# Issue #63: stop/purge/resume conversation-id decision (lib_py.agents.session_ids).
|
||||
# ---------------------------------------------------------------------------
|
||||
capture_conversation_id() {
|
||||
local agent="$1" workdir="$2" session_name="${3:-}"
|
||||
find_workspace_uuid "$workdir" "$agent" "$session_name"
|
||||
mam_session_ids() {
|
||||
env_python "$AGENT_SESSIONS_YAML" "$@" <<'PYEOF'
|
||||
import sys
|
||||
from lib_py.agents.session_ids import main
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mam_id_order_wait <workspace> <agent> <session> [uuid]
|
||||
# Issue #63: same-cwd creation/resume ordering for post-discovered agents.
|
||||
# ---------------------------------------------------------------------------
|
||||
mam_id_order_wait() {
|
||||
local workspace="$1" agent="$2" session_name="$3" uuid="${4:-}"
|
||||
local abs; abs="$(mam_abs_workspace "$workspace")"
|
||||
env_python "$AGENT_SESSIONS_YAML" wait --cwd "$abs" --agent "$agent" \
|
||||
--session "$session_name" --owner-pid "$$" --uuid "$uuid" \
|
||||
--herdr-server "${HERDR_SESSION_NAME:-}" <<'PYEOF'
|
||||
import sys
|
||||
from lib_py.agents.id_order import main
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2194,6 +2235,41 @@ _delegate_publish_event_legacy() {
|
||||
"$py_bin" "$pub" --job "$job_id" --event "$event" --detail "$detail" ${extra[@]+"${extra[@]}"} || true
|
||||
}
|
||||
|
||||
# _monitor_pids <monitor_script>
|
||||
# Issue #72: PIDs whose command line is exactly `<bash> <monitor_script>
|
||||
# --subscribe[ ...]` - the shell twin of lib_py.delegation.daemon.find_running,
|
||||
# so both start_watchdog branches recognise the same daemons. Not a `pgrep -f`
|
||||
# regex: that also matched another workspace's daemon (an unescaped `.`) and a
|
||||
# process that only names the path in an argument.
|
||||
_monitor_pids() {
|
||||
ps -A -ww -o pid= -o args= | MAM_MONITOR_WANT="$1 --subscribe" awk '
|
||||
{ line = $0; sub(/^ +/, "", line)
|
||||
i = index(line, " "); if (!i) next
|
||||
pid = substr(line, 1, i - 1); args = substr(line, i + 1)
|
||||
j = index(args, " "); if (!j) next
|
||||
n = split(substr(args, 1, j - 1), a0, "/"); rest = substr(args, j + 1)
|
||||
want = ENVIRON["MAM_MONITOR_WANT"]
|
||||
if (a0[n] == "bash" && (rest == want || index(rest, want " ") == 1)) print pid }'
|
||||
}
|
||||
|
||||
# mam_monitor_started <workspace> <pid...>
|
||||
# Issue #72: 0 once one of the PIDs start_watchdog printed is WORKSPACE's
|
||||
# monitor (_monitor_pids), so a restart is only reported when it happened.
|
||||
# Polled for up to 5 s: a daemon just started from the shell carries its
|
||||
# parent's command line until it execs bash.
|
||||
mam_monitor_started() {
|
||||
local script="$1/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh" n=0 p
|
||||
shift
|
||||
while [ "$n" -lt 50 ]; do
|
||||
for p in $(_monitor_pids "$script"); do
|
||||
case " $* " in *" $p "*) return 0 ;; esac
|
||||
done
|
||||
sleep 0.1
|
||||
n=$((n + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# start_watchdog <job_id> [workdir]
|
||||
# Spawns a watchdog process to monitor a delegate-job JOB in the background.
|
||||
# The watchdog re-spawns the subscriber every 2 minutes (or whatever hard
|
||||
@@ -2212,7 +2288,7 @@ _start_watchdog_legacy() {
|
||||
|
||||
# Check if reconcile.sh --subscribe is already running on this workspace
|
||||
local pid
|
||||
pid=$(pgrep -f "bash $monitor_script --subscribe" || true)
|
||||
pid=$(_monitor_pids "$monitor_script" || true)
|
||||
|
||||
# Issue #19: MAM_NO_SUBSCRIBER_DAEMON=1 reports a running daemon but never
|
||||
# starts one (test sandboxes; externally supervised monitors).
|
||||
@@ -2221,10 +2297,16 @@ _start_watchdog_legacy() {
|
||||
elif [ -z "$pid" ]; then
|
||||
# Start the wildcard monitor subscriber daemon with --idle-timeout 0 (never idle out)
|
||||
# and ensure it runs with $workdir as cwd to anchor relative log paths.
|
||||
local orig_pwd="$PWD"
|
||||
# Issue #72 (decision 2): job control (`set -m`) starts it in its own
|
||||
# process group, out of the caller's, as the python body's new session
|
||||
# does; macOS has no setsid(1). The caller's option is restored.
|
||||
local orig_pwd="$PWD" had_m=0
|
||||
case $- in *m*) had_m=1 ;; esac
|
||||
cd "$workdir" || return 1
|
||||
set -m
|
||||
nohup bash "$monitor_script" --subscribe --idle-timeout 0 </dev/null >> "$log_file" 2>&1 &
|
||||
pid=$!
|
||||
[ "$had_m" = 1 ] || set +m
|
||||
cd "$orig_pwd" || return 1
|
||||
fi
|
||||
|
||||
@@ -2467,6 +2549,18 @@ delegate_wait_terminal() {
|
||||
--job "$job_id" --registry-dir "$reg_dir" --timeout "$timeout_s" --watch-pid "$$"
|
||||
}
|
||||
|
||||
# delegate_autopin_completed <job_id> [dispatch_token] [registry_dir]
|
||||
# Issue #40: once this side has seen the job `completed`, pin the session's
|
||||
# post-discovered id (lib_py.agents.autopin decides and writes; remote, human
|
||||
# and pre-assigned sessions are left alone, one job/token/attempt is handled
|
||||
# once). Reports on stderr; never changes the job's result or this exit code.
|
||||
delegate_autopin_completed() {
|
||||
local job_id="$1" token="${2:-}" reg_dir="${3:-${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}}"
|
||||
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" HOME_DIR="${HOME_DIR:-$HOME}" LIB_SH="$SKILL_DIR/lib.sh" \
|
||||
"$(_delegate_py_bin)" -m lib_py.agents.autopin --job "$job_id" --registry-dir "$reg_dir" \
|
||||
--yaml "$AGENT_SESSIONS_YAML" ${token:+--expect-dispatch-token "$token"} >&2 || true
|
||||
}
|
||||
|
||||
# delegate_onboard_exit <status>
|
||||
# Issue #50: create's exit code for an onboarding status (0, 44 or 45).
|
||||
delegate_onboard_exit() {
|
||||
@@ -2762,7 +2856,7 @@ wait_for_agent_idle() {
|
||||
fi
|
||||
|
||||
local target
|
||||
target=$(_sanitize_herdr_agent_name "$sess")
|
||||
target=$(_sanitize_herdr_agent_name "$sess") || return 3
|
||||
|
||||
local raw_status="" agent_st=""
|
||||
raw_status="$(_sks_herdr agent get "$target" 2>/dev/null || _sks_herdr agent get "$sess" 2>/dev/null || true)"
|
||||
@@ -2887,7 +2981,7 @@ send_keys_safe() {
|
||||
fi
|
||||
|
||||
local agent_target
|
||||
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
||||
agent_target=$(_sanitize_herdr_agent_name "$sess") || return 1
|
||||
|
||||
# Busy gate: wait up to SKS_BUSY_TIMEOUT only if wait_for_agent_idle returned 3 (unknown)
|
||||
if [ "${_SKS_IDLE_RC:-0}" = "3" ]; then
|
||||
@@ -3194,3 +3288,92 @@ mam_control_plane_prefix() {
|
||||
[ -n "${1:-}" ] || return 0
|
||||
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.control_plane prefix "$1"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Issue #72 (decision 8): the update admission gate.
|
||||
#
|
||||
# The update skill publishes .mam/update-in-progress (pid + lstart, loop_lock.sh
|
||||
# rules) while it holds .mam/update-admission.lock exclusively, then runs its
|
||||
# pre-check. Every entry that starts something the pre-check must see - create,
|
||||
# resume, run_loop.sh, delegate submit - or writes the registry - stop,
|
||||
# orc-onboard - takes that lock shared on fd 7, refuses while the marker is
|
||||
# `live` or `unknown`, and keeps fd 7 until its record is written
|
||||
# (mam_update_admit_release). An update therefore either waits for that record
|
||||
# and sees it, or publishes first and the entry refuses. The lock is a BSD
|
||||
# flock taken by lib_py.pane_lock on the open fd: it ends when every process
|
||||
# holding fd 7 has ended, however that ends, so nothing long-lived may inherit
|
||||
# fd 7 (`7>&-` on such a child, as pane_lock's fd 9). The wait is bounded by
|
||||
# MAM_UPDATE_ADMISSION_WAIT_S (default 120).
|
||||
#
|
||||
# mam_update_admit [workspace [fd]]: rc 0 admitted (fd 7, or the given one,
|
||||
# held unless the workspace has no .mam), 1 refused (stderr). Delegate submit
|
||||
# also takes the workspaces its target is in on fds 20 and up, create and
|
||||
# resume those their pane's cwd is in (mam_update_admit_at).
|
||||
mam_update_admit() {
|
||||
local root="${1:-$WORKSPACE_ROOT}" fd="${2:-7}"
|
||||
local lock="$root/.mam/update-admission.lock" marker="$root/.mam/update-in-progress" st
|
||||
# No .mam: not an installed workspace, which is all the update runs in (and a
|
||||
# checkout running its own scripts gets no .mam from this).
|
||||
[ -d "$root/.mam" ] || return 0
|
||||
if ! eval "exec $fd>>\"\$lock\""; then
|
||||
echo "ERROR: cannot open the update admission lock $lock" >&2
|
||||
return 1
|
||||
fi
|
||||
case "$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" python3 -m lib_py.pane_lock acquire --fd "$fd" --shared --wait-env MAM_UPDATE_ADMISSION_WAIT_S)" in
|
||||
locked) ;;
|
||||
timeout)
|
||||
mam_update_admit_release "$fd"
|
||||
echo "ERROR: an update of $root holds its admission lock (MAM_UPDATE_ADMISSION_WAIT_S passed); try again when it has finished" >&2
|
||||
return 1 ;;
|
||||
*)
|
||||
mam_update_admit_release "$fd"
|
||||
echo "ERROR: the update admission lock $lock could not be decided (lib_py.pane_lock)" >&2
|
||||
return 1 ;;
|
||||
esac
|
||||
[ -f "$marker" ] || return 0
|
||||
# shellcheck disable=SC1091
|
||||
declare -F _mam_lock_holder_state >/dev/null || source "$SKILL_DIR/multi-agent-mux-loop/scripts/loop_lock.sh"
|
||||
st="$(_mam_lock_holder_state "$marker")"
|
||||
case "$st" in
|
||||
stale)
|
||||
echo "NOTE: stale update marker $marker (its update is gone); ignored" >&2
|
||||
return 0 ;;
|
||||
esac
|
||||
mam_update_admit_release "$fd"
|
||||
echo "ERROR: an update of $root is in progress (marker $marker, holder $st); try again when it has finished" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
mam_update_admit_release() {
|
||||
eval "exec ${1:-7}>&-"
|
||||
}
|
||||
|
||||
# mam_update_admit_at <cwd>: what create and resume take for an agent they
|
||||
# start in <cwd> - mam_update_admit for the control root (fd 7) and for every
|
||||
# other installed workspace at or above <cwd> (fds 20 and up), whose update
|
||||
# counts that agent (decision 1). rc 1 refused, nothing held.
|
||||
# mam_update_admit_release_all drops them all; a long-lived child started
|
||||
# while they are held runs it first.
|
||||
MAM_UPDATE_ADMIT_FDS=""
|
||||
mam_update_admit_at() {
|
||||
local roots root fd=20
|
||||
roots="$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.update target-workspaces \
|
||||
--workspace "$WORKSPACE_ROOT" --cwd "$1")" \
|
||||
|| { echo "ERROR: cannot resolve the installed workspaces at or above $1" >&2; return 1; }
|
||||
mam_update_admit || return 1
|
||||
while IFS= read -r root; do
|
||||
[ -n "$root" ] || continue
|
||||
if ! mam_update_admit "$root" "$fd"; then
|
||||
mam_update_admit_release_all
|
||||
return 1
|
||||
fi
|
||||
MAM_UPDATE_ADMIT_FDS="$MAM_UPDATE_ADMIT_FDS $fd"; fd=$((fd + 1))
|
||||
done <<< "$roots"
|
||||
}
|
||||
|
||||
mam_update_admit_release_all() {
|
||||
local fd
|
||||
mam_update_admit_release
|
||||
for fd in $MAM_UPDATE_ADMIT_FDS; do mam_update_admit_release "$fd"; done
|
||||
MAM_UPDATE_ADMIT_FDS=""
|
||||
}
|
||||
@@ -117,6 +117,15 @@ def main():
|
||||
print(adapter.resume_spec(binary, uuid, materialized=mat))
|
||||
sys.exit(0)
|
||||
|
||||
elif cmd == 'auth-ok':
|
||||
# auth-ok <agent>: exit 0 when the adapter's auth_ok() holds, else 1.
|
||||
agent_name = sys.argv[2] if len(sys.argv) > 2 else ''
|
||||
adapter = get_adapter(agent_name)
|
||||
if not adapter:
|
||||
print(f"ERROR: unknown agent {agent_name!r}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
sys.exit(0 if adapter.auth_ok() else 1)
|
||||
|
||||
elif cmd == 'exit-key':
|
||||
agent_name = sys.argv[2] if len(sys.argv) > 2 else ''
|
||||
adapter = get_adapter(agent_name)
|
||||
|
||||
@@ -1,6 +1,43 @@
|
||||
import os, sys, json, sqlite3, shutil
|
||||
from typing import Optional, Any
|
||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, conversation_in_use
|
||||
import os, sys, json, shutil, subprocess
|
||||
from typing import Optional, Any, Dict, List
|
||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, _ro_connect, capture_result, conversation_in_use
|
||||
from lib_py.verify_session import verify_session_uuid, workspace_key
|
||||
|
||||
|
||||
def _holder_pids(paths: List[str]) -> Optional[List[int]]:
|
||||
"""PIDs holding any of `paths` open (lsof -t), [] when none does, None
|
||||
when lsof cannot answer (missing, failed, timed out)."""
|
||||
existing = [p for p in paths if p and os.path.exists(p)]
|
||||
if not existing:
|
||||
return []
|
||||
lsof = shutil.which("lsof")
|
||||
if not lsof:
|
||||
return None
|
||||
try:
|
||||
r = subprocess.run([lsof, "-t"] + existing, capture_output=True, text=True, timeout=10)
|
||||
except Exception:
|
||||
return None
|
||||
pids = sorted({int(t) for t in r.stdout.split() if t.isdigit()})
|
||||
# lsof exits 1 both when nothing holds the files and on errors.
|
||||
if r.returncode not in (0, 1) or (r.returncode == 1 and not pids and r.stderr.strip()):
|
||||
return None
|
||||
return pids
|
||||
|
||||
|
||||
def _process_parents() -> Optional[Dict[int, int]]:
|
||||
"""pid -> ppid of every running process (ps), None when ps fails."""
|
||||
try:
|
||||
out = subprocess.run(["ps", "-eo", "pid=,ppid="], capture_output=True, text=True, timeout=10)
|
||||
except Exception:
|
||||
return None
|
||||
if out.returncode != 0:
|
||||
return None
|
||||
parents: Dict[int, int] = {}
|
||||
for line in out.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
|
||||
parents[int(parts[0])] = int(parts[1])
|
||||
return parents or None
|
||||
|
||||
class AgyAgentAdapter(BaseAgentAdapter):
|
||||
@property
|
||||
@@ -58,20 +95,17 @@ class AgyAgentAdapter(BaseAgentAdapter):
|
||||
if ctx.epoch and os.path.getmtime(path) < ctx.epoch:
|
||||
return False
|
||||
if ctx.mode == "discover":
|
||||
lc = f"{ctx.home_dir}/.gemini/antigravity-cli/cache/last_conversations.json"
|
||||
cache_match = False
|
||||
if os.path.exists(lc):
|
||||
try:
|
||||
with open(lc) as f:
|
||||
lc_data = json.load(f)
|
||||
cache_match = (lc_data.get(ctx.cwd) == uuid)
|
||||
except Exception:
|
||||
cache_match = False
|
||||
if not cache_match:
|
||||
# Issue #40: the cache maps each workspace to the conversation last
|
||||
# opened there, so an id it maps to another workspace is that
|
||||
# workspace's; and an id another row records is never this row's,
|
||||
# whether or not the cache names it for this cwd.
|
||||
here = workspace_key(ctx.cwd)
|
||||
if any(v == uuid and workspace_key(k) != here for k, v in self._last_conversations(ctx).items()):
|
||||
return False
|
||||
if uuid in (ctx.row.get("_sibling_claimed_uuids") or []):
|
||||
return False
|
||||
try:
|
||||
conn = sqlite3.connect(path)
|
||||
conn = _ro_connect(path)
|
||||
r = conn.execute("SELECT count(*) FROM steps").fetchone()
|
||||
conn.close()
|
||||
if not r or r[0] < 1:
|
||||
@@ -83,6 +117,71 @@ class AgyAgentAdapter(BaseAgentAdapter):
|
||||
def _cli_root(self, ctx: DiscoveryContext) -> str:
|
||||
return f"{ctx.home_dir}/.gemini/antigravity-cli"
|
||||
|
||||
def _last_conversations(self, ctx: DiscoveryContext) -> dict:
|
||||
"""cache/last_conversations.json: {cwd: id of the conversation last opened there}."""
|
||||
try:
|
||||
with open(f"{self._cli_root(ctx)}/cache/last_conversations.json") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def capture_active_session_id(self, ctx: DiscoveryContext) -> dict:
|
||||
"""Issue #40: the cache only proposes an id - it holds one id per cwd,
|
||||
rewritten whenever any agy opens a conversation there, so it cannot tell
|
||||
two sessions in one cwd apart. The id is this session's only when its
|
||||
presence lock (or DB) is held open by the row's pane process or one of
|
||||
its descendants. Anything unprovable is not pinned (no cache-only fallback)."""
|
||||
here = workspace_key(ctx.cwd)
|
||||
proposed = sorted({v for k, v in self._last_conversations(ctx).items()
|
||||
if v and workspace_key(k) == here})
|
||||
if not proposed:
|
||||
return capture_result("no-candidate", reason="last_conversations.json names no conversation for this cwd")
|
||||
if len(proposed) > 1:
|
||||
return capture_result("ambiguous", reason="last_conversations.json maps this cwd to several conversations",
|
||||
candidates=proposed)
|
||||
uuid = proposed[0]
|
||||
if uuid in (ctx.row.get("_sibling_claimed_uuids") or []):
|
||||
return capture_result("sibling-claimed", reason=f"{uuid} is recorded by another session", candidates=[uuid])
|
||||
if not verify_session_uuid(ctx.cwd, self.name, uuid, row=ctx.row, home_dir=ctx.home_dir, mode="discover"):
|
||||
return capture_result("rejected", reason=f"{uuid} failed verification (missing, older than the session, or empty)",
|
||||
candidates=[uuid])
|
||||
status, reason = self._owner_check(uuid, ctx)
|
||||
if status != "ok":
|
||||
return capture_result(status, reason=reason, candidates=[uuid])
|
||||
return capture_result("captured", session_id=uuid, candidates=[uuid])
|
||||
|
||||
def _owner_check(self, uuid: str, ctx: DiscoveryContext):
|
||||
"""('ok', '') when a holder of the conversation's presence lock or DB is
|
||||
the row's pane process or its descendant; otherwise
|
||||
('unverifiable-owner' | 'owner-mismatch', reason)."""
|
||||
try:
|
||||
pane_pid = int((ctx.row.get("pane") or {}).get("pid") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pane_pid = 0
|
||||
if pane_pid <= 1:
|
||||
return "unverifiable-owner", "the row records no pane pid"
|
||||
db = self.artifact_path(uuid, ctx)
|
||||
paths = [f"{self._cli_root(ctx)}/presence/{uuid}.lock", db, f"{db}-wal", f"{db}-shm"]
|
||||
holders = _holder_pids(paths)
|
||||
if holders is None:
|
||||
return "unverifiable-owner", "lsof is unavailable or failed"
|
||||
if not holders:
|
||||
return "unverifiable-owner", f"no process holds {uuid}'s presence lock or DB"
|
||||
if pane_pid in holders:
|
||||
return "ok", ""
|
||||
parents = _process_parents()
|
||||
if parents is None or pane_pid not in parents:
|
||||
return "unverifiable-owner", f"pane process {pane_pid} cannot be found"
|
||||
for pid in holders:
|
||||
seen = set()
|
||||
while pid > 1 and pid not in seen:
|
||||
if pid == pane_pid:
|
||||
return "ok", ""
|
||||
seen.add(pid)
|
||||
pid = parents.get(pid, 0)
|
||||
return "owner-mismatch", f"{uuid} is held by pid(s) {holders}, none in pane process {pane_pid}'s tree"
|
||||
|
||||
def _artifact_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||
"""Every on-disk piece of one conversation, DB first: the SQLite
|
||||
sidecars, the brain dir, the title annotation and the presence lock."""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os, re, json, glob, math, stat, time, shutil, calendar, subprocess
|
||||
from typing import Optional, Any
|
||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, conversation_in_use, scan_names
|
||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, PRE_ASSIGNED, conversation_in_use, scan_names
|
||||
from lib_py.verify_session import workspace_key
|
||||
|
||||
|
||||
@@ -184,6 +184,11 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
|
||||
def own_key(self) -> str:
|
||||
return 'claude_session_id_own'
|
||||
|
||||
@property
|
||||
def id_lifecycle(self) -> str:
|
||||
# create hands the CLI its id (--session-id); nothing to capture (#40)
|
||||
return PRE_ASSIGNED
|
||||
|
||||
@property
|
||||
def ready_tokens(self) -> str:
|
||||
return 'Anthropic|Assistant|Chat|Welcome|Claude Code|Opus|Sonnet|Haiku'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os, json, glob, sqlite3, subprocess
|
||||
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, _ro_connect, scan_names, db_rows_naming
|
||||
from lib_py.verify_session import workspace_key
|
||||
|
||||
class CodexAgentAdapter(BaseAgentAdapter):
|
||||
@@ -77,7 +77,7 @@ class CodexAgentAdapter(BaseAgentAdapter):
|
||||
if not db or not os.path.exists(db):
|
||||
return None, None
|
||||
try:
|
||||
conn = sqlite3.connect(db)
|
||||
conn = _ro_connect(db)
|
||||
row = conn.execute("SELECT rollout_path, cwd FROM threads WHERE id=?", (uuid,)).fetchone()
|
||||
conn.close()
|
||||
if row and row[0] and os.path.exists(row[0]):
|
||||
@@ -192,7 +192,7 @@ class CodexAgentAdapter(BaseAgentAdapter):
|
||||
|
||||
if db and os.path.exists(db):
|
||||
try:
|
||||
conn = sqlite3.connect(db)
|
||||
conn = _ro_connect(db)
|
||||
cur = conn.cursor()
|
||||
cur.execute("PRAGMA table_info(threads)")
|
||||
cols = {r[1] for r in cur.fetchall()}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import os, json, glob, shutil
|
||||
from typing import Optional, Any
|
||||
from urllib.parse import quote
|
||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, scan_names
|
||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, PRE_ASSIGNED, scan_names
|
||||
from lib_py.verify_session import workspace_key
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ class GrokAgentAdapter(BaseAgentAdapter):
|
||||
def own_key(self) -> str:
|
||||
return 'grok_session_id_own'
|
||||
|
||||
@property
|
||||
def id_lifecycle(self) -> str:
|
||||
# create hands the CLI its id (--session-id); nothing to capture (#40)
|
||||
return PRE_ASSIGNED
|
||||
|
||||
@property
|
||||
def ready_tokens(self) -> str:
|
||||
return 'Grok|xAI|Assistant|❯|>>>'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os, sys, sqlite3
|
||||
from typing import Optional, Any
|
||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext
|
||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, _ro_connect
|
||||
from lib_py.verify_session import workspace_key
|
||||
|
||||
class HermesAgentAdapter(BaseAgentAdapter):
|
||||
@@ -48,7 +48,7 @@ class HermesAgentAdapter(BaseAgentAdapter):
|
||||
if not os.path.exists(hdb):
|
||||
return False
|
||||
try:
|
||||
conn = sqlite3.connect(hdb)
|
||||
conn = _ro_connect(hdb)
|
||||
r = conn.execute("SELECT cwd, started_at FROM sessions WHERE id=?", (uuid,)).fetchone()
|
||||
conn.close()
|
||||
if not r:
|
||||
@@ -97,7 +97,7 @@ class HermesAgentAdapter(BaseAgentAdapter):
|
||||
if not os.path.exists(hdb):
|
||||
return []
|
||||
try:
|
||||
conn = sqlite3.connect(hdb)
|
||||
conn = _ro_connect(hdb)
|
||||
if ctx.epoch:
|
||||
rows = conn.execute(
|
||||
"SELECT id FROM sessions WHERE cwd=? AND started_at >= ? ORDER BY started_at DESC LIMIT 20",
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import os, sys, sqlite3, shutil, subprocess
|
||||
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, _ro_connect, scan_names, db_rows_naming, conversation_in_use
|
||||
from lib_py.verify_session import workspace_key
|
||||
|
||||
_PROVIDER_ENV_VARS = (
|
||||
'ANTHROPIC_API_KEY',
|
||||
'OPENAI_API_KEY',
|
||||
'GEMINI_API_KEY',
|
||||
'GROQ_API_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"))
|
||||
|
||||
|
||||
# (binary, HOME, XDG_DATA_HOME) -> the db `debug paths` printed for them.
|
||||
_DB_PATHS = {}
|
||||
|
||||
class OpenCodeAgentAdapter(BaseAgentAdapter):
|
||||
@property
|
||||
@@ -43,6 +46,13 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
||||
def input_placeholder(self) -> Optional[str]:
|
||||
return ''
|
||||
|
||||
@property
|
||||
def input_placeholder_pattern(self) -> Optional[str]:
|
||||
# 2.x home screen: `Ask anything… "<example>"`, the example picked per
|
||||
# launch and cut without its closing quote in a narrow box (Issue #60).
|
||||
# The example is required: a bare `Ask anything…` is a typed draft.
|
||||
return r'Ask anything(?:…|\.\.\.) "[^"]*"?'
|
||||
|
||||
@property
|
||||
def input_rule_pattern(self) -> Optional[str]:
|
||||
# Kept for lib.sh's readiness corroboration; the input box itself is a
|
||||
@@ -59,6 +69,12 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
||||
# blanks after the ┃) or the `Build auto · <model>` status line.
|
||||
return r'^\s{20,}\S|^\s*(Build|Plan)\b.*·'
|
||||
|
||||
@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 bin_path:
|
||||
@@ -71,6 +87,15 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
||||
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."""
|
||||
@@ -81,7 +106,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
||||
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()
|
||||
return subprocess.run([bin_path] + args, capture_output=True, text=True, timeout=timeout,
|
||||
env=env, cwd=cwd if os.path.exists(cwd) else None)
|
||||
@@ -89,16 +114,26 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
||||
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.
|
||||
# Each run writes a line to opencode's log, so a process asks once
|
||||
# per (binary, HOME, XDG_DATA_HOME) the run would see (Issue #60).
|
||||
bin_path = self._bin(ctx)
|
||||
if ctx.home_dir:
|
||||
key = (bin_path, ctx.home_dir, self._data_home(ctx))
|
||||
else:
|
||||
key = (bin_path, os.environ.get("HOME"), os.environ.get("XDG_DATA_HOME"))
|
||||
if bin_path and key in _DB_PATHS:
|
||||
return _DB_PATHS[key]
|
||||
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()
|
||||
_DB_PATHS[key] = parts[1].strip()
|
||||
return _DB_PATHS[key]
|
||||
except Exception:
|
||||
pass
|
||||
return f"{ctx.home_dir}/.local/share/opencode/opencode.db"
|
||||
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)
|
||||
@@ -111,7 +146,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
||||
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)
|
||||
conn = _ro_connect(db)
|
||||
r = conn.execute("SELECT directory, time_created FROM session_v2 WHERE id=?", (uuid,)).fetchone()
|
||||
conn.close()
|
||||
if not r:
|
||||
@@ -133,10 +168,15 @@ 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) and self._has_session(db, uuid):
|
||||
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
|
||||
# row it keeps for them; --standalone so no background service is
|
||||
# started for it.
|
||||
# 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:
|
||||
@@ -148,36 +188,50 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
||||
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")
|
||||
if db and os.path.exists(db):
|
||||
# Rows a 1.x opencode left in the tables a migration keeps.
|
||||
# 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()
|
||||
if tables & {"message", "messages", "part", "parts", "session", "sessions"}:
|
||||
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 _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 = _ro_connect(db)
|
||||
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)
|
||||
conn = _ro_connect(db)
|
||||
try:
|
||||
return conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (uuid,)).fetchone() is not None
|
||||
finally:
|
||||
@@ -186,11 +240,43 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
||||
return False
|
||||
|
||||
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||
# Issue #31: whatever still names the session (event-sourced rows a
|
||||
# 1.x opencode left, a failed `session delete`) is 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"), ("session_v2", "id")))
|
||||
# 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")), connect=_ro_connect)
|
||||
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
|
||||
@@ -204,11 +290,11 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
||||
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):
|
||||
return True
|
||||
# opencode 2.x needs no login: a fresh HOME runs a free default model
|
||||
# and local providers have no key, so the binary is the whole check.
|
||||
home = os.environ.get("HOME_DIR") or os.environ.get("HOME") or os.path.expanduser("~")
|
||||
auth_file = f"{home}/.local/share/opencode/auth.json"
|
||||
return os.path.exists(auth_file)
|
||||
local = f"{home}/.opencode/bin/opencode"
|
||||
return bool(shutil.which("opencode")) or (os.path.isfile(local) and os.access(local, os.X_OK))
|
||||
|
||||
def discover(self, ctx: DiscoveryContext) -> list:
|
||||
db = self._resolve_db(ctx)
|
||||
@@ -227,7 +313,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
||||
params.append(int(float(ctx.epoch) * 1000))
|
||||
query += " ORDER BY time_created DESC LIMIT 20"
|
||||
|
||||
conn = sqlite3.connect(db)
|
||||
conn = _ro_connect(db)
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
candidates = []
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Auto-pin of a post-discovered session id on a verified local `completed` (Issue #40).
|
||||
|
||||
Class A agents (agy, opencode, codex, hermes) write their conversation id to
|
||||
disk only after their first turn, so create records the row without one. The
|
||||
side that verified a job's `completed` - the delegate-job wrapper, create
|
||||
--onboard, and the loop's wait_for_job as a recovery path - calls
|
||||
handle_completed(). It captures the id through the adapter
|
||||
(capture_active_session_id) and pins it with pin_row() under atomic_dump_yaml.
|
||||
reconcile's drift C calls the same capture() and pin_row(), so the two paths
|
||||
reach one decision.
|
||||
|
||||
One (job_id, dispatch_token, attempt_id) is handled once: a job-scoped flock
|
||||
and a marker in the job's directory make a second call (wrapper + loop
|
||||
recovery) a no-op. Remote and human jobs have no local disk or pane and are
|
||||
skipped; the agent's own publish_event.py never writes the control plane
|
||||
(#15/#20/#21). A failed pin never changes the job's result: the reason is
|
||||
written on the row and returned.
|
||||
|
||||
CLI: python -m lib_py.agents.autopin --registry-dir DIR --job ID
|
||||
[--yaml AGENT_SESSIONS_YAML] [--expect-dispatch-token TOKEN]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from lib_py.agents.base import DiscoveryContext, POST_DISCOVERED, capture_result
|
||||
from lib_py.agents.registry import _ADAPTERS, agent_of_row, get_adapter
|
||||
|
||||
OWN_KEYS = tuple(a.own_key for a in _ADAPTERS.values())
|
||||
MARKER = "autopin.json"
|
||||
|
||||
|
||||
def claimed_elsewhere(row: Dict[str, Any], state: Dict[str, Any]) -> List[str]:
|
||||
"""Every id another row records: an id already some session's is never this one's."""
|
||||
ids: List[str] = []
|
||||
for other in state.get("herdr_sessions") or []:
|
||||
if other is row or other.get("name") == row.get("name"):
|
||||
continue
|
||||
for key in OWN_KEYS:
|
||||
v = other.get(key)
|
||||
if v and v not in ids:
|
||||
ids.append(v)
|
||||
return ids
|
||||
|
||||
|
||||
def capture(row: Dict[str, Any], agent: str, state: Dict[str, Any], home_dir: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""The adapter's decision for this row (read-only). Besides the capture
|
||||
statuses: 'pre-assigned' and 'already-set' (nothing to capture), 'skipped'."""
|
||||
adapter = get_adapter(agent)
|
||||
if adapter is None:
|
||||
return capture_result("skipped", reason=f"unknown agent {agent!r}")
|
||||
if adapter.id_lifecycle != POST_DISCOVERED:
|
||||
return capture_result("pre-assigned")
|
||||
if row.get(adapter.own_key):
|
||||
return capture_result("already-set", session_id=row[adapter.own_key])
|
||||
cwd = (row.get("pane") or {}).get("cwd") or ""
|
||||
if not cwd:
|
||||
return capture_result("no-candidate", reason="the row has no pane cwd")
|
||||
row_eval = dict(row)
|
||||
row_eval["_sibling_claimed_uuids"] = claimed_elsewhere(row, state)
|
||||
ctx = DiscoveryContext(workspace=cwd, agent_name=agent, home_dir=home_dir,
|
||||
epoch=row.get("herdr_session_epoch") or 0, row=row_eval, mode="discover")
|
||||
return adapter.capture_active_session_id(ctx)
|
||||
|
||||
|
||||
def pin_row(row: Dict[str, Any], agent: str, uuid: str) -> None:
|
||||
"""Record a verified id: own key, source and verified flag, in one change."""
|
||||
row[get_adapter(agent).own_key] = uuid
|
||||
row["session_id_source"] = "discovered"
|
||||
row["session_id_verified"] = True
|
||||
row["last_visible_status"] = "pinned"
|
||||
|
||||
|
||||
def _skills_dir() -> str:
|
||||
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def resume_dry_run(skills_dir: str, cwd: str, agent: str, name: str, env: Optional[Dict[str, str]] = None) -> str:
|
||||
"""last_visible_status after a pin: 'resume_verified' or why the dry-run failed."""
|
||||
cmd = ["bash", os.path.join(skills_dir, "multi-agent-mux-resume", "scripts", "resume_session.sh"),
|
||||
"--workspace", cwd, "--agent", agent, "--session", name, "--dry-run"]
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
||||
if res.returncode == 0:
|
||||
return "resume_verified"
|
||||
return f"resume dry-run failed: {res.stderr.strip() or res.stdout.strip()}"
|
||||
|
||||
|
||||
def tui_viewport(lib_sh: str, name: str, agent: str, cwd: str, env: Optional[Dict[str, str]] = None) -> int:
|
||||
"""lib.sh verify_tui_viewport: 0 match, 1 mismatch, other = no evidence."""
|
||||
script = 'source "$1" && verify_tui_viewport "$2" "$3" "$4"'
|
||||
return subprocess.run(["bash", "-c", script, "_", lib_sh, name, agent, cwd], env=env).returncode
|
||||
|
||||
|
||||
def _load_record(registry_dir: str, job_id: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
with open(os.path.join(registry_dir, f"{job_id}.json"), encoding="utf-8") as f:
|
||||
rec = json.load(f)
|
||||
return rec if isinstance(rec, dict) else None
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
_MUTATION: Optional[Callable[[Dict[str, Any]], bool]] = None
|
||||
|
||||
|
||||
def _atomic_update(yaml_path: str, mutate: Callable[[Dict[str, Any]], bool]) -> None:
|
||||
"""Run `mutate(d)` as an atomic_dump_yaml mutation in this process; it
|
||||
returns False to leave the registry untouched."""
|
||||
from lib_py import atomic_yaml
|
||||
global _MUTATION
|
||||
saved = {k: os.environ.get(k) for k in ("YAML_PATH", "AGENT_SESSIONS_MUTATION")}
|
||||
_MUTATION = mutate
|
||||
os.environ["YAML_PATH"] = yaml_path
|
||||
# this very module (it is __main__ under `python -m`), not a second copy
|
||||
os.environ["AGENT_SESSIONS_MUTATION"] = (
|
||||
"import sys as _sys\n"
|
||||
f"if not _sys.modules[{__name__!r}]._MUTATION(d):\n"
|
||||
" raise SystemExit(0)\n")
|
||||
try:
|
||||
atomic_yaml.atomic_dump_yaml_main()
|
||||
except SystemExit as exc:
|
||||
if exc.code not in (0, None):
|
||||
raise RuntimeError(str(exc.code))
|
||||
finally:
|
||||
_MUTATION = None
|
||||
for k, v in saved.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
|
||||
|
||||
|
||||
def _subprocess_env(yaml_path: str, registry_dir: str) -> Dict[str, str]:
|
||||
"""The control plane the lib.sh checks run against: the registry's own."""
|
||||
env = dict(os.environ)
|
||||
env.setdefault("AGENT_SESSIONS_YAML", yaml_path)
|
||||
env.setdefault("MAM_JOBS_DIR", registry_dir)
|
||||
mam = os.path.dirname(yaml_path)
|
||||
if os.path.basename(mam) == ".mam":
|
||||
env.setdefault("WORKSPACE_ROOT", os.path.dirname(mam))
|
||||
return env
|
||||
|
||||
|
||||
def _pin_session(name: str, job_id: str, yaml_path: str, home_dir: Optional[str],
|
||||
viewport: Callable[[str, str, str], int], resume: Callable[[str, str, str], str]) -> Dict[str, Any]:
|
||||
out: Dict[str, Any] = {}
|
||||
|
||||
def mutate(d: Dict[str, Any]) -> bool:
|
||||
row = next((s for s in d.get("herdr_sessions") or [] if s.get("name") == name), None)
|
||||
if row is None:
|
||||
out.update(capture_result("skipped", reason=f"no session row {name!r}"))
|
||||
return False
|
||||
if row.get("status") != "running":
|
||||
out.update(capture_result("skipped", reason=f"session {name!r} is {row.get('status')}"))
|
||||
return False
|
||||
agent = agent_of_row(row)
|
||||
res = capture(row, agent or "", d, home_dir=home_dir)
|
||||
cwd = (row.get("pane") or {}).get("cwd") or ""
|
||||
if res["status"] == "captured":
|
||||
rc = viewport(name, agent, cwd)
|
||||
if rc == 1:
|
||||
res = capture_result("viewport-mismatch", reason="the pane does not show this workspace",
|
||||
candidates=res["candidates"])
|
||||
else:
|
||||
pin_row(row, agent, res["session_id"])
|
||||
row["last_visible_status"] = resume(name, agent, cwd)
|
||||
res = dict(res, status="pinned", degraded=(rc != 0))
|
||||
out.update(res)
|
||||
return True
|
||||
out.update(res)
|
||||
if res["status"] in ("pre-assigned", "already-set", "skipped"):
|
||||
return False
|
||||
row["last_visible_status"] = f"autopin {res['status']} (job {job_id}): {res['reason']}"
|
||||
return True
|
||||
|
||||
_atomic_update(yaml_path, mutate)
|
||||
return out
|
||||
|
||||
|
||||
def handle_completed(registry_dir: str, job_id: str, yaml_path: Optional[str] = None,
|
||||
expect_token: Optional[str] = None, home_dir: Optional[str] = None,
|
||||
viewport: Optional[Callable[[str, str, str], int]] = None,
|
||||
resume: Optional[Callable[[str, str, str], str]] = None) -> Dict[str, Any]:
|
||||
"""Pin the job's session id if the job completed locally and the row still
|
||||
lacks a post-discovered id. Returns the result (also kept in the marker)."""
|
||||
rec = _load_record(registry_dir, job_id)
|
||||
if rec is None:
|
||||
return capture_result("skipped", reason=f"no job record {job_id}")
|
||||
if rec.get("status") != "completed":
|
||||
return capture_result("skipped", reason=f"job {job_id} is {rec.get('status')}")
|
||||
token = rec.get("dispatch_token") or ""
|
||||
if expect_token and token != expect_token:
|
||||
return capture_result("skipped", reason="stale dispatch token")
|
||||
session = str(rec.get("agent_session") or "")
|
||||
if (rec.get("transport") == "remote" or session.startswith("remote:")
|
||||
or rec.get("agent") == "human" or session in ("", "human", "herdr:human")):
|
||||
return capture_result("skipped", reason="not a local agent session")
|
||||
name = session[len("herdr:"):] if session.startswith("herdr:") else session
|
||||
yaml_path = yaml_path or os.environ.get("AGENT_SESSIONS_YAML") or os.path.join(
|
||||
os.path.dirname(os.path.abspath(registry_dir)), "agent-sessions.yaml")
|
||||
if not (os.path.exists(yaml_path) or os.path.exists(os.path.splitext(yaml_path)[0] + ".db")):
|
||||
return capture_result("skipped", reason=f"no session registry at {yaml_path}")
|
||||
|
||||
key = f"{job_id}:{token}:{rec.get('attempt_id')}"
|
||||
job_dir = os.path.join(registry_dir, job_id)
|
||||
os.makedirs(job_dir, exist_ok=True)
|
||||
env = _subprocess_env(yaml_path, registry_dir)
|
||||
if viewport is None:
|
||||
lib_sh = os.environ.get("LIB_SH") or os.path.join(_skills_dir(), "lib.sh")
|
||||
viewport = lambda n, a, c: tui_viewport(lib_sh, n, a, c, env=env) # noqa: E731
|
||||
if resume is None:
|
||||
resume = lambda n, a, c: resume_dry_run(_skills_dir(), c, a, n, env=env) # noqa: E731
|
||||
|
||||
with open(os.path.join(job_dir, "autopin.lock"), "w") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
marker = os.path.join(job_dir, MARKER)
|
||||
try:
|
||||
with open(marker, encoding="utf-8") as f:
|
||||
done = json.load(f)
|
||||
if done.get("key") == key:
|
||||
return dict(done.get("result") or {}, deduplicated=True)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
try:
|
||||
result = _pin_session(name, job_id, yaml_path, home_dir, viewport, resume)
|
||||
except Exception as exc:
|
||||
result = capture_result("error", reason=f"{type(exc).__name__}: {exc}")
|
||||
result["session"] = name
|
||||
fd, tmp = tempfile.mkstemp(dir=job_dir, prefix=".autopin.", suffix=".tmp")
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump({"key": key, "at": int(time.time()), "result": result}, f)
|
||||
os.replace(tmp, marker)
|
||||
return result
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
p = argparse.ArgumentParser(prog="python -m lib_py.agents.autopin")
|
||||
p.add_argument("--registry-dir", required=True)
|
||||
p.add_argument("--job", required=True)
|
||||
p.add_argument("--yaml", default=None, help="agent-sessions.yaml (default: beside the registry)")
|
||||
p.add_argument("--expect-dispatch-token", default=None)
|
||||
a = p.parse_args(argv)
|
||||
res = handle_completed(a.registry_dir, a.job, yaml_path=a.yaml, expect_token=a.expect_dispatch_token)
|
||||
if res.get("status") == "pinned":
|
||||
print(f"autopin: {res['session']} pinned {res['session_id']} (job {a.job})", file=sys.stderr)
|
||||
elif res.get("status") not in ("skipped", "pre-assigned", "already-set") and not res.get("deduplicated"):
|
||||
print(f"autopin: {res.get('session')} not pinned - {res['status']}: {res.get('reason')} (job {a.job})",
|
||||
file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,6 +1,6 @@
|
||||
# base.py — BaseAgentAdapter abstract base class, SpawnSpec, and DiscoveryContext
|
||||
|
||||
import os, json, sqlite3, shutil, subprocess
|
||||
import os, json, sqlite3, shutil, subprocess, tempfile
|
||||
from typing import Optional, Dict, Any, List
|
||||
from urllib.parse import quote
|
||||
from lib_py.paths import resolve_home
|
||||
@@ -108,14 +108,41 @@ def scan_names(root: str, uuid: str, skip=()) -> list:
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def db_rows_naming(db: str, uuid: str, columns=(), id_tables=()) -> list:
|
||||
def _ro_connect(db: str):
|
||||
"""A read that neither creates nor removes anything beside db (Issues #60, #64).
|
||||
Both WAL sidecars (a live writer keeps them): read through them. No -wal:
|
||||
nothing to apply, so the file is read as immutable. A -wal without its
|
||||
-shm (left behind - no writer runs without one) still holds committed
|
||||
rows: they are read from a private copy, where SQLite may rebuild the
|
||||
index, loaded into memory."""
|
||||
path = os.path.abspath(db)
|
||||
wal, shm = os.path.exists(path + "-wal"), os.path.exists(path + "-shm")
|
||||
if wal and shm:
|
||||
return sqlite3.connect(f"file:{quote(path)}?mode=ro", uri=True)
|
||||
if not wal:
|
||||
return sqlite3.connect(f"file:{quote(path)}?mode=ro&immutable=1", uri=True)
|
||||
mem = sqlite3.connect(":memory:")
|
||||
with tempfile.TemporaryDirectory(prefix="mam-ro-") as tmp:
|
||||
copy = os.path.join(tmp, os.path.basename(path) or "state.db")
|
||||
shutil.copyfile(path, copy)
|
||||
shutil.copyfile(path + "-wal", copy + "-wal")
|
||||
src = sqlite3.connect(copy)
|
||||
try:
|
||||
src.backup(mem)
|
||||
finally:
|
||||
src.close()
|
||||
return mem
|
||||
|
||||
|
||||
def db_rows_naming(db: str, uuid: str, columns=(), id_tables=(), connect=None) -> list:
|
||||
""""<db> (<table>.<col>: <n> rows)" for every table holding rows of this
|
||||
conversation in one of `columns`, or in the id column of an `id_tables`
|
||||
(table, column) pair (Issue #31). Read-only; unreadable tables are skipped."""
|
||||
(table, column) pair (Issue #31). Read-only; unreadable tables are skipped.
|
||||
`connect(db)` opens the database (default: `_ro_connect`)."""
|
||||
if not _plausible_id(uuid) or not db or not os.path.isfile(db):
|
||||
return []
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{quote(os.path.abspath(db))}?mode=ro", uri=True)
|
||||
conn = (connect or _ro_connect)(db)
|
||||
except Exception:
|
||||
return []
|
||||
found = []
|
||||
@@ -142,6 +169,22 @@ def db_rows_naming(db: str, uuid: str, columns=(), id_tables=()) -> list:
|
||||
conn.close()
|
||||
return found
|
||||
|
||||
# Session-id lifecycles (Issue #40): the id is handed to the CLI before it
|
||||
# starts, or the CLI writes it to disk after its first turn.
|
||||
PRE_ASSIGNED = 'pre_assigned'
|
||||
POST_DISCOVERED = 'post_discovered'
|
||||
|
||||
|
||||
def capture_result(status: str, session_id: Optional[str] = None, reason: str = "",
|
||||
candidates: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
"""What capture_active_session_id found (Issue #40). `status` is
|
||||
'captured' (session_id set) or why nothing may be pinned: 'no-candidate',
|
||||
'ambiguous', 'sibling-claimed', 'rejected', 'unverifiable-owner',
|
||||
'owner-mismatch'."""
|
||||
return {"status": status, "session_id": session_id, "reason": reason,
|
||||
"candidates": list(candidates or [])}
|
||||
|
||||
|
||||
class SpawnSpec:
|
||||
def __init__(self, agent_name: str, session_name: str, command: str, env: Optional[Dict[str, str]] = None):
|
||||
self.agent_name = agent_name
|
||||
@@ -208,6 +251,13 @@ class BaseAgentAdapter:
|
||||
def input_placeholder(self) -> Optional[str]:
|
||||
return None
|
||||
|
||||
@property
|
||||
def input_placeholder_pattern(self) -> Optional[str]:
|
||||
"""Regex the input box's first line matches in full while it shows
|
||||
only a placeholder whose text varies (#60); input_placeholder stays
|
||||
the literal lib.sh greps for."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def input_rule_pattern(self) -> Optional[str]:
|
||||
return None
|
||||
@@ -324,3 +374,28 @@ class BaseAgentAdapter:
|
||||
|
||||
def discover(self, ctx: DiscoveryContext) -> list:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def id_lifecycle(self) -> str:
|
||||
"""PRE_ASSIGNED: the id is given to the CLI at spawn (nothing to capture).
|
||||
POST_DISCOVERED: the CLI writes it after its first turn (Issue #40)."""
|
||||
return POST_DISCOVERED
|
||||
|
||||
def capture_active_session_id(self, ctx: DiscoveryContext) -> Dict[str, Any]:
|
||||
"""This session's id, decided only when it is unambiguous (Issue #40):
|
||||
exactly one discover() candidate that verify_session_uuid accepts and
|
||||
no other row records (ctx.row['_sibling_claimed_uuids']). Read-only."""
|
||||
claimed = set(ctx.row.get("_sibling_claimed_uuids") or [])
|
||||
found: List[str] = []
|
||||
for uuid in self.discover(ctx):
|
||||
if not uuid or uuid in claimed or uuid in found:
|
||||
continue
|
||||
if verify_session_uuid(ctx.cwd, self.name, uuid, row=ctx.row, home_dir=ctx.home_dir,
|
||||
claude_dir=ctx.claude_dir, mode="discover"):
|
||||
found.append(uuid)
|
||||
if not found:
|
||||
return capture_result("no-candidate", reason="no verified candidate newer than the session")
|
||||
if len(found) > 1:
|
||||
return capture_result("ambiguous", reason=f"{len(found)} candidates newer than the session",
|
||||
candidates=found)
|
||||
return capture_result("captured", session_id=found[0], candidates=found)
|
||||
@@ -0,0 +1,383 @@
|
||||
"""Same-cwd creation/resume ordering for post-discovered agents (Issue #63).
|
||||
|
||||
While a post-discovered agent (agy, codex, hermes, opencode) in a workspace is
|
||||
still being created (marker held by a live create/resume process) or is running
|
||||
without a recorded conversation id (live pane pid), a second create or resume of
|
||||
the same agent type in that workspace waits until the first records its id, up
|
||||
to MAM_ID_ORDER_WAIT_S (default 600 s; 0 = do not wait). A marker whose owner
|
||||
died before the row was registered keeps blocking until herdr answers that its
|
||||
session is gone (the pane may still discover the workspace id).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List, Mapping, Optional
|
||||
|
||||
from lib_py import ints
|
||||
from lib_py.agents.autopin import claimed_elsewhere
|
||||
from lib_py.agents.base import POST_DISCOVERED
|
||||
from lib_py.agents.registry import agent_of_row, get_adapter
|
||||
from lib_py.agents.sanitize import sanitize_herdr_agent_name
|
||||
from lib_py.agents.session_ids import canonical_cwd, claiming_rows, own_id
|
||||
from lib_py.state import load_state_json
|
||||
|
||||
DEFAULT_WAIT_S = 600
|
||||
PROBE_TIMEOUT_S = 30.0
|
||||
|
||||
|
||||
def wait_s(env: Mapping[str, str] = os.environ) -> int:
|
||||
raw = env.get("MAM_ID_ORDER_WAIT_S")
|
||||
if raw is None:
|
||||
return DEFAULT_WAIT_S
|
||||
n = ints.positive_int(raw, 0)
|
||||
return DEFAULT_WAIT_S if n is None else n
|
||||
|
||||
|
||||
def pid_alive(pid: int) -> bool:
|
||||
if not isinstance(pid, int) or pid <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def herdr_session_state(server: str, session: str, timeout: float) -> Optional[bool]:
|
||||
"""Is `session` live on herdr server `server`: True, False, or None when
|
||||
herdr gave no authoritative answer. False only from a successful, parsed
|
||||
`agent list` without the name; a failed (server down included), timed-out
|
||||
or unparseable lookup, or no time left, is None. Never starts a server
|
||||
(MAM_NO_AUTOSTART)."""
|
||||
if timeout <= 0:
|
||||
return None
|
||||
skills_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
env = dict(os.environ, MAM_NO_AUTOSTART="1", HERDR_SESSION_NAME=server or "default")
|
||||
script = 'source "$1" && _herdr agent list'
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["bash", "-c", script, "_", os.path.join(skills_dir, "lib.sh")],
|
||||
env=env, stdin=subprocess.DEVNULL, capture_output=True, text=True,
|
||||
timeout=min(timeout, PROBE_TIMEOUT_S),
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
if res.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
agents = json.loads(res.stdout)["result"]["agents"]
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return None
|
||||
if not isinstance(agents, list):
|
||||
return None
|
||||
names = {session, sanitize_herdr_agent_name(session)}
|
||||
return any(isinstance(a, dict) and a.get("name") in names for a in agents)
|
||||
|
||||
|
||||
def order_dir(cwd: str, agent: str, env: Mapping[str, str] = os.environ,
|
||||
home: Optional[str] = None) -> str:
|
||||
h = home or env.get("HOME") or env.get("HOME_DIR") or os.path.expanduser("~")
|
||||
real_cwd = canonical_cwd(cwd or ".")
|
||||
digest = hashlib.sha256(real_cwd.encode("utf-8")).hexdigest()[:16]
|
||||
return os.path.join(h, ".local", "state", "mam", "id-order", f"{digest}-{agent}")
|
||||
|
||||
|
||||
def _parse_pos_int(val: Any) -> Optional[int]:
|
||||
if isinstance(val, bool):
|
||||
return None
|
||||
if isinstance(val, int):
|
||||
return val if val > 0 else None
|
||||
s = str(val or "").strip()
|
||||
return int(s) if s.isdigit() and int(s) > 0 else None
|
||||
|
||||
|
||||
def blockers(state: Dict[str, Any], cwd: str, agent: str, me: str,
|
||||
markers: List[Dict[str, Any]],
|
||||
is_alive: Callable[[int], bool] = pid_alive) -> List[str]:
|
||||
target_cwd = canonical_cwd(cwd)
|
||||
found: List[str] = []
|
||||
rows_by_name: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
for s in state.get("herdr_sessions") or []:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
rname = str(s.get("name") or "")
|
||||
if rname:
|
||||
rows_by_name[rname] = s
|
||||
if not rname or rname == me:
|
||||
continue
|
||||
s_cwd = (s.get("pane") or {}).get("cwd") or ""
|
||||
if not s_cwd or canonical_cwd(s_cwd) != target_cwd:
|
||||
continue
|
||||
if agent_of_row(s) != agent or s.get("status") != "running":
|
||||
continue
|
||||
if own_id(s, agent):
|
||||
continue
|
||||
pane_pid = _parse_pos_int((s.get("pane") or {}).get("pid"))
|
||||
if pane_pid is None or is_alive(pane_pid):
|
||||
if rname not in found:
|
||||
found.append(rname)
|
||||
|
||||
for m in markers or []:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
m_sess = str(m.get("session") or "")
|
||||
if not m_sess or m_sess in found:
|
||||
continue
|
||||
m_cwd = str(m.get("cwd") or "")
|
||||
if m_cwd and canonical_cwd(m_cwd) != target_cwd:
|
||||
continue
|
||||
m_agent = str(m.get("agent") or "")
|
||||
if m_agent and m_agent != agent:
|
||||
continue
|
||||
row = rows_by_name.get(m_sess)
|
||||
if row is None:
|
||||
found.append(m_sess)
|
||||
continue
|
||||
status = str(row.get("status") or "")
|
||||
cur_own = own_id(row, agent)
|
||||
row_epoch = int(row.get("herdr_session_epoch") or 0)
|
||||
end_epoch = int(row.get("stopped_at_epoch") or row.get("terminated_at_epoch") or 0)
|
||||
m_at = int(m.get("at") or 0)
|
||||
prev_own = str(m.get("prev_own_id") or "")
|
||||
prev_epoch = int(m.get("prev_epoch") or 0)
|
||||
prev_end_epoch = int(m.get("prev_end_epoch") or 0)
|
||||
|
||||
pinned_for_attempt = bool(cur_own) and (
|
||||
cur_own != prev_own
|
||||
or (status == "running" and row_epoch > prev_epoch)
|
||||
)
|
||||
if pinned_for_attempt:
|
||||
continue
|
||||
if status == "running":
|
||||
found.append(m_sess)
|
||||
else:
|
||||
ended_after_attempt = (
|
||||
end_epoch > max(m_at, prev_end_epoch)
|
||||
and (prev_epoch == 0 or row_epoch > prev_epoch)
|
||||
)
|
||||
if not ended_after_attempt:
|
||||
found.append(m_sess)
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def _registered_for_attempt(m: Dict[str, Any], row: Optional[Dict[str, Any]]) -> bool:
|
||||
if not isinstance(row, dict) or row.get("status") != "running":
|
||||
return False
|
||||
return int(row.get("herdr_session_epoch") or 0) > int(m.get("prev_epoch") or 0)
|
||||
|
||||
|
||||
def _read_and_prune_markers(dpath: str, my_pid: int, state: Dict[str, Any],
|
||||
is_alive: Callable[[int], bool] = pid_alive,
|
||||
pane_state: Callable[[str, str], Optional[bool]] = (
|
||||
lambda server, session: None),
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Markers that still count. A live owner keeps its marker (another live
|
||||
attempt on the caller's session included). A dead owner's marker goes once
|
||||
its row is registered for the attempt (the row decides from then on) or
|
||||
herdr answers that its session is gone; until then it is kept as an orphan,
|
||||
one an earlier attempt left on the caller's session included."""
|
||||
rows_by_name = {
|
||||
str(s.get("name") or ""): s
|
||||
for s in (state.get("herdr_sessions") or []) if isinstance(s, dict)
|
||||
}
|
||||
out: List[Dict[str, Any]] = []
|
||||
try:
|
||||
names = sorted(os.listdir(dpath))
|
||||
except OSError:
|
||||
return out
|
||||
for fname in names:
|
||||
if not fname.endswith(".json") or fname.startswith("."):
|
||||
continue
|
||||
fpath = os.path.join(dpath, fname)
|
||||
try:
|
||||
with open(fpath, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
try:
|
||||
os.remove(fpath)
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
owner_pid = _parse_pos_int(data.get("owner_pid"))
|
||||
sess = str(data.get("session") or "")
|
||||
if owner_pid is None or owner_pid == my_pid:
|
||||
drop = True
|
||||
elif is_alive(owner_pid):
|
||||
drop = False
|
||||
elif not sess:
|
||||
drop = True
|
||||
elif _registered_for_attempt(data, rows_by_name.get(sess)):
|
||||
drop = True
|
||||
else:
|
||||
live = pane_state(str(data.get("herdr_server") or ""), sess)
|
||||
drop = live is False
|
||||
data["_orphan"] = "alive" if live else "unknown"
|
||||
if drop:
|
||||
try:
|
||||
os.remove(fpath)
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
out.append(data)
|
||||
return out
|
||||
|
||||
|
||||
def write_marker(dpath: str, session: str, owner_pid: int, cwd: str, agent: str,
|
||||
prev_row: Optional[Dict[str, Any]] = None, herdr_server: str = "") -> str:
|
||||
os.makedirs(dpath, exist_ok=True)
|
||||
marker_path = os.path.join(dpath, f"{session}.json")
|
||||
payload: Dict[str, Any] = {
|
||||
"session": session,
|
||||
"owner_pid": int(owner_pid),
|
||||
"cwd": canonical_cwd(cwd),
|
||||
"agent": agent,
|
||||
"herdr_server": herdr_server,
|
||||
"at": int(time.time()),
|
||||
}
|
||||
if isinstance(prev_row, dict):
|
||||
payload["prev_own_id"] = own_id(prev_row, agent)
|
||||
payload["prev_epoch"] = int(prev_row.get("herdr_session_epoch") or 0)
|
||||
payload["prev_end_epoch"] = int(
|
||||
prev_row.get("stopped_at_epoch") or prev_row.get("terminated_at_epoch") or 0
|
||||
)
|
||||
fd, tmp = tempfile.mkstemp(dir=dpath, prefix=".marker.", suffix=".tmp")
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f)
|
||||
os.replace(tmp, marker_path)
|
||||
return marker_path
|
||||
|
||||
|
||||
def wait(cwd: str, agent: str, session: str, owner_pid: int,
|
||||
uuid: str = "", yaml_path: Optional[str] = None,
|
||||
home: Optional[str] = None, wait_s_val: Optional[int] = None,
|
||||
poll: float = 0.25,
|
||||
is_alive: Callable[[int], bool] = pid_alive,
|
||||
herdr_server: str = "",
|
||||
pane_state: Optional[Callable[[str, str, float], Optional[bool]]] = None) -> int:
|
||||
adapter = get_adapter(agent)
|
||||
if adapter is None or adapter.id_lifecycle != POST_DISCOVERED:
|
||||
return 0
|
||||
|
||||
cap = wait_s(os.environ) if wait_s_val is None else int(wait_s_val)
|
||||
dpath = order_dir(cwd, agent, os.environ, home=home)
|
||||
os.makedirs(dpath, exist_ok=True)
|
||||
lock_file = os.path.join(dpath, "lock")
|
||||
deadline = time.monotonic() + cap
|
||||
announced = False
|
||||
row_ref = {"name": session}
|
||||
probe = pane_state or herdr_session_state
|
||||
|
||||
answers: Dict[str, bool] = {} # last definite answer per session, for the message
|
||||
|
||||
def bounded_probe(server: str, sess: str) -> Optional[bool]:
|
||||
live = probe(server, sess, deadline - time.monotonic())
|
||||
if live is not None:
|
||||
answers[sess] = live
|
||||
return live
|
||||
|
||||
while True:
|
||||
with open(lock_file, "a", encoding="utf-8") as lf:
|
||||
fcntl.flock(lf, fcntl.LOCK_EX)
|
||||
state = load_state_json(yaml_path)
|
||||
if uuid:
|
||||
claimed = set(claimed_elsewhere(row_ref, state))
|
||||
if uuid in claimed:
|
||||
others = ", ".join(claiming_rows(row_ref, uuid, state))
|
||||
print(
|
||||
f"ERROR: {session}'s recorded id {uuid} is also recorded by {others}; "
|
||||
f"refusing to resume on a conversation another session claims (Issue #63).",
|
||||
file=sys.stderr, flush=True,
|
||||
)
|
||||
return 1
|
||||
markers = _read_and_prune_markers(dpath, owner_pid, state,
|
||||
is_alive=is_alive, pane_state=bounded_probe)
|
||||
blks = blockers(state, cwd, agent, session, markers, is_alive=is_alive)
|
||||
if not blks:
|
||||
prev_row = next(
|
||||
(
|
||||
s for s in (state.get("herdr_sessions") or [])
|
||||
if isinstance(s, dict) and s.get("name") == session
|
||||
),
|
||||
None,
|
||||
)
|
||||
already_recorded = bool(uuid) and own_id(prev_row, agent) == uuid
|
||||
if not already_recorded and owner_pid and owner_pid > 0:
|
||||
write_marker(dpath, session, owner_pid, cwd, agent, prev_row=prev_row,
|
||||
herdr_server=herdr_server)
|
||||
return 0
|
||||
|
||||
names = ", ".join(blks)
|
||||
if time.monotonic() >= deadline:
|
||||
print(
|
||||
f"ERROR: {names} in {cwd} still has no recorded {agent} id after {cap}s; "
|
||||
f"not starting {session} (Issue #63). Wait for its first turn to finish, "
|
||||
f"or stop it with multi-agent-mux-stop.",
|
||||
file=sys.stderr, flush=True,
|
||||
)
|
||||
for m in markers:
|
||||
if m.get("_orphan") and m.get("session") in blks:
|
||||
srv = m.get("herdr_server") or "default"
|
||||
alive = m["_orphan"] == "alive" or answers.get(m["session"]) is True
|
||||
seen = ("its herdr session is alive" if alive
|
||||
else "herdr could not tell whether its session is gone")
|
||||
if not alive and cap == 0:
|
||||
seen += " (MAM_ID_ORDER_WAIT_S=0 leaves no time to ask herdr)"
|
||||
print(
|
||||
f"ERROR: {m['session']}'s create/resume exited before recording it, "
|
||||
f"and {seen}; close it with: "
|
||||
f"bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh "
|
||||
f"--session {m['session']} --herdr-session {srv}",
|
||||
file=sys.stderr, flush=True,
|
||||
)
|
||||
return 1
|
||||
if not announced:
|
||||
print(
|
||||
f"id-order: waiting for {names} ({agent}, no recorded id yet) in {cwd} "
|
||||
f"- up to {cap}s (MAM_ID_ORDER_WAIT_S)",
|
||||
file=sys.stderr, flush=True,
|
||||
)
|
||||
announced = True
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining > 0:
|
||||
time.sleep(min(poll, remaining))
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
p = argparse.ArgumentParser(prog="python -m lib_py.agents.id_order")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
s_wait = sub.add_parser("wait")
|
||||
s_wait.add_argument("--cwd", required=True)
|
||||
s_wait.add_argument("--agent", required=True)
|
||||
s_wait.add_argument("--session", required=True)
|
||||
s_wait.add_argument("--owner-pid", type=int, default=0)
|
||||
s_wait.add_argument("--uuid", default="")
|
||||
s_wait.add_argument("--herdr-server", default="")
|
||||
a = p.parse_args(argv)
|
||||
return wait(
|
||||
cwd=a.cwd,
|
||||
agent=a.agent,
|
||||
session=a.session,
|
||||
owner_pid=a.owner_pid,
|
||||
uuid=a.uuid or "",
|
||||
herdr_server=a.herdr_server or "",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -17,7 +17,8 @@ def extract_input_region(pane_text: str, agent_name: str) -> str:
|
||||
prompt line is input: a multi-line draft is never cut to its first line.
|
||||
|
||||
Then `input_frame_chars` are stripped, and the prompt and the placeholder
|
||||
(whole-line) are stripped from the box's first non-blank line only. There
|
||||
(whole-line: `input_placeholder` exactly or `input_placeholder_pattern` in
|
||||
full) are stripped from the box's first non-blank line only. There
|
||||
is no whole-pane fallback: a pane with none of these shapes has no input
|
||||
region and yields a non-empty "unknown" result rather than a guess.
|
||||
Raises ValueError for agents without input area facts (like hermes' peers).
|
||||
@@ -79,7 +80,8 @@ def extract_input_region(pane_text: str, agent_name: str) -> str:
|
||||
if first is not None:
|
||||
head = re.sub(r'^\s*' + re.escape(prompt) + r'\s*', '', region_lines[first], count=1)
|
||||
placeholder = adapter.input_placeholder
|
||||
if placeholder and head.strip() == placeholder:
|
||||
pattern = adapter.input_placeholder_pattern
|
||||
if (placeholder and head.strip() == placeholder) or (pattern and re.fullmatch(pattern, head.strip())):
|
||||
head = ''
|
||||
region_lines[first] = head
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import hashlib
|
||||
import re
|
||||
import shlex
|
||||
|
||||
def sanitize_herdr_agent_name(name: str) -> str:
|
||||
"""
|
||||
@@ -29,3 +30,14 @@ def sanitize_herdr_agent_name(name: str) -> str:
|
||||
|
||||
return s
|
||||
|
||||
|
||||
def attach_command(server, name):
|
||||
"""The registry row's attach_command (Issue #71).
|
||||
|
||||
A native herdr command, so it works from any shell: the server is always
|
||||
named (`default` too - herdr's env-free default), and the agent by the name
|
||||
herdr registered (sanitize_herdr_agent_name). create, resume and reconcile
|
||||
all write it from here.
|
||||
"""
|
||||
return "herdr --session %s agent attach %s" % (
|
||||
shlex.quote(server or "default"), shlex.quote(sanitize_herdr_agent_name(name)))
|
||||
@@ -0,0 +1,351 @@
|
||||
"""Stop, purge, and resume conversation-id decisions (Issue #63).
|
||||
|
||||
Post-discovered agents (agy, codex, hermes, opencode) write their conversation
|
||||
id to disk only after their first turn, and agy's workspace cache holds one id
|
||||
per cwd. Stop, purge, and resume therefore use the row's own recorded id first
|
||||
(never falling through to a workspace guess when a recorded id is present), and
|
||||
otherwise use the #40 active-session capture when herdr is alive. Pre-assigned
|
||||
agents (claude, grok) keep find_workspace_uuid with other rows' claimed ids
|
||||
excluded.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from lib_py.agents.autopin import OWN_KEYS, capture, claimed_elsewhere
|
||||
from lib_py.agents.base import DiscoveryContext, POST_DISCOVERED
|
||||
from lib_py.agents.registry import agent_of_row, get_adapter
|
||||
from lib_py.paths import resolve_home
|
||||
from lib_py.state import load_state_json
|
||||
from lib_py.verify_session import verify_session_uuid
|
||||
from lib_py.workspace_uuid import find_workspace_uuid
|
||||
|
||||
|
||||
def canonical_cwd(path: str) -> str:
|
||||
if not path:
|
||||
return ""
|
||||
try:
|
||||
return os.path.realpath(path)
|
||||
except Exception:
|
||||
return os.path.abspath(path)
|
||||
|
||||
|
||||
def own_id(row: Optional[Dict[str, Any]], agent: str) -> str:
|
||||
adapter = get_adapter(agent)
|
||||
if not adapter or not isinstance(row, dict):
|
||||
return ""
|
||||
val = row.get(adapter.own_key)
|
||||
return str(val).strip() if val else ""
|
||||
|
||||
|
||||
def claiming_rows(row: Dict[str, Any], uuid: str, state: Dict[str, Any]) -> List[str]:
|
||||
if not uuid:
|
||||
return []
|
||||
me = row.get("name")
|
||||
holders: List[str] = []
|
||||
for other in state.get("herdr_sessions") or []:
|
||||
if other is row or (me and other.get("name") == me):
|
||||
continue
|
||||
if any(other.get(k) == uuid for k in OWN_KEYS):
|
||||
other_name = str(other.get("name") or "<unnamed>")
|
||||
if other_name not in holders:
|
||||
holders.append(other_name)
|
||||
return holders
|
||||
|
||||
|
||||
def same_type_rows(state: Dict[str, Any], cwd: str, agent: str) -> List[Dict[str, Any]]:
|
||||
if not cwd or not agent:
|
||||
return []
|
||||
target_cwd = canonical_cwd(cwd)
|
||||
out: List[Dict[str, Any]] = []
|
||||
for s in state.get("herdr_sessions") or []:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
s_cwd = (s.get("pane") or {}).get("cwd") or ""
|
||||
if s_cwd and canonical_cwd(s_cwd) == target_cwd and agent_of_row(s) == agent:
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def row_for_capture(row: Optional[Dict[str, Any]], pane_pid: Optional[int],
|
||||
cwd: str = "", name: str = "", agent: str = "") -> Dict[str, Any]:
|
||||
copy = dict(row) if isinstance(row, dict) else {}
|
||||
if name and not copy.get("name"):
|
||||
copy["name"] = name
|
||||
if agent and not copy.get("agent"):
|
||||
copy["agent"] = agent
|
||||
pane = dict(copy.get("pane") or {})
|
||||
if cwd and not pane.get("cwd"):
|
||||
pane["cwd"] = cwd
|
||||
if isinstance(pane_pid, int) and pane_pid > 0:
|
||||
pane["pid"] = pane_pid
|
||||
copy["pane"] = pane
|
||||
return copy
|
||||
|
||||
|
||||
def _find_row(state: Dict[str, Any], name: str) -> Optional[Dict[str, Any]]:
|
||||
if not name:
|
||||
return None
|
||||
for s in state.get("herdr_sessions") or []:
|
||||
if isinstance(s, dict) and s.get("name") == name:
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def _parse_pid(raw: Any) -> Optional[int]:
|
||||
if isinstance(raw, int):
|
||||
return raw if raw > 0 else None
|
||||
s = str(raw or "").strip()
|
||||
return int(s) if s.isdigit() and int(s) > 0 else None
|
||||
|
||||
|
||||
def decide_stop(state: Dict[str, Any], name: str, agent: str, cwd: str,
|
||||
herdr_alive: bool, pane_pid: Optional[int] = None,
|
||||
purge: bool = False, home: Optional[str] = None,
|
||||
claude_dir: Optional[str] = None) -> Dict[str, str]:
|
||||
home_resolved = resolve_home(home)
|
||||
claude_resolved = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR") or f"{home_resolved}/.claude/projects"
|
||||
row = _find_row(state, name)
|
||||
row_ref = row if row is not None else ({"name": name} if name else {})
|
||||
claimed = set(claimed_elsewhere(row_ref, state))
|
||||
adapter = get_adapter(agent)
|
||||
own = own_id(row, agent)
|
||||
|
||||
if own:
|
||||
if own in claimed:
|
||||
others = ", ".join(claiming_rows(row_ref, own, state))
|
||||
return {
|
||||
"captured": "",
|
||||
"status": "sibling-claimed",
|
||||
"purge_uuid": "",
|
||||
"note": f"{own} is also recorded by {others}; registry needs fixing",
|
||||
}
|
||||
if adapter and adapter.id_lifecycle == POST_DISCOVERED:
|
||||
if verify_session_uuid(cwd, agent, own, row, home_dir=home_resolved,
|
||||
claude_dir=claude_resolved, mode="revalidate"):
|
||||
return {
|
||||
"captured": own,
|
||||
"status": "already-set",
|
||||
"purge_uuid": own if purge else "",
|
||||
"note": "",
|
||||
}
|
||||
ctx = DiscoveryContext(workspace=cwd, agent_name=agent, home_dir=home_resolved,
|
||||
claude_dir=claude_resolved, row=row, mode="revalidate")
|
||||
can_purge = bool(purge and adapter.has_artifacts(own, ctx))
|
||||
return {
|
||||
"captured": "",
|
||||
"status": "unverified-own",
|
||||
"purge_uuid": own if can_purge else "",
|
||||
"note": f"recorded id {own} no longer verifies; kept as is",
|
||||
}
|
||||
|
||||
if adapter is None or adapter.id_lifecycle != POST_DISCOVERED:
|
||||
found = find_workspace_uuid(cwd, agent, name, state, home_resolved, claude_resolved,
|
||||
exclude_ids=claimed) if cwd else ""
|
||||
if found and found in claimed:
|
||||
others = ", ".join(claiming_rows(row_ref, found, state))
|
||||
return {
|
||||
"captured": "",
|
||||
"status": "sibling-claimed",
|
||||
"purge_uuid": "",
|
||||
"note": f"{found} is also recorded by {others}; registry needs fixing",
|
||||
}
|
||||
return {
|
||||
"captured": found,
|
||||
"status": "resolved" if found else "no-candidate",
|
||||
"purge_uuid": found if purge else "",
|
||||
"note": "" if found else "nothing on disk yet",
|
||||
}
|
||||
|
||||
if herdr_alive:
|
||||
cap_row = row_for_capture(row, _parse_pid(pane_pid), cwd=cwd, name=name, agent=agent)
|
||||
res = capture(cap_row, agent, state, home_dir=home_resolved)
|
||||
if res.get("status") == "captured" and res.get("session_id"):
|
||||
u = str(res["session_id"])
|
||||
if u in claimed:
|
||||
others = ", ".join(claiming_rows(row_ref, u, state))
|
||||
return {
|
||||
"captured": "",
|
||||
"status": "sibling-claimed",
|
||||
"purge_uuid": "",
|
||||
"note": f"{u} is also recorded by {others}; registry needs fixing",
|
||||
}
|
||||
return {
|
||||
"captured": u,
|
||||
"status": "captured",
|
||||
"purge_uuid": u if purge else "",
|
||||
"note": "",
|
||||
}
|
||||
st = str(res.get("status") or "no-candidate")
|
||||
reason = str(res.get("reason") or "")
|
||||
return {
|
||||
"captured": "",
|
||||
"status": st,
|
||||
"purge_uuid": "",
|
||||
"note": f"{st}: {reason}" if reason else st,
|
||||
}
|
||||
|
||||
return {
|
||||
"captured": "",
|
||||
"status": "herdr-dead",
|
||||
"purge_uuid": "",
|
||||
"note": "herdr session was already gone, so the pane that owns the conversation could not be checked; no id recorded",
|
||||
}
|
||||
|
||||
|
||||
def decide_resume(state: Dict[str, Any], name: str, agent: str, workspace: str,
|
||||
herdr_alive: bool = False, pane_pid: Optional[int] = None,
|
||||
home: Optional[str] = None,
|
||||
claude_dir: Optional[str] = None) -> Dict[str, Any]:
|
||||
home_resolved = resolve_home(home)
|
||||
claude_resolved = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR") or f"{home_resolved}/.claude/projects"
|
||||
row = _find_row(state, name)
|
||||
adapter = get_adapter(agent)
|
||||
if row is None and not name and workspace:
|
||||
same = same_type_rows(state, workspace, agent)
|
||||
if len(same) == 1:
|
||||
row = same[0]
|
||||
name = str(row.get("name") or "")
|
||||
elif adapter is None or adapter.id_lifecycle != POST_DISCOVERED:
|
||||
creator = next(
|
||||
(s for s in same if str(s.get("name") or "").endswith(f"-creator-{agent}")),
|
||||
None,
|
||||
)
|
||||
if creator is not None:
|
||||
row = creator
|
||||
name = str(row.get("name") or "")
|
||||
row_ref = row if row is not None else ({"name": name} if name else {})
|
||||
claimed = set(claimed_elsewhere(row_ref, state))
|
||||
own = own_id(row, agent)
|
||||
|
||||
if own:
|
||||
if own in claimed:
|
||||
others = ", ".join(claiming_rows(row_ref, own, state))
|
||||
return {
|
||||
"uuid": "",
|
||||
"drop_stale_id": False,
|
||||
"notes": [],
|
||||
"error": (f"ERROR: {name}'s recorded id {own} is also recorded by {others}; "
|
||||
"refusing to resume on a conversation another session claims (Issue #63). "
|
||||
"One of these rows holds the wrong id - fix the registry before resuming."),
|
||||
}
|
||||
if adapter and adapter.id_lifecycle == POST_DISCOVERED:
|
||||
if verify_session_uuid(workspace, agent, own, row, home_dir=home_resolved,
|
||||
claude_dir=claude_resolved, mode="revalidate"):
|
||||
return {"uuid": own, "drop_stale_id": False, "notes": [], "error": None}
|
||||
if herdr_alive:
|
||||
return {"uuid": own, "drop_stale_id": False, "notes": [], "error": None}
|
||||
note = (f"NOTE: {name}'s recorded {agent} id {own} no longer verifies "
|
||||
"(its conversation is missing or empty); starting a new conversation (Issue #63)")
|
||||
return {"uuid": "", "drop_stale_id": True, "notes": [note], "error": None}
|
||||
|
||||
if adapter is None or adapter.id_lifecycle != POST_DISCOVERED:
|
||||
u = find_workspace_uuid(workspace, agent, name, state, home_resolved, claude_resolved,
|
||||
exclude_ids=claimed)
|
||||
if u and u in claimed:
|
||||
others = ", ".join(claiming_rows(row_ref, u, state))
|
||||
return {
|
||||
"uuid": "",
|
||||
"drop_stale_id": False,
|
||||
"notes": [],
|
||||
"error": (f"ERROR: {name or agent}'s resolved id {u} is also recorded by {others}; "
|
||||
"refusing to resume on a conversation another session claims (Issue #63)."),
|
||||
}
|
||||
return {"uuid": u, "drop_stale_id": False, "notes": [], "error": None}
|
||||
|
||||
if herdr_alive:
|
||||
cap_row = row_for_capture(row, _parse_pid(pane_pid), cwd=workspace, name=name, agent=agent)
|
||||
res = capture(cap_row, agent, state, home_dir=home_resolved)
|
||||
u = str(res["session_id"]) if res.get("status") == "captured" and res.get("session_id") else ""
|
||||
else:
|
||||
same = same_type_rows(state, workspace, agent)
|
||||
n = len(same)
|
||||
if name and all(r.get("name") != name for r in same):
|
||||
n += 1
|
||||
if n >= 2:
|
||||
label = name or agent
|
||||
note = (f"NOTE: {label} has no recorded {agent} id and {n} {agent} sessions share "
|
||||
f"{workspace}; the cache cannot tell them apart, so it starts a new conversation (Issue #63)")
|
||||
return {"uuid": "", "drop_stale_id": False, "notes": [note], "error": None}
|
||||
u = find_workspace_uuid(workspace, agent, name, state, home_resolved, claude_resolved,
|
||||
exclude_ids=claimed)
|
||||
|
||||
if u and u in claimed:
|
||||
others = ", ".join(claiming_rows(row_ref, u, state))
|
||||
return {
|
||||
"uuid": "",
|
||||
"drop_stale_id": False,
|
||||
"notes": [],
|
||||
"error": (f"ERROR: {name or agent}'s resolved id {u} is also recorded by {others}; "
|
||||
"refusing to resume on a conversation another session claims (Issue #63)."),
|
||||
}
|
||||
return {"uuid": u, "drop_stale_id": False, "notes": [], "error": None}
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
p = argparse.ArgumentParser(prog="python -m lib_py.agents.session_ids")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
s_stop = sub.add_parser("stop")
|
||||
s_stop.add_argument("--session", required=True)
|
||||
s_stop.add_argument("--agent", required=True)
|
||||
s_stop.add_argument("--cwd", default="")
|
||||
s_stop.add_argument("--herdr-alive", default="0")
|
||||
s_stop.add_argument("--pane-pid", default="")
|
||||
s_stop.add_argument("--purge", default="0")
|
||||
|
||||
s_res = sub.add_parser("resume")
|
||||
s_res.add_argument("--session", default="")
|
||||
s_res.add_argument("--agent", required=True)
|
||||
s_res.add_argument("--workspace", required=True)
|
||||
s_res.add_argument("--herdr-alive", default="0")
|
||||
s_res.add_argument("--pane-pid", default="")
|
||||
s_res.add_argument("--shell", action="store_true")
|
||||
|
||||
a = p.parse_args(argv)
|
||||
state = load_state_json()
|
||||
q = shlex.quote
|
||||
|
||||
if a.cmd == "stop":
|
||||
res = decide_stop(
|
||||
state=state,
|
||||
name=a.session,
|
||||
agent=a.agent,
|
||||
cwd=a.cwd,
|
||||
herdr_alive=(a.herdr_alive == "1"),
|
||||
pane_pid=_parse_pid(a.pane_pid),
|
||||
purge=(a.purge == "1"),
|
||||
)
|
||||
print(f"CAPTURED_UUID={q(res['captured'])}")
|
||||
print(f"CAPTURE_STATUS={q(res['status'])}")
|
||||
print(f"PURGE_UUID={q(res['purge_uuid'])}")
|
||||
print(f"STOP_ID_NOTE={q(res['note'])}")
|
||||
return 0
|
||||
|
||||
res = decide_resume(
|
||||
state=state,
|
||||
name=a.session,
|
||||
agent=a.agent,
|
||||
workspace=a.workspace,
|
||||
herdr_alive=(a.herdr_alive == "1"),
|
||||
pane_pid=_parse_pid(a.pane_pid),
|
||||
)
|
||||
for note in res.get("notes") or []:
|
||||
print(note, file=sys.stderr)
|
||||
if res.get("error"):
|
||||
print(res["error"], file=sys.stderr)
|
||||
return 1
|
||||
if a.shell:
|
||||
print(f"RESUME_UUID={q(res['uuid'])}")
|
||||
print(f"DROP_STALE_ID={'1' if res.get('drop_stale_id') else '0'}")
|
||||
else:
|
||||
print(res["uuid"])
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -4,6 +4,7 @@
|
||||
import os, sys, tempfile, shutil, glob, subprocess, json, sqlite3, fcntl
|
||||
from datetime import datetime, timezone
|
||||
import yaml
|
||||
from lib_py import registry_format
|
||||
|
||||
def atomic_dump_yaml_main():
|
||||
yaml_path = os.environ['YAML_PATH']
|
||||
@@ -114,6 +115,27 @@ def atomic_dump_yaml_main():
|
||||
elif 'herdr_sessions' not in d:
|
||||
d['herdr_sessions'] = []
|
||||
|
||||
# Issue #71: an older registry is migrated here, under the lock and
|
||||
# before the caller's mutation. It changes row fields only, so the
|
||||
# YAML is rewritten whether or not the mutation changes anything.
|
||||
from_version = registry_format.stored_version(d)
|
||||
migrated_rows = 0
|
||||
force_yaml = False
|
||||
if from_version < registry_format.CURRENT:
|
||||
d, migrated_rows = registry_format.migrate(d)
|
||||
force_yaml = True
|
||||
elif row:
|
||||
# The DB is current; its YAML copy may not be (a migration whose
|
||||
# YAML rewrite failed after the DB commit). Then rewrite it.
|
||||
y = None
|
||||
if os.path.exists(yaml_path):
|
||||
try:
|
||||
with open(yaml_path) as f:
|
||||
y = yaml.safe_load(f)
|
||||
except Exception:
|
||||
y = None
|
||||
force_yaml = registry_format.stored_version(y) < registry_format.CURRENT
|
||||
|
||||
old_sessions = get_all_sessions_status(d)
|
||||
# a mutation that replaces a row the monitor registered for a pane it
|
||||
# created itself (create_session.sh) drops that name from old_roles
|
||||
@@ -167,7 +189,7 @@ def atomic_dump_yaml_main():
|
||||
|
||||
conn.commit()
|
||||
|
||||
if new_sessions != old_sessions:
|
||||
if force_yaml or new_sessions != old_sessions:
|
||||
if os.path.exists(yaml_path):
|
||||
try:
|
||||
shutil.copy2(yaml_path, yaml_path + '.bak')
|
||||
@@ -207,6 +229,7 @@ def atomic_dump_yaml_main():
|
||||
_flock_f.close()
|
||||
except Exception:
|
||||
pass
|
||||
return from_version, registry_format.stored_version(d), migrated_rows
|
||||
|
||||
if __name__ == '__main__':
|
||||
atomic_dump_yaml_main()
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
Behaviour is byte-for-byte compatible with the shell original (issue #5 phase 1,
|
||||
D5): same monitor path, same stderr wording and exit status when it is missing,
|
||||
same `pgrep -f` pre-check pattern, same log file, same "print the PID" contract.
|
||||
the same daemons found by the pre-check (matched exactly since Issue #72, see
|
||||
find_running), same log file, same "print the PID" contract.
|
||||
Callers capture stdout with $( ), so nothing else may be printed there.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -39,20 +40,34 @@ def monitor_script_path(workdir: str) -> str:
|
||||
def find_running(monitor_script: str) -> List[str]:
|
||||
"""PIDs of subscribers already running for this monitor script.
|
||||
|
||||
Uses the exact `pgrep -f` pattern the shell helper used, so a daemon started
|
||||
by either implementation is found by the other. Multiple matches are returned
|
||||
in full: the legacy helper echoed every line and callers only ever echo it
|
||||
back, so trimming here would be a silent behaviour change.
|
||||
A subscriber is a process whose command line is `<bash> <monitor_script>
|
||||
--subscribe[ ...]`, argv0 a bash, as both this module and the shell helper
|
||||
start it. Issue #72: matched exactly (lib.sh _monitor_pids is the shell
|
||||
twin the legacy helper uses), not as a `pgrep -f` regex, because the update skill signals what this returns - a daemon of
|
||||
another workspace the unescaped path also matched, or a process that only
|
||||
names the path in an argument, is not this workspace's monitor. Multiple
|
||||
matches are returned in full: the legacy helper echoed every line and
|
||||
callers only ever echo it back, so trimming here would be a silent
|
||||
behaviour change.
|
||||
"""
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["pgrep", "-f", "bash {} --subscribe".format(monitor_script)],
|
||||
["ps", "-A", "-ww", "-o", "pid=", "-o", "args="],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
except OSError as exc: # pragma: no cover - pgrep missing
|
||||
logger.warning("pgrep unavailable: %s", exc)
|
||||
except OSError as exc: # pragma: no cover - ps missing
|
||||
logger.warning("ps unavailable: %s", exc)
|
||||
return []
|
||||
return [line for line in res.stdout.split() if line.strip()]
|
||||
want = "{} --subscribe".format(monitor_script)
|
||||
pids = []
|
||||
for line in res.stdout.splitlines():
|
||||
parts = line.strip().split(" ", 1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
argv0, _, rest = parts[1].partition(" ")
|
||||
if os.path.basename(argv0) == "bash" and (rest == want or rest.startswith(want + " ")):
|
||||
pids.append(parts[0])
|
||||
return pids
|
||||
|
||||
|
||||
def _ignore_sighup(): # pragma: no cover - runs in the forked child
|
||||
|
||||
@@ -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.12.0"
|
||||
|
||||
CORE_MODULES: Sequence[str] = (
|
||||
# Standard library modules
|
||||
|
||||
@@ -676,6 +676,7 @@ class LoopRunner:
|
||||
if status == "completed":
|
||||
if self.verbose:
|
||||
log_success(f"Job '{job_id}' completed successfully.")
|
||||
self._autopin(job_id)
|
||||
return True
|
||||
elif status in ("error", "cancelled"):
|
||||
log_error(f"Job '{job_id}' finished with status '{status}'.")
|
||||
@@ -686,6 +687,23 @@ class LoopRunner:
|
||||
log_error(f"Job '{job_id}' timed out after {max_wait}s.")
|
||||
return False
|
||||
|
||||
def _autopin(self, job_id: str) -> None:
|
||||
"""Issue #40 recovery path: the handler the wrapper already ran for this
|
||||
job (one job/token/attempt is handled once), run like the wrapper from
|
||||
the control root's skills - lib_py.loop imports no sibling but
|
||||
delegation (G9). Never fails the wait; the handler reports on stderr."""
|
||||
skills = self.workspace_root / ".agents" / "skills"
|
||||
if not (skills / "lib_py" / "agents" / "autopin.py").exists():
|
||||
skills = Path(__file__).resolve().parents[2]
|
||||
yaml_path = os.environ.get("AGENT_SESSIONS_YAML") or str(self.mam_dir / "agent-sessions.yaml")
|
||||
env = dict(os.environ)
|
||||
env["PYTHONPATH"] = str(skills) + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
|
||||
try:
|
||||
subprocess.run([sys.executable, "-m", "lib_py.agents.autopin", "--registry-dir", str(self.jobs_dir),
|
||||
"--job", job_id, "--yaml", yaml_path], env=env, timeout=300)
|
||||
except Exception as exc:
|
||||
log_warn(f"autopin for job '{job_id}' failed: {exc}")
|
||||
|
||||
def _relay_while_submitting(self, label: str, since: str, stop: threading.Event) -> None:
|
||||
"""Issue #30 (review R2): the wrapper runs until its job ends and its
|
||||
output is captured, so a blocked agent would only reach the loop log
|
||||
|
||||
@@ -18,6 +18,9 @@ one lock per server from the choice to the claim.
|
||||
pane_lock.py acquire --fd N flock(LOCK_EX) on the inherited fd N; prints
|
||||
`locked`, or `timeout` once MAM_PANE_CLAIM_WAIT_S
|
||||
(default 120, minimum 1) has passed
|
||||
[--shared] [--wait-env VAR] Issue #72: LOCK_SH instead, and the wait read
|
||||
from VAR (same default and minimum) - the
|
||||
update admission lock (lib.sh mam_update_admit)
|
||||
|
||||
The lock is a BSD flock, which belongs to the open file on fd 9: it outlives
|
||||
this helper and ends when the last process holding that fd ends, however it
|
||||
@@ -63,11 +66,11 @@ def lock_path(session: Optional[str], env: Mapping[str, str]) -> str:
|
||||
return os.path.join(home, ".local", "state", "mam", "pane-lock", session + ".lock")
|
||||
|
||||
|
||||
def wait_s(env: Mapping[str, str]) -> int:
|
||||
return ints.positive_int(env.get("MAM_PANE_CLAIM_WAIT_S"), 1) or DEFAULT_WAIT_S
|
||||
def wait_s(env: Mapping[str, str], var: str = "MAM_PANE_CLAIM_WAIT_S") -> int:
|
||||
return ints.positive_int(env.get(var), 1) or DEFAULT_WAIT_S
|
||||
|
||||
|
||||
def acquire(fd: int, wait: float) -> str:
|
||||
def acquire(fd: int, wait: float, shared: bool = False) -> str:
|
||||
"""`locked`, or `timeout` when the lock was not free within `wait` s. The
|
||||
deadline is on the monotonic clock; a step of the wall clock does not
|
||||
stretch or cut it. Only contention is waited out: any other error (a bad
|
||||
@@ -75,7 +78,7 @@ def acquire(fd: int, wait: float) -> str:
|
||||
deadline = time.monotonic() + wait
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
fcntl.flock(fd, (fcntl.LOCK_SH if shared else fcntl.LOCK_EX) | fcntl.LOCK_NB)
|
||||
return "locked"
|
||||
except BlockingIOError:
|
||||
if time.monotonic() >= deadline:
|
||||
@@ -90,11 +93,13 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
s.add_argument("--session", default="")
|
||||
s = sub.add_parser("acquire")
|
||||
s.add_argument("--fd", type=int, required=True)
|
||||
s.add_argument("--shared", action="store_true")
|
||||
s.add_argument("--wait-env", default="MAM_PANE_CLAIM_WAIT_S")
|
||||
a = p.parse_args(argv)
|
||||
if a.cmd == "path":
|
||||
print(lock_path(a.session, os.environ))
|
||||
else:
|
||||
print(acquire(a.fd, wait_s(os.environ)))
|
||||
print(acquire(a.fd, wait_s(os.environ, a.wait_env), a.shared))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Registry format version and its migration (Issue #71).
|
||||
|
||||
The session registry (agent-sessions.yaml + agent-sessions.db) carries a
|
||||
top-level `registry_format` key; it lives in the DB `state` row like every
|
||||
other top-level key. A registry without it is format 1.
|
||||
|
||||
1: rows may carry `kill_command` and `start_command`, and `attach_command`
|
||||
reads `HERDR_SESSION_NAME=<server> herdr agent attach <original name>`.
|
||||
2: no `kill_command` / `start_command`; `attach_command` is
|
||||
lib_py.agents.sanitize.attach_command(<row server>, <row name>).
|
||||
|
||||
The writer (lib_py.atomic_yaml, the only code that writes the registry)
|
||||
migrates an older registry under its lock before the caller's mutation, and
|
||||
then rewrites the YAML as well as the DB. Readers (lib_py.state) never
|
||||
migrate; they see the old rows until the first write.
|
||||
|
||||
Entry point (for #72, the update skill):
|
||||
|
||||
python -m lib_py.registry_format --yaml <agent-sessions.yaml>
|
||||
|
||||
runs that migration through the writer (same lock, YAML rewritten), prints one
|
||||
line `registry-format: <from> -> <to>, rows changed: <n>` and exits 0 when the
|
||||
registry was migrated or already current, nonzero on error. Run it with the
|
||||
skills directory on PYTHONPATH (as lib.sh does), e.g.
|
||||
`PYTHONPATH=.agents/skills python3 -m lib_py.registry_format --yaml .mam/agent-sessions.yaml`.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import os
|
||||
import sys
|
||||
|
||||
from lib_py.agents.sanitize import attach_command
|
||||
|
||||
FORMAT_KEY = "registry_format"
|
||||
CURRENT = 2
|
||||
_DROPPED = ("kill_command", "start_command")
|
||||
|
||||
|
||||
def stored_version(d):
|
||||
"""The format a registry dict says it has (1 when it says nothing)."""
|
||||
v = d.get(FORMAT_KEY) if isinstance(d, dict) else None
|
||||
return v if isinstance(v, int) and not isinstance(v, bool) else 1
|
||||
|
||||
|
||||
def _row_server(s):
|
||||
return s.get("herdr_session") or s.get("herdr_server") or "default"
|
||||
|
||||
|
||||
def migrate(d):
|
||||
"""Return (new registry dict, rows changed). Pure and idempotent: `d` is
|
||||
not modified, and migrating the result again changes nothing. Every row is
|
||||
migrated, `stopped` and `terminated` ones included."""
|
||||
out = copy.deepcopy(d) if isinstance(d, dict) else {}
|
||||
changed = 0
|
||||
for s in out.get("herdr_sessions") or []:
|
||||
if not isinstance(s, dict) or not s.get("name"):
|
||||
continue
|
||||
before = dict(s)
|
||||
for k in _DROPPED:
|
||||
s.pop(k, None)
|
||||
s["attach_command"] = attach_command(_row_server(s), s["name"])
|
||||
if s != before:
|
||||
changed += 1
|
||||
out[FORMAT_KEY] = CURRENT
|
||||
return out, changed
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(prog="python -m lib_py.registry_format",
|
||||
description="Migrate the session registry to the current format.")
|
||||
ap.add_argument("--yaml", required=True, help="path of agent-sessions.yaml")
|
||||
a = ap.parse_args(argv)
|
||||
yaml_path = os.path.abspath(a.yaml)
|
||||
db_path = os.path.splitext(yaml_path)[0] + ".db"
|
||||
if not (os.path.exists(yaml_path) or os.path.exists(db_path)):
|
||||
print("registry-format: error: no registry at %s" % yaml_path, file=sys.stderr)
|
||||
return 1
|
||||
from lib_py.atomic_yaml import atomic_dump_yaml_main
|
||||
os.environ["YAML_PATH"] = yaml_path
|
||||
os.environ["AGENT_SESSIONS_MUTATION"] = "pass"
|
||||
try:
|
||||
frm, to, n = atomic_dump_yaml_main()
|
||||
except (Exception, SystemExit) as exc: # the writer refuses with SystemExit
|
||||
print("registry-format: error: %s" % (exc,), file=sys.stderr)
|
||||
return 1
|
||||
print("registry-format: %d -> %d, rows changed: %d" % (frm, to, n), flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -38,6 +38,7 @@ from typing import List, Optional
|
||||
|
||||
from lib_py.agents.input_region import extract_input_region
|
||||
from lib_py.agents.registry import agent_of_row, get_adapter
|
||||
from lib_py.agents.sanitize import sanitize_herdr_agent_name
|
||||
from lib_py.delegation.engine import TERMINAL_STATUSES
|
||||
from lib_py.loop import watcher
|
||||
|
||||
@@ -154,8 +155,12 @@ class HerdrError(Exception):
|
||||
class HerdrSource(object):
|
||||
"""Every herdr and OS read the judgement needs, in one place."""
|
||||
|
||||
def __init__(self, target, agent, session=None):
|
||||
def __init__(self, target, agent, session=None, *, name):
|
||||
self.target, self.session = target, session or None
|
||||
# `name` is the name herdr knows the agent by (#66), worked out once
|
||||
# by StallWatch (#71); the kind is read off the original name, whose
|
||||
# suffix a long name's hash replaces.
|
||||
self.name = name
|
||||
self.agent = resolve_agent(agent, target)
|
||||
adapter = get_adapter(self.agent) if self.agent else None
|
||||
self.resident = tuple(adapter.resident_children) if adapter else ()
|
||||
@@ -176,7 +181,7 @@ class HerdrSource(object):
|
||||
return proc.stdout
|
||||
|
||||
def agent_get(self):
|
||||
payload = json.loads(self._herdr("agent", "get", self.target) or "{}")
|
||||
payload = json.loads(self._herdr("agent", "get", self.name) or "{}")
|
||||
return (payload.get("result") or {}).get("agent") or {}
|
||||
|
||||
def agent_pid(self, pane_id):
|
||||
@@ -199,12 +204,12 @@ class HerdrSource(object):
|
||||
if not self.agent:
|
||||
return None
|
||||
try:
|
||||
return extract_input_region(self._herdr("agent", "read", self.target), self.agent) == ""
|
||||
return extract_input_region(self._herdr("agent", "read", self.name), self.agent) == ""
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def prompt(self, text):
|
||||
self._herdr("agent", "prompt", self.target, text)
|
||||
self._herdr("agent", "prompt", self.name, text)
|
||||
|
||||
def collect(self, sig: Signals) -> Signals:
|
||||
"""Fill the process and input signals of an idle agent."""
|
||||
@@ -221,7 +226,8 @@ class HerdrSource(object):
|
||||
class StallWatch(object):
|
||||
def __init__(self, args, source=None):
|
||||
self.args = args
|
||||
self.src = source or HerdrSource(args.target, args.agent, args.herdr_session)
|
||||
self.herdr_name = sanitize_herdr_agent_name(args.target) # for herdr only; logs keep args.target
|
||||
self.src = source or HerdrSource(args.target, args.agent, args.herdr_session, name=self.herdr_name)
|
||||
name = resolve_agent(args.agent, args.target)
|
||||
adapter = get_adapter(name) if name else None
|
||||
reliable = bool(adapter.agent_status_reliable) if adapter else True
|
||||
@@ -323,7 +329,7 @@ class StallWatch(object):
|
||||
until, tmo = ("idle", "done", "working"), 60000
|
||||
else:
|
||||
until, tmo = ("idle", "done", "blocked"), 60000
|
||||
out = watcher.wait_once(self.args.target, until=until, timeout_ms=tmo, session=self.args.herdr_session)
|
||||
out = watcher.wait_once(self.herdr_name, until=until, timeout_ms=tmo, session=self.args.herdr_session)
|
||||
if self._wrapper_gone(): # round-2 review D2: before any notice
|
||||
return RC_TERMINAL
|
||||
if out.kind in (watcher.TRANSPORT_ERROR, watcher.AGENT_NOT_FOUND):
|
||||
@@ -425,7 +431,7 @@ def main(argv=None) -> int:
|
||||
p = argparse.ArgumentParser(description="notify-only stall watcher for one local herdr job (#30)")
|
||||
p.add_argument("--job", required=True)
|
||||
p.add_argument("--registry-dir", required=True)
|
||||
p.add_argument("--target", required=True, help="herdr agent name of the job's session")
|
||||
p.add_argument("--target", required=True, help="session name of the job (herdr is called with its sanitized form)")
|
||||
p.add_argument("--agent", required=True, help="agent CLI (adapter name)")
|
||||
p.add_argument("--herdr-session", default="")
|
||||
p.add_argument("--expect-dispatch-token", default=None)
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
"""Decisions behind the multi-agent-mux-update skill (Issue #72).
|
||||
|
||||
update.sh keeps the orchestration in shell (phase 1: this tree's code) and asks
|
||||
this module what needs parsing. Standard library only, Python 3.9.
|
||||
|
||||
python -m lib_py.update precheck --workspace W --herdr H
|
||||
What refuses the update (comment 921, decisions 1 and 7): the running
|
||||
rows of the registry (MAM_STATE_JSON, from lib.sh `load_state_json
|
||||
strict`), every live herdr agent whose cwd is inside W on any server
|
||||
`herdr session list --json` reports running (read only: the real herdr H,
|
||||
never the shim, so no server is started), and the open jobs. The agent
|
||||
that called the update - by (server, pane): pane_lock.caller_server and
|
||||
HERDR_PANE_ID - is not counted, nor its row and its one running job.
|
||||
Prints one line per blocker with how to clear it; rc 0 nothing blocks,
|
||||
1 something does (a herdr or registry that cannot be read included).
|
||||
python -m lib_py.update monitor --workspace W
|
||||
JSON {"pids": [...], "lock": "free"|"held"}: the monitor daemons
|
||||
daemon.find_running recognises, and whether .mam/monitor.lock is held.
|
||||
python -m lib_py.update target-workspaces --workspace W --agent-session S --server V --herdr H
|
||||
The workspaces other than W whose update admission a delegate submit
|
||||
to `herdr:<name>` must take (decision 8), one per line: every installed
|
||||
workspace at or above the target's cwd (its row's pane.cwd; with no row
|
||||
the live agent's on server V) and its row's control_plane
|
||||
workspace_root. Nothing for a non-herdr target. rc 1: herdr cannot
|
||||
answer for a rowless target.
|
||||
python -m lib_py.update target-workspaces --workspace W --cwd C
|
||||
The same for an agent create or resume starts in C: every installed
|
||||
workspace at or above C other than W.
|
||||
python -m lib_py.update backup-registry --workspace W --dest D
|
||||
Copies the registry into D under the registry's own write lock
|
||||
(agent-sessions.db.lock, as atomic_yaml takes it): the DB through the
|
||||
SQLite backup API (commits still in -wal included), the YAML and its
|
||||
.bak as files. Prints the names it copied.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Dict, Iterable, List, Mapping, Optional, Tuple
|
||||
|
||||
from lib_py.agents.sanitize import sanitize_herdr_agent_name
|
||||
from lib_py.delegation import daemon
|
||||
from lib_py.delegation.engine import TERMINAL_STATUSES
|
||||
from lib_py.pane_lock import caller_server
|
||||
|
||||
REGISTRY_FILES = ("agent-sessions.yaml", "agent-sessions.yaml.bak", "agent-sessions.db")
|
||||
HERDR_TIMEOUT_S = 20
|
||||
|
||||
|
||||
def _real(p: str) -> str:
|
||||
return os.path.realpath(p)
|
||||
|
||||
|
||||
def _inside(path: str, root: str) -> bool:
|
||||
if not path:
|
||||
return False
|
||||
p, r = _real(path), _real(root).rstrip(os.sep)
|
||||
return p == r or p.startswith(r + os.sep)
|
||||
|
||||
|
||||
def row_server(row: dict) -> str:
|
||||
return row.get("herdr_session") or row.get("herdr_server") or "default"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- herdr
|
||||
|
||||
class HerdrError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _herdr_json(herdr: str, args: List[str], env: Mapping[str, str]) -> dict:
|
||||
# A pane's own scope ids would narrow the answer to the caller's workspace.
|
||||
cenv = {k: v for k, v in env.items()
|
||||
if k not in ("HERDR_WORKSPACE_ID", "HERDR_PANE_ID", "HERDR_TAB_ID")}
|
||||
try:
|
||||
res = subprocess.run([herdr, *args], env=cenv, stdin=subprocess.DEVNULL,
|
||||
capture_output=True, text=True, timeout=HERDR_TIMEOUT_S)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise HerdrError("`herdr %s` failed: %s" % (" ".join(args), exc))
|
||||
if res.returncode != 0:
|
||||
raise HerdrError("`herdr %s` exited %d: %s" % (" ".join(args), res.returncode,
|
||||
(res.stderr or "").strip()[:200]))
|
||||
try:
|
||||
out = res.stdout
|
||||
return json.loads(out[out.index("{"):])
|
||||
except ValueError:
|
||||
raise HerdrError("`herdr %s` printed no JSON" % " ".join(args))
|
||||
|
||||
|
||||
def running_servers(herdr: str, env: Mapping[str, str]) -> List[str]:
|
||||
d = _herdr_json(herdr, ["session", "list", "--json"], env)
|
||||
sessions = d.get("sessions")
|
||||
if not isinstance(sessions, list):
|
||||
raise HerdrError("`herdr session list --json` has no sessions list")
|
||||
return [s["name"] for s in sessions
|
||||
if isinstance(s, dict) and s.get("running") and s.get("name")]
|
||||
|
||||
|
||||
def server_agents(herdr: str, server: str, env: Mapping[str, str]) -> List[dict]:
|
||||
d = _herdr_json(herdr, ["--session", server, "agent", "list"], env)
|
||||
agents = (d.get("result") or {}).get("agents")
|
||||
if not isinstance(agents, list):
|
||||
raise HerdrError("`herdr --session %s agent list` has no agents list" % server)
|
||||
return [a for a in agents if isinstance(a, dict)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- jobs
|
||||
|
||||
def _job_records(d: str) -> Iterable[Tuple[str, Optional[dict]]]:
|
||||
"""(path, record) per *.json; (path, None) when it cannot be read, and
|
||||
(d, None) when d itself cannot be listed (not an empty registry)."""
|
||||
try:
|
||||
names = sorted(n for n in os.listdir(d) if n.endswith(".json") and not n.startswith("."))
|
||||
except OSError:
|
||||
yield d, None
|
||||
return
|
||||
for p in (os.path.join(d, n) for n in names):
|
||||
try:
|
||||
with open(p, encoding="utf-8") as fh:
|
||||
rec = json.load(fh)
|
||||
except (OSError, ValueError):
|
||||
yield p, None
|
||||
continue
|
||||
yield p, rec if isinstance(rec, dict) else None
|
||||
|
||||
|
||||
def _is_jobs_dir(d: str) -> bool:
|
||||
"""d is a directory, or cannot be looked at (an unreadable parent): only an
|
||||
absent d (or a file) is skipped."""
|
||||
try:
|
||||
os.stat(d)
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
return False
|
||||
except OSError:
|
||||
return True
|
||||
return os.path.isdir(d)
|
||||
|
||||
|
||||
def _is_open(rec: dict) -> bool:
|
||||
return rec.get("status") not in TERMINAL_STATUSES and rec.get("dry_run") is not True
|
||||
|
||||
|
||||
def open_jobs(workspace: str, state: dict, env: Mapping[str, str]) -> List[dict]:
|
||||
"""Every open record (not completed/error/cancelled, not dry_run) in this
|
||||
workspace's .mam/jobs and MAM_JOBS_DIR; in another row's
|
||||
control_plane.mam_jobs_dir only those addressed to a row of this registry.
|
||||
A record, or a directory, that cannot be read is open (path only)."""
|
||||
own, seen = [], set()
|
||||
for c in (os.path.join(workspace, ".mam", "jobs"), env.get("MAM_JOBS_DIR") or ""):
|
||||
if c and _is_jobs_dir(c) and _real(c) not in seen:
|
||||
seen.add(_real(c))
|
||||
own.append(c)
|
||||
rows = state.get("herdr_sessions") or []
|
||||
names = {"herdr:%s" % s.get("name") for s in rows if s.get("name")}
|
||||
other = []
|
||||
for s in rows:
|
||||
c = (s.get("control_plane") or {}).get("mam_jobs_dir") or ""
|
||||
if c and _is_jobs_dir(c) and _real(c) not in seen:
|
||||
seen.add(_real(c))
|
||||
other.append(c)
|
||||
out = []
|
||||
for dirs, addressed_only in ((own, False), (other, True)):
|
||||
for d in dirs:
|
||||
for path, rec in _job_records(d):
|
||||
if rec is None:
|
||||
out.append({"path": path, "unreadable": True, "dir": path == d})
|
||||
elif _is_open(rec) and (not addressed_only or rec.get("agent_session") in names):
|
||||
out.append({"path": path, "job_id": rec.get("job_id") or "",
|
||||
"agent_session": rec.get("agent_session") or "",
|
||||
"status": rec.get("status"), "registry_dir": d})
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- precheck
|
||||
|
||||
def precheck(workspace: str, state: dict, herdr: str, env: Mapping[str, str]) -> List[str]:
|
||||
"""The lines that refuse the update; [] when nothing does."""
|
||||
blockers: List[str] = []
|
||||
stop = ("bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session %s"
|
||||
" (resume it after the update with multi-agent-mux-resume)")
|
||||
# A rowless agent: stop finds no row to take its server from (#71).
|
||||
stop_rowless = ("bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session %s"
|
||||
" --herdr-session %s")
|
||||
|
||||
# The caller (decision 7): its server and pane, both or nothing.
|
||||
me_server, me_pane = caller_server(env), env.get("HERDR_PANE_ID") or ""
|
||||
me_name: Optional[str] = None
|
||||
me_found = False
|
||||
|
||||
agents_by_server: Dict[str, List[dict]] = {}
|
||||
try:
|
||||
for server in running_servers(herdr, env):
|
||||
agents_by_server[server] = server_agents(herdr, server, env)
|
||||
except HerdrError as exc:
|
||||
blockers.append("herdr: cannot list the live agents (%s); fix herdr and try again" % exc)
|
||||
|
||||
if me_server and me_pane:
|
||||
for a in agents_by_server.get(me_server, []):
|
||||
if a.get("pane_id") == me_pane:
|
||||
me_found = True
|
||||
me_name = a.get("name") or None # a nameless pane is excluded by pane only
|
||||
break
|
||||
|
||||
rows = state.get("herdr_sessions") or []
|
||||
me_row = None
|
||||
if me_name:
|
||||
for s in rows:
|
||||
if (s.get("status") == "running" and row_server(s) == me_server
|
||||
and s.get("name") and sanitize_herdr_agent_name(s["name"]) == me_name):
|
||||
me_row = s
|
||||
break
|
||||
|
||||
for s in rows:
|
||||
if s.get("status") == "running" and s is not me_row:
|
||||
blockers.append("session %s is running (server %s); stop it: %s"
|
||||
% (s.get("name"), row_server(s), stop % shlex.quote(s.get("name") or "")))
|
||||
|
||||
row_keys = {(row_server(s), sanitize_herdr_agent_name(s["name"]))
|
||||
for s in rows if s.get("status") == "running" and s.get("name")}
|
||||
for server, agents in agents_by_server.items():
|
||||
for a in agents:
|
||||
if me_found and server == me_server and a.get("pane_id") == me_pane:
|
||||
continue
|
||||
if not (_inside(a.get("cwd") or "", workspace)
|
||||
or _inside(a.get("foreground_cwd") or "", workspace)):
|
||||
continue
|
||||
name = a.get("name") or ""
|
||||
if name and (server, name) in row_keys:
|
||||
continue # already listed as its running row
|
||||
blockers.append("herdr agent %s (pane %s, server %s) runs in %s; stop it: %s"
|
||||
% (name or "<unnamed>", a.get("pane_id"), server, a.get("cwd"),
|
||||
stop_rowless % (shlex.quote(name), shlex.quote(server)) if name else
|
||||
"herdr --session %s pane close %s"
|
||||
% (shlex.quote(server), shlex.quote(a.get("pane_id") or ""))))
|
||||
|
||||
jobs = open_jobs(workspace, state, env)
|
||||
if me_row is not None:
|
||||
mine = [j for j in jobs if not j.get("unreadable")
|
||||
and j.get("agent_session") == "herdr:%s" % me_row.get("name")
|
||||
and j.get("status") == "running"]
|
||||
if len(mine) == 1:
|
||||
jobs = [j for j in jobs if j is not mine[0]]
|
||||
for j in jobs:
|
||||
if j.get("dir"):
|
||||
blockers.append("jobs directory %s cannot be listed; fix its permissions, then try again"
|
||||
% j["path"])
|
||||
elif j.get("unreadable"):
|
||||
blockers.append("job record %s cannot be read; check it, then move or delete it by hand"
|
||||
% j["path"])
|
||||
else:
|
||||
blockers.append("job %s (%s, %s) is open; wait for it to end or close it: python "
|
||||
".agents/skills/multi-agent-mux-delegate-job/scripts/registry.py"
|
||||
" --registry-dir %s close --job %s --request cancelled"
|
||||
% (j["job_id"], j["agent_session"] or "-", j["status"],
|
||||
j["registry_dir"], j["job_id"]))
|
||||
|
||||
if blockers and not me_found:
|
||||
blockers.append("note: no herdr agent was found at the caller's server %r and pane %r, so"
|
||||
" nothing was excluded as the calling agent" % (me_server, me_pane))
|
||||
return blockers
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- delegate target
|
||||
|
||||
def _installed(d: str) -> bool:
|
||||
"""update.sh's own test of an installed workspace."""
|
||||
return os.path.isfile(os.path.join(d, ".mam_deploy", "remove.sh")) or os.path.isfile(os.path.join(d, "remove.sh"))
|
||||
|
||||
|
||||
def target_workspaces(workspace: str, state: dict, agent_session: str, server: str,
|
||||
herdr: str, env: Mapping[str, str]) -> List[str]:
|
||||
"""Every installed workspace at or above the target's cwd - an update of
|
||||
any of them counts that agent (decision 1) - and its row's
|
||||
control_plane.workspace_root; the caller's own workspace left out. With
|
||||
no row the cwd is the live agent's on <server> (the one delivery uses),
|
||||
read with the real herdr. No herdr, a server that is not running, or no
|
||||
such agent gives nothing (delivery then fails on its own); a herdr that
|
||||
cannot answer raises HerdrError."""
|
||||
if not agent_session.startswith("herdr:"):
|
||||
return []
|
||||
name = agent_session[len("herdr:"):]
|
||||
row = next((s for s in state.get("herdr_sessions") or []
|
||||
if isinstance(s, dict) and s.get("name") == name), None)
|
||||
cwds, out = [], []
|
||||
if row is not None:
|
||||
cwds.append((row.get("pane") or {}).get("cwd") or "")
|
||||
root = (row.get("control_plane") or {}).get("workspace_root") or ""
|
||||
if root:
|
||||
out.append(_real(root))
|
||||
elif herdr and server in running_servers(herdr, env):
|
||||
for a in server_agents(herdr, server, env):
|
||||
if a.get("name") in (name, sanitize_herdr_agent_name(name)):
|
||||
cwds += [a.get("cwd") or "", a.get("foreground_cwd") or ""]
|
||||
break
|
||||
return _others(workspace, out + installed_above(cwds))
|
||||
|
||||
|
||||
def installed_above(cwds: Iterable[str]) -> List[str]:
|
||||
"""Every installed workspace at or above each cwd."""
|
||||
out = []
|
||||
for cwd in cwds:
|
||||
d = _real(cwd) if cwd else ""
|
||||
while d:
|
||||
if _installed(d):
|
||||
out.append(d)
|
||||
if os.path.dirname(d) == d:
|
||||
break
|
||||
d = os.path.dirname(d)
|
||||
return out
|
||||
|
||||
|
||||
def _others(workspace: str, dirs: List[str]) -> List[str]:
|
||||
me = _real(workspace)
|
||||
return [d for i, d in enumerate(dirs) if d != me and d not in dirs[:i]]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- monitor
|
||||
|
||||
def monitor_state(workspace: str) -> dict:
|
||||
pids = daemon.find_running(daemon.monitor_script_path(workspace))
|
||||
lock = os.path.join(workspace, ".mam", "monitor.lock")
|
||||
held = False
|
||||
if os.path.exists(lock):
|
||||
with open(lock, "a") as fh:
|
||||
try:
|
||||
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
fcntl.flock(fh, fcntl.LOCK_UN)
|
||||
except BlockingIOError:
|
||||
held = True
|
||||
return {"pids": pids, "lock": "held" if held else "free"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- registry backup
|
||||
|
||||
def backup_registry(workspace: str, dest: str) -> List[str]:
|
||||
mam = os.path.join(workspace, ".mam")
|
||||
db = os.path.join(mam, "agent-sessions.db")
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
copied = []
|
||||
with open(db + ".lock", "w") as lk:
|
||||
fcntl.flock(lk, fcntl.LOCK_EX)
|
||||
try:
|
||||
if os.path.exists(db):
|
||||
src = sqlite3.connect(db, timeout=60.0)
|
||||
dst = sqlite3.connect(os.path.join(dest, "agent-sessions.db"))
|
||||
try:
|
||||
src.backup(dst)
|
||||
finally:
|
||||
dst.close()
|
||||
src.close()
|
||||
copied.append("agent-sessions.db")
|
||||
for name in REGISTRY_FILES:
|
||||
p = os.path.join(mam, name)
|
||||
if name != "agent-sessions.db" and os.path.exists(p):
|
||||
shutil.copy2(p, os.path.join(dest, name))
|
||||
copied.append(name)
|
||||
finally:
|
||||
fcntl.flock(lk, fcntl.LOCK_UN)
|
||||
return copied
|
||||
|
||||
|
||||
def _state() -> dict:
|
||||
return json.loads(os.environ.get("MAM_STATE_JSON") or "{}") or {}
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
p = argparse.ArgumentParser(prog="lib_py.update")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
s = sub.add_parser("precheck")
|
||||
s.add_argument("--workspace", required=True)
|
||||
s.add_argument("--herdr", required=True)
|
||||
s = sub.add_parser("monitor")
|
||||
s.add_argument("--workspace", required=True)
|
||||
s = sub.add_parser("target-workspaces")
|
||||
s.add_argument("--workspace", required=True)
|
||||
s.add_argument("--agent-session", default="")
|
||||
s.add_argument("--cwd", default="")
|
||||
s.add_argument("--server", default="default")
|
||||
s.add_argument("--herdr", default="")
|
||||
s = sub.add_parser("backup-registry")
|
||||
s.add_argument("--workspace", required=True)
|
||||
s.add_argument("--dest", required=True)
|
||||
a = p.parse_args(argv)
|
||||
ws = _real(a.workspace)
|
||||
if a.cmd == "precheck":
|
||||
try:
|
||||
state = _state()
|
||||
except ValueError as exc:
|
||||
print("registry: cannot parse MAM_STATE_JSON (%s)" % exc)
|
||||
return 1
|
||||
lines = precheck(ws, state, a.herdr, os.environ)
|
||||
for line in lines:
|
||||
print(line)
|
||||
return 1 if lines else 0
|
||||
if a.cmd == "target-workspaces" and a.cwd:
|
||||
for root in _others(ws, installed_above([a.cwd])):
|
||||
print(root)
|
||||
return 0
|
||||
if a.cmd == "target-workspaces":
|
||||
try:
|
||||
state = _state()
|
||||
except ValueError:
|
||||
state = {}
|
||||
try:
|
||||
roots = target_workspaces(ws, state, a.agent_session, a.server, a.herdr, os.environ)
|
||||
except HerdrError as exc:
|
||||
print("cannot find %s live: %s" % (a.agent_session, exc), file=sys.stderr)
|
||||
return 1
|
||||
for root in roots:
|
||||
print(root)
|
||||
return 0
|
||||
if a.cmd == "monitor":
|
||||
print(json.dumps(monitor_state(ws)))
|
||||
return 0
|
||||
for name in backup_registry(ws, a.dest):
|
||||
print(name)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,7 +1,7 @@
|
||||
# workspace_uuid.py — workspace UUID discovery logic
|
||||
# Extracted from lib.sh find_workspace_uuid PYEOF block
|
||||
|
||||
import os, sys, json
|
||||
import os, json
|
||||
from lib_py.verify_session import verify_session_uuid, mam_orchestrator_uuids, mam_row_own_uuid
|
||||
|
||||
OWN_KEY = {
|
||||
@@ -15,17 +15,10 @@ OWN_KEY = {
|
||||
|
||||
from lib_py.paths import resolve_home
|
||||
|
||||
def find_workspace_uuid_main():
|
||||
ws = os.environ['WS_ABS']
|
||||
agent = os.environ['AGENT']
|
||||
home = resolve_home()
|
||||
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
|
||||
target = os.environ.get('TARGET_SESSION', '')
|
||||
|
||||
try:
|
||||
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
||||
except Exception:
|
||||
d = {}
|
||||
def find_workspace_uuid(ws, agent, target='', state=None, home=None, claude_dir=None, exclude_ids=()):
|
||||
d = state if isinstance(state, dict) else {}
|
||||
home = resolve_home(home)
|
||||
claude_project_dir = claude_dir or os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
|
||||
|
||||
running_ids = set()
|
||||
for s_item in d.get('herdr_sessions', []):
|
||||
@@ -38,6 +31,10 @@ def find_workspace_uuid_main():
|
||||
running_ids.add(val)
|
||||
|
||||
orchestrator_ids = set(mam_orchestrator_uuids())
|
||||
if "MAM_ORCHESTRATOR_UUIDS" not in os.environ:
|
||||
raw_orc = d.get('orchestrator_uuids')
|
||||
if isinstance(raw_orc, list) and all(isinstance(x, str) and x.strip() for x in raw_orc):
|
||||
orchestrator_ids |= set(raw_orc)
|
||||
if target:
|
||||
for s_item in d.get('herdr_sessions', []):
|
||||
if s_item.get('name') == target:
|
||||
@@ -45,11 +42,7 @@ def find_workspace_uuid_main():
|
||||
if own:
|
||||
orchestrator_ids.discard(own)
|
||||
|
||||
def emit(u):
|
||||
if u in running_ids or u in orchestrator_ids:
|
||||
return
|
||||
print(u)
|
||||
sys.exit(0)
|
||||
excluded = running_ids | orchestrator_ids | {u for u in (exclude_ids or ()) if u}
|
||||
|
||||
# Fetch all sessions matching this workspace
|
||||
sessions = []
|
||||
@@ -63,15 +56,17 @@ def find_workspace_uuid_main():
|
||||
if s.get('name') != target:
|
||||
continue
|
||||
cand = s.get(OWN_KEY.get(agent, ''), None)
|
||||
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
|
||||
emit(cand)
|
||||
if cand and verify_session_uuid(ws, agent, cand, s, home_dir=home, claude_dir=claude_project_dir, mode="revalidate"):
|
||||
if cand not in excluded:
|
||||
return cand
|
||||
|
||||
for s in sessions:
|
||||
name = s.get('name', '')
|
||||
if name.endswith(f"-creator-{agent}"):
|
||||
cand = s.get(OWN_KEY.get(agent, ''))
|
||||
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
|
||||
emit(cand)
|
||||
if cand and verify_session_uuid(ws, agent, cand, s, home_dir=home, claude_dir=claude_project_dir, mode="revalidate"):
|
||||
if cand not in excluded:
|
||||
return cand
|
||||
|
||||
from lib_py.agents.registry import get_adapter
|
||||
from lib_py.agents.base import DiscoveryContext
|
||||
@@ -80,9 +75,25 @@ def find_workspace_uuid_main():
|
||||
if adapter:
|
||||
ctx = DiscoveryContext(workspace=ws, agent_name=agent, home_dir=home, claude_dir=claude_project_dir)
|
||||
for cand in adapter.discover(ctx):
|
||||
emit(cand)
|
||||
if cand and cand not in excluded:
|
||||
return cand
|
||||
|
||||
print('')
|
||||
return ''
|
||||
|
||||
|
||||
def find_workspace_uuid_main():
|
||||
ws = os.environ['WS_ABS']
|
||||
agent = os.environ['AGENT']
|
||||
home = resolve_home()
|
||||
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
|
||||
target = os.environ.get('TARGET_SESSION', '')
|
||||
|
||||
try:
|
||||
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
||||
except Exception:
|
||||
d = {}
|
||||
|
||||
print(find_workspace_uuid(ws, agent, target, d, home, claude_project_dir))
|
||||
|
||||
if __name__ == '__main__':
|
||||
find_workspace_uuid_main()
|
||||
@@ -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.12.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
@@ -146,12 +146,10 @@ herdr_sessions:
|
||||
cmd: claude
|
||||
cmd_full: claude --dangerously-skip-permissions
|
||||
cwd: /path/to/project
|
||||
start_command: "HERDR_SESSION_NAME=<herdr_session> herdr new-session -d -s <SESSION_NAME> -x 140 -y 40 -c <WORKSPACE> <CMD_FULL>"
|
||||
attach_command: "HERDR_SESSION_NAME=<herdr_session> herdr agent attach <SESSION_NAME>"
|
||||
kill_command: "HERDR_SESSION_NAME=<herdr_session> herdr kill-session -t <SESSION_NAME>"
|
||||
attach_command: "herdr --session <herdr_session> agent attach <herdr name of SESSION_NAME>"
|
||||
```
|
||||
|
||||
The three commands require `source .agents/skills/lib.sh` first — `new-session`/`kill-session` are tmux-compat pseudo-commands the shim translates, and `HERDR_SESSION_NAME` is what the shim reads to route to the right isolated herdr *session* (real `herdr` has no env-var-based scoping of its own; `herdr_session: default` needs no prefix at all).
|
||||
`attach_command` is a native herdr command and runs from any shell: it always names the server (`default` too) and names the agent as herdr registered it (lower case, at most 32 characters: `lib_py/agents/sanitize.py`). Start and stop sessions only through the create, resume and stop skills.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -183,7 +181,7 @@ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace
|
||||
| Exit | Meaning |
|
||||
|---|---|
|
||||
| 0 | Created; or `--dry-run` |
|
||||
| 1 | Pre-flight failure, nothing was spawned: workspace not a directory, herdr not installed, agent CLI not in `PATH`, agent not authenticated. After spawn: the task brief could not be written or the instruction injection failed (the session is rolled back) |
|
||||
| 1 | Pre-flight failure, nothing was spawned: workspace not a directory, herdr not installed, agent CLI not in `PATH`, agent not authenticated, or a same-cwd session of the same Class A agent did not record its conversation ID within `MAM_ID_ORDER_WAIT_S` (default 600), or an update of the workspace is in progress (`multi-agent-mux-update`). After spawn: the task brief could not be written or the instruction injection failed (the session is rolled back) |
|
||||
| 2 | Bad arguments; control-plane binding refused for a worktree; whitespace in a control-plane path that must be forwarded |
|
||||
| 3 | A herdr session of that name already exists (`multi-agent-mux-resume`, or `multi-agent-mux-stop` first) |
|
||||
| 4 | The YAML already has a `running` row of that name. The YAML write raises it after spawn, and the herdr session is rolled back. A row the monitor registered for this very pane while it was being created is replaced instead |
|
||||
@@ -220,6 +218,7 @@ bash .agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh \
|
||||
- **Don't use `nohup`/`disown`/`setsid` for the agent itself** — those background the agent outside herdr. The whole point of this skill is *the herdr session is the supervisor*. `nohup` is OK only for *launching the wrapper* (which itself creates the herdr session via `herdr new-session -d`).
|
||||
- **Claude Session ID Auto-Assignment**: Fresh `claude` sessions automatically generate a new UUID (`mam_gen_uuid`) passed via `claude --session-id <uuid>`. `claude_session_id_own` is stored in `.mam/agent-sessions.yaml` with `session_id_source: assigned` and `session_id_verified: false`.
|
||||
- **First Message Materialization**: The transcript file `.jsonl` is created on disk when the first user prompt is sent. The monitor loop (`reconcile.sh`) verifies the transcript and promotes `session_id_verified: true` and `last_visible_status: pinned`.
|
||||
- **Post-discovered ids (agy, hermes, opencode, codex)**: these rows start with no id (`session_id_source: pending-discovery`). With `--onboard`, create pins the id itself once the onboarding job completes (Issue #40, `lib_py.agents.autopin`), monitor or not; later, every completed delegate job does the same until the id is set.
|
||||
- **Wrapper script MUST NOT be created via `hermes profile alias`** — that command writes a `hermes -p <profile>` wrapper that destroys the herdr behavior. Create wrappers manually (see `lab-landing-page-creator-claude` template).
|
||||
- **Always use the workspace-relative path** in herdr `cwd` — relative paths break when herdr respawns in a different shell context.
|
||||
|
||||
@@ -257,5 +256,5 @@ herdr agent read "$SESSION_NAME" --source visible --lines 20 # TUI ready = age
|
||||
|
||||
- **Resuming an old conversation** → `multi-agent-mux-resume`
|
||||
- **Killing an existing session** → `multi-agent-mux-stop`
|
||||
- **Just attaching to an existing session** → `herdr agent attach <name>` (no skill needed)
|
||||
- **Just attaching to an existing session** → the row's `attach_command` (no skill needed)
|
||||
- **One-shot print mode (claude -p "...")** → no herdr needed; use `claude-code` skill's print mode
|
||||
@@ -143,9 +143,9 @@ elif [ "$AGENT" = "grok" ]; then
|
||||
exit 1
|
||||
fi
|
||||
elif [ "$AGENT" = "opencode" ]; then
|
||||
if [ -f "$HOME/.local/share/opencode/auth.json" ] || [ -n "${ANTHROPIC_API_KEY:-}" ] || [ -n "${OPENAI_API_KEY:-}" ] || [ -n "${GEMINI_API_KEY:-}" ] || [ -n "${GROQ_API_KEY:-}" ]; then
|
||||
true
|
||||
elif ! opencode --version >/dev/null 2>&1; then
|
||||
# opencode needs no login (2.x: a free default model, keyless local
|
||||
# providers); the adapter's auth_ok is the one verdict.
|
||||
if ! "$(_delegate_py_bin)" -m lib_py.agents auth-ok opencode >/dev/null 2>&1; then
|
||||
echo "ERROR: opencode is not functional or configured." >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -260,7 +260,7 @@ spawn() {
|
||||
SESSION_UUID=""
|
||||
CMD_FULL="$("$(_delegate_py_bin)" -m lib_py.agents spawn-spec "$AGENT" "$RESOLVED_BIN" "" "true" 2>/dev/null || echo "${RESOLVED_BIN} --dangerously-skip-permissions")"
|
||||
[ -z "$CP_PREFIX" ] || echo "WARNING: the ~/.local/bin wrapper path bypasses herdr; the control-plane binding is recorded but not forwarded" >&2
|
||||
nohup "$WRAPPER" >/dev/null 2>&1 &
|
||||
( mam_update_admit_release_all; exec nohup "$WRAPPER" >/dev/null 2>&1 ) &
|
||||
disown
|
||||
else
|
||||
HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL"
|
||||
@@ -283,10 +283,14 @@ if [ "$DRY_RUN" = "1" ]; then
|
||||
echo "[dry-run] would spawn: herdr session '$SESSION_NAME' in $WORKSPACE (agent=$AGENT, herdr_session=${HERDR_SESSION_NAME:-default}, herdr_workspace=${MAM_WS_LABEL})"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Issue #63: a same-cwd session of this post-discovered agent records its id first (lib.sh mam_id_order_wait).
|
||||
mam_id_order_wait "$WORKSPACE" "$AGENT" "$SESSION_NAME" || exit 1
|
||||
# Issue #72: no new session while an update of the control root or of an
|
||||
# installed workspace the pane's cwd is in runs; held until the row is written
|
||||
# (lib.sh mam_update_admit_at).
|
||||
mam_update_admit_at "$WORKSPACE" || exit 1
|
||||
HERDR_EPOCH=$(date +%s)
|
||||
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
spawn
|
||||
|
||||
# _mark_row_rolled_back <status> <note>
|
||||
@@ -361,7 +365,7 @@ cleanup_herdr_on_error() {
|
||||
if [ "$alive" = "1" ]; then
|
||||
_mark_row_rolled_back "running" "rollback-failed"
|
||||
echo "ERROR: rollback could not kill herdr session '$SESSION_NAME' (kill rc=$kill_rc) - it is still running." >&2
|
||||
echo " Clean it up with: HERDR_SESSION_NAME=${HERDR_SESSION_NAME:-default} herdr kill-session -t $SESSION_NAME" >&2
|
||||
echo " Clean it up with: bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session $SESSION_NAME --herdr-session ${HERDR_SESSION_NAME:-default}" >&2
|
||||
exit 43
|
||||
fi
|
||||
_mark_row_rolled_back "terminated" "rolled-back"
|
||||
@@ -428,11 +432,6 @@ PANE_PID=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null ||
|
||||
PANE_CWD=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_path}' 2>/dev/null || echo "$WORKSPACE")
|
||||
PANE_CMD=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_command}' 2>/dev/null || echo "$AGENT")
|
||||
|
||||
# 시작 명령
|
||||
# NOTE: this must match what `spawn()` actually ran above — env-var-driven
|
||||
# herdr server shim (HERDR_SESSION_NAME picked up by the lib.sh shim).
|
||||
START_CMD="HERDR_SESSION_NAME=${HERDR_SESSION_NAME:-default} herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
|
||||
|
||||
# If --onboard is specified, automatically build the onboarding prompt (if session is ready)
|
||||
if [ "$ONBOARD" = "1" ] && [ -z "$SUBMIT_JOB_PROMPT" ] && [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then
|
||||
SUBMIT_JOB_PROMPT="You are a newly spawned $ROLE Team Leader agent in this workspace. Without making any non-standard pre-preparations or modifying files directly, proceed immediately to align yourself with the project context by performing the following tasks:
|
||||
@@ -508,12 +507,13 @@ fi
|
||||
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
|
||||
SESSION_NAME="$SESSION_NAME" AGENT="$AGENT" NOW_ISO="$NOW_ISO" \
|
||||
HERDR_EPOCH="$HERDR_EPOCH" PANE_PID="$PANE_PID" PANE_CWD="$PANE_CWD" \
|
||||
CMD_FULL="$CMD_FULL" START_CMD="$START_CMD" CHILD_PID="$CHILD_PID" \
|
||||
CMD_FULL="$CMD_FULL" CHILD_PID="$CHILD_PID" \
|
||||
HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-default}" \
|
||||
MAM_WS_LABEL="$MAM_WS_LABEL" \
|
||||
SESSION_UUID="$SESSION_UUID" \
|
||||
LAST_VISIBLE_STATUS_OVERRIDE="${LAST_VISIBLE_STATUS_OVERRIDE:-}" \
|
||||
DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" CONTROL_PLANE_JSON="$CP_JSON" <<'PYEOF'
|
||||
from lib_py.agents.sanitize import attach_command
|
||||
name = os.environ['SESSION_NAME']
|
||||
agent = os.environ['AGENT']
|
||||
role = os.environ['ROLE']
|
||||
@@ -567,9 +567,7 @@ entry = {
|
||||
'cmd_full': os.environ['CMD_FULL'],
|
||||
'cwd': os.environ['PANE_CWD'],
|
||||
},
|
||||
'start_command': os.environ['START_CMD'],
|
||||
'attach_command': f'HERDR_SESSION_NAME={server_name} herdr agent attach {name}',
|
||||
'kill_command': f'HERDR_SESSION_NAME={server_name} herdr kill-session -t {name}',
|
||||
'attach_command': attach_command(server_name, name),
|
||||
}
|
||||
|
||||
if agent == 'claude':
|
||||
@@ -644,6 +642,7 @@ snap['cwd'] = os.environ['PANE_CWD']
|
||||
print(f"appended: {name}", flush=True)
|
||||
PYEOF
|
||||
ROW_APPENDED=1
|
||||
mam_update_admit_release_all
|
||||
|
||||
echo
|
||||
echo "=== created ==="
|
||||
@@ -751,6 +750,9 @@ If you delegate any part of it with multi-agent-mux-delegate-job submit, add --p
|
||||
case "$ONBOARD_STATUS" in
|
||||
completed)
|
||||
echo "onboarding completed: job $DELEGATE_JOB_ID ($(( $(date +%s) - ONBOARD_T0 ))s)"
|
||||
# Issue #40: the first turn is done, so a post-discovered id is on disk -
|
||||
# pin it now instead of waiting for a monitor (worktree creators run none).
|
||||
delegate_autopin_completed "$DELEGATE_JOB_ID" "$DELEGATE_JOB_TOKEN" "$task_reg_dir"
|
||||
;;
|
||||
*)
|
||||
# The session is kept, as for a missing handshake above: the row says
|
||||
@@ -770,6 +772,6 @@ fi
|
||||
trap - EXIT
|
||||
echo "agent-sessions.yaml updated"
|
||||
echo
|
||||
echo "Attach: herdr session attach $SESSION_NAME"
|
||||
echo "Attach: $(session_attach_command "$SESSION_NAME" "${HERDR_SESSION_NAME:-default}")"
|
||||
echo "Delete: use multi-agent-mux-stop skill"
|
||||
echo "Resume: use multi-agent-mux-resume skill (after first message creates a session id)"
|
||||
@@ -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.12.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos, windows]
|
||||
|
||||
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user