fix(issue-31): a claude purge removes every path the session owns, reports what is left, and waits for the process; the stop docs say a purge removes the row #59

Merged
Godopu merged 26 commits from fix/issue-31 into main 2026-10-17 16:25:21 +00:00
43 changed files with 2380 additions and 56 deletions

No files matched your search

@@ -0,0 +1,51 @@
# 리뷰 리포트 — PR #59 (Issue #31, claude purge 잔여물) / job 207c65b8
- 리뷰어: planner-reviewer-claude-01 (Opus 5.5)
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261016T003524Z-a51548/1`
- identity: HEAD `6f4f40a4adb2115d5997dffd31fb6473f05cab68`, tree `f4d9422ad0c1d616b5f37065f3e96b34c28f78d8` — 일치. 트리 clean, `.mam` 없음(테스트 실행 후에도 동일).
- 범위: base 26e2c43 .. head 6f4f40a (13 커밋, 12 파일)
## 요약
설계와 대부분의 구현은 이 Mac에서 맞게 동작한다. 실제 `~/.claude` 구조, BSD `ps -o lstart=`, `/tmp → /private/tmp` 스크래치패드 레이아웃이 어댑터 가정과 일치하고, 다른 세션·공유 파일·심볼릭 링크 너머는 지우지 않았다. 다만 **삭제 범위 가드(`_plausible_id`)가 UUID가 아닌 id를 거르지 못해, id `memory` 로 purge 하면 `projects/<key>/memory/`(Claude Code 자동 메모리 디렉터리)가 통째로 지워진다**(F1). 이 PR이 `<id>.jsonl` 삭제에서 `<id>` 디렉터리 rmtree 로 범위를 넓히면서 생긴 회귀이고, 브리프 중점 항목("잘못된 uuid 거부")에 해당하므로 NOT PASS 로 판정한다. 수정은 한 줄 수준이다.
## 차단 결함
### F1. 비-UUID id `memory` 가 자동 메모리 디렉터리를 지운다
- 위치: `.agents/skills/lib_py/agents/base.py:89-92` (`_plausible_id`: 빈 문자열·점만·`/`·`..`·`*` 만 거부), `.agents/skills/lib_py/agents/adapters/claude.py:262-268` (`base = projects/<key>/<uuid>`, 디렉터리면 `_remove` → `shutil.rmtree`), `claude.py:287-289` (`has_artifacts` 가 같은 목록을 써서 `purge auto` 도 claude 로 해석).
- 실제 이 Mac 의 `~/.claude/projects/<key>/` 에는 세션 디렉터리와 나란히 `memory/` 가 있다(읽기만 확인: `projects/-Users-godopu16-PuKi-laa-canary-projects-multi-agent-mux/memory/`). 세션 id 와 같은 층에 있는 유일한 비-UUID 이름이다.
- 재현 1(어댑터 직접, 샌드박스 HOME/CLAUDE_PROJECT_DIR/CLAUDE_CODE_TMPDIR/TMPDIR, `/usr/bin/python3` 3.9.6):
`has_artifacts("memory") → True`, `purge_artifacts("memory") → [.../projects/<key>/memory, .../file-history/memory]`, `MEMORY.md survives: False`.
- 재현 2(CLI): `python -m lib_py.agents purge claude memory <ws>` → 이 호스트에서는 다른 프로세스 29개의 argv 에 "memory" 가 들어 있어 우연히 in-use(rc 4)로 거부됐다. argv 에 "memory" 가 없는 호스트를 흉내 내려고 `ps` 출력에서 그 줄만 거르는 스텁을 PATH 앞에 두면 rc 0, `"purged": [".../projects/<key>/memory"]`, `memory survives: False`.
(스크립트: `/private/tmp/claude-501/-Users-godopu16-PuKi-laa-canary-projects-multi-agent-mux/8cb38cbf-0d61-4d78-8c7e-065e409ce13d/scratchpad/j207/probe_mem.py`)
- 도달 경로: 스킬 진입점은 안전하다 — `orc_onboard.sh:88-95` 은 정규식으로 UUID 를 강제하고, `stop_session.sh` 의 `PURGE_UUID` 는 `find_workspace_uuid` 가 `<id>.jsonl` 의 sessionId 일치로 검증한 값이다. 위험한 경로는 직접 쓰는 `lib_py.agents purge`/어댑터 API(VERSIONS.md 에 문서화)다. 그러나 이 PR 이전에는 같은 입력이 존재하지 않는 `memory.jsonl` 만 겨냥해 무해했다. 지워지는 것은 복구할 수 없는 사용자 메모리이고, 테스트(`tests/test_issue31_purge_residual.py:242` 의 `bad` 목록)도 이 경우를 다루지 않는다.
- 제안: claude 어댑터의 `_owned_paths`/`residual_artifacts`(또는 `purge_artifacts`·`has_artifacts` 입구)에서 정규 UUID(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`, 대소문자 무시)만 받게 하고, `bad` 목록에 `"memory"` 와 UUID 가 아닌 평범한 이름을 추가하는 회귀 테스트. 다른 어댑터는 이름 스캔(`scan_names`)·보고만 하므로 공용 `_plausible_id` 는 그대로 둬도 된다.
## 확인한 사항 (정상)
1. **BSD `ps -o lstart=`**: 이 Mac 출력은 `Fri Oct 16 08:25:14 2026` (LC_ALL=C 와 기본 로캘 동일). `" ".join(out.split())` + `%a %b %d %H:%M:%S %Y` 로 파싱된다. 실제 살아 있는 claude 4개의 `sessions/<pid>.json` 에서 `startedAt − lstart` = 0.69 / 1.14 / 1.93 / 2.43 s → 모두 live 판정(`lstart <= startedAt+2`). 샌드박스 `sleep` 프로세스로: 방금 쓴 startedAt → in-use rc 4(`--wait 2` 로 2.2 s 대기 후), purge CLI rc 4, jsonl 보존; 프로세스 종료 후 rc 0. startedAt 을 1시간 전으로 둔(pid 재사용) 경우 → free, 그 세션 파일만 삭제. `ps` 실패·빈 출력·파싱 실패·`startedAt` 없음 → live(`claude.py:66-74`).
2. **tmp 루트**: TMPDIR=`/var/folders/.../T/` 아래에는 `claude-501` 이 없고, 실제 스크래치패드는 `/private/tmp/claude-501/<key>/<uuid>/{scratchpad,tasks}` 다. 후보에 항상 `/tmp/claude-<uid>` 가 들어가고(`claude.py:41`) realpath 로 `/private/tmp` 와 중복 제거된다. `<key>` 는 계산하지 않고 루트 아래 모든 키를 나열해 `<k>/<uuid>` 만 고르므로 키 규칙 차이에 영향받지 않는다. 샌드박스에서 CLAUDE_CODE_TMPDIR·TMPDIR 양쪽 루트의 `<uuid>` 만 지워졌다.
3. **실제 `~/.claude` 구조**(읽기만): `sessions/<pid>.json`(int `pid`, ms `startedAt`, `sessionId`) + `<pid>.<64hex>.key`, `jobs/<8hex>/state.json`(sessionId[:8] = 디렉터리명), `file-history/<uuid>/`, `session-env/<uuid>/`, `projects/<key>/<uuid>/{tool-results,…}` — 어댑터 가정과 일치.
4. **삭제 범위**(샌드박스, `/usr/bin/python3`): 같은 jobs 접두사의 다른 세션, 다른 세션의 sessions json·key, 같은 pid 접두사이지만 64hex 가 아닌 `.key`, `history.jsonl`, 다른 uuid 의 file-history·jsonl·스크래치패드가 모두 보존됐다. `session-env/<U>/link → 외부`, 스크래치패드 안의 `linkout → 외부` 심볼릭 링크는 링크만 지워지고 대상 파일은 남았다. 재실행 → rc 5(남은 것 없음). `""`/`.`/`..`/`...`/`a/b`/`*`/`../x` → 아무것도 지우지 않음(rc 1 또는 5).
5. **stop exit 4 계약**: 게이트(`stop_session.sh:285-300`)는 레지스트리 잠금 밖, YAML 쓰기 전에 실행되고 0 이외는 모두 exit 4 + cancelled 이벤트. `_delegate_py_bin` 가 `/usr/bin/python3` 로 떨어져도 `-m lib_py.agents in-use` 는 paho 없이 동작한다(직접 확인). bash 3.2 `-n` 통과, `inuse_rc=0; … || inuse_rc=$?` 는 `set -u` 에서 안전.
6. **residual 보고**: purge 후 `residual_artifacts` 가 빈 목록이면 `residual: none (agent: …)`. 실제 `~/.claude` 에 대한 이름 스캔(읽기 전용, 가짜 uuid)은 0.02 s.
7. **Python 3.9 / bash 3.2**: 변경된 .py 8개 모두 `feature_version=(3,9)` 로 파싱되고, 프로브는 전부 `/usr/bin/python3` 3.9.6 으로 실행됐다(registry 가 모든 어댑터를 import). `lib.sh`·`stop_session.sh` 는 `/bin/bash` 3.2.57 `-n` 통과.
8. **문서·skill_code_blocks.json**: stop SKILL.md 의 "purge 는 행을 지운다"·exit 4·삭제 목록이 코드와 맞고, `test_issue42_skill_blocks.py` 통과(sha·passthrough 라인 갱신 정합).
9. **codex·grok·opencode residual**: 이름 스캔 + `mode=ro` SQLite 조회만 하므로 쓰기 없음.
## 테스트
- 검증 경로에서 `env -i …`(HERDR_*·WORKSPACE_ROOT·MAM_*·DELEGATE_JOB_LOGS_DIR·CLAUDE_CONFIG_DIR 미설정), `--basetemp` 는 스크래치패드, `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider`:
`test_issue31_purge_residual, test_issue10_13_purge, test_a4_adapter_contract, test_issue42_skill_blocks, test_issue55_agent_homes, test_orc_onboard, test_uuid_target` → **188 passed, 1 skipped** (276 s). 실행 후 트리 clean.
- 사전 수정 대조: `git archive HEAD` 복사본에서 제품 파일 8개를 base 26e2c43 로 되돌리고 `test_issue31_purge_residual.py` 실행 → **24 failed, 8 passed**. 통과한 8개는 "다른 세션을 건드리지 않음"·"잘못된 uuid 거부"처럼 이전 코드에서도 성립하는 안전성 테스트로, 기대한 결과다. stop 게이트 테스트(`test_stop_purge_refuses_while_the_claude_process_lives`)와 pid 재사용 테스트도 사전 코드에서 실패한다.
## 비차단 메모
- N1. DST: `time.mktime(time.strptime(...))` 는 `tm_isdst=-1` 이라 서머타임이 끝나는 겹치는 1시간에는 lstart 가 1시간 늦게 해석될 수 있고, 그러면 살아 있는 세션을 stale 로 볼 수 있다(fail-open). 이 Mac 은 KST(서머타임 없음)라 영향 없음. 실제 세션 파일에 `procStart`(UTC 문자열, `pidDomain: "darwin"`)가 있으니 이를 쓰거나 비교를 UTC 로 하는 것도 고려할 만하다.
- N2. stop 의 exit 4 메시지(`stop_session.sh:296`)는 `~/.claude/sessions/` 만 가리킨다. CLAUDE_CONFIG_DIR 을 쓰면 위치가 다르고, argv 에 uuid 를 담은 아무 프로세스(에디터·grep·위임 래퍼)도 거부 사유가 된다. orc_onboard SKILL.md 처럼 `ps -axww | grep <uuid>` 안내를 함께 두면 좋다.
- N3. SKILL.md exit 4 행의 "(nothing was changed)" — 프로세스 게이트는 herdr kill 이후에 돌므로 세션은 이미 내려가 있을 수 있다(레코드는 그대로). 기존 herdr 게이트 문구와 같은 뉘앙스이지만 "records preserved" 가 더 정확하다.
- N4. stop 이 이제 모든 에이전트에 in-use 게이트를 적용한다. agy 처럼 CLI 종료 뒤에도 다른 프로세스가 DB/락을 열어 두는 구성이면 이전에는 purge 되던 것이 exit 4 로 바뀐다. 의도된 fail-closed 이지만 실제 agy 로는 확인하지 못했다(브리프상 실제 에이전트 실행 금지).
- N5. 테스트는 하드코딩된 `/tmp/claude-<uid>` 후보를 격리하지 못한다(실제 `/private/tmp/claude-501` 을 나열). 무작위 uuid 라 실제로 지워지는 것은 없지만, `_tmp_roots` 를 테스트에서 패치할 수 있게 하면 더 안전하다.
[VERDICT: NOT PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261017T145452Z-6c5aa6",
"base_sha": "26e2c43753169671b600d03274901dbe203988dd",
"head_sha": "de6f3fc81f8c33a9d2335320742dc5a47f566cda",
"tree_sha": "ffc291e6fc93822070f2b9f1d17009e83f176fcf",
"review_commit": "de6f3fc81f8c33a9d2335320742dc5a47f566cda",
"reviewer": "planner-reviewer-claude-01",
"job_id": "48d0a01c",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-17T15:16:54Z"
}
@@ -0,0 +1,52 @@
# 리뷰 리포트 — PR #59 3차 (Issue #31) / job 48d0a01c
- 리뷰어: planner-reviewer-claude-01 (Opus 5.5)
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261017T145452Z-6c5aa6/1`
- identity: HEAD `de6f3fc81f8c33a9d2335320742dc5a47f566cda`, tree `ffc291e6fc93822070f2b9f1d17009e83f176fcf` 일치. 트리는 clean이고 `.mam` 없음(테스트 후에도 동일).
- 범위: 26e2c43..de6f3fc 전체. 2a8d738 이후 변경은 de6f3fc 한 커밋으로, `tests/test_issue31_purge_residual.py` 의 +3/−1 뿐이다. `git diff 2a8d738 HEAD -- .agents` 는 비어 있다.
## 결론
2차의 유일한 차단(T1: macOS `/bin/sh` 의 단일 명령 exec 로 보유자 argv 에서 uuid 가 사라지던 문제)이 해결됐다. 제품 코드는 2a8d738 과 바이트 단위로 같으므로, 2차에서 이 Mac 에서 확인한 내용이 그대로 유효하다. 확인한 항목은 비-UUID 거부, 링크 경계, 유한하지 않은 startedAt, UTC lstart, exit 4 안내, 삭제 범위, 실제 `~/.claude`·`/private/tmp/claude-501` 레이아웃 정합이다. 판정은 **PASS**.
## (a) de6f3fc 검증
1. **보유자 argv 에 uuid 가 남는다.**
- 새 보유자는 `[sys.executable, "-c", "import time; time.sleep(120)", conv]` 이다(`tests/test_issue31_purge_residual.py:317`).
- Python 인터프리터는 `-c` 뒤 인자를 `sys.argv[1]` 로 받을 뿐 exec 로 자신을 바꾸지 않으므로, `ps` 의 args 에 소문자 `conv` 가 그대로 남는다.
- `subprocess.Popen` 은 자식의 exec 성공을 확인한 뒤에 반환하므로, 이어지는 `time.sleep(0.05)` 시점에는 argv 가 이미 보인다(경합 없음).
- `sys` 는 파일 머리(`:22`)에서 이미 import 되어 있다. 같은 패턴인 `test_issue10_13_purge.py` 의 `_holder()` 와도 일관된다.
2. **upper/mixed 에서 통과한다.** 검증 경로에서 `-k any_spelling` 을 5회 반복 실행해 5회 모두 `2 passed` 였다. 관련 모듈 전체 실행에서도 통과했다(아래).
3. **철자 처리 회귀를 여전히 잡는다.** `git clone` 사본에서 claude 어댑터를 한 군데씩 변형해 test 파일 전체를 돌렸다.
- `conversation_in_use` 의 argv 검색을 `{canon, uuid}` 에서 `{uuid}` 로 바꿈(소문자 정규화 제거): 2 failed (upper, mixed). 실패 지점은 정확히 새 보유자 단언 `:320`, `AssertionError: a command line naming the lowercase id` 이다. 즉 수정된 보유자가 이 회귀를 잡는 바로 그 장치다.
- `_session_files(canon)` 을 `_session_files(uuid)` 로 바꿈: 2 failed (세션 파일 경로 단언에서).
- `_canonical` 의 `.lower()` 제거: 2 failed.
- 세 변형 모두 나머지 65개는 통과했으므로, 실패가 해당 테스트에 국한된다.
4. **정리 동작이 유지된다.**
- `try/finally: named.kill(); named.wait()` 구조는 그대로다(`:318-323`).
- 변형 실행(일부러 실패시킨 경우 포함)이 끝난 뒤 `ps -axo pid=,args=` 에서 `python … import time; time.sleep(120)` 프로세스는 0개였다. argv0 가 python 인 행만 세어 자기 자신과의 매칭은 배제했다.
## (b) 전체 판정을 위한 재확인
- 제품 파일(`.agents/` 전체)은 2a8d738 과 동일하므로, 2차 리뷰(job 778fc866)의 확인 결과를 그대로 승계한다. 다시 돌린 것은 위 변형 대조와 아래 관련 모듈이다.
- 알려진 비차단 세 가지는 브리프대로 병합 차단이 아니다. 내용은 2차 리포트에 적은 그대로다.
- 링크된 `projects/<key>` 아래 jsonl 은 purge 되지 않고 residual 로만 보고된다.
- 소문자 질의는 대문자 argv 보유자를 보지 못한다.
- `purge_artifacts` 를 직접 호출하면 in-use 게이트를 거치지 않는다.
## 테스트
검증 경로에서 `env -i`(HERDR_*·WORKSPACE_ROOT·MAM_*·DELEGATE_JOB_LOGS_DIR·CLAUDE_CONFIG_DIR 미설정), 스크래치패드 basetemp, `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider` 로 실행했다.
- 실행 모듈: `test_issue31_purge_residual, test_issue10_13_purge, test_a4_adapter_contract, test_issue42_skill_blocks, test_issue55_agent_homes, test_orc_onboard, test_uuid_target, test_tier1_unit, test_tier2_component`
- 결과: **332 passed, 1 skipped, 1 error** (624 s). 2차의 2 failed 는 사라졌다.
- 1 error 는 `test_tier2_component.py::test_comp_codex_create_and_reconcile_and_stop` 의 **teardown 에서 #55 가드**가 낸 것으로, 브리프가 밝힌 알려진 오탐이다. 판정 근거로 쓰지 않고 보고만 한다.
- 가드가 잡은 경로: `~/.gemini/antigravity-cli/conversations/0dd4a87f-6170-472b-886a-eef6e099b375.db-shm` 과 `.db-wal`.
- 읽기 전용 확인 결과: 이 DB 는 다른 곳에서 돌고 있는 실제 `agy` 프로세스(pid 66733)가 열고 있다(`lsof -t`). WAL 이 자라는 중이다(mtime Oct 18 00:06, 크기 19.6 MB). 테스트 산출물이 아니다.
- 이 테스트는 2차 실행에서는 통과했고, 이번 커밋은 tier2 와 무관한 테스트 한 줄만 바꿨다.
## 비차단 메모
- 2차 리포트의 비차단 사항(`projects/<key>` 링크 시 동작 문서화 등)은 그대로다. 병합 후 별도 이슈로 다뤄도 된다.
[VERDICT: PASS]
@@ -0,0 +1,95 @@
# 리뷰 리포트 — PR #59 2차 (Issue #31) / job 778fc866
- 리뷰어: planner-reviewer-claude-01 (Opus 5.5)
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261017T135731Z-e93f0a/1`
- identity: HEAD `2a8d73825b3832376aeaf96b09548359dc3cb887`, tree `d146f1ac68bcc3485d24fb92edb87264e4d1e8cb` 일치. 트리는 clean이고 `.mam` 없음(테스트 후에도 동일).
- 범위: 26e2c43..2a8d738 누적, 중점은 1차 이후 커밋 11개(26fb1c4..2a8d738)
## 요약
1차 수정 요청 3건(차단 F1, P1, P2)은 **제품 코드에서 모두 해결됐다**. 비차단 메모(서머타임/UTC, exit 4 안내, SKILL.md 문구, 테스트의 실제 `/tmp` 나열)도 해결됐다. 샌드박스 재현과 변형(mutation) 대조로 각 수정과 회귀 테스트가 실제로 맞물리는 것도 확인했다.
그러나 **새로 추가된 테스트 `test_any_spelling_of_the_uuid_is_the_lowercase_session[upper|mixed]` 는 이 Mac 에서 항상 실패한다**(T1). 원인은 제품 코드가 아니라 테스트의 macOS 이식성 버그다. 이대로 병합하면 이 Mac 의 스위트가 빨간불이 되므로 NOT PASS 로 판정한다. 수정은 테스트 한 줄이다.
## 차단 결함
### T1. `sh -c "sleep 120" <uuid>` 보유자 프로세스가 macOS 에서 uuid 를 argv 에서 잃는다
- 위치: `tests/test_issue31_purge_residual.py:315-318`
```python
named = subprocess.Popen(["sh", "-c", "sleep 120", conv])
time.sleep(0.05)
assert adapter.conversation_in_use(other, ctx) is True, "a command line naming the lowercase id"
```
- 원인: 이 Mac 의 `/bin/sh` 는 GNU bash 3.2.57 이다. bash 는 `-c` 의 단일 단순 명령을 exec 로 바꿔 실행하므로, 실행된 프로세스의 argv 는 `sleep 120` 만 남고 `$0` 인 uuid 는 사라진다.
- 직접 확인: `/bin/sh -c 'sleep 30' MARKER-abc &` → `ps` 에 `sleep 30` 만 보인다.
- `/bin/sh -c 'sleep 30; :' MARKER-abc` → `/bin/sh -c sleep 30; : MARKER-abc` 로 uuid 가 남는다.
- 이 저장소도 같은 함정을 이미 기록해 두었다: `tests/test_issue10_13_purge.py:114` ("a shell execs its last simple command and the uuid …").
- 재현: 검증 경로의 관련 모듈 실행(아래)에서 **2 failed**. 수정하지 않은 `git clone` 사본에서 `-k any_spelling` 만 돌려도 2 failed:
`AssertionError: a command line naming the lowercase id … conversation_in_use('91151A0D-0114-4C3C-a120-aec043818d1f', …) → False`
아래 변형 대조 4종(F1/P1/P2/UTC)에서도 변형과 무관하게 같은 2개가 실패했다.
- 제품 로직은 정상이다. argv 를 유지하는 보유자(`["sh","-c","sleep 30; :", <lowercase uuid>]`)로 샌드박스에서 확인했다:
- `conversation_in_use(UPPER)` = True, `conversation_in_use(lower)` = True.
- 단일 명령 보유자로는 두 철자 모두 False.
- 제안: 보유자를 `["sh", "-c", "sleep 120; :", conv]` 나 `[sys.executable, "-c", "import time; time.sleep(120)", conv]` 처럼 uuid 가 argv 에 남는 형태로 바꾼다. 고정 `time.sleep(0.05)` 대신 짧은 폴링(예: 최대 2 s 동안 `ps` 에 보일 때까지)으로 기다리면 느린 호스트의 경합도 피할 수 있다.
## 1차 지적사항 확인
샌드박스 조건: HOME·CLAUDE_PROJECT_DIR·CLAUDE_CODE_TMPDIR·TMPDIR 를 모두 스크래치패드 아래로 두고, CLAUDE_CONFIG_DIR 은 비웠다. 인터프리터는 `/usr/bin/python3` 3.9.6. 실제 경로는 읽기만 했다.
1. **[차단] 비-UUID id — 해결.**
- 방식: `_canonical()`(`claude.py:8-19`)이 정규 UUID 만 받아 소문자로 바꾼다. `_candidates`·`purge_artifacts`·`residual_artifacts`·`conversation_in_use` 의 모든 입구가 이 함수를 거친다.
- 재현: 아래 14개 id 는 purge 결과가 모두 rc 5 이고, 아무것도 지우지 않았다. `projects/<key>/memory/MEMORY.md` 와 다른 세션 파일은 전부 남았다.
- `""`, `.`, `..`, `a/b`, `*`, `../x`
- `memory`, `MEMORY`, `subagents`, `<key>`
- uuid 앞 8자리, `<U>x`, `" <U>"`, `"<U>\n"`
- 대문자 uuid 로 purge 하면 소문자 경로 9개가 정확히 지워진다(`lower jsonl gone True`). 다른 세션·공유 파일은 모두 보존됐다.
- 변형 대조(`_canonical` 을 옛 `_plausible_id` 규칙으로 되돌림): `rejects_malformed_uuid[memory, ' ', notes, …g, '{conv} ', 'x{conv}']`, `a_non_uuid_id_reaches_nothing[uuid-c, memory, notes]`, `any_spelling` 이 실패해 **11 failed**.
2. **[P1] 링크 경계 — 해결.**
- 방식: `_inside()`(`claude.py:109-123`)는 루트와 대상 사이의 디렉터리 구성요소를 `lstat` 으로 하나씩 확인하고, 부모의 realpath 가 루트의 realpath 아래에 있는지 본다.
- 링크로 연결된 tmp `<key>`, `file-history/`, `projects/<key>` 뒤의 내용은 모두 보존되고 `residual` 로 보고된다.
- 말단 링크(`session-env/<U> → 외부`)는 링크만 지워지고 대상 파일은 남는다.
- 루트 자체가 링크인 경우(`~/.claude → realhome`, `CLAUDE_CODE_TMPDIR → realtmp`, 즉 `/tmp → /private/tmp` 같은 형태)는 정상적으로 purge 된다.
- Python 3.9 macOS 의 `shutil._use_fd_functions` 는 True 로, rmtree 가 내부 링크를 따라가지 않는다.
- 변형 대조(`_inside` → 항상 True): `never_follows_a_linked_dir_below_a_root`, `reports_a_linked_orphan_it_will_not_remove` 실패(T1 의 2개 포함 **4 failed**).
3. **[P2] 유한하지 않은 startedAt — 해결.**
- 방식: `claude.py:84-96`.
- 살아 있는 `sleep` 보유자에 대해 startedAt 을 아래 9가지 값으로 바꿔 봤다. 모두 in-use rc 4, purge rc 4 였고 jsonl 은 보존됐다.
- `NaN`, `Infinity`, `-Infinity`, `"NaN"`, `true`, `1e400`, 400자리 정수, `null`, `"1760000000000"`
- 정상 ms 값이면 live(rc 4), 1시간 전 값(pid 재사용)이면 free 다.
- 변형 대조(`isfinite` 제거): `unusable_started_at_means_live[nan]`, `live_holder…[NaN]` 실패. inf 계열은 다른 분기에서도 live 로 판정되므로 이 변형으로는 실패하지 않는 것이 맞다.
## 비차단 메모 확인
- **UTC (af8ed39)**: 실제 살아 있는 claude 4개를 읽기 전용으로 비교했다(`TZ=UTC0 LC_ALL=C ps -o lstart=` + `calendar.timegm`).
- lstart 가 세션 파일의 `procStart`(UTC)와 초 단위까지 일치했다. 예: `Sat Oct 17 13:55:10 2026`.
- startedAt − lstart 는 1.34 / 1.49 / 1.99 / 2.36 s 로, 모두 live 판정이다.
- 변형 대조(mktime·로컬 TZ 로 되돌림): `liveness_reads_lstart_as_utc` 실패.
- **exit 4 안내 (e156179)**: in-use stderr 가 두 가지를 출력한다.
- "a claude process whose session file under <cfg>/sessions/ names it" (CLAUDE_CONFIG_DIR 을 반영)
- "ps -axww | grep <uuid>"
- `stop_session.sh:292` 의 `h="$(… 2>&1 >/dev/null)" || rc=$?` 는 `/bin/bash` 3.2 + `set -euo pipefail` 에서 rc 4 와 안내 문구를 올바르게 잡고, 안내가 비었을 때도 스크립트가 계속 진행된다(직접 확인). `bash -n` 통과.
- **SKILL.md exit 4 문구**: "Records preserved … the session itself may already be down" 으로 정확해졌다.
- **테스트의 실제 /tmp (26fb1c4)**: autouse 픽스처가 `_SYSTEM_TMP` 를 테스트 디렉터리로 패치하고, `test_tests_never_reach_the_real_tmp_root` 가 하위 프로세스까지 확인한다.
- **agy 게이트**: 동작 변화는 그대로다(fail-closed). 실제 agy 로는 확인하지 않았다(브리프상 금지).
## 회귀 관점 (dd292c1 등)
- `has_artifacts` 는 링크 뒤의 후보도 포함하므로 `purge auto` 가 claude 를 고른다. 이때 purge 는 아무것도 지우지 않고 residual 만 보고한다(위 links 재현: rc 0, `purged` 에는 말단 링크 1개만 있고 `residual` 4개). 의도대로다.
- 동작이 바뀌는 지점이 하나 있다(비차단): `projects/<key>` 자체가 심볼릭 링크인 사용자는 이제 transcript jsonl 이 purge 되지 않고 residual 로만 보고된다. 반면 `discover`/`verify_artifact` 는 링크를 따라가 그 대화를 계속 찾는다. P1 요구("링크를 따라가지 않음")의 결과이고 residual 로 드러나므로 수용 가능하지만, SKILL.md 에 한 줄 적어 두면 좋다.
- 철자 비대칭(비차단): 소문자 id 로 질의하면 대문자 argv 보유자는 보지 못한다(`claude -r <UPPER>` 로 띄운 경우). claude 가 소문자로 기록하는 한 실질 영향은 없다.
- `base.in_use_hints` 추가와 `__main__` 의 in-use stderr 출력은 다른 어댑터에 기본 안내만 준다. 회귀 없음.
- `test_a4_adapter_contract` 의 claude id 를 실제 uuid 로 바꾼 것은 새 계약(정규 UUID 만 허용)에 따른 정당한 갱신이다.
- Python 3.9 파싱(`feature_version=(3,9)`)과 `/usr/bin/python3` 실행(모든 프로브)에서 문제 없었다.
## 테스트
- 검증 경로, `env -i`(HERDR_*·WORKSPACE_ROOT·MAM_*·DELEGATE_JOB_LOGS_DIR·CLAUDE_CONFIG_DIR 미설정), 스크래치패드 basetemp, `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider`.
- 실행 모듈: `test_issue31_purge_residual, test_issue10_13_purge, test_a4_adapter_contract, test_issue42_skill_blocks, test_issue55_agent_homes, test_orc_onboard, test_uuid_target, test_tier1_unit, test_tier2_component`
- 결과: **2 failed (T1), 330 passed, 1 skipped** (630 s).
- 사본에서 `test_issue31_purge_residual.py` 를 반복 실행한 결과:
- 비변형 사본: 2 failed (T1) / 65 passed.
- P2 변형 사본: 첫 실행에서 `test_grok_residual_reports_memory_observations` 가 한 번 ERROR 였다. 이후 재실행 4회에서는 재현되지 않아 일회성으로 보고만 한다.
- 6f4f40a 의 제품 파일로 통째로 되돌린 대조는 쓸 수 없었다. autouse 픽스처가 26fb1c4 에서 추가된 `claude._SYSTEM_TMP` 를 패치하므로 67개 모두 setup ERROR 가 난다. 그래서 위의 수정별 변형 대조로 대신했다.
[VERDICT: NOT PASS]
@@ -0,0 +1,128 @@
# PR #59 Mac review — NOT PASS
Job `05572944`, role Reviewer. 소스 수정 없이 검토했다. 선택 테스트 총 **80 passed, 1 skipped**이지만, 추가 탐침에서 삭제 범위 문제 두 건과 live-process 판정 문제 한 건을 확인했다. 병합 전 수정이 필요하다. 국소적인 안전성 수정 및 회귀 테스트로 해결 가능하므로 재계획 에스컬레이션은 하지 않는다.
## Review identity
- loop: `loop-20261016T003524Z-a51548`
- base: `26e2c43753169671b600d03274901dbe203988dd`
- head: `6f4f40a4adb2115d5997dffd31fb6473f05cab68`
- tree: `f4d9422ad0c1d616b5f37065f3e96b34c28f78d8`
- 검증 트리: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261016T003524Z-a51548/1`
- 범위: base..head 13 commits / 12 files. 아래 파일:라인은 이 head 기준이다.
- PR #59 본문과 issue #31을 tea의 읽기 명령으로 확인했다.
## R1 — [P1] 상위 디렉터리의 심볼릭 링크를 따라 소유 루트 밖을 삭제한다
근거:
- `.agents/skills/lib_py/agents/adapters/claude.py:279`–`:284`: tmp root의 모든 entry를 key로 간주하고 `root/key/uuid`를 후보에 넣는다. key가 외부 디렉터리를 가리키는 링크인지 검사하지 않는다.
- 같은 파일 `:80`–`:85`: `_remove`는 최종 path 자체가 링크인지 확인하지만, 상위 key가 링크이면 최종 uuid 디렉터리는 일반 디렉터리로 보인다. 따라서 `shutil.rmtree`가 링크 너머 실제 디렉터리를 삭제한다.
- 같은 파일 `:266`–`:270`의 projects/file-history/session-env 경로에도 상위 구성요소 경계 검증은 없다.
재현(실제 홈/스크래치패드는 사용하지 않음):
1. 임시 config/HOME/workspace/tmp를 만들고 새 UUID U를 생성한다.
2. 임시 소유 루트 밖인 `<review-temp>/outside-owned-roots/U/keep.txt`를 만든다. 이는 테스트용 sentinel이며 실제 사용자 파일이 아니다.
3. 임시 `<ctmp>/claude-<uid>/linked-workspace`를 `outside-owned-roots`에 연결한다.
4. `_tmp_roots`를 이 임시 claude root 하나로 제한하고 `ClaudeAgentAdapter.purge_artifacts(U, ctx)`를 실행한다. 제한은 실제 `/tmp/claude-<uid>`에 접근하지 않도록 하는 시험 장치이며 경로 열거/삭제 로직은 변경하지 않는다.
기대: 소유 경계 밖 sentinel은 보존되어야 한다. 최소한 링크를 경유한 후보를 거부하고 residual/경고로 남겨야 한다.
실제:
```text
ANCESTOR_SYMLINK {"victim_exists": false, "link_survives": true,
"purged": [".../ctmp/claude-501/linked-workspace/<U>"]}
```
상위 링크 자체는 남고 링크 너머 디렉터리가 삭제됐다. 최종 UUID entry 자체를 symlink로 만든 대조군에서는 target이 살아 있고 링크만 삭제됐다. 즉 leaf symlink 보호만으로 해결되지 않는 문제다.
수정 방향: 명시적으로 설정한 root를 신뢰 경계로 정규화한 뒤, 그 아래 탐색 중 만난 symlink 디렉터리를 따라가지 않는다. tmp의 workspace key 등 중간 경로도 `lstat`/no-follow로 확인해야 한다. 경계 밖 realpath 후보는 삭제하지 말고 보고한다. 링크 교체 경합까지 고려하면 디렉터리 descriptor와 no-follow 연산으로 안전하게 경로를 여는 방식이 적절하다. leaf-link 대조군과 ancestor-link 탈출 회귀 테스트를 둘 다 추가한다.
## R2 — [P2] UUID가 아닌 `memory`를 세션으로 받아 공유 메모리 디렉터리를 삭제한다
근거:
- `.agents/skills/lib_py/agents/base.py:89`–`:92`: `_plausible_id`는 일부 경로 문자만 배제한다. `memory`, 공백, 임의 문자열은 UUID가 아니어도 통과한다.
- `.agents/skills/lib_py/agents/adapters/claude.py:262`–`:268`: 이 검증만으로 `projects/<key>/<입력>` 디렉터리 전체를 소유 경로로 간주한다.
- 같은 파일 `:287` 및 `:296`: `has_artifacts`와 purge가 같은 오판을 공유한다. 기존 코드의 단일 `<uuid>.jsonl` 삭제와 달리 이제 디렉터리 전체 삭제로 이어진다.
재현:
1. 임시 config의 `projects/<workspace_key>/memory/MEMORY.md`에 공유 메모리 sentinel을 만든다.
2. `adapter.has_artifacts("memory", ctx)`를 호출한다.
3. `adapter.purge_artifacts("memory", ctx)`를 호출한다.
기대: Claude 세션 UUID가 아닌 입력을 거부하고 memory를 보존해야 한다.
실제:
```text
MEMORY_ACCEPTED True
NON_UUID {"memory_exists": false,
"purged": [".../home/.claude/projects/<workspace_key>/memory"]}
```
현재 malformed-id 테스트는 빈 값, 점, 별표, slash만 다루므로 이 경우를 검출하지 않는다. 정상 UUID를 purge할 때 memory를 보존하는 테스트가 통과하는 것과는 별개다.
한계/구분: 이 호스트에서 실행한 **별도의 무수정 CLI** `purge claude memory` 탐침은 일반 `conversation_in_use`의 프로세스 문자열 검사에 걸려 rc 4로 끝났고 memory를 보존했다. 따라서 CLI가 이 실행에서 memory를 삭제했다고 주장하지 않는다. 위 삭제는 어댑터 호출에서 직접 재현했다. 프로세스 목록에 우연히 `memory`가 등장하는지 여부는 UUID 검증이나 공유 파일 소유권 보장의 대안이 아니며, CLI target 탐색도 이 잘못된 `has_artifacts`에 의존한다.
수정 방향: Claude의 디렉터리 삭제 후보를 만들기 전에 Claude 세션 식별자 형식을 검증한다(예: UUID 파싱 및 허용된 표기 검증). 다른 어댑터의 `ses_...` 식별자까지 전역적으로 금지하지 말고 Claude에 맞는 검증을 적용한다. `memory`, 공백, 임의 비-UUID 문자열은 has/purge에서 거부하고 실제 공유 memory 파일이 살아 있음을 회귀 테스트로 고정한다.
## R3 — [P2] NaN startedAt을 판정 불가로 처리하지 않아 live guard가 열린다
근거:
- `.agents/skills/lib_py/agents/adapters/claude.py:70`: `float(data["startedAt"])`가 `"NaN"`과 JSON NaN을 예외 없이 변환한다.
- 같은 파일 `:73`, `:77`: NaN은 `<= 0`도 거짓이고 `lstart <= started + 2`도 거짓이므로, 살아 있는 PID에 대해 False를 반환한다.
- 같은 파일 `:275`: 그 세션 파일은 죽은 프로세스의 소유 경로로 선택된다.
- `.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh:292`–`:298`: in-use rc 0이면 purge gate를 통과한다.
재현:
1. 실제 Claude 대신 `/bin/sleep 60`을 띄운다.
2. 임시 config의 `sessions/<sleep-pid>.json`에 새 UUID, 해당 live pid, `"startedAt": "NaN"`을 기록한다.
3. 모든 Claude/home/tmp 환경을 임시 위치로 지정한 CLI `python -m lib_py.agents in-use claude <UUID> <temp-workspace>`를 실행한다.
기대: 시작 시각을 비교할 수 없으므로 보수적으로 live=True, CLI rc 4.
실제:
```text
CLI_NAN {"rc": 0, "stdout": "{\"agent\": \"claude\", \"in_use\": false}\n",
"holder_alive": true, "session_file_exists": true}
```
직접 함수 탐침에서도 string NaN과 JSON NaN은 둘 다 False였고, `_owned_paths`에는 이 live PID의 세션 파일이 포함됐다. live fixture를 실제로 purge하지는 않았다.
수정 방향: startedAt 변환 후 `math.isfinite` 및 유효 범위를 확인하고, 비유한 값이면 True(live/unknown)를 반환한다. 필요하면 bool 등 시각이 아닌 타입도 배제한다. NaN/무한대/결손/파싱 불가 입력과 정상 live/dead/reused PID를 함께 검사한다.
## Mac 검증 및 통과한 범위
- `tests/test_issue31_purge_residual.py`: **32 passed in 23.89s**, rc 0.
- `tests/test_issue42_skill_blocks.py`, `tests/test_issue55_agent_homes.py`: **48 passed, 1 skipped in 1.48s**, rc 0. skip은 리뷰 worktree에 untracked `.claude/skills`가 없는 경우이다.
- base `26e2c43`의 임시 archive 복사본에 새 테스트 파일을 올려 핵심 3건을 실행: `test_claude_purge_removes_every_owned_path`, `test_claude_residual_lists_leftovers_then_nothing`, `test_purge_cli_refuses_a_live_claude_without_uuid_on_argv` 모두 예상대로 실패. 현재 head에서는 통과한다. 전체 32건의 base 비교를 다시 수행한 것은 아니다.
- 실제 `/usr/bin/python3` 3.9.6에서 변경 Python 8개 AST 파싱 통과. 변경 shell 2개를 각각 `/bin/bash -n`으로 검사하여 통과. 실제 Bash 3.2로 stop/in-use fixture 테스트 실행.
- 실제 BSD `/bin/ps -p <sleep-pid> -o lstart=` + LC_ALL=C 출력 예: `Fri Oct 16 10:18:16 2026`. 정상 현재 startedAt은 live=True, 한 시간 이전 startedAt은 PID 재사용으로 판정되어 live=False, 결손은 unknown/live=True. 파싱 불가 ps 출력의 보수적 처리는 신규 테스트로 통과했다. NaN은 위 별도 결함이다.
- 정상 UUID의 다른 세션/동일 jobs 접두사의 다른 세션/shared history 보존, stale session/key 삭제, 부분 삭제 실패의 소유 증거 유지, CLAUDE_CONFIG_DIR, stop rc 4 및 행 보존/삭제, codex/grok/opencode residual은 새 테스트 범위에서 통과했다.
- stop SKILL.md의 row 제거 설명과 `skill_code_blocks.json` 변경은 코드/문서 검사와 일치했다.
- `git diff --check base HEAD` 통과. 검증 후 HEAD/tree 일치 및 clean git status 확인.
## 실제 구조 관찰과 격리 범위
실제 홈에서는 디렉터리 목록/파일 종류/존재 여부만 읽었다. JSON, key, transcript 내용은 열지 않았다. 관찰 당시 `~/.claude`에는 projects 16개 디렉터리, file-history 18개, session-env 21개, sessions의 JSON 5개/key 5개가 있었다. jobs의 표본 디렉터리 두 곳에 state.json이 존재했다. `/private/tmp/claude-501`에는 workspace 디렉터리 5개가 있었고 표본에서 `<key>/<session>/scratchpad` 구조를 확인했다. `/tmp`의 realpath는 `/private/tmp`, 이 Mac의 TMPDIR realpath는 `/private/var/folders/.../T`였다. 이 관찰은 디렉터리 형태를 확인한 것이며 실제 session JSON 필드 값을 검증했다는 뜻은 아니다.
브리프의 금지 범위를 지키기 위해 테스트는 다음과 같이 격리했다.
- 지정 `env -u` 목록(HERDR/WORKSPACE_ROOT/MAM/DELEGATE/CLAUDE_CONFIG_DIR)을 적용하고, HOME/HOME_DIR 및 Claude 프로젝트·tmp 경로를 임시 위치로 지정했다. config 기본값 테스트는 임시 HOME의 `.claude`를 사용하며 custom config 테스트도 임시 경로만 사용했다.
- 코드가 명시적 임시 환경과 무관하게 `/tmp/claude-<uid>`도 탐색하므로, 테스트용 sitecustomize에서 **그 실제 fallback root에 대한 isdir만 False로 처리**했다. 그 실제 root에 대한 remove/rmdir/rmtree/rename도 방어적으로 차단했다. 이 테스트 보조 파일은 임시 디렉터리에만 있고 제품 소스는 변경하지 않았다.
- 따라서 tmp root 단위 테스트는 실제 fallback을 제외한 격리 환경에서 통과한 것이다. 실제 fallback의 존재/레이아웃은 위 읽기 관찰로 별도 확인했다.
- 수동 purge 탐침의 `_tmp_roots`는 임시 root 하나로 제한했다. CLI 탐침도 동일한 real-fallback 차단을 적용했다. 삭제 sentinel은 전부 리뷰 임시 디렉터리 안의 합성 데이터다.
- 실제 Claude 프로세스, herdr 서버, monitor는 시작하지 않았다. 실제 홈/실제 scratchpad/실제 agent session에 purge 또는 in-use를 실행하지 않았다. 제어 평면 쓰기는 지시된 lifecycle 이벤트와 본 리포트뿐이다.
- 모든 pytest 실행에 지정 venv Python, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 검증 트리 밖 basetemp를 사용했다. creator 트리에서 실행하지 않았다.
- 전체 스위트는 오케스트레이터의 별도 실행 범위이며 이번 리뷰에서 재실행하지 않았다. 선택 테스트에서는 #55 오탐/teardown 오류가 없었다.
산출물: `/tmp/review-05572944-u3lt5wk_/`의 `pytest.log`, `docs-home.log`, `before.log`, `probes.py`, `probes.log`, `cli-probe.py`, `cli-probe.log`, `guard/sitecustomize.py`. 임시 스크립트는 해당 검증 트리 cwd에서 실행하도록 작성됐으며 모든 파괴적 동작은 자체 임시 환경을 만든 후 수행한다.
[VERDICT: NOT PASS]
@@ -0,0 +1,76 @@
# PR #59 2차 Mac 리뷰 — NOT PASS
- Job: `a59e709d`; loop: `loop-20261017T135731Z-e93f0a`
- Base: `26e2c43753169671b600d03274901dbe203988dd`
- Head: `2a8d73825b3832376aeaf96b09548359dc3cb887`
- Tree: `d146f1ac68bcc3485d24fb92edb87264e4d1e8cb`
- 검증 트리: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261017T135731Z-e93f0a/1`
- 환경: macOS, 시스템 Python 3.9.6, bash 3.2, BSD ps. pytest는 지정된 제어 저장소의 `.venv/bin/python`으로 실행.
기존 3건의 결함은 이번 HEAD에서 해결됐고 수정 전 실패도 확인했다. 다만 새 UUID 대소문자 회귀 테스트 2건이 macOS에서 실패한다. 원인은 제품의 UUID 정규화가 아니라 테스트가 UUID 없는 프로세스를 생성하는 데 있다. 현재 HEAD를 이 Mac에서 통과한 상태로 승인할 수 없으므로 NOT PASS다. 수정 범위는 테스트 픽스처이며 재계획은 필요하지 않다.
## 수정 필요: [P2] UUID 명령행 보유자 픽스처가 macOS에서 UUID를 잃음
**근거:** `tests/test_issue31_purge_residual.py:315`의 `subprocess.Popen(["sh", "-c", "sleep 120", conv])`, 이어지는 `:318`의 live 단언. `upper`와 `mixed` 두 매개변수에서 동일하게 실패한다.
macOS의 sh는 마지막 명령을 실행하면서 동일 PID의 명령행을 `sleep 120`으로 바꾼다. `conv`는 셸의 `$0`였으며 sleep 인자로 전달되지 않는다. 앞서 `live.kill()` / `live.wait()`로 세션 파일의 PID도 죽였기 때문에 이 시점에는 세션 파일이나 명령행 어디에도 살아 있는 보유자가 없다. 어댑터가 False를 돌려주는 것은 이 픽스처 상태에서 올바르다.
**재현 절차:**
1. 검증 트리에서 아래 테스트 노드를 실행한다. 임시 HOME/HOME_DIR, CLAUDE_PROJECT_DIR, CLAUDE_CODE_TMPDIR, TMPDIR를 사용하고 브리프의 HERDR/MAM/WORKSPACE 환경 변수들을 제거한다. 테스트의 `home` 픽스처는 CLAUDE_CONFIG_DIR를 제거하여 임시 HOME/.claude를 쓰며, `own_tmp_roots`는 고정 /tmp fallback까지 임시 경로로 대체한다.
```sh
PYTHONDONTWRITEBYTECODE=1 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python -m pytest -p no:cacheprovider --basetemp=<검증트리밖의새임시경로> -q tests/test_issue31_purge_residual.py::test_any_spelling_of_the_uuid_is_the_lowercase_session
```
2. 독립적으로 새 UUID를 생성하고 `Popen(["sh", "-c", "sleep 120", uuid])`를 띄운 뒤 0.1초 후 `ps -p <그 PID> -o args=`를 읽는다. 관측 결과는 `sleep 120`으로 UUID가 없다. 종료 시 생성한 프로세스만 kill/wait한다.
3. 검증 트리 HEAD의 git archive 임시 복사본에서 해당 한 줄만 `Popen([sys.executable, "-c", "import time; time.sleep(120)", conv])`로 대체하고 같은 두 테스트를 실행한다.
**기대/실제:** 원래 테스트는 lowercase UUID를 명령행에 가진 살아 있는 프로세스를 만들고 upper/mixed ID 모두 in-use=True임을 검증해야 한다. 실제로는 UUID 없는 sleep을 만들어 두 건 모두 `:318`에서 실패했다. 임시 복사본의 한 줄 대체 대조군에서는 **2 passed in 2.36s**, 종료 코드 0이었다. 별도 ps 확인에서도 Python 프로세스는 UUID 인자를 유지했다.
**수정 방향:** 셸의 마지막 명령 최적화에 의존하지 않는 프로세스를 생성한다. 이미 import한 `sys.executable`을 사용한 위 Python sleep 픽스처가 최소 수정이다. 생성 PID의 명령행에 UUID가 실제로 남아 있는지 확인하고 기존 finally의 kill/wait 정리는 유지한다. 원본 검증 트리나 creator 트리는 수정하지 않았다.
## 기존 지적의 해결 및 수정 전 실패 검증
현재 테스트 파일을 각 수정 직전 커밋의 격리된 git archive 복사본에 놓고 해당 노드를 실행했다. 테스트 임시 루트 격리 픽스처도 함께 사용했다. 이는 예전 테스트가 아니라 새 회귀 단언이 예전 구현을 실제로 거부하는지 확인하기 위한 것이다.
| 기존 지적 | 현재 구현 및 HEAD 결과 | 수정 전 결과 |
| --- | --- | --- |
| UUID 아닌 `memory`가 공유 폴더를 삭제 | `claude.py:10` 정규 UUID 확인/소문자 변환, `:301` 후보 경로 구성 및 `:368`, `:390` purge/residual 진입 가드. malformed/non-UUID 보존 테스트 통과. upper/mixed의 세션 파일 기반 in-use 및 CLI exit 4 단계도 통과 | `bd09c6a^`에서 `test_claude_rejects_malformed_uuid[memory]` 실패: 기대 []와 달리 memory 관련 경로 3개 삭제 |
| 상위 심볼릭 링크를 통한 범위 밖 삭제 | `claude.py:105`의 `_inside`가 루트 아래 상위 구성요소를 lstat하고 `:336`의 `_owned_paths`가 필터링. tmp key/project key/file-history 링크 뒤 파일 보존, leaf 링크만 삭제, 루트 자체 링크 허용 테스트 통과 | `6e0361c^`에서 `test_claude_purge_never_follows_a_linked_dir_below_a_root` 실패: 링크 뒤 경로 4개 삭제 |
| NaN/inf/과대 정수 startedAt이 죽음으로 판정 | `claude.py:69`의 `_session_file_live`: bool/type/finite/positive 검사 및 float 변환 OverflowError 처리로 판정 불가를 live로 유지. 숫자 경계값 및 CLI 테스트 통과 | `f943b0d^`에서 `[NaN]` 테스트 실패: 살아 있는 PID인데 False. 추가로 `60779ba^`에서 `[10**400]` 테스트 실패: OverflowError |
각 수정 전 실행은 1 failed였으며, 동일 노드들이 현재 HEAD의 관련 모듈 실행에서는 통과했다. 기존 3건을 계속 미해결인 제품 결함으로 보고하는 것은 아니다.
## 검증 결과와 범위
다음 네 모듈을 함께 실행: `tests/test_issue31_purge_residual.py`, `tests/test_issue42_skill_blocks.py`, `tests/test_issue55_agent_homes.py`, `tests/test_a4_adapter_contract.py`.
- 원본 HEAD: **146 passed, 2 failed, 1 skipped in 37.80s**. 위 P2의 upper/mixed 두 건만 실패. 전체 스위트는 실행하지 않았다.
- 임시 복사본의 픽스처 대조군: 위 두 건만 **2 passed**. 이를 원본 HEAD 전체 통과로 합산하지 않는다.
- 변경 Python 파일의 시스템 Python 3.9 구문 검사, 변경 셸 파일의 bash 구문 검사, `git diff --check` 통과.
- 종료 전 HEAD/tree 재확인 일치, 검증 트리 `git status --short` 출력 없음.
- 실제 Claude 실행/실제 세션 purge·stop·in-use는 수행하지 않았다. destructive 테스트는 임시 홈/프로젝트/config/tmp에서만 실행. issue31 외 모듈에도 실제 `/tmp/claude-<uid>`에 접근하지 않도록 별도 테스트 프로세스용 가드를 적용했다.
## 추가 검토 사항
- BSD ps 시작 시각: `LC_ALL=C`, `TZ=UTC0`로 읽어 `calendar.timegm`과 epoch-ms를 비교한다. 실제 임시 sleep 프로세스의 live 판정, 재사용 PID 모형, 판정 불가, UTC/DST 회귀 테스트를 확인했다. startedAt+2초 비교는 유지됐다.
- tmp 레이아웃: 실제 경로는 디렉터리 이름/종류 메타데이터만 확인했다. 이 Mac의 `/tmp`는 `/private/tmp`로 연결되고 실제 per-uid scratchpad는 workspace key 아래 UUID 디렉터리 구조였다. TMPDIR는 별도의 `/var/folders/.../T/`로 해석된다. override/base/per-uid/realpath 중복 제거 테스트 통과.
- 실제 ~/.claude의 projects, file-history, session-env, sessions, jobs 디렉터리 구조는 구현이 가정한 배치와 부합했다. 실제 세션 파일 및 key 내용은 읽지 않았다. 관측된 metadata 수: projects 디렉터리 16개, file-history 19개, session-env 21개, sessions JSON 6개와 key 6개, jobs 디렉터리 2개와 JSON 파일 1개. 이는 구조 확인이지 모든 실제 파일의 소유권을 검증했다는 뜻은 아니다.
- `dd292c1`: 삭제 가능한 `_owned_paths` 대신 전체 `_candidates`로 has_artifacts가 Claude를 선택하므로, 링크 뒤 잔여물만 있어도 auto purge는 Claude로 선택하여 보존 및 residual을 보고한다. `test_purge_cli_reports_a_linked_orphan_it_will_not_remove` 통과.
- 공유 파일/다른 UUID scratchpad/동일 접두사 jobs/다른 세션 metadata 보존 및 실패 후 ownership evidence 유지 검증을 관련 모듈에서 확인했다. key 경로는 보고 시 hex를 마스킹한다.
- `base.py:311`, `__main__.py:186`, `stop_session.sh:292`의 안내 전달을 검토했다. stdout JSON과 stderr 힌트를 분리하고 stop은 in-use gate의 실패를 exit 4로 처리한다. exit 4 및 purge 시 registry 행 삭제 설명이 stop SKILL에 반영됐다. skill code block 관련 검사 통과.
- agy에도 in-use gate가 적용되는 점은 공통 어댑터 계약의 의도된 동작이며 이 자체를 결함으로 판정하지 않았다.
- `test_tests_never_reach_the_real_tmp_root` 및 자식 Python의 fallback 주입 검증 통과. #55 관련 외부 agy 아티팩트를 결함 근거로 사용하지 않았다.
- 상위 경로 링크 검사는 검사 시점의 디렉터리 구조에 관한 보장이다. 동시 파일시스템 변경을 포함한 원자적 삭제 보장까지 검증한 것은 아니다.
## 로컬 증거
임시 증거 루트: `/tmp/review-a59e709d-4yb9c6z4` (macOS 실경로 `/private/tmp/...`).
- `head.log`: 원본 HEAD 네 모듈 실행 결과.
- `before-id.log`, `before-links.log`, `before-nan.log`, `before-overflow.log`: 수정 전 실패 결과.
- `fixture-control.log`: 한 줄 픽스처 대체 대조군의 2건 통과.
- `fixture-control/`: HEAD archive의 임시 대조군. 제품 코드 변경 없음.
[VERDICT: NOT PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261017T145452Z-6c5aa6",
"base_sha": "26e2c43753169671b600d03274901dbe203988dd",
"head_sha": "de6f3fc81f8c33a9d2335320742dc5a47f566cda",
"tree_sha": "ffc291e6fc93822070f2b9f1d17009e83f176fcf",
"review_commit": "de6f3fc81f8c33a9d2335320742dc5a47f566cda",
"reviewer": "planner-reviewer-codex-01",
"job_id": "d5e47fc2",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-17T15:16:54Z"
}
@@ -0,0 +1,77 @@
# PR #59 3차 Mac 리뷰
판정: **PASS**. base..head 누적 변경에 대해 병합을 막을 추가 결함을 발견하지 않았다. 2차 리뷰에서 남았던 macOS UUID 명령행 보유자 테스트 실패가 해결됐으며, 수정한 테스트가 정규화 회귀를 실제로 검출하는 것도 확인했다.
## 리뷰 identity
- Job: `d5e47fc2`
- Loop: `loop-20261017T145452Z-6c5aa6`
- Base: `26e2c43753169671b600d03274901dbe203988dd`
- Head: `de6f3fc81f8c33a9d2335320742dc5a47f566cda`
- Tree: `ffc291e6fc93822070f2b9f1d17009e83f176fcf`
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261017T145452Z-6c5aa6/1`
- macOS / 시스템 Python 3.9.6 / bash 3.2 / BSD ps. pytest는 지정된 제어 저장소 `.venv/bin/python` 사용.
시작과 종료 시 HEAD/tree가 브리프와 일치했고 `git status --short` 출력은 없었다. creator 트리에서 실행하거나 수정하지 않았다. 제품 코드, 원본 테스트, 브랜치 및 커밋 변경 없음. 회귀 대조군만 검증 HEAD의 별도 임시 archive 복사본에서 변경했다.
## 이번 수정 검증
`git diff 2a8d738..HEAD`는 `tests/test_issue31_purge_residual.py`의 프로세스 생성 한 줄 변경과 설명 주석 두 줄뿐이다. `.agents` 제품 코드와 문서, 다른 테스트 및 공통 fixture는 이전 리뷰 HEAD와 동일하다.
- `tests/test_issue31_purge_residual.py:317`: `[sys.executable, "-c", "import time; time.sleep(120)", conv]`로 UUID를 Python argv에 남긴다. macOS sh의 마지막 명령 exec 최적화를 피하는 적절한 최소 수정이다.
- 독립 확인에서 새 UUID로 동일한 Python 프로세스를 만들고 0.05초 뒤 BSD `ps -p <pid> -o args=`를 읽었다. 명령행 끝에 lowercase UUID가 유지됐다.
- `:320`의 보유자 단언은 그대로이며 `:321`~`:323`의 finally kill/wait도 유지됐다. 독립 프로브에서 kill/wait 후 반환 코드 -9, 해당 PID의 ps 출력 없음으로 정리를 확인했다.
- 원본 HEAD의 upper/mixed 테스트는 둘 다 통과했다. 세션 metadata 기반 live 및 CLI exit 4, 그 프로세스를 죽인 후 명령행 기반 live, 이후 purge/잔여물 없음까지 기존 검증을 그대로 수행한다.
### 회귀 검출 대조군
검증 HEAD를 `/tmp/review-d5e47fc2-oqyptnum/mutation`으로 git archive하고, **제품 원본이 아닌 복사본에서만** `.agents/skills/lib_py/agents/adapters/claude.py:354`의 `for u in {canon, uuid}`를 `for u in {uuid}`로 바꿨다. 세션 파일 조회의 정규화 및 다른 코드는 유지해 명령행 소문자 검색만 제거했다.
동일 테스트 노드 `tests/test_issue31_purge_residual.py::test_any_spelling_of_the_uuid_is_the_lowercase_session`를 실행한 결과 **2 failed in 1.52s**, pytest 종료 코드 1. upper/mixed 모두 `:320`의 `a command line naming the lowercase id` 단언에서 실패했다. 그 앞의 세션 파일 기반 판정 및 CLI exit 4는 통과했다. 따라서 이번 픽스처 변경은 단언을 약화하지 않으며, 정확히 명령행 UUID 철자 처리 회귀를 검출한다. 이 의도적인 실패는 원본 HEAD의 실패가 아니다.
## 실행 결과
원본 HEAD에서 다음 네 모듈을 함께 실행했다.
- `tests/test_issue31_purge_residual.py`
- `tests/test_issue42_skill_blocks.py`
- `tests/test_issue55_agent_homes.py`
- `tests/test_a4_adapter_contract.py`
결과: **148 passed, 1 skipped in 38.52s**, pytest 종료 코드 0. 전체 스위트는 브리프대로 오케스트레이터 범위이며 이 리뷰에서 전체 통과를 주장하지 않는다.
실행 형식은 `PYTHONDONTWRITEBYTECODE=1 <제어저장소>/.venv/bin/python -m pytest -p no:cacheprovider --basetemp=/tmp/review-d5e47fc2-oqyptnum/pytest-head -q <위 네 모듈>`이다. 테스트 실행 전 HERDR/MAM/WORKSPACE/DELEGATE_JOB 바인딩 및 CLAUDE_CONFIG_DIR를 제거하고 HOME/HOME_DIR, CLAUDE_PROJECT_DIR, CLAUDE_CODE_TMPDIR, TMPDIR를 임시 경로로 설정했다. 실제 config 대신 임시 HOME/.claude 또는 각 테스트가 지정하는 임시 config만 사용한다. issue31의 own_tmp_roots fixture는 자식 Python까지 고정 /tmp fallback을 임시화한다. 다른 모듈에는 테스트 프로세스 전용 sitecustomize로 실제 `/tmp/claude-<uid>` 후보를 제외하는 추가 가드를 적용했다.
- base..head `git diff --check` 통과.
- 변경 Python 파일 모두 시스템 Python 3.9의 AST 구문 검사 통과.
- 변경 shell 파일 모두 `/bin/bash -n` 통과.
- 실제 Claude나 다른 실제 에이전트를 실행/중지/purge하지 않았다. 실제 세션에 대한 in-use 호출도 없었다. 실제 .mam/.mam.env/monitor/herdr 설정을 변경하지 않았다. 지정된 잡 이벤트와 보고서만 제어 평면에 기록한다.
## 누적 변경 재확인 및 이전 리뷰 근거
2차 리뷰 리포트: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.mam/jobs/a59e709d/codex-reports/report-final.md`.
이번 제품 코드가 그 HEAD와 동일함을 diff로 확인했다. 누적 diff의 공통 base/CLI/stop 변경과 Claude 소유 경로 선택·삭제 흐름을 재검토하고 관련 테스트를 재실행했다.
- 잘못된 UUID로 memory 등 공유 폴더에 도달하는 문제: canonical UUID 가드 및 소문자 경로 처리 유지. malformed/non-UUID 보존 테스트 통과.
- 상위 링크를 통한 범위 밖 삭제: `_inside`의 lstat 검사와 `_owned_paths` 필터 유지. 링크 뒤 잔여물 보존·보고, leaf 링크만 제거, 루트 자체 링크 허용 테스트 통과.
- NaN/inf/float 범위 초과 startedAt: 유효 시각이 아니면 live로 처리하는 가드 유지. 관련 숫자 경계와 CLI 테스트 통과.
- 기존 세 결함의 수정 전 실패 검증은 2차 리뷰에서 수행한 결과를 재사용한다. memory/상위 링크/NaN 및 추가 OverflowError 회귀 테스트가 각 수정 직전 코드에서 실패했다. 이번에는 그 옛 커밋 테스트를 반복하지 않았다.
- BSD ps/UTC 시작 시각 비교, PID 재사용 판별, 판정 불가 시 live 처리, tmp root 우선순위와 realpath 중복 제거, 소유권 state.json을 통한 jobs 선택, 다른 세션/공유 파일 보존 테스트 통과.
- `dd292c1`의 링크 뒤 orphan만 남은 경우도 Claude로 선택되어 residual로 보고하는 테스트 통과.
- `base.py`의 residual 헬퍼 및 in-use 힌트, CLI stdout JSON/stderr 안내 분리, stop exit 4와 records preserved, 정상 purge 후 registry 행 제거 및 residual 출력 계약을 재확인했다.
- CLAUDE_CONFIG_DIR 적용, codex/grok/opencode residual 테스트, stop SKILL/code-block 정합성 테스트 통과.
- 실제 Mac 홈/tmp 구조의 read-only 메타데이터 확인은 동일 환경의 2차 리뷰 관측을 재사용했다. `/tmp`→`/private/tmp`, 별도 TMPDIR, per-uid workspace-key/UUID scratchpad 배치 및 ~/.claude 부속 디렉터리 구조는 그 보고서에 기록돼 있다. 이번에는 실제 세션 내용을 읽거나 실제 경로에 파괴적 검증을 추가하지 않았다.
브리프에서 수용한 비차단 제한은 유지된다: 링크된 projects/<key> 뒤 transcript는 삭제하지 않고 residual로 보고, lowercase 질의가 uppercase argv만 가진 보유자를 못 보는 경우, 직접 purge_artifacts 호출에는 in-use gate가 없는 점. 이를 이번 수정의 신규 차단 결함으로 재분류하지 않는다. 동시 파일시스템 경로 교체까지 원자적으로 방어함을 검증한 것은 아니다.
## 증거 파일
`/tmp/review-d5e47fc2-oqyptnum` (실경로 `/private/tmp/...`):
- `head.log`: 원본 HEAD 148 passed / 1 skipped.
- `mutation.log`: 명령행 정규화 제거 대조군의 의도된 2건 실패.
- `argv-probe.json`: lowercase UUID가 남은 BSD ps 결과, kill/wait 후 PID 부재.
- `mutation/`: 원본과 분리된 회귀 대조군 복사본.
[VERDICT: PASS]
@@ -0,0 +1,64 @@
# Review Report — PR #59 (Issue #31: Complete Claude purge, residual reporting, and live-process stop gate)
- **Reviewer**: `agy` (`herdr:reviewer-agy-01`)
- **Job ID**: `2fff60bd`
- **Branch / Range**: `fix/issue-31` (`26e2c43753169671b600d03274901dbe203988dd..6f4f40a4adb2115d5997dffd31fb6473f05cab68`, 13 commits, 12 files)
- **Verification Worktree**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261016T003524Z-a51548/1`
- **Platform**: macOS Darwin (`arm64-apple-darwin25`, `/usr/bin/python3` 3.9.6, `.venv/bin/python` 3.9.6, `/bin/bash` 3.2.57)
---
## 1. Executive Summary
PR #59 resolves Issue #31 by unifying the Claude adapter's owned-path resolution (`_owned_paths`) across `purge_artifacts`, `has_artifacts`, and `residual_artifacts`, covering:
- `projects/<key>/<uuid>.jsonl` and `projects/<key>/<uuid>/` (across both `ctx.claude_dir` and `<config_dir>/projects`),
- `file-history/<uuid>/` and `session-env/<uuid>/`,
- `jobs/<uuid[:8]>/` (only when `state.json` has `sessionId == uuid`, removing `state.json` last so partial failure preserves ownership evidence),
- `sessions/<pid>.<64hex>.key` (masked in output as `<pid>.*.key`) and `sessions/<pid>.json` for dead or PID-reused processes (removing keys before the `.json` file so ownership evidence survives partial failure),
- `<tmp>/claude-<uid>/<key>/<uuid>/` across `CLAUDE_CODE_TMPDIR`, `TMPDIR`/`TMP`/`TEMP`, and `/tmp`.
It also adds `residual_artifacts` for `claude`, `codex`, `grok`, and `opencode`; wires `stop_session.sh` to report residuals after `--purge-conversation` and to check `python -m lib_py.agents in-use <agent> <uuid> <cwd> --wait 5` before purging (exiting 4 without mutating state if a live agent process still holds the conversation); supports `CLAUDE_CONFIG_DIR` across `lib.sh`, `DiscoveryContext`, `claude.py`, and `_agent_home_guard.py`; and updates `multi-agent-mux-stop/SKILL.md` + `tests/data/skill_code_blocks.json` to reflect that `--purge-conversation` removes the session row rather than leaving `status: terminated`.
All 12 modified files pass Python 3.9.6 AST compilation and Bash 3.2 (`set -u`) syntax checks, all 11 regression test cases fail against base `26e2c43` and pass on head `6f4f40a`, and 217 tests across 6 test suites pass on macOS.
---
## 2. macOS Environment & Deletion-Scope Safety Verification
1. **BSD `ps -o lstart=` Format, Locale (`LC_ALL=C`), and `startedAt + 2` Comparison on macOS**:
- Verified BSD `ps -p <pid> -o lstart=` on this Mac under `LC_ALL=C`: outputs `'Fri Oct 16 09:48:41 2026 \n'`.
- `" ".join(out.split())` normalizes multi-space padding (including single-digit days like `'Oct 2'`) and trailing whitespace before `time.strptime(..., "%a %b %d %H:%M:%S %Y")`.
- Inspected 4 live `~/.claude/sessions/<pid>.json` files on this Mac (read-only): `startedAt / 1000.0 - lstart` ranged from `+0.694 s` to `+2.430 s`, so `lstart <= started + 2` holds for all live processes and rejects reused PIDs started after the session file was written.
- Fail-safe behavior: invalid/non-positive PIDs, `started <= 0`, missing `startedAt`, or `ps`/parse errors all return `True` (treated as live, refusing purge).
2. **macOS Temp Root Resolution (`TMPDIR` vs `/tmp` -> `/private/tmp`) & Real Layout Agreement**:
- On this Mac, `TMPDIR` is `/var/folders/q_/.../T/` (which has no `claude-501`), while Claude Code's actual scratchpad directory is `/private/tmp/claude-501/<workspace_key>/<uuid>/` (accessed via `/tmp/claude-501` symlink).
- `_tmp_roots()` checks `CLAUDE_CODE_TMPDIR` (both `<override>/claude-<uid>` and `<override>` when already named `claude-<uid>`), `TMPDIR`/`TMP`/`TEMP`, and `/tmp/claude-<uid>`, deduplicating via `os.path.realpath(c)`. This ensures `/private/tmp/claude-<uid>` is always discovered on macOS even when `TMPDIR` points to `/var/folders/.../T/`.
- Read-only inspection of `~/.claude` on this Mac confirmed exact structural agreement for `projects/<key>/`, `file-history/<uuid>/`, `session-env/<uuid>/`, `sessions/<pid>.json` + `<pid>.<64hex>.key`, and `jobs/<uuid[:8]>/state.json` (while shared files like `jobs/pins.json` and `history.jsonl` are untouched).
3. **Deletion Scope Isolation, Malformed UUID Rejection, and Symlink Safety**:
- `_plausible_id(uuid)` rejects `""`, `"."`, `".."`, `"*"`, and any string containing `"/"`, `".."`, or `"*"` before constructing any path or scanning any directory.
- `_owned_job` checks `json.load(open(job/state.json)).get("sessionId") == uuid`, leaving another session's `jobs/<uuid[:8]>/` (and `jobs/pins.json`) untouched.
- `_key_files` matches `rf"{stem}\.[0-9a-f]{{64}}\.key"` only for a `sessions/<pid>.json` whose `sessionId == uuid`, never reading key contents and never touching lone keys or other sessions' PIDs.
- `_remove` checks `if os.path.islink(path) or not os.path.isdir(path): os.remove(path)` before `shutil.rmtree(path, ignore_errors=True)`, and `scan_names` uses `os.walk(root)` (`followlinks=False`), ensuring symlinks are never traversed outside the target scope.
4. **Stop Exit 4 Contract, Residual Reporting, and Documentation Lockstep**:
- `stop_session.sh` runs `"$(_delegate_py_bin)" -m lib_py.agents in-use "$AGENT" "$PURGE_UUID" "$TARGET_CWD" --wait 5` before `atomic_dump_yaml`; if non-zero, it publishes a `cancelled` job event and exits `4` with all records and files preserved.
- `tests/data/skill_code_blocks.json` hashes (`301c347ff881`, `a973676cb2d1`) and line number (`stop_session.sh:327`) match `multi-agent-mux-stop/SKILL.md` and `stop_session.sh` (`test_issue42_skill_blocks.py` passes).
---
## 3. Test Suite Execution Summary
Executed in `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261016T003524Z-a51548/1` with `HERDR_*`, `WORKSPACE_ROOT`, `MAM_ENV_FILE`, `MAM_JOBS_DIR`, `DELEGATE_JOB_LOGS_DIR`, and `CLAUDE_CONFIG_DIR` scrubbed, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp=/tmp/mam_verify_pr59_bt`:
- `tests/test_issue31_purge_residual.py`: **32 passed** (11 core regression cases verified to fail on base `26e2c43`)
- `tests/test_issue55_agent_homes.py`: **11 passed**
- `tests/test_issue42_skill_blocks.py`: **15 passed**
- `tests/test_a4_adapter_contract.py`: **55 passed**
- `tests/test_orc_onboard.py`: **37 passed, 1 skipped**
- `tests/test_tier1_unit.py`: **67 passed**
Total: **217 passed, 1 skipped, 0 failed**. Verification worktree `git status -s` is empty and no `.mam` directory was created.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261017T145452Z-6c5aa6",
"base_sha": "26e2c43753169671b600d03274901dbe203988dd",
"head_sha": "de6f3fc81f8c33a9d2335320742dc5a47f566cda",
"tree_sha": "ffc291e6fc93822070f2b9f1d17009e83f176fcf",
"review_commit": "de6f3fc81f8c33a9d2335320742dc5a47f566cda",
"reviewer": "reviewer-agy-01",
"job_id": "7c325532",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-17T15:16:54Z"
}
@@ -0,0 +1,43 @@
# Pre-Merge Review Report (Round 3): PR #59 (`fix/issue-31`)
- **Job ID**: `7c325532`
- **Reviewer**: `herdr:reviewer-agy-01` (`agy`)
- **Commit Range**: `26e2c43753169671b600d03274901dbe203988dd` .. `de6f3fc81f8c33a9d2335320742dc5a47f566cda` (25 commits; Round 3 delta: `2a8d73825b3832376aeaf96b09548359dc3cb887..de6f3fc81f8c33a9d2335320742dc5a47f566cda`, 1 commit)
- **Verification Worktree**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261017T145452Z-6c5aa6/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
---
## 1. Round 3 Delta Verification (`2a8d738..de6f3fc`)
- **Commit `de6f3fc81f8c33a9d2335320742dc5a47f566cda`**:
- Replaces `subprocess.Popen(["sh", "-c", "sleep 120", conv])` at `tests/test_issue31_purge_residual.py:315` with:
```python
# Not `sh -c "sleep 120" <uuid>`: a shell execs its last simple command
# and the uuid would vanish from the process list (macOS /bin/sh).
named = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)", conv])
```
- **Verification Checklist**:
1. **Lowercase UUID retained in `argv` on macOS**: The Python holder process does not `exec` a separate binary, so `conv` stays visible in `ps -axo pid=,command=` for the lifetime of `named`.
2. **Passes `[upper]` and `[mixed]` on macOS**: Both `test_any_spelling_of_the_uuid_is_the_lowercase_session[upper]` and `[mixed]` pass on Darwin (`67/67` tests in `tests/test_issue31_purge_residual.py` pass).
3. **Regression sensitivity preserved**: Reverting `for u in {canon, uuid}` to `for u in (uuid,)` in `ClaudeAgentAdapter.conversation_in_use` (in an isolated `/tmp` copy) causes both `[upper]` and `[mixed]` to fail immediately at `tests/test_issue31_purge_residual.py:320` (`AssertionError: a command line naming the lowercase id`).
4. **Process cleanup (`finally: named.kill(); named.wait()`)**: Because `named` is a single Python process with no forked child `sleep`, `named.kill()` and `named.wait()` terminate it cleanly without leaving orphaned processes.
---
## 2. Cumulative Review Summary (`26e2c43..de6f3fc`)
- **Canonical UUID & deletion scope safety**: `_canonical(uuid)` in `.agents/skills/lib_py/agents/adapters/claude.py` gates `_candidates`, `has_artifacts`, `conversation_in_use`, `purge_artifacts`, and `residual_artifacts`, preventing non-UUID names (`memory`, `notes`, `uuid-c`, etc.) from matching or deleting `projects/<key>/memory/` or any other directory.
- **Symlink boundary protection**: `_inside(root, path)` verifies via `os.lstat` that no intermediate directory component between `root` and `os.path.dirname(path)` is a symlink and that `realpath(dirname(path))` stays strictly inside `realpath(root)`, while `has_artifacts` counts `_candidates` so symlinked orphans still select `claude` and appear in `residual_artifacts`.
- **macOS `ps -o lstart=` & `startedAt` fail-safe liveness**: `_session_file_live` runs `ps -p <pid> -o lstart=` with `LC_ALL="C", TZ="UTC0"`, parses via `calendar.timegm`, and treats `bool`, non-`(int, float)`, `OverflowError`, `NaN`, `±inf`, and `<= 0` `startedAt` values as live when the PID exists.
- **`stop_session.sh` & CLI `in-use` gate**: `in_use_hints` and exit-4 error messages accurately report where live holders may be found and that registry records and conversation files are preserved.
- **Test suite execution**:
- `tests/test_issue31_purge_residual.py`: **67 passed**
- `tests/test_issue55_agent_homes.py`: **PASSED**
- `tests/test_issue42_skill_blocks.py`: **PASSED**
- `tests/test_a4_adapter_contract.py`: **PASSED**
- `tests/test_orc_onboard.py`: **PASSED**
- Total run in Round 3 verification worktree: **185 passed, 1 skipped** in 48.05s (`test_tier1_unit.py` also verified passing in Round 2 with no production changes since `2a8d738`).
- **Worktree hygiene**: Verification worktree `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261017T145452Z-6c5aa6/1` is clean (`git status -s` empty, no `.mam` directory created, basetemp removed).
[VERDICT: PASS]
@@ -0,0 +1,81 @@
# Pre-Merge Review Report (Round 2): PR #59 (`fix/issue-31`)
- **Job ID**: `b4aa3aff`
- **Reviewer**: `herdr:reviewer-agy-01` (`agy`)
- **Commit Range**: `26e2c43753169671b600d03274901dbe2039887` .. `2a8d73825b3832376aeaf96b09548359dc3cb887` (Round 2 delta: `6f4f40a4adb2115d5997dffd31fb6473f05cab68..2a8d73825b3832376aeaf96b09548359dc3cb887`, 11 commits)
- **Verification Worktree**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261017T135731Z-e93f0a/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
---
## 1. Round 1 Findings Verification
All three required production fixes and all four non-blocking improvements from Round 1 are cleanly resolved in `6f4f40a..2a8d738`:
1. **[Blocker — Resolved] Canonical UUID enforcement across Claude adapter (`bd09c6a`, `c0248b8`, `81b6c2e`, `2a8d738`)**:
- `_canonical(uuid)` in `.agents/skills/lib_py/agents/adapters/claude.py:10-18` validates `isinstance(uuid, str) and _UUID.fullmatch(uuid)` (`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`, case-insensitive) and normalizes to lowercase.
- `_candidates`, `has_artifacts`, `conversation_in_use`, `purge_artifacts`, and `residual_artifacts` all gate on `_canonical(uuid)`, ensuring non-UUID names (such as `memory`, `notes`, or `uuid-c`) never touch `projects/<key>/memory/` or any other directory/file.
- `conversation_in_use` checks `any(conversation_in_use(u, [self.artifact_path(canon, ctx)]) for u in {canon, uuid})` plus `self._session_files(canon, ctx)`, properly handling uppercase/mixed-case UUIDs on case-insensitive APFS.
- `tests/test_a4_adapter_contract.py:225-232` was updated with user approval to use a canonical UUID (`0c1a5f3e-6b2d-4c8e-9a7f-1d2e3f4a5b6c`).
2. **[P1 — Resolved] Symlink boundary check below Claude roots (`6e0361c`, `dd292c1`)**:
- `_inside(root, path)` in `.agents/skills/lib_py/agents/adapters/claude.py:108-122` walks every path component of `os.path.relpath(os.path.dirname(path), root)` and verifies `stat.S_ISDIR(os.lstat(cur).st_mode)` (rejecting any intermediate symlink) and `os.path.realpath(os.path.dirname(path)).startswith(real_root.rstrip(os.sep) + os.sep)`.
- Roots that are themselves symlinks (such as `/tmp -> /private/tmp` on macOS or a symlinked `~/.claude`) and leaf symlinks (`_remove` unlinks the leaf symlink without traversing) work as intended.
- `has_artifacts` checks `bool(self._candidates(uuid, ctx))` so a candidate behind a symlink still selects `claude` in `purge auto` / `purge claude` and is reported in `residual_artifacts` without being deleted.
3. **[P2 — Resolved] Fail-safe `startedAt` validation in `_session_file_live` (`f943b0d`, `60779ba`)**:
- `.agents/skills/lib_py/agents/adapters/claude.py:87-95` rejects `bool`, non-`(int, float)`, `OverflowError` on `float(started) / 1000.0` (e.g. `10**400`), `NaN`, `±inf`, and `<= 0`, returning `True` (fail-safe live) whenever the PID is alive.
4. **[Non-Blocking Notes — Resolved]**:
- **UTC `lstart` parsing (`af8ed39`)**: `ps -p <pid> -o lstart=` runs with `LC_ALL="C", TZ="UTC0"` and parses via `calendar.timegm`. Verified directly on macOS Darwin with both `.venv/bin/python` and `/usr/bin/python3` (`abs(time.time() - lstart) < 0.7s`).
- **Actionable `in_use_hints` & exit-4 wording (`e156179`)**: `BaseAgentAdapter.in_use_hints` and `ClaudeAgentAdapter.in_use_hints` print the active `<cfg>/sessions/` path and `ps -axww | grep <uuid>` hint on stderr when `in-use` or `stop_session.sh --purge-conversation` exits 4; `SKILL.md` and `stop_session.sh` accurately state that registry records and conversation files are preserved even though the herdr session itself may already be down.
- **Host `/tmp/claude-<uid>` test isolation (`26fb1c4`)**: `_SYSTEM_TMP = "/tmp"` in `claude.py` is redirected by the `own_tmp_roots` autouse fixture (and `sitecustomize.py` for child Python processes) in `tests/test_issue31_purge_residual.py`.
5. **Pre-fix failure verification**:
- Running the new Round 2 regression tests from `tests/test_issue31_purge_residual.py` against pre-fix code (`26fb1c4`, before `bd09c6a..2a8d738`) in an isolated `/tmp` copy produced **25 test failures** across all targeted bug classes (`test_claude_rejects_malformed_uuid`, `test_any_spelling_of_the_uuid_is_the_lowercase_session`, `test_claude_purge_never_follows_a_linked_dir_below_a_root`, `test_purge_cli_reports_a_linked_orphan_it_will_not_remove`, `test_a_non_uuid_id_reaches_nothing`, `test_liveness_reads_lstart_as_utc`, `test_an_unusable_started_at_means_live`, `test_a_live_holder_with_an_unusable_started_at_blocks_the_purge`).
---
## 2. Remaining Issue Blocking Merge
### [P1] `test_any_spelling_of_the_uuid_is_the_lowercase_session` fails on macOS due to `/bin/sh -c` single-command `exec` optimization
- **Location**: `tests/test_issue31_purge_residual.py:315` (introduced in commit `c0248b8`)
- **Symptom**:
Running pytest on macOS fails both parametrizations (`[upper]` and `[mixed]`):
```text
FAILED tests/test_issue31_purge_residual.py::test_any_spelling_of_the_uuid_is_the_lowercase_session[upper]
FAILED tests/test_issue31_purge_residual.py::test_any_spelling_of_the_uuid_is_the_lowercase_session[mixed]
AssertionError: a command line naming the lowercase id
assert False is True
```
- **Root Cause**:
Line 315 spawns the dummy holder process as:
```python
named = subprocess.Popen(["sh", "-c", "sleep 120", conv])
```
On macOS (`/bin/sh` is GNU bash 3.2.57 in POSIX mode), `sh -c "sleep 120" <conv>` sees a single simple command with no subsequent commands or traps and optimizes by calling `execve("/bin/sleep", ["sleep", "120"])` directly in-place (`CMD_NO_FORK`). Within ~10ms (before `time.sleep(0.05)` finishes), the process image and argv in `ps -axo pid=,command=` become `sleep 120`, and `$0` (`conv`) vanishes from the process list.
Note that `tests/test_issue10_13_purge.py:111-118` (`_holder`) already documents this exact pitfall:
> `(Not sh -c 'sleep …': a shell execs its last simple command and the uuid would vanish from the process list.)`
- **Required Fix**:
In `tests/test_issue31_purge_residual.py:315`, use `sys.executable` instead of `sh -c`:
```python
named = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)", conv])
```
*(Verified in an isolated `/tmp` copy on macOS: replacing line 315 with `sys.executable` makes both `[upper]` and `[mixed]` pass in 2.24s, with no orphaned child `sleep` process after `named.kill()`).*
---
## 3. Verification Summary
- Targeted pytest suite run against `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261017T135731Z-e93f0a/1` with scrubbed herdr/MAM/Claude environment variables:
- `tests/test_issue55_agent_homes.py`: **PASSED**
- `tests/test_issue42_skill_blocks.py`: **PASSED**
- `tests/test_a4_adapter_contract.py`: **PASSED**
- `tests/test_orc_onboard.py`: **PASSED**
- `tests/test_tier1_unit.py`: **PASSED**
- `tests/test_issue31_purge_residual.py`: **65 passed, 2 failed** (`test_any_spelling_of_the_uuid_is_the_lowercase_session[upper]` and `[mixed]`)
- Total: **250 passed, 2 failed, 1 skipped** (all 252 pass once `tests/test_issue31_purge_residual.py:315` uses `sys.executable`).
- Verification worktree `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261017T135731Z-e93f0a/1` verified clean (`git status -s` empty, no `.mam` directory created).
[VERDICT: NOT PASS]
@@ -0,0 +1,51 @@
# Review 916abd41 — PR #59 second pass, Issue #31
Reviewed only the snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261017T135731Z-e93f0a/1` (`loop-20261017T135731Z-e93f0a`). Head `2a8d73825b3832376aeaf96b09548359dc3cb887`, tree `d146f1ac68bcc3485d24fb92edb87264e4d1e8cb`, base `26e2c43753169671b600d03274901dbe203988dd` is an ancestor. Versus base: 13 files, +1462/−30. Versus the first-review head `6f4f40a4adb2115d5997dffd31fb6473f05cab68`: 11 commits (`26fb1c4`..`2a8d738`), 7 files, +382/−50. `git diff --check` is clean. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane main is still `26e2c43753169671b600d03274901dbe203988dd`, 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 (and `/bin/sh` is that bash). `bash -n` accepts `stop_session.sh` and `lib.sh`.
Every purge and `in-use` exercised here used a temporary `HOME`, `CLAUDE_CONFIG_DIR`, `CLAUDE_PROJECT_DIR`, `CLAUDE_CODE_TMPDIR`, and `TMPDIR`. The real `~/.claude` and `/private/tmp/claude-<uid>` were listed with read-only counts. `_session_file_live` was called on the real session files and deleted nothing.
## What this Mac already stores
`~/.claude` has real directories `file-history`, `session-env`, `sessions`, `jobs`, and `projects`. `file-history` holds 19 entries and no symlinks. `session-env` holds 21 entries and no symlinks. `sessions` holds 6 `*.json` files and 6 key files. `jobs` holds 3 directories, none of them symlinks, and 2 of those names are 8 characters. `projects` has 16 keys, none of them symlinks.
`/tmp` is a symlink to `/private/tmp`. `/tmp/claude-<uid>` and `/private/tmp/claude-<uid>` both exist, share one real path, and that directory has 7 entries. `CLAUDE_CODE_TMPDIR` was unset in this review process before the probes pointed it at a temp directory.
## lstart on this host
`ps -p <pid> -o lstart=` with `LC_ALL=C` prints five fields. With `TZ=UTC0` the string differs from the default-TZ string. `calendar.timegm` of the UTC string sits 0.367 s behind `time.time()` for a fresh `sleep`. `time.mktime` of the default-TZ string sits the same 0.367 s behind. macOS `ps` honors `TZ=UTC0`, so the UTC parse in `_session_file_live` (`claude.py:97-105`) is the process start, and a DST fold is the same instant in both readings.
On the six live session files, `_session_file_live` returned live for all six and stale for none. `lstart - startedAt/1000` is between −2.362 s and −0.771 s, so `lstart <= startedAt/1000 + 2` holds for every one of them.
A temp-home probe of the same function: a live pid with `startedAt` set to now, NaN, inf, `10**400`, a digit string, or boolean `True` is live. A dead pid is stale, including a dead pid whose `startedAt` is NaN (`ProcessLookupError` returns before the time check, `claude.py:78-81`). The same live pid with `startedAt` an hour earlier is stale.
## The three findings from the first review
Canonical uuid. `_canonical` (`claude.py:10-18`) returns the lowercase spelling only when the whole string is a uuid. `_canonical("memory")` is `None`, and an uppercase uuid comes back lowercase. On a temp config, `purge_artifacts("memory")` returned `[]`, residual was empty, `conversation_in_use("memory")` was false, and both `projects/<key>/memory/MEMORY.md` and `memory.jsonl` stayed. `purge_artifacts("....")` returned `[]`.
Linked directories. `_inside` (`claude.py:108-122`) walks `dirname(path)` with `lstat` and rejects a symlink component below the root, then requires the resolved parent to sit under the resolved root. A temp `<tmp-root>/<key>` symlink to an outside directory stayed a symlink, the outside `keep.txt` stayed, that linked path was listed by `residual_artifacts`, and the purged list had no outside path. The real `file-history/<uuid>` and the jsonl for that same id were removed once the holder was gone. A `file-history/<uuid>` symlink was unlinked and its target `secret.txt` stayed. A symlink child inside a real owned `<uuid>/` directory was removed with that directory, and the target file stayed.
Non-finite `startedAt`. The probe above: NaN, inf, and an int too large for float are live while the pid is alive, and a dead pid is stale. That is `claude.py:84-95`.
Uppercase spelling while a session file names a live pid: `conversation_in_use` of the uppercase id was true. `python -m lib_py.agents purge claude <UPPER> <ws>` exited 4 with `{"in_use": true, "purged": []}`, and `in-use` exited 4 with `What may hold it:` on stderr. The jsonl and `file-history/<uuid>` stayed (`__main__.py:146-148`).
A process that still has the lowercase uuid in its argv (`sh -c 'sleep 60; true' <uuid>`, argv0 `sh`, 57-character args, visible to `ps -eo pid=,ppid=,args=`) makes `conversation_in_use` true for the uppercase spelling and for the lowercase spelling (`claude.py:354`).
`purge_artifacts` itself has no in-use gate. It skips a live session file (`claude.py:323-327`) and still removes the other owned paths when called directly. The purge CLI and `stop_session.sh:290-298` both ask `conversation_in_use` first. Stop captures the in-use stderr with `2>&1 >/dev/null`, prints the records-preserved sentence, and exits 4. The passthrough `raise SystemExit(1)` is `stop_session.sh:327`. The stop SKILL.md exit-4 row (line 65) says the records are preserved and the session itself may already be down. `tests/data/skill_code_blocks.json` is unchanged since `6f4f40a` and still points that passthrough at `stop_session.sh:327`.
## Tests
Pytest used the control-plane venv, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp` `/tmp/mam916-916abd41-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. The suite's own fixtures then point `HOME` at the test tmp.
On the snapshot, 149 tests ran in 39.61 s: 2 failed, 146 passed, 1 skipped. Modules: `tests/test_issue31_purge_residual.py`, `tests/test_a4_adapter_contract.py`, `tests/test_issue42_skill_blocks.py`, `tests/test_issue55_agent_homes.py`. The skip is `test_claude_skills_link_resolves_to_agents_skills` (`tests/test_issue42_skill_blocks.py:266`), because `.claude/skills` is absent in this worktree. The issue-31 cases for a malformed id including `memory`, a linked directory, a linked orphan the CLI must report, NaN/`10**400`/bool `startedAt`, the UTC lstart mock, `_SYSTEM_TMP` staying inside the test, and the stop exit-4 text all passed. So did the a4 composite purge and the issue-55 home guard.
Both failures are `test_any_spelling_of_the_uuid_is_the_lowercase_session` (upper and mixed) at `tests/test_issue31_purge_residual.py:318`. The asserts above that line passed: the uppercase id was in use through the session file, purge and in-use both exited 4, and the owned files stayed. Line 315 then starts `sh -c "sleep 120" <lowercase-uuid>`. Bash 3.2 replaces a one-command `-c` script with that command. `ps` shows argv0 `sleep` and an 8-character argument string, and the uuid is gone. `conversation_in_use` of the uppercase id is false, which is what line 318 asserts against. The `sleep 60; true` form keeps the uuid and the same call returns true. The check at `claude.py:354` sees a command line that still carries the lowercase id. The process this test starts has already dropped it.
The same new tests copied onto an archive of `6f4f40a` fail on the pre-fix behavior. That archive is a separate clone. One line, `_SYSTEM_TMP = "/tmp"`, was added there so the new autouse fixture could patch a name `6f4f40a` does not define. `_tmp_roots` in that commit still hardcodes `/tmp` and does not read the name. `purge_artifacts("memory")` returned a `session-env/memory` path (`test_issue31_purge_residual.py:285`). The linked-dir purge returned removed paths (`:351`). The CLI asked about a leftover behind a link did not report that residual (`:373`). NaN, a digit string, and `True` were stale for a live pid (`:539`), and the live-holder purge was allowed through (`:556`). An uppercase id was outside `conversation_in_use` (`:307`). 21 failed, 13 passed, in 2.98 s. The real `/tmp/claude-<uid>` entry count stayed 7.
The in-use gate in `stop_session.sh` still runs for every agent that has a purge uuid, agy included. That was already true at `6f4f40a`.
After the report, snapshot porcelain is still empty, the snapshot still has no `.mam`, and control-plane porcelain is still empty at `26e2c43`. The herdr config mtime is still 1784456394.
[VERDICT: NOT PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261017T145452Z-6c5aa6",
"base_sha": "26e2c43753169671b600d03274901dbe203988dd",
"head_sha": "de6f3fc81f8c33a9d2335320742dc5a47f566cda",
"tree_sha": "ffc291e6fc93822070f2b9f1d17009e83f176fcf",
"review_commit": "de6f3fc81f8c33a9d2335320742dc5a47f566cda",
"reviewer": "reviewer-grok-01",
"job_id": "b1d3a736",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-17T15:16:54Z"
}
@@ -0,0 +1,33 @@
# Review b1d3a736 — PR #59 third pass, Issue #31
Reviewed only the snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261017T145452Z-6c5aa6/1` (`loop-20261017T145452Z-6c5aa6`). Head `de6f3fc81f8c33a9d2335320742dc5a47f566cda`, tree `ffc291e6fc93822070f2b9f1d17009e83f176fcf`, base `26e2c43753169671b600d03274901dbe203988dd` is an ancestor. Versus base: 25 commits. Versus the second-pass head `2a8d73825b3832376aeaf96b09548359dc3cb887` there is one commit, `de6f3fc`, and it changes one file: `tests/test_issue31_purge_residual.py`, +3/−1. `git diff --check` is clean. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane main is still `26e2c43753169671b600d03274901dbe203988dd`, 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. The second pass on `2a8d738` already measured the three Mac findings (canonical uuid, symlink boundary, non-finite `startedAt`) and the UTC `lstart` parse. This commit does not touch that product code. `conversation_in_use` still folds any spelling to the lowercase uuid and searches both that spelling and the one that was asked (`claude.py:350-354`).
## The holder keeps the uuid
`de6f3fc` replaces the stand-in at `tests/test_issue31_purge_residual.py:317`. The old line was `subprocess.Popen(["sh", "-c", "sleep 120", conv])`. macOS `/bin/sh` (bash 3.2) replaces a one-command `-c` script with `sleep`, and the uuid left the process list, so both spelling cases failed at the command-line assert. The new line is the same shape as `_holder` in `tests/test_issue10_13_purge.py:116`:
```python
named = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)", conv])
```
`sys` is the import already at the top of the file. The `try` / `finally` around it is unchanged: `named.kill()` and `named.wait()` are still `tests/test_issue31_purge_residual.py:321-323`.
A temp-home probe started that exact command with the control-plane interpreter (73-character path) and a fresh lowercase uuid. `ps -eo pid=,ppid=,args=`, the same invocation `conversation_in_use` uses, showed a 196-character argument string containing the uuid. The same string was complete with `COLUMNS=80` and with `COLUMNS=10000` (`COLUMNS` was unset in the probe process). The holder was still alive. `conversation_in_use` of the uppercase spelling and of the lowercase spelling were both true.
## Tests
Pytest used the control-plane venv, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp` `/tmp/mamb1-b1d3a736-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, 149 tests ran in 42.05 s: 148 passed, 1 skipped, 1 error. Modules: `tests/test_issue31_purge_residual.py`, `tests/test_a4_adapter_contract.py`, `tests/test_issue42_skill_blocks.py`, `tests/test_issue55_agent_homes.py`. The skip is `test_claude_skills_link_resolves_to_agents_skills` (`tests/test_issue42_skill_blocks.py:266`), because `.claude/skills` is absent in this worktree. Both `test_any_spelling_of_the_uuid_is_the_lowercase_session` cases passed, along with the rest of the issue-31 file.
The error is the session-scoped issue-55 guard at teardown of `test_r1_adapter_discover_writes_nothing_into_a_panes_claude_project_dir` (`tests/conftest.py:1603`). It reported 14 new entries, all `*.db-shm` or `*.db-wal` sidecars for 7 ids under the agy conversations directory. The guard left them in place. This is the known false positive from an agy conversation in another workspace, and it is not a verdict basis.
Regression of the spelling fold. A separate clone of this snapshot had one edit: the command-line search in `conversation_in_use` looked up the asked spelling only, while session files still used the lowercase id. The session-file asserts and the CLI exit 4 still passed. Both spelling cases then failed at `tests/test_issue31_purge_residual.py:320` (`a command line naming the lowercase id`), in 1.62 s. The new holder is what makes that assert see a live lowercase argv, so dropping the lowercase search fails the test. The clone was discarded. The snapshot was not modified (porcelain still empty after that run).
The three non-blocking notes from the second pass are unchanged, because this commit is the test line only: a linked `projects/<key>` is reported as residual, a lowercase query does not see an uppercase argv, and `purge_artifacts` itself has no in-use gate. The purge CLI and `stop_session.sh` still ask `conversation_in_use` first.
After the report, snapshot porcelain is still empty, the snapshot still has no `.mam`, and control-plane porcelain is still empty at `26e2c43`. The herdr config mtime is still 1784456394.
[VERDICT: PASS]
@@ -0,0 +1,47 @@
# Review f0df1be0 — PR #59, Issue #31 claude purge
Reviewed only the snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261016T003524Z-a51548/1` (`loop-20261016T003524Z-a51548`). Head `6f4f40a4adb2115d5997dffd31fb6473f05cab68`, tree `f4d9422ad0c1d616b5f37065f3e96b34c28f78d8`, base `26e2c43753169671b600d03274901dbe203988dd` is an ancestor. Thirteen commits, 12 files, +1127/−27. `git diff --check` is clean. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane main is still `26e2c43753169671b600d03274901dbe203988dd`, 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 `stop_session.sh` and `lib.sh`. With `set -u`, the new `CLAUDE_PROJECT_DIR` default (`lib.sh:78`) expands to `$HOME/.claude/projects` when both variables are unset, and to `$CLAUDE_CONFIG_DIR/projects` when only the config dir is set.
Every purge, `in-use`, and `stop --purge-conversation` exercised here used a temporary `HOME`, `CLAUDE_CONFIG_DIR`, `CLAUDE_PROJECT_DIR`, `CLAUDE_CODE_TMPDIR`, and `TMPDIR`. The real `~/.claude` and `/private/tmp/claude-<uid>` were listed with `ls`-level reads.
## What this Mac already stores
`~/.claude` has real directories `file-history`, `session-env`, `sessions`, `jobs`, and `projects`. `file-history` holds 18 uuid-named directories and no symlinks. `session-env` holds 21 uuid-named directories and no symlinks. `sessions` holds 5 `*.json` files and 5 key files; every key name matches `<pid>.<64 lowercase hex>.key`. Each json has integer `pid`, `sessionId`, and a millisecond `startedAt` (13 digits). The filename stem equals `pid` in all 5. All 5 pids were alive at the read. `jobs` holds 2 real directories, both 8 characters long, and each `state.json` `sessionId` starts with that directory name. No job entry is a symlink. `projects` has 16 keys, none of them symlinks; across those keys the entries include 25 `<uuid>.jsonl` files and 7 `<uuid>` directories, plus shared names such as the memory directory the purge leaves alone.
`/tmp` is a symlink to `/private/tmp`. `/tmp/claude-<uid>` and `/private/tmp/claude-<uid>` both exist and share one real path. Under that root, project keys are real directories. Their children are uuid directories (16) plus 3 other directories whose names are 57 or 58 alphanumeric characters, contain no uuid, and are siblings of the uuid scratchpads. `$TMPDIR/claude-<uid>` does not exist. `CLAUDE_CODE_TMPDIR` was unset in this review process. That is the layout `_tmp_roots` (`claude.py:25-48`) and `_owned_paths` (`claude.py:256-285`) assume: an existing per-uid root, then `<key>/<uuid>`.
## lstart on this host
`ps -p <pid> -o lstart=` with `LC_ALL=C` prints five fields. `time.strptime` of the collapsed line with `%a %b %d %H:%M:%S %Y` accepts both `Oct 5` and the BSD space-padded `Oct 5`. For the five live session files, `lstart - startedAt/1000` is between −2.43 s and −0.694 s, so `lstart <= startedAt/1000 + 2` (`claude.py:77`) holds for every one of them. Zero of those live files would be treated as a reused pid.
A temp-home probe of `_session_file_live`: the current pid with `startedAt` set to now is live; the same pid with `startedAt` 1.5 s after its own `lstart` is live; missing `startedAt`, `startedAt` 0, a non-numeric `startedAt`, `pid` 0, a negative pid, and boolean `True` are live. A dead pid is stale. The same live pid with `startedAt` an hour earlier, or 3 s earlier than its `lstart`, is stale. The process locale reported `en_US` / `UTF-8`, and the English month from `LC_ALL=C` parsed. A parse failure returns live (`claude.py:71-72`), which refuses the purge.
## Deletion stays on this session
On a temporary config root the purge removed the workspace `<uuid>.jsonl`, the sibling `<uuid>/` directory, `file-history/<uuid>/`, `session-env/<uuid>/`, `jobs/<uuid[:8]>/` whose `state.json` named the session, the dead `sessions/<pid>.json`, its `<pid>.<64 hex>.key`, and `<CLAUDE_CODE_TMPDIR>/claude-<uid>/<key>/<uuid>/`. The printed key label contains `<pid>.*.key` and the hex is absent from the purged list.
These stayed: another session's `sessions/<pid>.json` and key for a live pid, that session's scratchpad, `history.jsonl` whose body names the purged uuid, `projects/<key>/memory/MEMORY.md`, and a job directory whose 8-character name matches the uuid but whose `state.json` names a different session (`claude.py:244-254`). `_plausible_id` (`base.py:89-92`) rejects `""`, `.`, `..`, `*`, `../x`, `a/b`, and `....` (`".."` is a substring). Purging those ids left the tree unchanged.
Symlinks. A `file-history/<uuid>` symlink to an outside directory was unlinked; the outside `secret.txt` remained. A `projects/<key>/<uuid>` symlink to an outside directory was unlinked; the outside file remained. A directory symlink and a file symlink inside a real owned `<uuid>/` directory were removed with the directory; both targets remained (`_remove`, `claude.py:80-87`, uses `os.path.islink` and then `os.remove`; `shutil.rmtree` sees the real directory). A `jobs/<8>` symlink whose target `state.json` names the session was unlinked; the target directory and its `other.txt` remained (`_remove_job`, `claude.py:107-109`). A `sessions/<pid>.json` symlink to an outside json was unlinked after the read saw this session id; the outside file remained.
A project key under the temp root that is itself a symlink is followed: `<symlink>/<uuid>` was removed on the far side. That far-side directory is still the one named this session's uuid, which is the scratchpad path when a key has been relocated. The real per-uid temp root on this Mac has no key symlinks. The cross-session paths above stayed in the same probe.
A live session file is omitted from `_owned_paths` (`claude.py:274-278`). Calling `purge_artifacts` directly while that pid was alive left `sessions/<pid>.json` in place and removed `file-history/<uuid>/`. After the process exited, the same call removed the session file. The purge CLI does not take that direct path: `in-use` was true, the process exited 4, and the file-history marker was still there (`__main__.py:146-148`). `in-use` with no holder exits 0. `in-use` without a uuid exits 1.
## Stop
`stop_session.sh:285-300` runs `python -m lib_py.agents in-use <agent> <uuid> <cwd> --wait 5` before the registry write. Any status other than 0 exits 4 and leaves the row. The registry python then prints `purged:` lines and either `WARN residual:` or `residual: none (agent: <agent>)` (`stop_session.sh:355-368`). `lib.sh` exports `PYTHONPATH` to the skill dir at source time, so the new `in-use` module resolves the same way `exit-key` already does. `DELEGATE_JOB_ID`, `DELEGATE_JOB_TOKEN`, and `DELEGATE_JOB_ATTEMPT` are set before that call (`stop_session.sh:146-161`), including to empty strings, so `set -u` does not trip. The passthrough `raise SystemExit(1)` is now `stop_session.sh:327`, and `tests/data/skill_code_blocks.json` records that line. The stop SKILL.md diagram and the purge paragraph match the fenced blocks in that json (the skill-block test passed).
## Tests
Pytest used the control-plane venv, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp` `/tmp/mamf0-f0df1be0-work/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. The suite's own fixtures then point `HOME` at the test tmp.
On the snapshot, 80 tests passed and 1 skipped in 28.31 s: all of `tests/test_issue31_purge_residual.py` (32 tests), `tests/test_issue42_skill_blocks.py`, and `tests/test_issue55_agent_homes.py`. The skip is `test_issue42_skill_blocks.py:266`, because `.claude/skills` is absent in this worktree. The issue-31 file covers the owned-path purge, the untouched neighbor files, the same-prefix job, malformed ids, the live-process refusal, the reused pid, a key that cannot be unlinked, a job directory that cannot be emptied, `CLAUDE_CONFIG_DIR`, the temp-root rules, the stop exit 4, the stray-key `WARN residual:`, and codex, grok, and opencode residuals.
The same issue-31 file copied onto an archive of base `26e2c43` failed 24 tests and passed 8 in 17.09 s. The failures are behavioral. The old purge left `file-history/<uuid>` and `session-env/<uuid>` behind. The old purge CLI, asked about a live pid whose argv does not contain the uuid, removed the jsonl and returned `in_use` false. The old stop did not exit 4 for that live process and did not print `WARN residual:` for a transcript under another project key. Two failures are the new helpers `_session_file_live` and `_tmp_roots` being absent; the assertions above fail on the old behavior itself.
After the report, snapshot porcelain is still empty, the snapshot still has no `.mam`, and control-plane porcelain is still empty at `26e2c43`. The herdr config mtime is still 1784456394.
[VERDICT: PASS]
+3 -2
View File
@@ -29,7 +29,7 @@ export WORKSPACE_ROOT
# Framework semantic version. Single runtime source of truth; kept in lockstep # 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. # 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. # NOTE: unlike other MAM_* variables this one is intentionally NOT env-overridable.
MAM_VERSION="4.9.4" MAM_VERSION="4.9.5"
export MAM_VERSION export MAM_VERSION
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}" AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
@@ -74,7 +74,8 @@ _MAM_DIALOG_TOKENS="${_MAM_MODAL_TOKENS}|${_MAM_HINT_TOKENS}"
# Workspace-relative defaults with environment overrides (Phase Z) # Workspace-relative defaults with environment overrides (Phase Z)
HOME_DIR="${HOME_DIR:-$HOME}" HOME_DIR="${HOME_DIR:-$HOME}"
CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$HOME/.claude/projects}" # claude keeps projects/ under its config root, which CLAUDE_CONFIG_DIR moves (Issue #31).
CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects}"
if [ -z "${LOCAL_BIN:-}" ]; then if [ -z "${LOCAL_BIN:-}" ]; then
if [ -x "$HOME_DIR/bin/herdr" ]; then if [ -x "$HOME_DIR/bin/herdr" ]; then
LOCAL_BIN="$HOME_DIR/bin" LOCAL_BIN="$HOME_DIR/bin"
+38 -1
View File
@@ -1,6 +1,6 @@
# __main__.py — CLI bridge for lib_py.agents facts and resolution (N4) # __main__.py — CLI bridge for lib_py.agents facts and resolution (N4)
import os, sys, json, shlex import os, sys, json, shlex, time
from lib_py.agents.registry import get_adapter, own_key, agent_of_row from lib_py.agents.registry import get_adapter, own_key, agent_of_row
# `purge auto` probes adapters in this order; opencode last because its # `purge auto` probes adapters in this order; opencode last because its
@@ -151,6 +151,43 @@ def main():
print(json.dumps({"agent": name, "purged": purged, "residual": residual, "in_use": False})) print(json.dumps({"agent": name, "purged": purged, "residual": residual, "in_use": False}))
sys.exit(0) sys.exit(0)
elif cmd == 'in-use':
# in-use <agent> <uuid> [workspace] [--wait <sec>] (Issue #31)
# The purge CLI's own in-use verdict, for stop_session.sh to ask
# before it purges. Re-checks every 0.25 s for up to --wait seconds.
# Prints {"agent", "in_use"}, and while in use where to look on
# stderr. Exit 0 free; 4 still in use; 1 bad args.
args = sys.argv[2:]
wait = 0.0
if '--wait' in args:
i = args.index('--wait')
try:
wait = float(args[i + 1])
except (IndexError, ValueError):
print("ERROR: --wait requires seconds", file=sys.stderr)
sys.exit(1)
del args[i:i + 2]
agent_name = args[0] if args else ''
uuid = args[1] if len(args) > 1 else ''
workspace = args[2] if len(args) > 2 and args[2] else os.getcwd()
adapter = get_adapter(agent_name)
if not adapter or not uuid:
print("ERROR: in-use requires <agent> <uuid>", file=sys.stderr)
sys.exit(1)
from lib_py.agents.base import DiscoveryContext
ctx = DiscoveryContext(workspace, agent_name)
deadline = time.monotonic() + wait
in_use = adapter.conversation_in_use(uuid, ctx)
while in_use and time.monotonic() < deadline:
time.sleep(0.25)
in_use = adapter.conversation_in_use(uuid, ctx)
print(json.dumps({"agent": agent_name, "in_use": in_use}))
if in_use:
print(" What may hold it:", file=sys.stderr)
for hint in adapter.in_use_hints(uuid, ctx):
print(f" {hint}", file=sys.stderr)
sys.exit(4 if in_use else 0)
else: else:
print(f"ERROR: Unknown CLI command {cmd!r}", file=sys.stderr) print(f"ERROR: Unknown CLI command {cmd!r}", file=sys.stderr)
sys.exit(1) sys.exit(1)
+320 -7
View File
@@ -1,8 +1,180 @@
import os, json, glob, subprocess import os, re, json, glob, math, stat, time, shutil, calendar, subprocess
from typing import Optional, Any from typing import Optional, Any
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, conversation_in_use, scan_names
from lib_py.verify_session import workspace_key from lib_py.verify_session import workspace_key
_UUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE)
def _canonical(uuid) -> Optional[str]:
"""The id claude itself writes, in lowercase, for any spelling of a
canonical uuid; None for anything else. Any other name in a session's
place is not one: projects/<key>/memory/ (auto memory) sits beside the
session dirs. Another spelling must go through the same checks as
claude's own: on macOS's default case-insensitive APFS it names the
same files, while the ids in session files and on command lines are
compared exactly."""
return uuid.lower() if isinstance(uuid, str) and _UUID.fullmatch(uuid) else None
def _config_dir(ctx: DiscoveryContext) -> str:
"""claude's config root: CLAUDE_CONFIG_DIR moves all of it (sessions/,
file-history/, session-env/, jobs/, projects/)."""
return os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(ctx.home_dir, ".claude")
def _projects_dirs(ctx: DiscoveryContext) -> list:
"""ctx.claude_dir (CLAUDE_PROJECT_DIR, else the config root's projects/)
and the config root's own projects/, each once."""
dirs, seen = [], set()
for d in (ctx.claude_dir, os.path.join(_config_dir(ctx), "projects")):
real = os.path.realpath(d)
if real not in seen:
seen.add(real)
dirs.append(d)
return dirs
# Node's os.tmpdir() when no TMPDIR/TMP/TEMP is set; tests point it elsewhere.
_SYSTEM_TMP = "/tmp"
def _tmp_roots() -> list:
"""The per-uid temp roots claude may have used: ${CLAUDE_CODE_TMPDIR:-
os.tmpdir()}/claude-<uid>. A sandboxed tool shell sets CLAUDE_CODE_TMPDIR
to that per-uid root itself. The session that wrote the files may have
run under another env, so every candidate that exists is returned."""
name = f"claude-{os.getuid()}"
cands = []
override = os.environ.get("CLAUDE_CODE_TMPDIR", "")
if override:
cands.append(os.path.join(override, name))
if os.path.basename(override.rstrip("/")) == name:
cands.append(override)
# Node's os.tmpdir() on POSIX, not tempfile.gettempdir() (which probes
# writability and falls back to the cwd).
tmp = os.environ.get("TMPDIR") or os.environ.get("TMP") or os.environ.get("TEMP") or _SYSTEM_TMP
cands.append(os.path.join(tmp.rstrip("/") or "/", name))
cands.append(os.path.join(_SYSTEM_TMP, name))
roots, seen = [], set()
for c in cands:
real = os.path.realpath(c)
if os.path.isdir(c) and real not in seen:
seen.add(real)
roots.append(c)
return roots
def _session_file_live(data: dict, pid) -> bool:
"""The process that wrote this sessions/<pid>.json is still running.
A dead pid is stale. A live pid whose start (`ps -o lstart=`) is later
than the file's startedAt was reused by another process, so it is stale
too. Anything that cannot be told is taken as live: a purge is then
refused, never run over a live conversation."""
if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
return True
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except OSError:
pass
# startedAt is epoch ms: a finite positive number. A bool, a string, NaN
# (every comparison with it is false), an infinity or an int too large
# for a float is no time at all.
started = data.get("startedAt")
if isinstance(started, bool) or not isinstance(started, (int, float)):
return True
try:
started = float(started) / 1000.0
except OverflowError:
return True
if not math.isfinite(started) or started <= 0:
return True
# lstart in UTC: a local time is ambiguous in the hour a DST change repeats.
try:
out = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="], capture_output=True, text=True,
timeout=5, env=dict(os.environ, LC_ALL="C", TZ="UTC0")).stdout
lstart = calendar.timegm(time.strptime(" ".join(out.split()), "%a %b %d %H:%M:%S %Y"))
except Exception:
return True
# startedAt is written 1.7-3.2 s after the process starts; 2 s covers
# lstart's one-second resolution.
return lstart <= started + 2
def _inside(root: str, path: str) -> bool:
"""`path` is reached from `root` through real directories only: no
component between the two is a symlink, and its parent resolves inside
the resolved root. The root may be a link itself (/tmp -> /private/tmp,
a linked ~/.claude), and so may `path`: _remove then takes the link alone."""
cur = root
for part in os.path.relpath(os.path.dirname(path), root).split(os.sep):
cur = os.path.join(cur, part)
try:
if not stat.S_ISDIR(os.lstat(cur).st_mode):
return False
except OSError:
return False
real_root = os.path.realpath(root)
return os.path.realpath(os.path.dirname(path)).startswith(real_root.rstrip(os.sep) + os.sep)
def _remove(path: str) -> None:
try:
if os.path.islink(path) or not os.path.isdir(path):
os.remove(path)
else:
shutil.rmtree(path, ignore_errors=True)
except OSError:
pass
def _key_files(session_file: str) -> list:
"""The key files of the pid a sessions/<pid>.json is named after,
matched by name only; their content is never read."""
d, name = os.path.split(session_file)
stem = name[:-len(".json")]
if not stem.isdigit():
return []
try:
names = sorted(os.listdir(d))
except OSError:
return []
return [os.path.join(d, n) for n in names if re.fullmatch(rf"{stem}\.[0-9a-f]{{64}}\.key", n)]
def _remove_job(job: str) -> None:
"""state.json goes last: it is the only proof that the dir is this
session's, so a dir that could not be emptied keeps it."""
if os.path.islink(job):
_remove(job)
return
try:
names = os.listdir(job)
except OSError:
return
for n in names:
if n != "state.json":
_remove(os.path.join(job, n))
try:
left = os.listdir(job)
except OSError:
return
if left in ([], ["state.json"]):
_remove(job)
def _label(path: str) -> str:
"""How a path is printed: a session key file is named by pid only, its
hex part is not shown."""
d, name = os.path.split(path)
m = re.fullmatch(r"(\d+)\.[0-9a-f]{64}\.key", name)
return os.path.join(d, f"{m.group(1)}.*.key") if m else path
class ClaudeAgentAdapter(BaseAgentAdapter): class ClaudeAgentAdapter(BaseAgentAdapter):
@property @property
def name(self) -> str: def name(self) -> str:
@@ -94,12 +266,153 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
return False return False
return True return True
def _session_files(self, uuid: str, ctx: DiscoveryContext) -> list:
"""(path, pid, data) of each sessions/<pid>.json naming this session -
the registry of running claude processes, which a live `claude -c`
is found by even though its command line carries no uuid."""
found = []
for path in sorted(glob.glob(os.path.join(_config_dir(ctx), "sessions", "*.json"))):
try:
with open(path) as f:
data = json.load(f)
except Exception:
continue
if not isinstance(data, dict) or data.get("sessionId") != uuid:
continue
pid = data.get("pid")
if not isinstance(pid, int):
stem = os.path.basename(path)[:-5]
pid = int(stem) if stem.isdigit() else None
found.append((path, pid, data))
return found
def _owned_job(self, uuid: str, ctx: DiscoveryContext) -> Optional[str]:
"""jobs/<first 8 chars>/ when its state.json names this session; the
short name alone can be another session's."""
job = os.path.join(_config_dir(ctx), "jobs", uuid[:8])
try:
with open(os.path.join(job, "state.json")) as f:
if json.load(f).get("sessionId") == uuid:
return job
except Exception:
pass
return None
def _candidates(self, uuid: str, ctx: DiscoveryContext) -> list:
"""(root, path) of every existing path that belongs to this session
alone (Issue #31), with the root (config, projects or tmp) it was
found under. Shared files that merely mention it (history.jsonl,
~/.claude.json, backups, paste-cache) are not the session's and are
never listed."""
# Checked before any path is built: whole directories named by the id
# are removed, so "", "." or ".." would reach file-history/ itself,
# and "memory" the auto memory. Only a uuid names a session.
uuid = _canonical(uuid)
if not uuid:
return []
cfg = _config_dir(ctx)
found = []
for pdir in _projects_dirs(ctx):
base = os.path.join(pdir, ctx.ws_key, uuid)
found += [(pdir, p) for p in (base + ".jsonl", base) if os.path.lexists(p)]
found += [(cfg, p) for p in (os.path.join(cfg, "file-history", uuid),
os.path.join(cfg, "session-env", uuid)) if os.path.lexists(p)]
job = self._owned_job(uuid, ctx)
if job:
found.append((cfg, job))
for path, pid, data in self._session_files(uuid, ctx):
if not _session_file_live(data, pid):
# Keys before the session file: the file is what ties them to
# this session, so it must outlive them.
found += [(cfg, p) for p in _key_files(path) + [path]]
for root in _tmp_roots():
try:
keys = sorted(os.listdir(root))
except OSError:
continue
found += [(root, p) for p in (os.path.join(root, k, uuid) for k in keys) if os.path.lexists(p)]
return found
def _owned_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
# What lies behind a linked dir below its root is not the session's:
# it is kept, and residual_artifacts reports it.
return [p for root, p in self._candidates(uuid, ctx) if _inside(root, p)]
def has_artifacts(self, uuid: str, ctx: DiscoveryContext) -> bool:
# Leftovers count without the transcript (a pre-#31 purge left them),
# and so does one behind a link: the purge keeps it, but picking
# claude is what gets it reported as residual.
return bool(self._candidates(uuid, ctx))
def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool:
# No claude process holds a name that is not a uuid; searching
# command lines for one ("a/b", "memory") only finds strangers.
canon = _canonical(uuid)
if not canon:
return False
# claude's own spelling and the one asked about, on a command line.
if any(conversation_in_use(u, [self.artifact_path(canon, ctx)]) for u in {canon, uuid}):
return True
return any(_session_file_live(data, pid) for _, pid, data in self._session_files(canon, ctx))
def in_use_hints(self, uuid: str, ctx: DiscoveryContext) -> list:
return [f"a claude process whose session file under {os.path.join(_config_dir(ctx), 'sessions')}/ names it"] \
+ super().in_use_hints(_canonical(uuid) or uuid, ctx)
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list: def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
jsonl = self.artifact_path(uuid, ctx) # Best effort: never raises (stop runs this inside its registry
if os.path.exists(jsonl): # transaction). What could not be removed is not listed here and
os.remove(jsonl) # shows up in residual_artifacts instead.
return [jsonl] # Ownership evidence (a job's state.json, a session file) is removed
return [] # only after what it vouches for, so a retry can still find the rest.
uuid = _canonical(uuid)
if not uuid:
return []
purged = []
job = self._owned_job(uuid, ctx)
for path in self._owned_paths(uuid, ctx):
if path == job:
_remove_job(path)
elif path.endswith(".json") and any(os.path.lexists(k) for k in _key_files(path)):
continue
else:
_remove(path)
if not os.path.lexists(path) and _label(path) not in purged:
purged.append(_label(path))
return purged
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
"""What still carries this session: names holding the uuid under the
config root, in projects (two levels: key, then entry) and in the tmp
roots (two levels), plus the content-matched jobs dir and session
files with their (masked) key files. projects is not walked deeper;
it can be very large."""
uuid = _canonical(uuid)
if not uuid:
return []
cfg = _config_dir(ctx)
pdirs = _projects_dirs(ctx)
found = set(scan_names(cfg, uuid, skip=pdirs))
for pdir in pdirs:
try:
keys = sorted(os.listdir(pdir))
except OSError:
keys = []
for key in keys:
kdir = os.path.join(pdir, key)
if uuid in key:
found.add(kdir)
elif os.path.isdir(kdir):
try:
found.update(os.path.join(kdir, n) for n in os.listdir(kdir) if uuid in n)
except OSError:
pass
# Every candidate, inside its root or behind a link (never purged).
found.update(_label(p) for _, p in self._candidates(uuid, ctx))
for path, _, _ in self._session_files(uuid, ctx):
found.add(path)
found.update(_label(k) for k in _key_files(path))
return sorted(found)
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str: def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
if use_wrapper or not session_uuid: if use_wrapper or not session_uuid:
+12 -1
View File
@@ -1,6 +1,6 @@
import os, json, glob, sqlite3, subprocess import os, json, glob, sqlite3, subprocess
from typing import Optional, Any, List from typing import Optional, Any, List
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, scan_names, db_rows_naming
from lib_py.verify_session import workspace_key from lib_py.verify_session import workspace_key
class CodexAgentAdapter(BaseAgentAdapter): class CodexAgentAdapter(BaseAgentAdapter):
@@ -158,6 +158,17 @@ class CodexAgentAdapter(BaseAgentAdapter):
pass pass
return purged return purged
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> List[str]:
# Issue #31: names under CODEX_HOME plus thread rows. logs_*.sqlite is
# not scanned (no thread index: a full scan of a large file).
root = self._codex_home(ctx)
found = scan_names(root, uuid)
for db in sorted(glob.glob(f"{root}/state_*.sqlite")):
found += db_rows_naming(db, uuid, ("thread_id",), id_tables=(("threads", "id"),))
for db in sorted(glob.glob(f"{root}/thread_history_*.sqlite")):
found += db_rows_naming(db, uuid, ("thread_id",))
return found
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str: def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
return f"{binary} --dangerously-bypass-approvals-and-sandbox" return f"{binary} --dangerously-bypass-approvals-and-sandbox"
@@ -1,7 +1,7 @@
import os, json, glob, shutil import os, json, glob, shutil
from typing import Optional, Any from typing import Optional, Any
from urllib.parse import quote from urllib.parse import quote
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, scan_names
from lib_py.verify_session import workspace_key from lib_py.verify_session import workspace_key
@@ -108,6 +108,11 @@ class GrokAgentAdapter(BaseAgentAdapter):
purged.append(self.artifact_path(uuid, ctx)) purged.append(self.artifact_path(uuid, ctx))
return purged return purged
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
# Issue #31: reported, not removed - memory-v2 observations are the
# shared store of grok's memory pipeline.
return scan_names(f"{ctx.home_dir}/.grok", uuid)
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str: def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
if session_uuid: if session_uuid:
return f"{binary} --session-id {session_uuid} --permission-mode bypassPermissions" return f"{binary} --session-id {session_uuid} --permission-mode bypassPermissions"
@@ -1,6 +1,6 @@
import os, sys, sqlite3, shutil import os, sys, sqlite3, shutil
from typing import Optional, Any, List from typing import Optional, Any, List
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, scan_names, db_rows_naming
from lib_py.verify_session import workspace_key from lib_py.verify_session import workspace_key
_PROVIDER_ENV_VARS = ( _PROVIDER_ENV_VARS = (
@@ -194,6 +194,13 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
sys.stderr.write(f"WARN: purge opencode db records failed: {e}\n") sys.stderr.write(f"WARN: purge opencode db records failed: {e}\n")
return purged return purged
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
# Issue #31: the purge leaves event-sourced rows (event, todo, ...);
# deleting those is not known to be safe, so they are reported.
found = scan_names(f"{ctx.home_dir}/.local/share/opencode", uuid)
return found + db_rows_naming(self._resolve_db(ctx), uuid, ("session_id", "aggregate_id"),
id_tables=(("session", "id"),))
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str: def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
return f"{binary} --auto --agent build" return f"{binary} --auto --agent build"
+66 -1
View File
@@ -2,6 +2,7 @@
import os, json, sqlite3, shutil, subprocess import os, json, sqlite3, shutil, subprocess
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from urllib.parse import quote
from lib_py.paths import resolve_home from lib_py.paths import resolve_home
from lib_py.verify_session import verify_session_uuid, workspace_key from lib_py.verify_session import verify_session_uuid, workspace_key
@@ -84,6 +85,63 @@ def conversation_in_use(uuid: str, paths=()) -> bool:
pass pass
return False return False
def _plausible_id(uuid: str) -> bool:
"""An id safe to build a path from and to search for: not empty, not
made of dots alone ("." names the parent dir itself), no "/", ".." or "*"."""
return isinstance(uuid, str) and bool(uuid.strip(".")) and not any(s in uuid for s in ("/", "..", "*"))
def scan_names(root: str, uuid: str, skip=()) -> list:
"""Paths under `root` whose name carries `uuid` (Issue #31). A matched
directory is reported whole and not descended; `skip` dirs are not walked."""
if not _plausible_id(uuid) or not os.path.isdir(root):
return []
skip_real = {os.path.realpath(s) for s in skip if s}
found = []
for dirpath, dirnames, filenames in os.walk(root):
for name in dirnames + filenames:
if uuid in name:
found.append(os.path.join(dirpath, name))
dirnames[:] = [d for d in dirnames if uuid not in d
and not (skip_real and os.path.realpath(os.path.join(dirpath, d)) in skip_real)]
return sorted(found)
def db_rows_naming(db: str, uuid: str, columns=(), id_tables=()) -> 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."""
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)
except Exception:
return []
found = []
try:
tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]
for table in tables:
qt = table.replace('"', '""')
try:
cols = {r[1] for r in conn.execute(f'PRAGMA table_info("{qt}")')}
except Exception:
continue
wanted = [c for c in columns if c in cols]
wanted += [c for t, c in id_tables if t == table and c in cols and c not in wanted]
for col in wanted:
try:
n = conn.execute(f'SELECT count(*) FROM "{qt}" WHERE "{col}"=?', (uuid,)).fetchone()[0]
except Exception:
continue
if n:
found.append(f"{db} ({table}.{col}: {n} rows)")
except Exception:
pass
finally:
conn.close()
return found
class SpawnSpec: class SpawnSpec:
def __init__(self, agent_name: str, session_name: str, command: str, env: Optional[Dict[str, str]] = None): def __init__(self, agent_name: str, session_name: str, command: str, env: Optional[Dict[str, str]] = None):
self.agent_name = agent_name self.agent_name = agent_name
@@ -98,7 +156,9 @@ class DiscoveryContext:
self.workspace = workspace self.workspace = workspace
self.agent_name = agent_name self.agent_name = agent_name
self.home_dir = resolve_home(home_dir) self.home_dir = resolve_home(home_dir)
self.claude_dir = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR", f"{self.home_dir}/.claude/projects") # projects/ lives under claude's config root, which CLAUDE_CONFIG_DIR moves.
config_root = os.environ.get("CLAUDE_CONFIG_DIR") or f"{self.home_dir}/.claude"
self.claude_dir = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR", f"{config_root}/projects")
self.epoch = epoch self.epoch = epoch
self.row = row or {} self.row = row or {}
self.mode = mode self.mode = mode
@@ -248,6 +308,11 @@ class BaseAgentAdapter:
"""True while a live process still references the conversation.""" """True while a live process still references the conversation."""
return conversation_in_use(uuid, [self.artifact_path(uuid, ctx)]) return conversation_in_use(uuid, [self.artifact_path(uuid, ctx)])
def in_use_hints(self, uuid: str, ctx: DiscoveryContext) -> list:
"""Where to look for what holds the conversation, shown when a purge
is refused because it is in use."""
return [f"any process with it on its command line (an editor, a grep, ...): ps -axww | grep {uuid}"]
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str: def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
raise NotImplementedError raise NotImplementedError
+2 -1
View File
@@ -13,7 +13,7 @@ import sys
from typing import List, Sequence from typing import List, Sequence
# Framework version locked with framework constants # Framework version locked with framework constants
FRAMEWORK_VERSION = "4.9.4" FRAMEWORK_VERSION = "4.9.5"
CORE_MODULES: Sequence[str] = ( CORE_MODULES: Sequence[str] = (
# Standard library modules # Standard library modules
@@ -56,6 +56,7 @@ CORE_MODULES: Sequence[str] = (
"lib_py.delegation.engine", "lib_py.delegation.engine",
"lib_py.delegation.finalize", "lib_py.delegation.finalize",
"lib_py.delegation.job_subscriber", "lib_py.delegation.job_subscriber",
"lib_py.delegation.logroot",
"lib_py.delegation.mqtt_common", "lib_py.delegation.mqtt_common",
"lib_py.delegation.notifier", "lib_py.delegation.notifier",
"lib_py.delegation.publish_event", "lib_py.delegation.publish_event",
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-create 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." 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.4 version: 4.9.5
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-delegate-job 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." 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.4 version: 4.9.5
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos, windows] platforms: [linux, macos, windows]
+1 -1
View File
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-loop name: multi-agent-mux-loop
description: "Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. Automatically orchestrates plan discussion, code changes, and peer reviews until a unanimous PASS is achieved or the maximum iteration limit is reached." description: "Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. Automatically orchestrates plan discussion, code changes, and peer reviews until a unanimous PASS is achieved or the maximum iteration limit is reached."
version: 4.9.4 version: 4.9.5
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-monitor name: multi-agent-mux-monitor
description: "Run a long-lived reconciler that watches .mam/agent-sessions.yaml against the actual herdr/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Runs as a persistent loop (`reconcile.sh --subscribe`) that keeps going until it times out, idles out, or is interrupted." description: "Run a long-lived reconciler that watches .mam/agent-sessions.yaml against the actual herdr/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Runs as a persistent loop (`reconcile.sh --subscribe`) that keeps going until it times out, idles out, or is interrupted."
version: 4.9.4 version: 4.9.5
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-orc-onboard name: multi-agent-mux-orc-onboard
description: "Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture." description: "Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture."
version: 4.9.4 version: 4.9.5
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-resume name: multi-agent-mux-resume
description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a herdr session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a herdr session of the matching name, and runs `claude -r <id>` or `agy --conversation <id>` inside. Use when you want to reattach to a previous session's context, or revive a session whose herdr died but the agent's conversation is still on disk." description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a herdr session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a herdr session of the matching name, and runs `claude -r <id>` or `agy --conversation <id>` inside. Use when you want to reattach to a previous session's context, or revive a session whose herdr died but the agent's conversation is still on disk."
version: 4.9.4 version: 4.9.5
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-status name: multi-agent-mux-status
description: "Read-only instant snapshot of all agent herdr sessions — name, YAML status, herdr alive, pane cmd/cwd, resume UUID on disk, and any drift. No mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up the monitor loop." description: "Read-only instant snapshot of all agent herdr sessions — name, YAML status, herdr alive, pane cmd/cwd, resume UUID on disk, and any drift. No mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up the monitor loop."
version: 4.9.4 version: 4.9.5
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
+7 -7
View File
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-stop name: multi-agent-mux-stop
description: "Stop an agent herdr session (claude, antigravity/agy) and update .mam/agent-sessions.yaml. Default stops gracefully and marks status=stopped with conversation preserved for resume. Does NOT delete on-disk conversation artifacts (jsonl/db) — those are preserved unless --purge-conversation is passed. Use when ending a work session, switching to a different one, or cleaning up before a fresh start." description: "Stop an agent herdr session (claude, antigravity/agy) and update .mam/agent-sessions.yaml. Default stops gracefully and marks status=stopped with conversation preserved for resume. Does NOT delete on-disk conversation artifacts (jsonl/db) — those are preserved unless --purge-conversation is passed. Use when ending a work session, switching to a different one, or cleaning up before a fresh start."
version: 4.9.4 version: 4.9.5
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
@@ -49,7 +49,7 @@ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
--session "$SESSION_NAME" --agent "$AGENT" --reason api_error --session "$SESSION_NAME" --agent "$AGENT" --reason api_error
# 3. Stop gracefully + clean up on-disk conversation (DANGEROUS) # 3. Stop gracefully + clean up on-disk conversation (DANGEROUS)
# — this prevents any future resume (status=terminated, resumable=false). # — this prevents any future resume and removes the session's registry row (YAML+DB).
bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
--session "$SESSION_NAME" --agent "$AGENT" --purge-conversation --yes --session "$SESSION_NAME" --agent "$AGENT" --purge-conversation --yes
``` ```
@@ -62,7 +62,7 @@ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
| 1 | From the checks before the stop, nothing was changed: YAML not found, or the session is not registered. **The registry update after the stop also exits 1** (the row disappeared from the YAML during the run) | | 1 | From the checks before the stop, nothing was changed: YAML not found, or the session is not registered. **The registry update after the stop also exits 1** (the row disappeared from the YAML during the run) |
| 2 | Bad arguments (including the retired `--mode` / `--capture-id` / `--graceful`), or `--agent` omitted and not resolvable | | 2 | Bad arguments (including the retired `--mode` / `--capture-id` / `--graceful`), or `--agent` omitted and not resolvable |
| 3 | `--purge-conversation` without `--yes` | | 3 | `--purge-conversation` without `--yes` |
| 4 | Purge aborted: the herdr session survived the kill chain | | 4 | Purge aborted: the herdr session, or an agent process still holding the conversation, survived the kill chain. Records preserved: the registry row and the conversation files are untouched, though the session itself may already be down |
| other | The registry update after the stop (YAML / DB write) failed and its code is passed through | | other | The registry update after the stop (YAML / DB write) failed and its code is passed through |
Exits 2 and 3, and exit 1 from the pre-stop checks, come before anything is stopped, so nothing was changed. On exit 1 from the registry update, or an `other` exit, the session may already be down while its row still says `running`: check with `multi-agent-mux-status`, then run the stop again (it is idempotent). Exits 2 and 3, and exit 1 from the pre-stop checks, come before anything is stopped, so nothing was changed. On exit 1 from the registry update, or an `other` exit, the session may already be down while its row still says `running`: check with `multi-agent-mux-status`, then run the stop again (it is idempotent).
@@ -80,20 +80,20 @@ while its registry row survives.
``` ```
running ──(stop default / --reason)────────► stopped (resumable:true, conv preserved) running ──(stop default / --reason)────────► stopped (resumable:true, conv preserved)
running ──(stop --purge-conversation --yes)► terminated (resumable:false, conv deleted) running ──(stop --purge-conversation --yes)► row removed (YAML+DB; conv deleted)
stopped ──(stop default … again)───────────► stopped (idempotent no-op) stopped ──(stop default … again)───────────► stopped (idempotent no-op)
``` ```
Fields written in STOP mode: `status: stopped`, `stopped_at`, `stopped_at_epoch`, `stop_reason`, `termination_mode: graceful`, `claude_session_id_own`/`agy_conversation_id_own` and `resumable: true`. Fields written in STOP mode: `status: stopped`, `stopped_at`, `stopped_at_epoch`, `stop_reason`, `termination_mode: graceful`, `claude_session_id_own`/`agy_conversation_id_own` and `resumable: true`.
If `--purge-conversation` is used: `status: terminated`, `terminated_at`, `terminated_at_epoch`, `termination_mode: purge` and `resumable: false`. If `--purge-conversation` is used: the row is removed from the YAML and the DB — no `terminated` row is left.
The script: The script:
1. Verifies the session is in agent-sessions.yaml 1. Verifies the session is in agent-sessions.yaml
2. If `delegate_job_id` is set, automatically publishes a `progress --detail "terminating"` event to the multi-agent-mux-delegate-job registry 2. If `delegate_job_id` is set, automatically publishes a `progress --detail "terminating"` event to the multi-agent-mux-delegate-job registry
3. Captures the `last_visible_status` from `herdr capture-pane` (so we have a final TUI snapshot for audit) 3. Captures the `last_visible_status` from `herdr capture-pane` (so we have a final TUI snapshot for audit)
4. Attempts graceful exit keys → SIGTERM kill-session → SIGKILL fallback 4. Attempts graceful exit keys → SIGTERM kill-session → SIGKILL fallback
5. For `purge-conversation`: deletes `~/.claude/projects/.../jsonl` (claude) or, for agy, `~/.gemini/antigravity-cli/conversations/<uuid>.db` with its `-wal`/`-shm`/`-journal` sidecars, `brain/<uuid>/`, `annotations/<uuid>.pbtxt` and `presence/<uuid>.lock` (the lock only once no live process references the conversation — Issue #10) 5. For `purge-conversation`: deletes, for claude, every path the session owns — `projects/<key>/<uuid>.jsonl` and `<uuid>/`, `file-history/<uuid>/`, `session-env/<uuid>/`, `jobs/<uuid[:8]>/` (when its `state.json` names the session), stale `sessions/<pid>.json` (+ that pid's `.key`) and `<tmp>/claude-<uid>/*/<uuid>/`, then prints anything still carrying the UUID as `WARN residual:`; shared files such as `history.jsonl` are not rewritten (Issue #31). Or, for agy, `~/.gemini/antigravity-cli/conversations/<uuid>.db` with its `-wal`/`-shm`/`-journal` sidecars, `brain/<uuid>/`, `annotations/<uuid>.pbtxt` and `presence/<uuid>.lock` (the lock only once no live process references the conversation — Issue #10)
6. Updates the YAML entry and SQLite database atomically 6. Updates the YAML entry and SQLite database atomically
7. If `delegate_job_id` is set, publishes a `completed` event to the multi-agent-mux-delegate-job registry 7. If `delegate_job_id` is set, publishes a `completed` event to the multi-agent-mux-delegate-job registry
@@ -120,7 +120,7 @@ Exit codes: 0 removed, 1 stop/git failure, 2 not a worktree of the control root,
- **Don't delete on-disk artifacts by default** — the agent's `*.jsonl` / `conversations/*.db` is the data that `multi-agent-mux-resume` needs. `--purge-conversation` is for when the user is genuinely done with the conversation and wants zero recovery chance. - **Don't delete on-disk artifacts by default** — the agent's `*.jsonl` / `conversations/*.db` is the data that `multi-agent-mux-resume` needs. `--purge-conversation` is for when the user is genuinely done with the conversation and wants zero recovery chance.
- **YAML is append-only until you write a stop** — if a previous run left the entry as `running` but herdr is actually dead (crash, host reboot), the YAML is stale. Running `multi-agent-mux-stop` will detect "herdr already dead, just update YAML" and proceed. - **YAML is append-only until you write a stop** — if a previous run left the entry as `running` but herdr is actually dead (crash, host reboot), the YAML is stale. Running `multi-agent-mux-stop` will detect "herdr already dead, just update YAML" and proceed.
- **Don't delete the `claude_session_id_own: null` placeholder** — when the user creates a fresh session with `multi-agent-mux-create` and never sent a message, the entry has `claude_session_id_own: null`. Stopping must preserve that field. - **Don't delete the `claude_session_id_own: null` placeholder** — when the user creates a fresh session with `multi-agent-mux-create` and never sent a message, the entry has `claude_session_id_own: null`. Stopping must preserve that field.
- **Monitor skill may still be tracking** — if `multi-agent-mux-monitor` is running a heartbeat loop, stopping a session while it watches will trigger its `herdr ls != yaml` reconciliation. That's expected — let the monitor run, it will mark the entry as `terminated` on its own. - **Monitor skill may still be tracking** — if `multi-agent-mux-monitor` is running a heartbeat loop, stopping a session while it watches will trigger its `herdr ls != yaml` reconciliation. That's expected — let the monitor run: it leaves a `stopped` row as it is (drift A only marks a `running` row whose herdr is gone as `terminated`), and a purged row is gone.
## Verification ## Verification
@@ -18,8 +18,8 @@
# 셋 다 실패하면 exit 2) # 셋 다 실패하면 exit 2)
# --herdr-session <name> — isolated herdr session name (alias: --herdr-server) # --herdr-session <name> — isolated herdr session name (alias: --herdr-server)
# --reason <reason> — 상태 전이 사유 (stop_reason). 기본값 manual_stop # --reason <reason> — 상태 전이 사유 (stop_reason). 기본값 manual_stop
# --purge-conversation — 디스크의 conversation artifact 까지 삭제. # --purge-conversation — 디스크의 conversation artifact 와 세션 부속 경로까지
# status=terminated, resumable=false 로 전이하며 # 삭제하고 레지스트리 행(YAML+DB)을 지우며
# resume 불가. --yes 없이는 확인 프롬프트(exit 3) # resume 불가. --yes 없이는 확인 프롬프트(exit 3)
# --yes — --purge-conversation 의 확인 프롬프트 생략 # --yes — --purge-conversation 의 확인 프롬프트 생략
# #
@@ -30,7 +30,7 @@
# Exit codes: # Exit codes:
# 0 = success (or already-stopped no-op) | 1 = YAML not found / not registered # 0 = success (or already-stopped no-op) | 1 = YAML not found / not registered
# 2 = invalid args | 3 = interactive confirmation required (--yes 누락) # 2 = invalid args | 3 = interactive confirmation required (--yes 누락)
# 4 = purge aborted (herdr session survived the kill chain) # 4 = purge aborted (herdr session or an agent process holding the conversation survived the kill chain)
set -euo pipefail set -euo pipefail
# shellcheck disable=SC1091 # shellcheck disable=SC1091
@@ -56,7 +56,7 @@ Arguments:
read from its registry row, not from where you stand. read from its registry row, not from where you stand.
--reason <reason> — stop_reason field (default: manual_stop) --reason <reason> — stop_reason field (default: manual_stop)
--purge-conversation — also delete on-disk conversation artifacts; --purge-conversation — also delete on-disk conversation artifacts;
status becomes terminated and resume is impossible the registry row is removed and resume is impossible
--yes — skip the --purge-conversation confirmation prompt --yes — skip the --purge-conversation confirmation prompt
EOF EOF
} }
@@ -282,6 +282,23 @@ if [ "$PURGE" = "1" ] && [ "$HERDR_ALIVE" = "1" ]; then
fi fi
fi fi
# Purge process gate (Issue #31): a closed herdr session is not an exited
# agent - the process may still be shutting down, or may have outlived a dead
# herdr ("herdr already dead" above runs no gate at all). The purge asks the
# adapter's own in-use verdict, the one the purge CLI uses, outside the
# registry lock. Any answer but "free" (0) keeps everything.
if [ "$PURGE" = "1" ] && [ -n "$PURGE_UUID" ]; then
inuse_rc=0
inuse_hints="$("$(_delegate_py_bin)" -m lib_py.agents in-use "$AGENT" "$PURGE_UUID" "$TARGET_CWD" --wait 5 2>&1 >/dev/null)" || inuse_rc=$?
if [ "$inuse_rc" != "0" ]; then
echo "ERROR: conversation $PURGE_UUID is still held by a live agent process (rc=$inuse_rc)." >&2
echo " Refusing purge — records preserved: the registry row and the conversation files are untouched (the session itself may already be down)." >&2
[ -n "$inuse_hints" ] && printf '%s\n' "$inuse_hints" >&2
delegate_publish_event "$DELEGATE_JOB_ID" cancelled "purge aborted: conversation still in use; records preserved" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
exit 4
fi
fi
# INVARIANT: Terminal events for a job must be published AFTER atomic_dump_yaml updates the session status # INVARIANT: Terminal events for a job must be published AFTER atomic_dump_yaml updates the session status
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
SESSION_NAME="$SESSION_NAME" AGENT="$AGENT" PURGE="$PURGE" \ SESSION_NAME="$SESSION_NAME" AGENT="$AGENT" PURGE="$PURGE" \
@@ -343,6 +360,12 @@ if purge and purge_uuid:
ctx = DiscoveryContext(workspace=ws, agent_name=agent, home_dir=home) ctx = DiscoveryContext(workspace=ws, agent_name=agent, home_dir=home)
for item in adapter.purge_artifacts(purge_uuid, ctx): for item in adapter.purge_artifacts(purge_uuid, ctx):
print(f"purged: {item}", flush=True) print(f"purged: {item}", flush=True)
# Issue #31: report what still carries the uuid, as orc_onboard does.
residual = adapter.residual_artifacts(purge_uuid, ctx)
for item in residual:
print(f"WARN residual: {item}", flush=True)
if not residual:
print(f"residual: none (agent: {agent})", flush=True)
target[adapter.own_key] = None target[adapter.own_key] = None
elif purge and not purge_uuid: elif purge and not purge_uuid:
print("WARN: --purge-conversation requested but no workspace-scoped UUID resolved; nothing purged", flush=True) print("WARN: --purge-conversation requested but no workspace-scoped UUID resolved; nothing purged", flush=True)
+1 -1
View File
@@ -2,7 +2,7 @@
본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다. 본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다.
[![Version: v4.9.4](https://img.shields.io/badge/version-v4.9.4-blue.svg)](VERSIONS.md) [![Version: v4.9.5](https://img.shields.io/badge/version-v4.9.5-blue.svg)](VERSIONS.md)
새로운 에이전트는 이 안내서에 기술된 절차를 순차적으로 실행하여 초기 환경을 안정적으로 설정할 수 있습니다. 새로운 에이전트는 이 안내서에 기술된 절차를 순차적으로 실행하여 초기 환경을 안정적으로 설정할 수 있습니다.
+1 -1
View File
@@ -2,7 +2,7 @@
This document guides you through the setup and initialization procedures required to adopt the `herdr_agent_orchestration` orchestration and messaging backplane workflow in a new project, enabling a new developer or agent to get up and running quickly. This document guides you through the setup and initialization procedures required to adopt the `herdr_agent_orchestration` orchestration and messaging backplane workflow in a new project, enabling a new developer or agent to get up and running quickly.
[![Version: v4.9.4](https://img.shields.io/badge/version-v4.9.4-blue.svg)](VERSIONS.md) [![Version: v4.9.5](https://img.shields.io/badge/version-v4.9.5-blue.svg)](VERSIONS.md)
A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment. A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment.
+1 -1
View File
@@ -1,6 +1,6 @@
# Messaging System Technical Analysis & Architecture Report # Messaging System Technical Analysis & Architecture Report
Applies to: MAM v4.9.4 Applies to: MAM v4.9.5
This report provides a comprehensive, deep-dive analysis of the messaging system implemented in the `multi-agent-mux-delegate-job` skill. It covers the MQTT broker architecture, event protocols, job lifecycles, codebase internals, cross-system integration, and a list of known limitations along with production recommendations. This report provides a comprehensive, deep-dive analysis of the messaging system implemented in the `multi-agent-mux-delegate-job` skill. It covers the MQTT broker architecture, event protocols, job lifecycles, codebase internals, cross-system integration, and a list of known limitations along with production recommendations.
+1 -1
View File
@@ -2,7 +2,7 @@
[herdr](https://herdr.dev)와 MQTT 위에 만든 **다중 에이전트 오케스트레이션·메시징 백플레인**입니다. 장시간 실행되는 코딩 에이전트(`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`)를 격리된 herdr pane 에서 돌리고, 작업을 추적되는 잡으로 위임하며, 모든 리뷰어가 통과시킬 때까지 planner / creator / reviewer 루프를 돌립니다. [herdr](https://herdr.dev)와 MQTT 위에 만든 **다중 에이전트 오케스트레이션·메시징 백플레인**입니다. 장시간 실행되는 코딩 에이전트(`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`)를 격리된 herdr pane 에서 돌리고, 작업을 추적되는 잡으로 위임하며, 모든 리뷰어가 통과시킬 때까지 planner / creator / reviewer 루프를 돌립니다.
[![Version: v4.9.4](https://img.shields.io/badge/version-v4.9.4-blue.svg)](VERSIONS.md) [![Version: v4.9.5](https://img.shields.io/badge/version-v4.9.5-blue.svg)](VERSIONS.md)
--- ---
+1 -1
View File
@@ -2,7 +2,7 @@
A **Multi-Agent Orchestration & Messaging Backplane** built on [herdr](https://herdr.dev) and MQTT. It runs long-lived coding agents (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`) in isolated herdr panes, delegates work to them as tracked jobs, and drives planner / creator / reviewer loops until every reviewer passes the change. A **Multi-Agent Orchestration & Messaging Backplane** built on [herdr](https://herdr.dev) and MQTT. It runs long-lived coding agents (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`) in isolated herdr panes, delegates work to them as tracked jobs, and drives planner / creator / reviewer loops until every reviewer passes the change.
[![Version: v4.9.4](https://img.shields.io/badge/version-v4.9.4-blue.svg)](VERSIONS.md) [![Version: v4.9.5](https://img.shields.io/badge/version-v4.9.5-blue.svg)](VERSIONS.md)
--- ---
+46 -11
View File
@@ -6,8 +6,8 @@
## 📌 현재 버전 개요 (Current Release) ## 📌 현재 버전 개요 (Current Release)
- **프레임워크 버전**: `v4.9.4` - **프레임워크 버전**: `v4.9.5`
- **최신 릴리스 일시**: 2026-10-15 (KST) - **최신 릴리스 일시**: 2026-10-18 (KST)
- **기준 브랜치**: `main` - **기준 브랜치**: `main`
- **핵심 아키텍처**: - **핵심 아키텍처**:
- **Terminal 상태 단방향 불변성 (`engine.py`, Issue #6 Phase 1)**: `completed`, `error`, `cancelled` 터미널 상태 진입 후 후속 이벤트나 업데이트에 의한 덮어쓰기를 엄격히 차단(Terminal Immutability). `registry.py close` 서브커맨드 및 fail-closed 조기 종료 도입. - **Terminal 상태 단방향 불변성 (`engine.py`, Issue #6 Phase 1)**: `completed`, `error`, `cancelled` 터미널 상태 진입 후 후속 이벤트나 업데이트에 의한 덮어쓰기를 엄격히 차단(Terminal Immutability). `registry.py close` 서브커맨드 및 fail-closed 조기 종료 도입.
@@ -24,23 +24,58 @@
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix) ## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.9.4`로 동기화되어 배포됩니다. 모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.9.5`로 동기화되어 배포됩니다.
| 스킬명 | 버전 | 역할 및 주요 책임 | 상태 | | 스킬명 | 버전 | 역할 및 주요 책임 | 상태 |
| :--- | :---: | :--- | :---: | | :--- | :---: | :--- | :---: |
| **`multi-agent-mux-create`** | `4.9.4` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 | | **`multi-agent-mux-create`** | `4.9.5` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
| **`multi-agent-mux-stop`** | `4.9.4` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 | | **`multi-agent-mux-stop`** | `4.9.5` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
| **`multi-agent-mux-resume`** | `4.9.4` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 | | **`multi-agent-mux-resume`** | `4.9.5` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
| **`multi-agent-mux-status`** | `4.9.4` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 | | **`multi-agent-mux-status`** | `4.9.5` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
| **`multi-agent-mux-monitor`** | `4.9.4` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 | | **`multi-agent-mux-monitor`** | `4.9.5` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
| **`multi-agent-mux-delegate-job`** | `4.9.4` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 | | **`multi-agent-mux-delegate-job`** | `4.9.5` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
| **`multi-agent-mux-loop`** | `4.9.4` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 | | **`multi-agent-mux-loop`** | `4.9.5` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
| **`multi-agent-mux-orc-onboard`** | `4.9.4` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 | | **`multi-agent-mux-orc-onboard`** | `4.9.5` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
--- ---
## 📋 버전별 상세 변경 내역 (Changelog) ## 📋 버전별 상세 변경 내역 (Changelog)
### 🚀 `v4.9.5` — claude purge 가 세션이 소유한 경로를 모두 지우고 남은 것을 보고한다 (Issue #31, PR #59) (2026-10-18)
> **패치 (PATCH Release)**: claude `--purge-conversation` 이 `.jsonl` 만 지우고 `file-history`·`session-env` 등을 남기면서 `residual: none` 으로 보고하던 결함. purge 범위는 정규형 UUID 가 가리키는 세션 소유 경로로 한정된다.
#### 🌟 주요 개선 사항
1. **소유 경로 하나의 목록**: claude 어댑터의 `_owned_paths` 를 purge·`has_artifacts`·residual 이 함께 쓴다. 대상은 다음과 같다.
- `projects/<key>/<uuid>.jsonl` 과 `projects/<key>/<uuid>/`
- `file-history/<uuid>/`, `session-env/<uuid>/`
- `state.json` 이 이 세션을 가리키는 `jobs/<uuid[:8]>/`
- 죽은 프로세스의 `sessions/<pid>.json`·`<pid>.<hex>.key`
- `<tmp>/claude-<uid>/<key>/<uuid>/` 스크래치패드(`CLAUDE_CODE_TMPDIR`·`TMPDIR`·`/tmp`)
공유 파일(`history.jsonl` 등)은 건드리지 않는다. 세션과의 연결 근거는 마지막에 지운다.
2. **삭제 범위 안전장치 (Mac 리뷰)**:
- 정규형 UUID 만 어떤 경로에든 닿는다. 그래서 `memory` 같은 id 가 `projects/<key>/memory/` 를 지우지 않는다. 대소문자 표기는 소문자로 정규화한다.
- 루트 아래 경로 구성요소가 심볼릭 링크면 그 아래는 지우지 않고 residual 로만 보고한다. 루트 자체의 링크(`/tmp → /private/tmp`)는 허용한다.
3. **residual 보고**: claude 에 `residual_artifacts` 를 신설했다. `stop_session.sh` 는 purge 뒤 residual 을 출력한다. codex·grok·opencode 도 residual 을 보고한다(purge 범위는 그대로).
4. **살아 있는 프로세스 대기**:
- 새 CLI `python -m lib_py.agents in-use <agent> <uuid> [--wait N]` 를 추가했다. 판정은 `sessions/<pid>.json` 과 `ps -o lstart=`(`LC_ALL=C`, `TZ=UTC0`)의 시작 시각 비교이며, pid 재사용을 구분한다. `startedAt` 이 유한한 시각이 아니거나 판정할 수 없으면 live 로 본다.
- `stop --purge-conversation` 은 대화가 아직 쓰이고 있으면 exit 4 로 아무것도 바꾸지 않고, 보유자가 있을 만한 곳을 안내한다.
5. **`CLAUDE_CONFIG_DIR`**: purge·liveness·residual·projects 루트가 이 값을 따른다. 명시적 `CLAUDE_PROJECT_DIR` 가 우선한다. stop `SKILL.md` 는 purge 가 레지스트리 행을 지운다고 정정했다.
6. **eager 로드**: `lib_py.delegation.logroot` 를 `eager.CORE_MODULES` 에 명시했다(v4.9.4 리뷰의 비차단 메모).
#### ✅ 품질 검증
- **회귀**: `tests/test_issue31_purge_residual.py`(67 tests). 리뷰어들이 수정 직전 커밋이나 변형 사본에서 실패를 확인했다. 대상은 비-UUID id, 링크 경계, 유한하지 않은 `startedAt`, UTC lstart, 철자 정규화이다.
- **리뷰 (`--review-only`, `--all-reviewer`, 이 Mac 정합성 중심)**:
- 1회차(@6f4f40a): agy·grok PASS, claude·codex NOT PASS. 지적은 비-UUID `memory` 가 자동 메모리 폴더를 지움(차단), 상위 링크를 따라 범위 밖 삭제(P1), NaN `startedAt`(P2)이다. PR 댓글로 전달했고 작성자가 커밋 11개로 수정했다.
- 2회차(@2a8d738): 4/4 가 세 지적 모두 해결을 확인했다. 그러나 새 테스트의 보유자 `sh -c "sleep 120" <uuid>` 가 macOS `/bin/sh`(bash 3.2)의 단일 명령 exec 로 uuid 를 잃어 `[upper|mixed]` 가 이 Mac 에서 항상 실패해 NOT PASS 였다. 오케스트레이터가 이 한 줄을 Python 보유자로 바꿨다(de6f3fc).
- 3회차(@de6f3fc): **4/4 PASS**. 리포트는 `.agents/reports/<session>/` 에 있다.
- **전체 스위트 (macOS, Python 3.9.6, @de6f3fc)**: 1934 passed, 56 skipped(tomllib·서브모듈·Linux 전용), 1 error. error 는 #55 가드가 다른 워크스페이스에서 돌던 agy 의 대화 파일을 잡은 알려진 오탐이다. 실제 `~/.claude`(projects·file-history·session-env·sessions·jobs)·`/private/tmp/claude-<uid>`·herdr 서버 pane 목록은 바뀌지 않았다.
- **알려진 비차단**:
- `projects/<key>` 자체가 링크인 경우, transcript 는 purge 되지 않고 residual 로만 보고된다.
- 소문자 질의는 대문자 argv 보유자를 보지 못한다.
- `purge_artifacts` 를 직접 호출하면 in-use 게이트가 없다(CLI·stop 은 먼저 확인한다).
### 🚀 `v4.9.4` — 감사 로그가 옮긴 레지스트리를 따라간다 (Issue #57, PR #58) (2026-10-15) ### 🚀 `v4.9.4` — 감사 로그가 옮긴 레지스트리를 따라간다 (Issue #57, PR #58) (2026-10-15)
> **패치 (PATCH Release)**: `--registry-dir`·`MAM_JOBS_DIR` 로 레지스트리만 옮긴 실행이 실제 `.mam/delegate_job_logs` 에 레지스트리 없는 감사 로그를 남기던 결함. 레지스트리를 옮기지 않은 평소 실행의 경로는 바뀌지 않는다. > **패치 (PATCH Release)**: `--registry-dir`·`MAM_JOBS_DIR` 로 레지스트리만 옮긴 실행이 실제 `.mam/delegate_job_logs` 에 레지스트리 없는 감사 로그를 남기던 결함. 레지스트리를 옮기지 않은 평소 실행의 경로는 바뀌지 않는다.
+1 -1
View File
@@ -18,7 +18,7 @@ import tempfile
# The pane-inherited variables conftest._isolate_agent_homes drops for every # The pane-inherited variables conftest._isolate_agent_homes drops for every
# test, so HOME (which the tests set) governs, as it does in a plain shell. # test, so HOME (which the tests set) governs, as it does in a plain shell.
DROPPED_VARS = ("HOME_DIR", "CLAUDE_PROJECT_DIR", "CODEX_HOME", "LOCAL_BIN") DROPPED_VARS = ("HOME_DIR", "CLAUDE_PROJECT_DIR", "CLAUDE_CONFIG_DIR", "CODEX_HOME", "LOCAL_BIN")
# Read by the code under test but left alone, on purpose: HOME is where the # Read by the code under test but left alone, on purpose: HOME is where the
# real herdr tests and the shim bootstrap find the server socket, and it is # real herdr tests and the shim bootstrap find the server socket, and it is
# what the suite's own tests set for an isolated home; XDG_CONFIG_HOME and # what the suite's own tests set for an isolated home; XDG_CONFIG_HOME and
+3 -3
View File
@@ -413,7 +413,7 @@
"start": 42, "start": 42,
"end": 55, "end": 55,
"lang": "bash", "lang": "bash",
"sha": "27ebf150b92a", "sha": "301c347ff881",
"class": "c", "class": "c",
"kind": "invocation", "kind": "invocation",
"evidence": [ "evidence": [
@@ -429,7 +429,7 @@
"start": 81, "start": 81,
"end": 85, "end": 85,
"lang": "-", "lang": "-",
"sha": "a2b56ac238ae", "sha": "a973676cb2d1",
"class": "c", "class": "c",
"kind": "diagram", "kind": "diagram",
"evidence": [ "evidence": [
@@ -599,7 +599,7 @@
"other" "other"
], ],
"passthrough": { "passthrough": {
"1": "multi-agent-mux-stop/scripts/stop_session.sh:310" "1": "multi-agent-mux-stop/scripts/stop_session.sh:327"
} }
}, },
"multi-agent-mux-create/scripts/create_session.sh": { "multi-agent-mux-create/scripts/create_session.sh": {
+3 -3
View File
@@ -209,12 +209,12 @@ def test_purge_artifacts_composite(tmp_path):
# 1. Claude # 1. Claude
claude_adapter = get_adapter('claude') claude_adapter = get_adapter('claude')
claude_ctx = DiscoveryContext(workspace=ws, agent_name='claude', home_dir=home) claude_ctx = DiscoveryContext(workspace=ws, agent_name='claude', home_dir=home)
c_path = claude_adapter.artifact_path('uuid-c', claude_ctx) c_path = claude_adapter.artifact_path('0c1a5f3e-6b2d-4c8e-9a7f-1d2e3f4a5b6c', claude_ctx)
os.makedirs(os.path.dirname(c_path), exist_ok=True) os.makedirs(os.path.dirname(c_path), exist_ok=True)
with open(c_path, 'w') as f: with open(c_path, 'w') as f:
f.write('{"sessionId": "uuid-c"}') f.write('{"sessionId": "0c1a5f3e-6b2d-4c8e-9a7f-1d2e3f4a5b6c"}')
assert os.path.exists(c_path) assert os.path.exists(c_path)
purged_c = claude_adapter.purge_artifacts('uuid-c', claude_ctx) purged_c = claude_adapter.purge_artifacts('0c1a5f3e-6b2d-4c8e-9a7f-1d2e3f4a5b6c', claude_ctx)
assert len(purged_c) == 1 assert len(purged_c) == 1
assert not os.path.exists(c_path) assert not os.path.exists(c_path)
+972
View File
@@ -0,0 +1,972 @@
#!/usr/bin/env python3
"""Issue #31 — a claude purge removes every path the session owns and reports
what still carries the uuid; stop and orc_onboard both go through it.
Before #31 the claude adapter removed only `<uuid>.jsonl`. file-history,
session-env, `projects/<key>/<uuid>/`, the daemon job dir, a stale
`sessions/<pid>.json` and the tmp scratch dir survived, and with no
`residual_artifacts` the purge printed `residual: none`. A live `claude -c`
names no uuid on its command line, so only `sessions/<pid>.json` shows it is
running: the purge CLI and stop both refuse while that process lives.
codex, grok and opencode report their leftovers (their purge is unchanged).
Every test makes fresh ids: conversation_in_use scans every process on the
host, so a fixed id here is named by anything carrying this file's text.
"""
from __future__ import annotations
import json
import os
import sqlite3
import subprocess
import sys
import time
import uuid as uuidlib
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SKILLS = REPO_ROOT / ".agents" / "skills"
ONBOARD_SH = SKILLS / "multi-agent-mux-orc-onboard" / "scripts" / "orc_onboard.sh"
sys.path.insert(0, str(SKILLS))
from lib_py.agents.registry import get_adapter # noqa: E402
from lib_py.agents.base import DiscoveryContext # noqa: E402
from lib_py.verify_session import workspace_key # noqa: E402
SESSION_ENV = ("CLAUDE_CODE_SESSION_ID", "ANTIGRAVITY_CONVERSATION_ID",
"HERMES_SESSION_ID", "GROK_SESSION_ID", "OPENCODE_SESSION_ID")
UID = os.getuid()
def _fresh_id() -> str:
ident = str(uuidlib.uuid4())
out = subprocess.run(["ps", "-axo", "pid=,command="], capture_output=True, text=True, timeout=10).stdout
assert ident not in out, f"fresh id {ident} is already named by a live process"
return ident
@pytest.fixture(autouse=True)
def own_tmp_roots(tmp_path, monkeypatch):
"""Every tmp root the claude adapter may look in stays inside the test:
TMP/TEMP/TMPDIR and the fixed /tmp fallback, here and in every python a
test starts (a sitecustomize on PYTHONPATH sets the fallback there)."""
from lib_py.agents.adapters import claude as claude_mod
sys_tmp, tmpdir, shim = tmp_path / "systmp", tmp_path / "tmpdir", tmp_path / "pyshim"
for d in (sys_tmp, tmpdir, shim):
d.mkdir()
monkeypatch.setattr(claude_mod, "_SYSTEM_TMP", str(sys_tmp))
monkeypatch.setenv("TMPDIR", str(tmpdir))
monkeypatch.delenv("TMP", raising=False)
monkeypatch.delenv("TEMP", raising=False)
(shim / "sitecustomize.py").write_text(
"try:\n"
" from lib_py.agents.adapters import claude\n"
f" claude._SYSTEM_TMP = {str(sys_tmp)!r}\n"
"except Exception:\n"
" pass\n")
monkeypatch.setenv("PYTHONPATH", f"{shim}:{os.environ.get('PYTHONPATH', '')}")
return sys_tmp
@pytest.fixture
def conv() -> str:
return _fresh_id()
@pytest.fixture
def other() -> str:
return _fresh_id()
@pytest.fixture
def home(tmp_path, monkeypatch):
"""A claude home of its own: HOME, projects and the claude tmp root."""
h = tmp_path / "home"
(h / ".claude" / "projects").mkdir(parents=True)
monkeypatch.setenv("HOME", str(h))
monkeypatch.setenv("HOME_DIR", str(h))
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(h / ".claude" / "projects"))
monkeypatch.setenv("CLAUDE_CODE_TMPDIR", str(tmp_path / "ctmp"))
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
for k in SESSION_ENV:
monkeypatch.delenv(k, raising=False)
return h
def _dead_pid() -> int:
p = subprocess.Popen(["true"])
p.wait()
return p.pid
@pytest.fixture
def live():
"""A running process standing in for claude; its session file must be
written after it started (startedAt >= its lstart)."""
p = subprocess.Popen(["sleep", "120"])
time.sleep(0.05)
yield p
p.kill()
p.wait()
def _session_file(cfg: Path, pid: int, sid: str, started_ms=None, key=True) -> list:
d = cfg / "sessions"
d.mkdir(parents=True, exist_ok=True)
started_ms = int(time.time() * 1000) if started_ms is None else started_ms
f = d / f"{pid}.json"
f.write_text(json.dumps({"pid": pid, "sessionId": sid, "cwd": "/x", "startedAt": started_ms}))
made = [f]
if key:
k = d / f"{pid}.{uuidlib.uuid4().hex}{uuidlib.uuid4().hex}.key"
k.write_bytes(os.urandom(140))
made.append(k)
return made
def _tree(h: Path, ws: Path, sid: str, tmp_root: Path, pid=None, cfg=None) -> dict:
"""Everything a claude session `sid` owns, as claude 2.1 lays it out
(`cfg`: a CLAUDE_CONFIG_DIR root instead of <home>/.claude)."""
cfg = cfg or h / ".claude"
key = workspace_key(str(ws))
proj = cfg / "projects" / key
proj.mkdir(parents=True, exist_ok=True)
t = {}
t["jsonl"] = proj / f"{sid}.jsonl"
t["jsonl"].write_text(json.dumps({"sessionId": sid, "cwd": os.path.realpath(str(ws))}) + "\n")
t["proj_dir"] = proj / sid
(t["proj_dir"] / "tool-results").mkdir(parents=True)
(t["proj_dir"] / "tool-results" / "a.txt").write_text("r")
(t["proj_dir"] / "subagents").mkdir()
(t["proj_dir"] / "subagents" / "agent-1.jsonl").write_text("{}\n")
t["file_history"] = cfg / "file-history" / sid
t["file_history"].mkdir(parents=True)
(t["file_history"] / "x@v1").write_text("edited")
t["session_env"] = cfg / "session-env" / sid
t["session_env"].mkdir(parents=True)
t["job"] = cfg / "jobs" / sid[:8]
t["job"].mkdir(parents=True)
(t["job"] / "state.json").write_text(json.dumps({"sessionId": sid, "backend": "daemon"}))
(t["job"] / "timeline.jsonl").write_text("{}\n")
t["session_json"], t["session_key"] = _session_file(cfg, pid or _dead_pid(), sid)
t["tmp"] = tmp_root / f"claude-{UID}" / key / sid
(t["tmp"] / "scratchpad").mkdir(parents=True)
(t["tmp"] / "tasks").mkdir()
(t["tmp"] / "tasks" / "b.output").write_text("out")
return t
def _ctx(ws: Path, h: Path) -> DiscoveryContext:
return DiscoveryContext(workspace=str(ws), agent_name="claude", home_dir=str(h))
def _masked(p: Path) -> str:
return os.path.join(str(p.parent), p.name.split(".")[0] + ".*.key")
def _env(extra=None) -> dict:
env = dict(os.environ)
for k in SESSION_ENV + ("MAM_ORCHESTRATOR_UUIDS",):
env.pop(k, None)
env["PYTHONPATH"] = f"{SKILLS}:{env.get('PYTHONPATH', '')}"
if extra:
env.update(extra)
return env
def _agents_cli(args, cwd) -> subprocess.CompletedProcess:
return subprocess.run([sys.executable, "-m", "lib_py.agents"] + list(args), capture_output=True,
text=True, env=_env(), cwd=str(cwd), timeout=60)
# ---------------------------------------------------------------------------
# claude adapter
# ---------------------------------------------------------------------------
def test_claude_purge_removes_every_owned_path(home, tmp_path, conv):
ws = tmp_path / "ws"
ws.mkdir()
t = _tree(home, ws, conv, tmp_path / "ctmp")
purged = get_adapter("claude").purge_artifacts(conv, _ctx(ws, home))
assert not any(p.exists() for p in t.values()), {k: str(p) for k, p in t.items() if p.exists()}
expected = {str(p) for k, p in t.items() if k != "session_key"} | {_masked(t["session_key"])}
assert set(purged) == expected
assert len(purged) == len(expected)
assert not any(t["session_key"].name.split(".")[1] in p for p in purged), "the key's hex is never printed"
def test_claude_purge_leaves_other_sessions_and_shared_files(home, tmp_path, conv, other, live):
ws = tmp_path / "ws"
ws.mkdir()
cfg = home / ".claude"
t = _tree(home, ws, conv, tmp_path / "ctmp")
o = _tree(home, ws, other, tmp_path / "ctmp")
o_live = _session_file(cfg, live.pid, other)
history = cfg / "history.jsonl"
history.write_bytes((json.dumps({"sessionId": conv, "display": "hi"}) + "\n").encode())
before = history.read_bytes()
memory = cfg / "projects" / workspace_key(str(ws)) / "memory" / "MEMORY.md"
memory.parent.mkdir()
memory.write_text("m")
get_adapter("claude").purge_artifacts(conv, _ctx(ws, home))
assert all(p.exists() for p in o.values())
assert all(p.exists() for p in o_live)
assert history.read_bytes() == before
assert memory.exists()
def test_claude_purge_keeps_a_job_dir_of_another_session_with_the_same_prefix(home, tmp_path, conv):
ws = tmp_path / "ws"
ws.mkdir()
t = _tree(home, ws, conv, tmp_path / "ctmp")
twin = conv[:8] + str(uuidlib.uuid4())[8:]
(t["job"] / "state.json").write_text(json.dumps({"sessionId": twin}))
adapter = get_adapter("claude")
adapter.purge_artifacts(conv, _ctx(ws, home))
assert (t["job"] / "state.json").exists()
assert str(t["job"]) not in adapter.residual_artifacts(conv, _ctx(ws, home))
def test_claude_residual_lists_leftovers_then_nothing(home, tmp_path, conv, other):
ws = tmp_path / "ws"
ws.mkdir()
t = _tree(home, ws, conv, tmp_path / "ctmp")
o = _tree(home, ws, other, tmp_path / "ctmp")
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
before = adapter.residual_artifacts(conv, ctx)
names = {str(t[k]) for k in ("jsonl", "proj_dir", "file_history", "session_env", "job", "session_json", "tmp")}
assert names <= set(before), names - set(before)
assert not any(other in p for p in before)
assert not any(str(o["job"]) == p or str(o["session_json"]) == p for p in before)
adapter.purge_artifacts(conv, ctx)
assert adapter.residual_artifacts(conv, ctx) == []
# A transcript under another workspace key is not this purge's to remove,
# but it still carries the uuid.
stray = home / ".claude" / "projects" / "-elsewhere" / f"{conv}.jsonl"
stray.parent.mkdir()
stray.write_text("{}\n")
adapter.purge_artifacts(conv, ctx)
assert stray.exists()
assert adapter.residual_artifacts(conv, ctx) == [str(stray)]
@pytest.mark.parametrize("bad", ["", ".", "..", "*", "../x", "a/b", "memory", " ", "notes",
"12345678-1234-1234-1234-12345678901g", "{conv} ", "x{conv}"])
def test_claude_rejects_malformed_uuid(home, tmp_path, conv, bad):
bad = bad.format(conv=conv)
ws = tmp_path / "ws"
ws.mkdir()
t = _tree(home, ws, conv, tmp_path / "ctmp")
# Claude Code's auto memory sits beside the session dirs, under the one
# non-uuid name there.
memory = home / ".claude" / "projects" / workspace_key(str(ws)) / "memory" / "MEMORY.md"
memory.parent.mkdir()
memory.write_text("m")
named = [home / ".claude" / d / bad / "x" for d in ("file-history", "session-env")
if bad.strip(". ") and "/" not in bad and "*" not in bad]
for p in named:
p.parent.mkdir(parents=True)
p.write_text("x")
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
assert adapter.purge_artifacts(bad, ctx) == []
assert adapter.residual_artifacts(bad, ctx) == []
assert adapter.has_artifacts(bad, ctx) is False
assert adapter.conversation_in_use(bad, ctx) is False
assert all(p.exists() for p in t.values())
assert memory.exists() and all(p.exists() for p in named)
@pytest.mark.parametrize("spell", [str.upper, lambda u: u[:18].upper() + u[18:]], ids=["upper", "mixed"])
def test_any_spelling_of_the_uuid_is_the_lowercase_session(home, tmp_path, conv, live, spell):
"""claude writes its ids in lowercase, and on macOS's default
case-insensitive APFS another spelling names the very same files. Every
check then uses the lowercase id: the live holder is seen, by its session
file or its command line, and a purge takes the session's own paths."""
ws = tmp_path / "ws"
ws.mkdir()
t = _tree(home, ws, conv, tmp_path / "ctmp", pid=live.pid)
other = spell(conv)
assert other != conv
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
assert adapter.conversation_in_use(other, ctx) is True
for args in (["purge", "claude", other, str(ws)], ["in-use", "claude", other, str(ws)]):
res = _agents_cli(args, ws)
assert res.returncode == 4, res.stdout + res.stderr
assert all(p.exists() for p in t.values())
live.kill()
live.wait()
# Not `sh -c "sleep 120" <uuid>`: a shell execs its last simple command
# and the uuid would vanish from the process list (macOS /bin/sh).
named = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)", conv])
try:
time.sleep(0.05)
assert adapter.conversation_in_use(other, ctx) is True, "a command line naming the lowercase id"
finally:
named.kill()
named.wait()
assert adapter.has_artifacts(other, ctx) is True
purged = adapter.purge_artifacts(other, ctx)
assert not any(p.exists() for p in t.values()), {k: str(p) for k, p in t.items() if p.exists()}
assert purged and all(other not in p for p in purged)
assert adapter.residual_artifacts(other, ctx) == []
def test_claude_purge_never_follows_a_linked_dir_below_a_root(home, tmp_path, conv):
"""A symlinked <key> (tmp, projects) or file-history/ below its root is
not followed: what lies behind it stays and is reported as residual."""
ws = tmp_path / "ws"
ws.mkdir()
cfg = home / ".claude"
key = workspace_key(str(ws))
out = tmp_path / "outside"
victims = [out / "tmp" / conv / "keep.txt", out / "proj" / f"{conv}.jsonl", out / "proj" / conv / "keep.txt",
out / "fh" / conv / "keep.txt"]
for v in victims:
v.parent.mkdir(parents=True, exist_ok=True)
v.write_text("keep")
troot = tmp_path / "ctmp" / f"claude-{UID}"
troot.mkdir(parents=True)
links = [troot / "linked-workspace", cfg / "projects" / key, cfg / "file-history"]
for link, target in zip(links, (out / "tmp", out / "proj", out / "fh")):
link.symlink_to(target)
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
assert adapter.purge_artifacts(conv, ctx) == []
assert all(v.exists() for v in victims) and all(link.is_symlink() for link in links)
assert adapter.residual_artifacts(conv, ctx) == sorted(
[str(troot / "linked-workspace" / conv), str(cfg / "projects" / key / f"{conv}.jsonl"),
str(cfg / "projects" / key / conv), str(cfg / "file-history" / conv)])
def test_purge_cli_reports_a_linked_orphan_it_will_not_remove(home, tmp_path, conv):
"""A candidate behind a link is all that is left: the CLI still picks
claude, so its residual names it, and removes nothing."""
ws = tmp_path / "ws"
ws.mkdir()
keep = tmp_path / "outside" / conv / "keep.txt"
keep.parent.mkdir(parents=True)
keep.write_text("keep")
troot = tmp_path / "ctmp" / f"claude-{UID}"
troot.mkdir(parents=True)
(troot / "linked-workspace").symlink_to(tmp_path / "outside")
res = _agents_cli(["purge", "claude", conv, str(ws)], ws)
assert res.returncode == 0, res.stdout + res.stderr
assert json.loads(res.stdout) == {"agent": "claude", "purged": [],
"residual": [str(troot / "linked-workspace" / conv)], "in_use": False}
assert keep.exists()
def test_claude_purge_removes_only_the_link_at_the_leaf(home, tmp_path, conv):
ws = tmp_path / "ws"
ws.mkdir()
key = workspace_key(str(ws))
out = tmp_path / "outside"
keep = [out / "tmp" / "keep.txt", out / "proj" / "keep.txt"]
for k in keep:
k.parent.mkdir(parents=True)
k.write_text("keep")
leaves = [tmp_path / "ctmp" / f"claude-{UID}" / key / conv, home / ".claude" / "projects" / key / conv]
for leaf, target in zip(leaves, (out / "tmp", out / "proj")):
leaf.parent.mkdir(parents=True)
leaf.symlink_to(target)
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
assert set(adapter.purge_artifacts(conv, ctx)) == {str(p) for p in leaves}
assert not any(os.path.lexists(p) for p in leaves)
assert all(k.exists() for k in keep)
assert adapter.residual_artifacts(conv, ctx) == []
def test_claude_roots_may_be_links(home, tmp_path, monkeypatch, conv):
"""A root that is itself a link (/tmp -> /private/tmp on macOS, a linked
~/.claude) is the boundary, not a way out of it."""
ws = tmp_path / "ws"
ws.mkdir()
real_cfg, real_tmp = tmp_path / "real-cfg", tmp_path / "real-tmp"
real_cfg.mkdir()
real_tmp.mkdir()
(tmp_path / "cfg-link").symlink_to(real_cfg)
(tmp_path / "tmp-link").symlink_to(real_tmp)
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "cfg-link"))
monkeypatch.delenv("CLAUDE_PROJECT_DIR")
monkeypatch.setenv("CLAUDE_CODE_TMPDIR", str(tmp_path / "tmp-link"))
t = _tree(home, ws, conv, tmp_path / "tmp-link", cfg=tmp_path / "cfg-link")
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
adapter.purge_artifacts(conv, ctx)
assert not any(p.exists() for p in t.values()), {k: str(p) for k, p in t.items() if p.exists()}
assert adapter.residual_artifacts(conv, ctx) == []
@pytest.mark.parametrize("name", ["uuid-c", "memory", "notes"])
def test_a_non_uuid_id_reaches_nothing(home, tmp_path, name):
"""Only a uuid names a claude session: no path is built for any other id,
not even the `<id>.jsonl` the adapter took before #31."""
ws = tmp_path / "ws"
ws.mkdir()
proj = home / ".claude" / "projects" / workspace_key(str(ws))
proj.mkdir()
jsonl = proj / f"{name}.jsonl"
jsonl.write_text(json.dumps({"sessionId": name, "cwd": os.path.realpath(str(ws))}) + "\n")
kept = [jsonl, proj / name / "a", home / ".claude" / "file-history" / name / "b"]
for p in kept[1:]:
p.parent.mkdir(parents=True)
p.write_text("x")
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
assert adapter.has_artifacts(name, ctx) is False
assert adapter.residual_artifacts(name, ctx) == []
assert adapter.purge_artifacts(name, ctx) == []
res = _agents_cli(["purge", "claude", name, str(ws)], ws)
assert json.loads(res.stdout)["purged"] == [], res.stdout + res.stderr
assert all(p.exists() for p in kept)
def test_purge_cli_resolves_claude_from_leftovers_only(home, tmp_path, conv):
ws = tmp_path / "ws"
ws.mkdir()
fh = home / ".claude" / "file-history" / conv
se = home / ".claude" / "session-env" / conv
fh.mkdir(parents=True)
se.mkdir(parents=True)
res = _agents_cli(["purge", "auto", conv, str(ws)], ws)
assert res.returncode == 0, res.stdout + res.stderr
out = json.loads(res.stdout)
assert out["agent"] == "claude"
assert set(out["purged"]) == {str(fh), str(se)}
assert out["residual"] == []
assert not fh.exists() and not se.exists()
def test_purge_cli_refuses_a_live_claude_without_uuid_on_argv(home, tmp_path, conv, live):
ws = tmp_path / "ws"
ws.mkdir()
t = _tree(home, ws, conv, tmp_path / "ctmp", pid=live.pid)
res = _agents_cli(["purge", "claude", conv, str(ws)], ws)
assert res.returncode == 4, res.stdout + res.stderr
assert json.loads(res.stdout)["in_use"] is True
assert all(p.exists() for p in t.values())
def test_a_reused_pid_does_not_hold_the_conversation(home, tmp_path, conv, live):
ws = tmp_path / "ws"
ws.mkdir()
cfg = home / ".claude"
# The file was written an hour before the process now holding that pid
# started: the pid was reused, the session is gone.
made = _session_file(cfg, live.pid, conv, started_ms=int((time.time() - 3600) * 1000))
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
assert adapter.conversation_in_use(conv, ctx) is False
purged = adapter.purge_artifacts(conv, ctx)
assert not any(p.exists() for p in made)
assert set(purged) == {str(made[0]), _masked(made[1])}
def test_session_file_liveness_fails_safe(monkeypatch, live):
from lib_py.agents.adapters import claude as claude_mod
now_ms = int(time.time() * 1000)
assert claude_mod._session_file_live({"startedAt": now_ms}, live.pid) is True
assert claude_mod._session_file_live({}, live.pid) is True, "no startedAt: cannot tell, so live"
assert claude_mod._session_file_live({"startedAt": now_ms}, _dead_pid()) is False
assert claude_mod._session_file_live({"startedAt": now_ms}, None) is True
assert claude_mod._session_file_live({"startedAt": now_ms}, 0) is True
assert claude_mod._session_file_live({"startedAt": 0}, live.pid) is True, "no usable startedAt: live"
hour_ago = int((time.time() - 3600) * 1000)
assert claude_mod._session_file_live({"startedAt": hour_ago}, live.pid) is False, "reused pid"
monkeypatch.setenv("PATH", "/nonexistent")
assert claude_mod._session_file_live({"startedAt": hour_ago}, live.pid) is True, "ps unavailable: live"
def test_liveness_reads_lstart_as_utc(tmp_path, monkeypatch, live):
"""2026-11-01 01:30 happens twice in EST5EDT. A process that started at
the second one (06:30 UTC) shows lstart 01:30 to a local-time ps; read as
the first (05:30 UTC) it predates a session file written at 05:45. ps
runs with TZ=UTC0 instead, which BSD ps and procps both honour."""
import calendar
from lib_py.agents.adapters import claude as claude_mod
fake = tmp_path / "fakebin"
fake.mkdir()
(fake / "ps").write_text('#!/bin/sh\nif [ "$TZ" = UTC0 ]; then echo "Sun Nov 1 06:30:00 2026"; '
'else echo "Sun Nov 1 01:30:00 2026"; fi\n')
(fake / "ps").chmod(0o755)
utc = lambda hhmmss: calendar.timegm(time.strptime(f"2026-11-01 {hhmmss}", "%Y-%m-%d %H:%M:%S")) * 1000 # noqa: E731
try:
with monkeypatch.context() as m:
m.setenv("PATH", f"{fake}:{os.environ['PATH']}")
m.setenv("TZ", "EST5EDT,M3.2.0,M11.1.0")
time.tzset()
assert claude_mod._session_file_live({"startedAt": utc("05:45:00")}, live.pid) is False, "reused pid"
assert claude_mod._session_file_live({"startedAt": utc("06:30:02")}, live.pid) is True
finally:
time.tzset()
@pytest.mark.parametrize("started", ["NaN", float("nan"), float("inf"), float("-inf"), "inf", "1760000000000",
"soon", True, False, None, [1], pytest.param(10 ** 400, id="10**400"),
"<missing>"])
def test_an_unusable_started_at_means_live(live, started):
from lib_py.agents.adapters import claude as claude_mod
data = {} if started == "<missing>" else {"startedAt": started}
assert claude_mod._session_file_live(data, live.pid) is True
assert claude_mod._session_file_live(data, _dead_pid()) is False, "a dead pid holds nothing"
@pytest.mark.parametrize("raw", ["NaN", '"NaN"', "Infinity", "true",
pytest.param("1" + "0" * 400, id="10**400")])
def test_a_live_holder_with_an_unusable_started_at_blocks_the_purge(home, tmp_path, conv, live, raw):
"""json.load turns a bare NaN or Infinity into a float that every
comparison fails, which once made a live holder look stale, and a huge
integer into an int no float can hold."""
ws = tmp_path / "ws"
ws.mkdir()
sfile, key = _session_file(home / ".claude", live.pid, conv)
sfile.write_text(f'{{"pid": {live.pid}, "sessionId": "{conv}", "cwd": "/x", "startedAt": {raw}}}')
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
assert adapter.conversation_in_use(conv, ctx) is True
res = _agents_cli(["in-use", "claude", conv, str(ws)], ws)
assert res.returncode == 4, res.stdout + res.stderr
assert adapter.purge_artifacts(conv, ctx) == []
assert sfile.exists() and key.exists()
def test_claude_purge_takes_the_stale_key_with_its_session_file(home, tmp_path, conv, other, live):
ws = tmp_path / "ws"
ws.mkdir()
cfg = home / ".claude"
stale = _session_file(cfg, _dead_pid(), conv)
held = _session_file(cfg, live.pid, other)
lone_key = cfg / "sessions" / f"{_dead_pid()}.{'a' * 64}.key"
lone_key.write_bytes(b"k")
purged = get_adapter("claude").purge_artifacts(conv, _ctx(ws, home))
assert not any(p.exists() for p in stale)
assert all(p.exists() for p in held)
assert lone_key.exists(), "a key with no session file of this conversation is not touched"
assert set(purged) == {str(stale[0]), _masked(stale[1])}
@pytest.mark.parametrize("project_override", [False, True])
def test_claude_config_dir_moves_the_config_root(home, tmp_path, monkeypatch, conv, live, project_override):
"""CLAUDE_CONFIG_DIR moves sessions/, file-history/, ... and projects/:
a live session there is refused, a stopped one is cleaned up. With
CLAUDE_PROJECT_DIR naming the same projects/ the result is the same."""
ws = tmp_path / "ws"
ws.mkdir()
cfg = tmp_path / "cfg"
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(cfg))
if project_override:
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(cfg / "projects"))
else:
monkeypatch.delenv("CLAUDE_PROJECT_DIR")
t = _tree(home, ws, conv, tmp_path / "ctmp", pid=live.pid, cfg=cfg)
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
assert adapter.conversation_in_use(conv, ctx) is True
assert adapter.in_use_hints(conv, ctx)[0] == f"a claude process whose session file under {cfg / 'sessions'}/ names it"
res = _agents_cli(["purge", "claude", conv, str(ws)], ws)
assert res.returncode == 4, res.stdout + res.stderr
assert all(p.exists() for p in t.values())
live.kill()
live.wait()
purged = adapter.purge_artifacts(conv, ctx)
assert not any(p.exists() for p in t.values()), {k: str(p) for k, p in t.items() if p.exists()}
assert str(t["file_history"]) in purged and str(t["jsonl"]) in purged
assert adapter.residual_artifacts(conv, ctx) == []
assert not (home / ".claude" / "file-history").exists(), "the default root is not used"
def _failing_remove(monkeypatch, victim: Path):
from lib_py.agents.adapters import claude as claude_mod
real = os.remove
def remove(path, *a, **kw):
if os.path.realpath(path) == os.path.realpath(str(victim)):
raise PermissionError(13, "denied", str(path))
return real(path, *a, **kw)
monkeypatch.setattr(claude_mod.os, "remove", remove)
def test_a_key_that_cannot_be_removed_keeps_its_session_file(home, tmp_path, monkeypatch, conv):
ws = tmp_path / "ws"
ws.mkdir()
sfile, key = _session_file(home / ".claude", _dead_pid(), conv)
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
with monkeypatch.context() as m:
_failing_remove(m, key)
purged = adapter.purge_artifacts(conv, ctx)
assert key.exists() and sfile.exists(), "the session file is what ties the key to this session"
assert purged == []
assert adapter.residual_artifacts(conv, ctx) == sorted([str(sfile), _masked(key)])
assert set(adapter.purge_artifacts(conv, ctx)) == {_masked(key), str(sfile)}
assert adapter.residual_artifacts(conv, ctx) == []
def test_a_job_dir_that_cannot_be_emptied_keeps_its_state_json(home, tmp_path, monkeypatch, conv):
ws = tmp_path / "ws"
ws.mkdir()
t = _tree(home, ws, conv, tmp_path / "ctmp")
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
with monkeypatch.context() as m:
_failing_remove(m, t["job"] / "timeline.jsonl")
purged = adapter.purge_artifacts(conv, ctx)
assert (t["job"] / "state.json").exists(), "state.json proves the dir is this session's"
assert str(t["job"]) not in purged
assert not t["file_history"].exists()
assert adapter.residual_artifacts(conv, ctx) == [str(t["job"])]
assert adapter.purge_artifacts(conv, ctx) == [str(t["job"])]
assert not t["job"].exists()
assert adapter.residual_artifacts(conv, ctx) == []
def test_claude_tmp_root_rules(tmp_path, monkeypatch, own_tmp_roots):
from lib_py.agents.adapters.claude import _tmp_roots
name = f"claude-{UID}"
x, y, z = tmp_path / "x", tmp_path / "y", tmp_path / "z"
for d in (x / name, y / name, z / name, own_tmp_roots / name):
d.mkdir(parents=True)
real = lambda roots: [os.path.realpath(r) for r in roots] # noqa: E731
sys_tmp = [os.path.realpath(str(own_tmp_roots / name))]
monkeypatch.setenv("CLAUDE_CODE_TMPDIR", str(x))
monkeypatch.setenv("TMPDIR", str(z) + "/")
assert real(_tmp_roots()) == [str(x / name), str(z / name)] + sys_tmp
# A sandboxed tool shell: CLAUDE_CODE_TMPDIR is the per-uid root itself.
monkeypatch.setenv("CLAUDE_CODE_TMPDIR", str(y / name))
assert real(_tmp_roots())[0] == str(y / name)
# Missing candidates are dropped, duplicates collapse.
monkeypatch.setenv("CLAUDE_CODE_TMPDIR", str(z))
monkeypatch.delenv("TMPDIR", raising=False)
monkeypatch.delenv("TMP", raising=False)
monkeypatch.setenv("TEMP", str(z))
assert real(_tmp_roots()) == [str(z / name)] + sys_tmp
monkeypatch.setenv("CLAUDE_CODE_TMPDIR", str(tmp_path / "absent"))
assert real(_tmp_roots()) == [str(z / name)] + sys_tmp
def test_tests_never_reach_the_real_tmp_root(tmp_path, own_tmp_roots):
from lib_py.agents.adapters.claude import _tmp_roots
assert all(os.path.realpath(r).startswith(os.path.realpath(str(tmp_path))) for r in _tmp_roots())
res = subprocess.run([sys.executable, "-c", "from lib_py.agents.adapters import claude; print(claude._SYSTEM_TMP)"],
capture_output=True, text=True, env=_env(), timeout=30)
assert res.stdout.strip() == str(own_tmp_roots), res.stdout + res.stderr
# ---------------------------------------------------------------------------
# in-use CLI and the stop gate
# ---------------------------------------------------------------------------
def test_in_use_cli_waits_then_answers(home, tmp_path, conv, live):
ws = tmp_path / "ws"
ws.mkdir()
t0 = time.monotonic()
res = _agents_cli(["in-use", "claude", conv, str(ws), "--wait", "1"], ws)
assert res.returncode == 0, res.stdout + res.stderr
assert json.loads(res.stdout) == {"agent": "claude", "in_use": False}
assert time.monotonic() - t0 < 5
_session_file(home / ".claude", live.pid, conv)
t0 = time.monotonic()
res = _agents_cli(["in-use", "claude", conv, str(ws), "--wait", "1"], ws)
assert res.returncode == 4, res.stdout + res.stderr
assert json.loads(res.stdout)["in_use"] is True
assert time.monotonic() - t0 >= 1
assert f"session file under {home / '.claude' / 'sessions'}/ names it" in res.stderr
assert f"ps -axww | grep {conv}" in res.stderr
assert _agents_cli(["in-use", "nosuchagent", conv, str(ws)], ws).returncode == 1
assert _agents_cli(["in-use", "claude"], ws).returncode == 1
def _stop_row(sandbox: Path, name: str, sid: str):
lib = sandbox / ".agents" / "skills" / "lib.sh"
yaml_path = sandbox / ".mam" / "agent-sessions.yaml"
mutation = (f"d['herdr_sessions'] = [{{'name': {name!r}, 'status': 'running', 'agent': 'claude', "
f"'claude_session_id_own': {sid!r}, 'pane': {{'cwd': {os.path.realpath(str(sandbox))!r}}}}}]\n")
res = subprocess.run(["bash", "-c", f"source {lib} && atomic_dump_yaml {yaml_path}"], input=mutation,
capture_output=True, text=True, env=_env())
assert res.returncode == 0, res.stdout + res.stderr
def _stop(sandbox: Path, name: str) -> subprocess.CompletedProcess:
script = sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
return subprocess.run(["bash", str(script), "--session", name, "--agent", "claude",
"--purge-conversation", "--yes"], capture_output=True, text=True,
env=_env(), cwd=str(sandbox), timeout=120)
def _row_left(sandbox: Path, name: str) -> tuple:
in_yaml = name in (sandbox / ".mam" / "agent-sessions.yaml").read_text()
conn = sqlite3.connect(str(sandbox / ".mam" / "agent-sessions.db"))
try:
in_db = conn.execute("SELECT COUNT(*) FROM sessions WHERE name=?", (name,)).fetchone()[0] > 0
finally:
conn.close()
return in_yaml, in_db
@pytest.fixture
def stop_box(mam_sandbox, mock_herdr, monkeypatch):
monkeypatch.setenv("CLAUDE_CODE_TMPDIR", str(mam_sandbox / "ctmp"))
return mam_sandbox
def test_stop_purge_reports_residual_and_removes_the_row(stop_box, conv):
name = "issue31-creator-claude"
t = _tree(stop_box, stop_box, conv, stop_box / "ctmp")
_stop_row(stop_box, name, conv)
assert _row_left(stop_box, name) == (True, True)
res = _stop(stop_box, name)
assert res.returncode == 0, res.stdout + res.stderr
assert "herdr already dead" in res.stdout
assert res.stdout.count("purged: ") == 8, res.stdout
assert "residual: none (agent: claude)" in res.stdout
assert not any(p.exists() for p in t.values())
assert _row_left(stop_box, name) == (False, False)
def test_stop_purge_warns_about_a_transcript_under_another_key(stop_box, conv):
name = "issue31-creator-claude"
_tree(stop_box, stop_box, conv, stop_box / "ctmp")
stray = stop_box / ".claude" / "projects" / "-elsewhere" / f"{conv}.jsonl"
stray.parent.mkdir()
stray.write_text("{}\n")
_stop_row(stop_box, name, conv)
res = _stop(stop_box, name)
assert res.returncode == 0, res.stdout + res.stderr
assert f"WARN residual: {stray}" in res.stdout
assert "residual: none" not in res.stdout
assert _row_left(stop_box, name) == (False, False)
def test_stop_purge_refuses_while_the_claude_process_lives(stop_box, conv):
name = "issue31-creator-claude"
holder = subprocess.Popen(["sleep", "120"])
try:
time.sleep(0.05)
t = _tree(stop_box, stop_box, conv, stop_box / "ctmp", pid=holder.pid)
_stop_row(stop_box, name, conv)
res = _stop(stop_box, name)
assert res.returncode == 4, res.stdout + res.stderr
assert "herdr already dead" in res.stdout
assert "records preserved" in res.stderr
assert f"session file under {stop_box / '.claude' / 'sessions'}/ names it" in res.stderr
assert f"ps -axww | grep {conv}" in res.stderr
assert "purged: " not in res.stdout
assert all(p.exists() for p in t.values())
assert _row_left(stop_box, name) == (True, True)
finally:
holder.kill()
holder.wait()
# Not a permanent block: once the process is gone the same stop purges.
res = _stop(stop_box, name)
assert res.returncode == 0, res.stdout + res.stderr
assert not any(p.exists() for p in t.values())
assert _row_left(stop_box, name) == (False, False)
@pytest.mark.parametrize("project_override", [False, True])
def test_stop_purge_follows_claude_config_dir(stop_box, conv, monkeypatch, project_override):
"""stop resolves the uuid before it purges: with CLAUDE_CONFIG_DIR alone
the transcript under <cfg>/projects must be found too, or the row goes
and every artifact stays ("nothing purged")."""
cfg = stop_box / "custom-config"
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(cfg))
if project_override:
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(cfg / "projects"))
else:
monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
name = "issue31-creator-claude"
t = _tree(stop_box, stop_box, conv, stop_box / "ctmp", cfg=cfg)
_stop_row(stop_box, name, conv)
res = _stop(stop_box, name)
assert res.returncode == 0, res.stdout + res.stderr
assert "nothing purged" not in res.stdout
assert "residual: none (agent: claude)" in res.stdout
assert not any(p.exists() for p in t.values()), {k: str(p) for k, p in t.items() if p.exists()}
assert _row_left(stop_box, name) == (False, False)
def test_discovery_context_projects_follow_claude_config_dir(tmp_path, monkeypatch):
from lib_py.agents.base import DiscoveryContext as Ctx
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "cfg"))
assert Ctx(str(tmp_path), "claude", home_dir=str(tmp_path / "h")).claude_dir == str(tmp_path / "cfg" / "projects")
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path / "p"))
assert Ctx(str(tmp_path), "claude", home_dir=str(tmp_path / "h")).claude_dir == str(tmp_path / "p")
# ---------------------------------------------------------------------------
# orc_onboard --remove --purge-conversation
# ---------------------------------------------------------------------------
def _run_onboard(args, sandbox: Path) -> subprocess.CompletedProcess:
env = _env({
"AGENT_SESSIONS_YAML": str(sandbox / ".mam" / "agent-sessions.yaml"),
"YAML_PATH": str(sandbox / ".mam" / "agent-sessions.yaml"),
"HOME_DIR": str(sandbox),
"AGENT_PYTHON_BIN": sys.executable,
"DELEGATE_JOB_PYTHON": sys.executable,
})
return subprocess.run(["bash", str(ONBOARD_SH)] + list(args), capture_output=True, text=True,
env=env, cwd=str(sandbox), timeout=120)
@pytest.mark.parametrize("stray", [False, True])
def test_orc_remove_purge_reports_claude_leftovers(stop_box, conv, stray):
t = _tree(stop_box, stop_box, conv, stop_box / "ctmp")
other_key = stop_box / ".claude" / "projects" / "-elsewhere" / f"{conv}.jsonl"
if stray:
other_key.parent.mkdir()
other_key.write_text("{}\n")
assert _run_onboard(["--uuid", conv], stop_box).returncode == 0
res = _run_onboard(["--remove", conv, "--purge-conversation", "--yes", "--agent", "claude"], stop_box)
assert res.returncode == 0, res.stdout + res.stderr
assert not any(p.exists() for p in t.values())
if stray:
assert f"WARN residual: {other_key}" in res.stdout
else:
assert "residual: none (agent: claude)" in res.stdout
# ---------------------------------------------------------------------------
# codex, grok, opencode: residual only, purge unchanged
# ---------------------------------------------------------------------------
def _db(path: Path, ddl: list, rows: list):
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(path))
for stmt in ddl:
conn.execute(stmt)
for stmt, args in rows:
conn.execute(stmt, args)
conn.commit()
conn.close()
def test_opencode_residual_reports_event_rows(home, tmp_path, monkeypatch):
u, o = f"ses_{uuidlib.uuid4().hex}", f"ses_{uuidlib.uuid4().hex}"
db = home / ".local" / "share" / "opencode" / "opencode.db"
ddl = ["CREATE TABLE session (id TEXT PRIMARY KEY)",
"CREATE TABLE message (id TEXT, session_id TEXT)",
"CREATE TABLE part (id TEXT, session_id TEXT)",
"CREATE TABLE event (id TEXT, aggregate_id TEXT)",
"CREATE TABLE event_sequence (aggregate_id TEXT, seq INTEGER)",
"CREATE TABLE todo (id TEXT, session_id TEXT)"]
rows = []
for s in (u, o):
rows += [("INSERT INTO session VALUES (?)", (s,)),
("INSERT INTO message VALUES ('m', ?)", (s,)),
("INSERT INTO part VALUES ('p', ?)", (s,)),
("INSERT INTO event VALUES ('e1', ?)", (s,)),
("INSERT INTO event VALUES ('e2', ?)", (s,)),
("INSERT INTO event_sequence VALUES (?, 2)", (s,)),
("INSERT INTO todo VALUES ('t', ?)", (s,))]
_db(db, ddl, rows)
adapter = get_adapter("opencode")
monkeypatch.setattr(adapter, "_resolve_db", lambda ctx: str(db)) # never run a real opencode
ctx = DiscoveryContext(workspace=str(tmp_path), agent_name="opencode", home_dir=str(home))
adapter.purge_artifacts(u, ctx)
assert adapter.residual_artifacts(u, ctx) == [f"{db} (event.aggregate_id: 2 rows)",
f"{db} (event_sequence.aggregate_id: 1 rows)",
f"{db} (todo.session_id: 1 rows)"]
def test_codex_residual_reports_name_and_thread_rows(home, tmp_path, monkeypatch, conv, other):
codex_home = tmp_path / "codex"
monkeypatch.setenv("CODEX_HOME", str(codex_home))
rollout = codex_home / "sessions" / "2026" / "10" / "15" / f"rollout-2026-10-15T00-00-00-{conv}.jsonl"
rollout.parent.mkdir(parents=True)
rollout.write_text("{}\n")
marker = codex_home / "tui-thread-reference-capabilities" / conv
marker.parent.mkdir()
marker.write_text("")
(codex_home / "tui-thread-reference-capabilities" / other).write_text("")
state = codex_home / "state_5.sqlite"
_db(state, ["CREATE TABLE threads (id TEXT PRIMARY KEY)"],
[("INSERT INTO threads VALUES (?)", (conv,)), ("INSERT INTO threads VALUES (?)", (other,))])
hist = codex_home / "thread_history_1.sqlite"
_db(hist, ["CREATE TABLE thread_items (thread_id TEXT, n INTEGER)"],
[("INSERT INTO thread_items VALUES (?, 1)", (conv,)), ("INSERT INTO thread_items VALUES (?, 2)", (conv,))])
ctx = DiscoveryContext(workspace=str(tmp_path), agent_name="codex", home_dir=str(home))
# purge is not run: it would execute the real `codex delete` on PATH.
residual = get_adapter("codex").residual_artifacts(conv, ctx)
assert residual == sorted([str(marker), str(rollout)]) + [
f"{state} (threads.id: 1 rows)", f"{hist} (thread_items.thread_id: 2 rows)"]
def test_grok_residual_reports_memory_observations(home, tmp_path, conv, other):
from urllib.parse import quote
ws = tmp_path / "ws"
ws.mkdir()
grok = home / ".grok"
sess = grok / "sessions" / quote(os.path.realpath(str(ws)), safe="") / conv
sess.mkdir(parents=True)
(sess / "chat_history.jsonl").write_text(json.dumps({"session_id": conv}) + "\n")
inbox = grok / "memory-v2" / "workspaces" / "w" / "observations" / "_inbox"
inbox.mkdir(parents=True)
mine = inbox / f"{conv}__t1.md"
mine.write_text("obs")
(inbox / f"{other}__t1.md").write_text("obs")
adapter = get_adapter("grok")
ctx = DiscoveryContext(workspace=str(ws), agent_name="grok", home_dir=str(home))
adapter.purge_artifacts(conv, ctx)
assert not sess.exists()
assert adapter.residual_artifacts(conv, ctx) == [str(mine)]