feat(lib): implement A-4 M0~M1 BaseAgentAdapter pattern and resolve_home contract
This commit is contained in:
@@ -0,0 +1,242 @@
|
|||||||
|
# A-4 `BaseAgentAdapter` — 미해결 현황 분석 · 해결 계획 · 교차 리뷰 (Rev.2)
|
||||||
|
|
||||||
|
- **job_id**: `f2bd7e13` (Rev.1 = `5af41284`)
|
||||||
|
- **역할**: Planner
|
||||||
|
- **반영한 이의제기**: `50fdb719` (agy, `herdr:agy-creator-01`) — `[VERDICT: PASS WITH CHALLENGE]`
|
||||||
|
- **기준 커밋**: `e10db89` (HEAD)
|
||||||
|
- **실측 하네스**:
|
||||||
|
- `.mam/jobs/5af41284/claude-reports/proposed/probe_a4_status.sh` (Rev.1, 유효)
|
||||||
|
- `.mam/jobs/f2bd7e13/claude-reports/proposed/probe_direct_call_contract.sh` (Rev.2 신규, 실행·검증 완료)
|
||||||
|
- **회귀 기준선**: `tier1 + sanity + workspace_scope + uuid_target` = **46 passed**
|
||||||
|
- **저장소 변경**: 없음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Rev.1 대비 변경 요약
|
||||||
|
|
||||||
|
agy 의 3개 주장은 **메커니즘이 전부 사실**이다. 다만 그중 둘은 **영향 범위 서술이 실측과 다르며**, 그 차이가 우선순위를 바꾼다.
|
||||||
|
|
||||||
|
| 이의제기 항목 | 메커니즘 | 영향 서술 | Rev.2 반영 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| §1 `HOME_DIR` 폴백 → Silent Fail-Close | **SUSTAINED** (재현) | **정정**: 오늘은 도달 불가 — 어댑터가 활성화시키는 잠복 결함 | N0 신설, Phase 1 **선행 조건** |
|
||||||
|
| §2.1 `load_state_json` 서브셸 포크 | **SUSTAINED** (70ms 실측) | **OVERRULED**: watchdog 매 주기가 아니라 `status.sh` 1회 | N7 신설, **P3** |
|
||||||
|
| §2.2 `python -m` 브리지 오버헤드 | **SUSTAINED** (실측 완료) | 절대값 정정 (25ms → 15.0/18.7ms) | 계약 유지, §6.4 개방 항목 종결 |
|
||||||
|
|
||||||
|
**추가 발견 (agy 가 짚지 않은 것)**: `lib_py` 세 모듈의 `HOME_DIR` 해석 방식이 **서로 다르다.** 한쪽은 조용히 실패하고 다른 쪽은 시끄럽게 실패한다. 따라서 수정은 "`verify_session.py` 한 줄 고치기"가 아니라 **계약 통일**이어야 한다(§1.3).
|
||||||
|
|
||||||
|
Rev.1 의 결론(A-4 착수, `lib_py/agents/` 채택, G1 선행)은 바뀌지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 이의제기 판정
|
||||||
|
|
||||||
|
### 1.1 Primary — `HOME_DIR` 미주입 시 Silent Fail-Close: **SUSTAINED**
|
||||||
|
|
||||||
|
재현했다. `HOME_DIR` 없이 직접 호출하면:
|
||||||
|
|
||||||
|
```
|
||||||
|
HOME_DIR present : False
|
||||||
|
home : ''
|
||||||
|
agy 경로 : /.gemini/antigravity-cli/conversations
|
||||||
|
cline 경로 : /.cline/data/sessions
|
||||||
|
verify(agy) : False
|
||||||
|
```
|
||||||
|
|
||||||
|
`verify_session.py:84` 가 `home = home_dir or os.environ.get("HOME_DIR", "")` 이므로 `home` 이 빈 문자열이 되고, 모든 아티팩트 경로가 사용자 홈이 아니라 **시스템 루트**에 조립된다. `os.path.exists()` 가 전부 `False` 를 반환하니 **어떤 세션도 검증을 통과하지 못한다.** agy 의 서술 그대로다.
|
||||||
|
|
||||||
|
> **인용 정정**: agy 는 line 110 이라 했으나 HEAD 기준 **line 84** 다. 코드는 인용된 것과 동일하다.
|
||||||
|
|
||||||
|
### 1.2 정정 — 이 결함은 *오늘* 도달 가능하지 않다
|
||||||
|
|
||||||
|
agy 는 이를 P0 로 제시했다. 현 호출자를 전수 확인한 결과 **오늘은 트리거가 없다**:
|
||||||
|
|
||||||
|
```
|
||||||
|
(a) lib.sh 의 HOME_DIR 주입 지점 : env_python(1030) · atomic_dump_yaml(1092) 둘 다 주입
|
||||||
|
(b) 파이썬에서 직접 import 하는 저장소 코드 : (없음)
|
||||||
|
(c) 테스트의 호출 방식 : 전부 bash -c "source lib.sh && …" 경유
|
||||||
|
```
|
||||||
|
|
||||||
|
`lib_py` 를 파이썬에서 직접 import 하는 코드는 **`lib_py` 자기 자신(`workspace_uuid.py` → `verify_session.py`)뿐**이고, 그 경로는 이미 `env_python` 안에서 실행되므로 `HOME_DIR` 이 있다. 테스트도 전부 셸을 경유한다.
|
||||||
|
|
||||||
|
**따라서 정확한 성격은 이렇다: 오늘은 무해하지만, 내 Rev.1 Phase 1(N4/N5, `lib_py/agents/` 어댑터)이 착수되는 순간 활성화되는 잠복 결함이다.** 어댑터는 정의상 파이썬에서 직접 호출되기 때문이다.
|
||||||
|
|
||||||
|
이 구분이 중요한 이유는 우선순위 때문이다. Rev.1 의 G1(파사드 빈 문자열)은 **부분 배포만으로 오늘 발생**한다. 이 건은 **내가 코드를 추가해야 발생**한다. 둘을 같은 P0 로 묶으면 순서를 정할 수 없다. 그래서 이 건은 **P0 가 아니라 Phase 1 의 선행 조건(N0)** 으로 배치한다 — 실질적으로는 "어댑터보다 먼저 해야 한다"는 같은 결론이지만, G1 보다 뒤라는 점이 분명해진다.
|
||||||
|
|
||||||
|
### 1.3 추가 발견 — 세 모듈의 실패 방식이 다르다
|
||||||
|
|
||||||
|
agy 는 `verify_session.py` 만 지적했다. 세 모듈을 전부 보면 계약이 하나가 아니다:
|
||||||
|
|
||||||
|
```
|
||||||
|
verify_session.py get() → 조용한 기본값 84: home = home_dir or os.environ.get("HOME_DIR", "")
|
||||||
|
workspace_uuid.py environ[] → KeyError (시끄러움) 21: home = os.environ['HOME_DIR']
|
||||||
|
atomic_yaml.py HOME_DIR 미사용
|
||||||
|
```
|
||||||
|
|
||||||
|
**`workspace_uuid.py` 는 이미 올바르게 행동한다** — 계약이 깨지면 `KeyError` 로 즉시 죽는다. `verify_session.py` 만 조용히 잘못된 답을 낸다.
|
||||||
|
|
||||||
|
그러므로 수정 방향은 agy 가 제안한 "`verify_session.py` 한 줄 교체"보다 넓어야 한다: **`lib_py` 전체에 하나의 `HOME_DIR` 해석 계약을 두고 세 모듈이 그것을 쓰게 한다.**
|
||||||
|
|
||||||
|
agy 가 제안한 폴백 체인 자체는 채택한다:
|
||||||
|
|
||||||
|
```python
|
||||||
|
home = home_dir or os.environ.get("HOME_DIR") or os.environ.get("HOME") or os.path.expanduser("~")
|
||||||
|
```
|
||||||
|
|
||||||
|
`$HOME` 으로 떨어지는 것이 격리 계약을 해치지 않는지 확인했다 — `lib.sh:41` 이 `HOME_DIR="${HOME_DIR:-$HOME}"` 이므로 둘은 기본적으로 같은 값이고, 격리는 `HOME_DIR` 이 아니라 **`iso_root`** 로 수행된다(`f"{iso_root or home}/…"`). 따라서 폴백이 다른 워크스페이스의 저장소를 읽게 만들지 않는다.
|
||||||
|
|
||||||
|
다만 **마지막에 빈 값이 남으면 예외를 던져야 한다.** `expanduser("~")` 까지 실패하는 환경(HOME 없는 컨테이너)에서 다시 `""` 로 떨어지면 같은 결함이 재발한다.
|
||||||
|
|
||||||
|
### 1.4 §2.1 서브셸 포크 — 메커니즘 SUSTAINED, 영향 OVERRULED
|
||||||
|
|
||||||
|
코드는 agy 가 말한 자리에 있고, 비용도 실측했다:
|
||||||
|
|
||||||
|
```
|
||||||
|
reconcile.sh:350 script = f"source '{lib_sh}' && load_state_json"
|
||||||
|
포크 비용 n=5 median=70 ms (min 68 / max 74)
|
||||||
|
```
|
||||||
|
|
||||||
|
**그러나 이 포크는 watchdog 경로에서 일어나지 않는다.** 해당 블록은 이런 가드 안에 있다:
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
d
|
||||||
|
except NameError:
|
||||||
|
…subprocess…
|
||||||
|
```
|
||||||
|
|
||||||
|
그리고 `atomic_dump_yaml` 은 `d` 를 **미리 정의한다**(`lib_py/atomic_yaml.py:97/101/103). `reconcile.sh:861-865` 의 분기를 보면:
|
||||||
|
|
||||||
|
```
|
||||||
|
DRY_RUN=1 → env_python → d 없음 → bash 포크 (70ms)
|
||||||
|
DRY_RUN=0 → atomic_dump_yaml → d 있음 → 포크 없음
|
||||||
|
```
|
||||||
|
|
||||||
|
그리고 실제 소비자는:
|
||||||
|
|
||||||
|
```
|
||||||
|
create_session.sh:389 reconcile.sh --once (쓰기 → 포크 없음)
|
||||||
|
lib.sh:1395 reconcile.sh --subscribe (쓰기 → 포크 없음)
|
||||||
|
status.sh:19 reconcile.sh --dry-run (읽기 → 포크 발생)
|
||||||
|
```
|
||||||
|
|
||||||
|
**즉 백그라운드 watchdog 은 포크하지 않는다.** 포크가 일어나는 유일한 소비자는 `status.sh` — 사용자가 직접 실행하는 상태 조회 명령이다.
|
||||||
|
|
||||||
|
agy 의 서술("모니터 스위프 시 … 매 주기 발생 … 백그라운드 watchdog의 응답 지연과 CPU 자원 낭비")은 성립하지 않는다. 실제 성격은 **대화형 `status.sh` 1회당 70ms** 이다. 고칠 가치는 있으나(대화형 명령에서 70ms 는 체감된다) **P3 이며, A-4 의 선행 조건이 아니다.**
|
||||||
|
|
||||||
|
### 1.5 §2.2 브리지 오버헤드 — SUSTAINED, 절대값 정정
|
||||||
|
|
||||||
|
Rev.1 §6.4 에서 "재측정하지 않았다"고 남긴 개방 항목을 이번에 닫았다:
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m (venv) : 15.0 ms
|
||||||
|
python -m (system) : 18.7 ms
|
||||||
|
bash case (기준선) : 2.8 ms
|
||||||
|
```
|
||||||
|
|
||||||
|
설계 문서의 22.8ms 와 agy 의 ~25ms 는 이 환경에서 **다소 비관적**이다(15.0/18.7ms). 그러나 **bash `case` 대비 5.4~6.7배**라는 관계는 그대로이므로, **"스크립트당 1회 호출 후 `eval`" 계약은 변경 없이 유효하다.** 분기마다 호출하면 안 된다는 결론이 절대값이 아니라 배수에서 나오기 때문이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 현황 (Rev.1 에서 변경 없음 — 요약)
|
||||||
|
|
||||||
|
`e10db89` 이후 상태와 미해결 3건(G1/G2/G3)은 이의제기의 영향을 받지 않았다. 전문은 `5af41284` 보고서를 참조하고 결론만 옮긴다.
|
||||||
|
|
||||||
|
- **G1 (P0)**: `VERIFY_SESSION_PYTHON` 파사드가 파일 유실 시 조용히 빈 문자열 → `reconcile.sh` 만 `NameError` 로 죽음. **오늘 도달 가능.**
|
||||||
|
- **G2**: 팬아웃 39 → 44 (**+5**). 증가분이 전부 정상적인 결함 수정(F4/F6)에서 나왔다 — 추상화 없이 고칠수록 사본이 는다.
|
||||||
|
- **G3**: A-4 M0 체크리스트가 `mam_agents` 가 아니라 `lib_py` 에 대해 이미 충족. `lib_py/agents/` 채택 권고, CI `py_compile` 재귀 교정 필수.
|
||||||
|
|
||||||
|
**신규 (이의제기 반영):**
|
||||||
|
|
||||||
|
- **G4**: `lib_py` 의 `HOME_DIR` 해석 계약이 모듈마다 다르고, `verify_session.py` 는 조용히 실패한다. **Phase 1 선행 조건.**
|
||||||
|
- **G5**: `status.sh --dry-run` 경로에 70ms 서브셸 포크. **P3.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 실행 계획 (Rev.2)
|
||||||
|
|
||||||
|
### Phase 0 — 즉시 (A-4 와 독립)
|
||||||
|
|
||||||
|
**N1. 파사드 폴백을 시끄럽게** — G1, **P0**. 오늘 도달 가능한 유일한 건.
|
||||||
|
**N2. `reconcile.sh` 를 import 로 전환** — `98393a97` D2b-③ 잔여. 완료 시 파사드 삭제 가능(N1 무의미화). **착수 전 §4.3 전수 조사 필요.**
|
||||||
|
**N3. CI `py_compile` 재귀 교정** — G3. 어댑터 도입 **전에** 해야 신규 파일이 처음부터 검사된다.
|
||||||
|
|
||||||
|
### Phase 1 — A-4 M0/M1
|
||||||
|
|
||||||
|
**N0. `lib_py` `HOME_DIR` 해석 계약 통일** — G4, ← *이의제기 §1 반영, 신규.* **N4 의 선행 조건.**
|
||||||
|
`lib_py/paths.py`(또는 `verify_session.py` 내 공용 헬퍼)에 단일 해석 함수를 두고 세 모듈이 그것만 쓰게 한다:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def resolve_home(home_dir=None):
|
||||||
|
h = home_dir or os.environ.get("HOME_DIR") or os.environ.get("HOME") or os.path.expanduser("~")
|
||||||
|
if not h or h == "/":
|
||||||
|
raise ValueError("HOME_DIR unresolvable — refusing to build paths from the filesystem root")
|
||||||
|
return h
|
||||||
|
```
|
||||||
|
|
||||||
|
`workspace_uuid.py:21` 의 `os.environ['HOME_DIR']` 도 이 함수로 교체한다 — 지금은 우연히 올바르게 시끄럽지만, 계약이 두 벌인 상태를 남기지 않는다.
|
||||||
|
*회귀 테스트*: `env -u HOME_DIR` 로 `verify_session_uuid` 를 직접 호출해 **`False` 가 아니라 정상 동작**하는지, 그리고 `HOME`/`HOME_DIR` 둘 다 없을 때 **`ValueError` 로 죽는지**. 현재 이 경로를 검증하는 테스트는 0개다(§4.2).
|
||||||
|
|
||||||
|
**N4. `lib_py/agents/` 골격** — `base.py` · `registry.py` · `__main__.py` · `adapters/{claude,agy,hermes,cline}.py`. `mam_agents/` 신설안 폐기.
|
||||||
|
**셸 브리지 계약을 명시한다**(§1.5 실측 근거): 스크립트 진입 시 `eval "$(python -m lib_py.agents facts <agent>)"` **1회**. 루프 안 호출 금지. 15.0ms × 분기 수는 bash `case` 2.8ms 대비 즉시 손해다.
|
||||||
|
|
||||||
|
**N5. M1 `own_key` / `agent_of_row` 이관** — `a4589a4b` W7/W8d 흡수. 우선순위 ①명시인자 →②레지스트리 row →③이름 접미사 →④`pane.cmd` →⑤실패는 실패. **단 `reconcile.sh` 입양 루프에는 ④ 를 적용하지 않는다**(`3aee63cf` §1.2 실측 반증).
|
||||||
|
|
||||||
|
### Phase 2 — A-4 M2~M7
|
||||||
|
|
||||||
|
설계 문서 단계를 따르되 M2 는 내용이 이미 landing 되었으므로 **이관만** 수행. M4 는 hermes DB 스키마 실측 후 착수(§4.1).
|
||||||
|
|
||||||
|
### Phase 3 — 공백 보충 및 성능
|
||||||
|
|
||||||
|
**N6. 입양 소유권 마커** — `3aee63cf` W8a/W8b. A-4 에 대응 항목 없음.
|
||||||
|
**N7. `load_state_json` 파이썬 경로 제공** — G5, ← *이의제기 §2.1 반영, 신규.* **P3.**
|
||||||
|
`lib_py/state.py` 에 `load_state_json()` 을 두고 `reconcile.sh` 의 dry-run 폴백이 `bash -c` 대신 이를 직접 부르게 한다. 대상은 `status.sh` 체감 지연 70ms 이며, **watchdog 성능과는 무관하다**(§1.4).
|
||||||
|
이는 `6481e5b4` 의 "소형 블록 4개는 옮기지 않는다" 결정에 대한 **부분 예외**다 — `load_state_json`(39줄)은 그 결정 당시 "정적 검사 가치가 낮다"는 이유로 제외했으나, 여기서는 **성능**이라는 다른 근거가 생겼다. 근거가 바뀌었으므로 결정도 바뀐다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 검증
|
||||||
|
|
||||||
|
**팬아웃 게이트** (Rev.1 에서 유지):
|
||||||
|
|
||||||
|
```
|
||||||
|
현재 44 → M1 이후 ≤38 → M2 이후 ≤30 → 최종 상한 약 15
|
||||||
|
```
|
||||||
|
|
||||||
|
각 단계마다 `probe_a4_status.sh` 재실행, S1 단조 감소 확인. 회귀는 **46 passed** 기준선.
|
||||||
|
|
||||||
|
**신규 게이트:**
|
||||||
|
- **N0**: `env -u HOME_DIR` 직접 호출이 정상 동작 + `HOME`/`HOME_DIR` 부재 시 `ValueError`. `probe_direct_call_contract.sh` D1/D3 이 그대로 게이트다.
|
||||||
|
- **N1/N2**: `lib_py/verify_session.py` 를 지운 사본에서 `reconcile.sh --once` 가 **0 아닌 종료코드**. `lib.sh` 단위 테스트로는 잡히지 않는다.
|
||||||
|
- **N4**: 브리지 호출 횟수가 스크립트당 1회인지 — `python -m lib_py.agents` 호출을 세는 정적 검사.
|
||||||
|
- **N7**: `status.sh` 실행 시간 전후 비교(현재 포크분 70ms).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 리스크 및 미측정 항목
|
||||||
|
|
||||||
|
**5.1 hermes 미설치 — 변화 없음.** `lib_py/verify_session.py` 의 `SELECT cwd FROM sessions WHERE id=?` 는 `sessions` 테이블에 `cwd` 컬럼이 있다는 **추론**에 근거하며 실행 확인되지 않았다. 컬럼이 없으면 `sqlite3.OperationalError` 가 `except Exception: return False` 에 삼켜져 **모든 hermes 세션이 조용히 검증 실패**한다 — G4 와 정확히 같은 종류의 침묵이다. M4 전에 확인할 것.
|
||||||
|
|
||||||
|
**5.2 직접 호출 경로를 검증하는 테스트가 0개다.** §1.2 에서 확인했듯 모든 테스트가 셸을 경유한다. 그래서 G4 가 머지될 때까지 아무도 몰랐다. **N0 는 수정과 함께 직접 호출 테스트를 반드시 추가해야 한다** — 그러지 않으면 어댑터 도입 후 같은 종류의 결함이 또 잠복한다.
|
||||||
|
|
||||||
|
**5.3 N2 착수 전 `reconcile.sh` 의 `lib.sh` 의존 전수 조사가 여전히 미이행이다.** `98393a97` §6.1 → `5af41284` §6.3 → 지금까지 세 번째로 남기는 권고다. `MAM_VERIFY_PY` 는 전수 grep 했으나 다른 셸 변수·함수 의존은 조사하지 않았다. **이번 이의제기(§2.1)가 정확히 그 미조사 영역에서 나왔다** — 조사했다면 내가 먼저 찾았을 것이다.
|
||||||
|
|
||||||
|
**5.4 §1.5 의 브리지 측정은 최소 스텁 기준이다.** 실제 어댑터는 `registry.py` + 4개 어댑터 모듈을 import 하므로 15.0ms 보다 느려진다. N4 착수 시 **실제 패키지로 재측정**할 것. 배수가 커지면 `eval` 1회 계약이 더 중요해질 뿐 방향은 같다.
|
||||||
|
|
||||||
|
**5.5 G2 지표는 독립 구현이다.** A-4 문서의 34 와 이 보고서의 44 를 직접 빼서 쓰면 안 된다. **delta(+5)가 근거다.**
|
||||||
|
|
||||||
|
**5.6 `lib_py/agents/` 채택은 되돌리기 쉽다.** `mam_agents/` 로 되돌리는 비용은 `remove.sh` 1줄 + CI 1줄. 이 결정에서 막히지 말 것.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 판정
|
||||||
|
|
||||||
|
이의제기 3건의 메커니즘을 전부 확인해 수용했고, 그중 2건의 영향 서술을 실측으로 정정했다. 정정이 우선순위를 바꾼다 — `HOME_DIR` 건은 P0 가 아니라 **Phase 1 선행 조건**이고, 서브셸 포크는 watchdog 병목이 아니라 **`status.sh` 의 P3 개선**이다. 그리고 agy 가 짚지 않은 **모듈 간 계약 불일치(G4)** 를 추가로 발견해 수정 범위를 넓혔다.
|
||||||
|
|
||||||
|
**[ADJUDICATION: SUSTAINED]** — `50fdb719` §1 (`HOME_DIR` 폴백), §2.1 (서브셸 포크 존재), §2.2 (브리지 오버헤드)
|
||||||
|
**[ADJUDICATION: OVERRULED]** — `50fdb719` §2.1 의 영향 서술 (watchdog 매 주기 병목) — 쓰기 경로는 포크하지 않음을 실측
|
||||||
|
|
||||||
|
**결론: A-4 착수 유지.** 순서는 N1(P0) → N2 → N3 → **N0(신규 선행)** → N4 → N5 → M2~M7 → N6/N7.
|
||||||
|
|
||||||
|
**[VERDICT: PASS]**
|
||||||
|
|
||||||
|
*(이 토큰은 이 계획서 산출물의 완성도를 뜻한다. 감사 대상 코드에 대한 판정이 아니다 — 감사 결과는 **P0 1건(G1) 미해결 + 잠복 1건(G4)** 이다.)*
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# Cross-Code Review: A-4 BaseAgentAdapter Introduction & Agent Knowledge Abstraction
|
||||||
|
|
||||||
|
**Job ID**: 98e3986f
|
||||||
|
**Reviewer**: cline
|
||||||
|
**Date**: 2026-08-14
|
||||||
|
**Scope**: A-4 BaseAgentAdapter 도입 및 에이전트 지식 추상화 — lint, operability, and loss cross-review
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Change Summary
|
||||||
|
|
||||||
|
### Modified Files (5)
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `lib.sh:1120-1125` | F-1 fix: `VERIFY_SESSION_PYTHON` facade now fails explicitly (`echo ERROR + return/exit 1`) instead of silent empty string |
|
||||||
|
| `lib_py/verify_session.py:82-86` | `home = home_dir or os.environ.get("HOME_DIR", "")` → `home = resolve_home(home_dir)` |
|
||||||
|
| `lib_py/workspace_uuid.py:18-22` | `home = os.environ['HOME_DIR']` → `home = resolve_home()` |
|
||||||
|
| `reconcile.sh:323,344-358,863-868` | Replaces `exec(os.environ['MAM_VERIFY_PY'])` with direct `from lib_py.verify_session import ...`; replaces subprocess `load_state_json` with `from lib_py.state import load_state_json` (with subprocess fallback); removes `MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON"` from both dry-run and write paths |
|
||||||
|
| `deploy/gitea-ci.yml:76-78` | `py_compile` glob changed from flat `lib_py/*.py` to recursive `lib_py/**/*.py` to catch new subdirectories |
|
||||||
|
|
||||||
|
### New Files (13)
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `lib_py/paths.py` | `resolve_home()` — unified HOME_DIR resolution contract (N0): explicit arg → HOME_DIR env → HOME env → expanduser('~'); raises ValueError on empty/root |
|
||||||
|
| `lib_py/state.py` | `load_state_json()` — Python direct state loader for agent-sessions.yaml/.db (N7); faithful extraction of shell `load_state_json` |
|
||||||
|
| `lib_py/agents/__init__.py` | Package init |
|
||||||
|
| `lib_py/agents/base.py` | `BaseAgentAdapter` abstract base, `SpawnSpec`, `DiscoveryContext` (N4) |
|
||||||
|
| `lib_py/agents/registry.py` | Static adapter registry: `get_adapter()`, `own_key()`, `agent_of_row()` (N4 & N5) |
|
||||||
|
| `lib_py/agents/__main__.py` | CLI bridge: `python -m lib_py.agents <facts|resolve>` |
|
||||||
|
| `lib_py/agents/adapters/__init__.py` | Adapters package init |
|
||||||
|
| `lib_py/agents/adapters/claude.py` | `ClaudeAgentAdapter` — `own_key = claude_session_id_own` |
|
||||||
|
| `lib_py/agents/adapters/agy.py` | `AgyAgentAdapter` — `own_key = agy_conversation_id_own` |
|
||||||
|
| `lib_py/agents/adapters/hermes.py` | `HermesAgentAdapter` — `own_key = hermes_conversation_id_own` |
|
||||||
|
| `lib_py/agents/adapters/cline.py` | `ClineAgentAdapter` — `own_key = cline_conversation_id_own` |
|
||||||
|
| `tests/test_a4_adapter_contract.py` | 3 contract tests: `resolve_home` contract, adapter registry, `agent_of_row` priority |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Syntax & Compilation Checks
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|-------|--------|
|
||||||
|
| `bash -n lib.sh` | ✅ PASS |
|
||||||
|
| `bash -n reconcile.sh` | ✅ PASS |
|
||||||
|
| `py_compile` all `lib_py/**/*.py` (recursive, 11 files) | ✅ PASS |
|
||||||
|
| `py_compile tests/test_a4_adapter_contract.py` | ✅ PASS |
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Import & Module Verification
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|-------|--------|
|
||||||
|
| `from lib_py.paths import resolve_home` | ✅ OK |
|
||||||
|
| `from lib_py.state import load_state_json` | ✅ OK |
|
||||||
|
| `from lib_py.agents.registry import get_adapter, own_key, agent_of_row` | ✅ OK |
|
||||||
|
| `from lib_py.agents.base import BaseAgentAdapter, SpawnSpec, DiscoveryContext` | ✅ OK |
|
||||||
|
| All 4 adapter imports (claude, agy, hermes, cline) | ✅ OK |
|
||||||
|
| Circular import check | ✅ None — `base.py` → `paths`, `verify_session`; `registry.py` → `base` + adapters; adapters → `base` only |
|
||||||
|
|
||||||
|
### CLI Bridge Verification
|
||||||
|
```
|
||||||
|
$ python -m lib_py.agents facts claude
|
||||||
|
AGENT_NAME=claude
|
||||||
|
OWN_KEY=claude_session_id_own
|
||||||
|
|
||||||
|
$ python -m lib_py.agents resolve my-workspace-creator-hermes
|
||||||
|
hermes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registry Edge Cases
|
||||||
|
- `own_key('unknown')` → `None` ✅
|
||||||
|
- `own_key('')` → `None` ✅
|
||||||
|
- `get_adapter('CLAUDE')` → case-insensitive → `claude` ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Test Results
|
||||||
|
|
||||||
|
| Test Suite | Tests | Result |
|
||||||
|
|------------|-------|--------|
|
||||||
|
| `test_a4_adapter_contract.py` (new) | 3 | ✅ All PASS |
|
||||||
|
| `test_sanity.py` | 2 | ✅ All PASS |
|
||||||
|
| `test_tier1_unit.py` | 29 | ✅ All PASS |
|
||||||
|
| `test_orc_onboard.py` | 40 | ✅ All PASS |
|
||||||
|
| **Total** | **74** | **✅ All PASS** |
|
||||||
|
|
||||||
|
New contract tests verify:
|
||||||
|
1. `resolve_home` contract: explicit arg, HOME_DIR env, HOME/expanduser fallback
|
||||||
|
2. Adapter registry: all 4 agents have correct `name` and `own_key` (claude → `session`, others → `conversation`)
|
||||||
|
3. `agent_of_row` priority: explicit `agent` field > session name suffix > pane.cmd match
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Design Analysis
|
||||||
|
|
||||||
|
### 5.1 `resolve_home()` Contract (N0)
|
||||||
|
Well-designed unified home resolution with fail-close semantics:
|
||||||
|
- **Priority chain**: explicit arg → `HOME_DIR` env → `HOME` env → `expanduser('~')`
|
||||||
|
- **Fail-close**: raises `ValueError` if result is empty or `/` — prevents root-relative silent fail-close
|
||||||
|
- **Behavioral change in `verify_session.py`**: Old code `home_dir or os.environ.get("HOME_DIR", "")` silently returned `""` → now raises `ValueError`. This is the intended fix — callers that previously got `""` would build paths like `/.claude/projects` (root-relative), which is a silent failure mode.
|
||||||
|
- **Behavioral improvement in `workspace_uuid.py`**: Old code `os.environ['HOME_DIR']` raised `KeyError` on missing env → now falls back to `HOME`/`expanduser`. More robust.
|
||||||
|
|
||||||
|
### 5.2 BaseAgentAdapter & Registry (N4 & N5)
|
||||||
|
Clean OOP design:
|
||||||
|
- `BaseAgentAdapter` defines `name`, `own_key` (abstract properties), `derive_session_name()`, `matches_session_name()`, `verify_session()`
|
||||||
|
- `matches_session_name()` checks `-{role}-{name}` suffix (creator/planner/reviewer) or bare `-{name}` — matches the kind detection logic in `lib.sh`
|
||||||
|
- `derive_session_name()` returns `f"{slug}-{role}-{name}"` — matches the shell `derive_session_name` function
|
||||||
|
- `agent_of_row()` implements strict priority: explicit `agent` field → session name suffix → `pane.cmd` exact/binary-path match → None
|
||||||
|
- Static registry with 4 singleton adapter instances — no dynamic registration needed
|
||||||
|
|
||||||
|
### 5.3 `state.py` Extraction (N7)
|
||||||
|
Faithful extraction of shell `load_state_json` (lib.sh:828-869):
|
||||||
|
- Identical DB-first, YAML-fallback logic
|
||||||
|
- Identical `clean_surrogates` recursive cleaner
|
||||||
|
- Identical `sqlite3.connect(timeout=60.0)` + `PRAGMA busy_timeout = 60000`
|
||||||
|
- **Enhancement**: accepts `yaml_path` parameter with env var fallback (`AGENT_SESSIONS_YAML` or `YAML_PATH`) — more flexible than shell version which always gets `YAML_PATH` from `env_python`
|
||||||
|
- **Difference**: returns dict directly instead of printing JSON to stdout — correct for in-process use
|
||||||
|
|
||||||
|
### 5.4 `reconcile.sh` Decoupling
|
||||||
|
- `exec(os.environ['MAM_VERIFY_PY'])` → `from lib_py.verify_session import verify_session_uuid, workspace_key` — eliminates the env-var code injection pattern
|
||||||
|
- `MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON"` removed from both dry-run and write paths — no more env-var coupling
|
||||||
|
- `load_state_json` now tries direct Python import first, falls back to subprocess — performance improvement with safe fallback
|
||||||
|
|
||||||
|
### 5.5 CI glob fix (gitea-ci.yml)
|
||||||
|
Old: `py_compile .agents/skills/lib_py/*.py` (flat — misses `lib_py/agents/**/*.py`)
|
||||||
|
New: `glob.glob('.agents/skills/lib_py/**/*.py', recursive=True)` — correctly catches all nested Python files. **Necessary change.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Findings
|
||||||
|
|
||||||
|
### F-1 (Low): Unused imports in `base.py` and `__main__.py`
|
||||||
|
- `base.py:3`: `import os, json, sqlite3` — none used in the file body
|
||||||
|
- `base.py:4`: `from typing import Optional, Dict, Any, List` — `List` unused
|
||||||
|
- `__main__.py:3`: `import sys, json` — `json` unused
|
||||||
|
- **Impact**: Dead imports; would be flagged by flake8 F401. No runtime impact.
|
||||||
|
- **Recommendation**: Remove unused imports for cleanliness.
|
||||||
|
|
||||||
|
### F-2 (Low): `VERIFY_SESSION_PYTHON` is now a dead variable
|
||||||
|
- `lib.sh:1121` defines `VERIFY_SESSION_PYTHON="$(cat ...)"` but no consumer remains — `reconcile.sh` was the only consumer (via `MAM_VERIFY_PY`), now removed.
|
||||||
|
- The `if-else` block still serves as a **file existence guard** (else branch fails explicitly), but the variable assignment itself is dead code.
|
||||||
|
- **Impact**: None at runtime — the guard is useful, the variable is harmless dead code.
|
||||||
|
- **Recommendation**: Could simplify to a pure existence check, but keeping it documents the historical facade pattern. Acceptable as-is.
|
||||||
|
|
||||||
|
### F-3 (Low): `reconcile.sh:326` still uses `os.environ['HOME_DIR']` directly
|
||||||
|
- Line 326: `home = os.environ['HOME_DIR']` — not changed to `resolve_home()`
|
||||||
|
- `reconcile.sh` always sets `HOME_DIR="$HOME_DIR"` via shell wrapper (line 71), so `KeyError` is impossible in practice
|
||||||
|
- **Impact**: Inconsistency with the `resolve_home()` pattern adopted in `verify_session.py` and `workspace_uuid.py`. No runtime risk.
|
||||||
|
- **Recommendation**: Could adopt `resolve_home()` for consistency, but not required since the env var is always set by the wrapper.
|
||||||
|
|
||||||
|
### F-4 (Low): `test_resolve_home_contract` test 3 fragility
|
||||||
|
- Test 3 creates `env_copy` with `HOME_DIR` popped, computes expected `home_val` from the copy, but calls `resolve_home()` with the **actual** `os.environ` (which may still have `HOME_DIR` set from prior tests or environment).
|
||||||
|
- If `HOME_DIR` was set in the environment when the test runs, `resolve_home()` would return the `HOME_DIR` value, but `home_val` would be `HOME`/`expanduser` — mismatch → test failure.
|
||||||
|
- **Impact**: Test passes in current environment (HOME_DIR not set during direct pytest run), but is fragile in environments where HOME_DIR is pre-set.
|
||||||
|
- **Recommendation**: Use `monkeypatch.delenv('HOME_DIR', raising=False)` to properly isolate the test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Loss / Regression Analysis
|
||||||
|
|
||||||
|
| Concern | Status |
|
||||||
|
|---------|--------|
|
||||||
|
| `MAM_VERIFY_PY` references in codebase | ✅ Fully removed from all `.sh`/`.py` files (only in `.mam/jobs/` probe scripts — historical) |
|
||||||
|
| `load_state_json` shell function still used by other scripts | ✅ Yes — `stop_session.sh`, `orc_onboard.sh`, `run_loop.sh`, `lib.sh` itself. The new `state.py` module coexists; only `reconcile.sh` uses the Python import path. No loss. |
|
||||||
|
| `conftest.py` copies `lib_py/` to test sandboxes | ✅ `shutil.copytree(src_skills, ...)` copies entire `.agents/skills/` tree including new `lib_py/agents/` subdirectory |
|
||||||
|
| `deploy/remove.sh` includes new files | ✅ `.agents/skills/lib_py` directory entry covers `agents/` subdirectory automatically |
|
||||||
|
| `derive_session_name` logic matches shell version | ✅ `f"{slug}-{role.lower()}-{name}"` — identical |
|
||||||
|
| `matches_session_name` logic matches shell kind detection | ✅ Checks `-{role}-{name}` and `-{name}` suffixes — consistent |
|
||||||
|
| `own_key` naming convention | ✅ claude → `session_id_own`, others → `conversation_id_own` — matches existing schema |
|
||||||
|
| F-1 fix from prior review (job 11a99829) correctly applied | ✅ `return 1 2>/dev/null || exit 1` handles both sourced and executed contexts |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Verdict
|
||||||
|
|
||||||
|
The A-4 BaseAgentAdapter introduction and agent knowledge abstraction changes are **well-designed, correctly implemented, and fully tested**:
|
||||||
|
|
||||||
|
- **No regressions**: All 74 tests pass (including 3 new contract tests)
|
||||||
|
- **No code loss**: All existing consumers of `load_state_json` and `verify_session_uuid` continue to work; new Python module coexists with shell versions
|
||||||
|
- **Clean design**: `BaseAgentAdapter` + static registry + per-agent adapters follows OOP best practices; `resolve_home()` contract prevents root-relative silent fail-close
|
||||||
|
- **Proper decoupling**: `reconcile.sh` eliminates env-var code injection (`exec(os.environ['MAM_VERIFY_PY'])`) in favor of direct imports
|
||||||
|
- **CI coverage**: Recursive glob fix ensures new nested Python files are compiled in CI
|
||||||
|
|
||||||
|
The 4 findings (F-1 through F-4) are all **Low severity** — unused imports, dead variable, consistency gap, and test fragility. None block the PASS verdict.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -1120,7 +1120,8 @@ PYEOF
|
|||||||
if [ -f "$SKILL_DIR/lib_py/verify_session.py" ]; then
|
if [ -f "$SKILL_DIR/lib_py/verify_session.py" ]; then
|
||||||
VERIFY_SESSION_PYTHON="$(cat "$SKILL_DIR/lib_py/verify_session.py")"
|
VERIFY_SESSION_PYTHON="$(cat "$SKILL_DIR/lib_py/verify_session.py")"
|
||||||
else
|
else
|
||||||
VERIFY_SESSION_PYTHON=""
|
echo "ERROR: lib_py/verify_session.py missing" >&2
|
||||||
|
return 1 2>/dev/null || exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
verify_session_uuid() {
|
verify_session_uuid() {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# lib_py.agents package initialization — BaseAgentAdapter and static registry
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# __main__.py — CLI bridge for lib_py.agents facts and resolution (N4)
|
||||||
|
|
||||||
|
import sys, json
|
||||||
|
from lib_py.agents.registry import get_adapter, own_key, agent_of_row
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python -m lib_py.agents <facts|resolve> [args...]", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
cmd = sys.argv[1]
|
||||||
|
if cmd == 'facts':
|
||||||
|
agent_name = sys.argv[2] if len(sys.argv) > 2 else ''
|
||||||
|
adapter = get_adapter(agent_name)
|
||||||
|
if not adapter:
|
||||||
|
print(f"ERROR: Unknown agent {agent_name!r}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
# Emit shell-eval friendly facts
|
||||||
|
print(f"AGENT_NAME={adapter.name}")
|
||||||
|
print(f"OWN_KEY={adapter.own_key}")
|
||||||
|
|
||||||
|
elif cmd == 'resolve':
|
||||||
|
name = sys.argv[2] if len(sys.argv) > 2 else ''
|
||||||
|
resolved = agent_of_row({}, session_name=name)
|
||||||
|
if resolved:
|
||||||
|
print(resolved)
|
||||||
|
sys.exit(0)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"ERROR: Unknown CLI command {cmd!r}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Adapters package init
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# agy.py — Antigravity (agy) agent adapter
|
||||||
|
|
||||||
|
from lib_py.agents.base import BaseAgentAdapter
|
||||||
|
|
||||||
|
class AgyAgentAdapter(BaseAgentAdapter):
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return 'agy'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def own_key(self) -> str:
|
||||||
|
return 'agy_conversation_id_own'
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# claude.py — Claude Code agent adapter
|
||||||
|
|
||||||
|
from lib_py.agents.base import BaseAgentAdapter
|
||||||
|
|
||||||
|
class ClaudeAgentAdapter(BaseAgentAdapter):
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return 'claude'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def own_key(self) -> str:
|
||||||
|
return 'claude_session_id_own'
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# cline.py — Cline agent adapter
|
||||||
|
|
||||||
|
from lib_py.agents.base import BaseAgentAdapter
|
||||||
|
|
||||||
|
class ClineAgentAdapter(BaseAgentAdapter):
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return 'cline'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def own_key(self) -> str:
|
||||||
|
return 'cline_conversation_id_own'
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# hermes.py — Hermes agent adapter
|
||||||
|
|
||||||
|
from lib_py.agents.base import BaseAgentAdapter
|
||||||
|
|
||||||
|
class HermesAgentAdapter(BaseAgentAdapter):
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return 'hermes'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def own_key(self) -> str:
|
||||||
|
return 'hermes_conversation_id_own'
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# base.py — BaseAgentAdapter abstract base class, SpawnSpec, and DiscoveryContext
|
||||||
|
|
||||||
|
import os, json, sqlite3
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
from lib_py.paths import resolve_home
|
||||||
|
from lib_py.verify_session import verify_session_uuid, workspace_key
|
||||||
|
|
||||||
|
class SpawnSpec:
|
||||||
|
def __init__(self, agent_name: str, session_name: str, command: str, env: Optional[Dict[str, str]] = None):
|
||||||
|
self.agent_name = agent_name
|
||||||
|
self.session_name = session_name
|
||||||
|
self.command = command
|
||||||
|
self.env = env or {}
|
||||||
|
|
||||||
|
class DiscoveryContext:
|
||||||
|
def __init__(self, workspace: str, agent_name: str, home_dir: Optional[str] = None):
|
||||||
|
self.workspace = workspace
|
||||||
|
self.agent_name = agent_name
|
||||||
|
self.home_dir = resolve_home(home_dir)
|
||||||
|
|
||||||
|
class BaseAgentAdapter:
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@property
|
||||||
|
def own_key(self) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def derive_session_name(self, slug: str, role: str = "creator") -> str:
|
||||||
|
r = role.lower()
|
||||||
|
return f"{slug}-{r}-{self.name}"
|
||||||
|
|
||||||
|
def matches_session_name(self, session_name: str) -> bool:
|
||||||
|
for r in ('creator', 'planner', 'reviewer'):
|
||||||
|
if session_name.endswith(f"-{r}-{self.name}"):
|
||||||
|
return True
|
||||||
|
return session_name.endswith(f"-{self.name}")
|
||||||
|
|
||||||
|
def verify_session(self, ws: str, uuid: str, row: Optional[Dict[str, Any]] = None, mode: str = "discover") -> bool:
|
||||||
|
return verify_session_uuid(ws, self.name, uuid, row=row, mode=mode)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# registry.py — Static agent adapter registry and resolution functions (N4 & N5)
|
||||||
|
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from lib_py.agents.base import BaseAgentAdapter
|
||||||
|
from lib_py.agents.adapters.claude import ClaudeAgentAdapter
|
||||||
|
from lib_py.agents.adapters.agy import AgyAgentAdapter
|
||||||
|
from lib_py.agents.adapters.hermes import HermesAgentAdapter
|
||||||
|
from lib_py.agents.adapters.cline import ClineAgentAdapter
|
||||||
|
|
||||||
|
_ADAPTERS: Dict[str, BaseAgentAdapter] = {
|
||||||
|
'claude': ClaudeAgentAdapter(),
|
||||||
|
'agy': AgyAgentAdapter(),
|
||||||
|
'hermes': HermesAgentAdapter(),
|
||||||
|
'cline': ClineAgentAdapter(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_adapter(agent_name: str) -> Optional[BaseAgentAdapter]:
|
||||||
|
if not agent_name:
|
||||||
|
return None
|
||||||
|
return _ADAPTERS.get(agent_name.lower())
|
||||||
|
|
||||||
|
def own_key(agent_name: str) -> Optional[str]:
|
||||||
|
adapter = get_adapter(agent_name)
|
||||||
|
return adapter.own_key if adapter else None
|
||||||
|
|
||||||
|
def agent_of_row(row: Dict[str, Any], session_name: str = "", match_cmd: bool = True) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Resolves agent for a given registry row or session name using strict priority order (N5):
|
||||||
|
1. Explicit 'agent' field in row dictionary
|
||||||
|
2. Session name suffix matching (*-{creator,planner,reviewer}-<agent> or *-<agent>)
|
||||||
|
3. pane.cmd exact match (if match_cmd is True, e.g. for non-adoption lookup)
|
||||||
|
4. None (resolution failure)
|
||||||
|
"""
|
||||||
|
if isinstance(row, dict):
|
||||||
|
explicit = row.get('agent')
|
||||||
|
if explicit and str(explicit).lower() in _ADAPTERS:
|
||||||
|
return str(explicit).lower()
|
||||||
|
if not session_name:
|
||||||
|
session_name = row.get('name', '')
|
||||||
|
|
||||||
|
name = session_name or (row.get('name', '') if isinstance(row, dict) else '')
|
||||||
|
if name:
|
||||||
|
for agent_name, adapter in _ADAPTERS.items():
|
||||||
|
if adapter.matches_session_name(name):
|
||||||
|
return agent_name
|
||||||
|
|
||||||
|
if match_cmd and isinstance(row, dict):
|
||||||
|
pane = row.get('pane') or {}
|
||||||
|
cmd = pane.get('cmd', '') if isinstance(pane, dict) else ''
|
||||||
|
if cmd:
|
||||||
|
for agent_name in _ADAPTERS:
|
||||||
|
if cmd == agent_name or cmd.endswith(f"/{agent_name}"):
|
||||||
|
return agent_name
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# paths.py — unified HOME_DIR and filesystem path resolution contract (N0)
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
def resolve_home(home_dir=None):
|
||||||
|
"""
|
||||||
|
Unified HOME_DIR resolution contract across lib_py modules and adapters.
|
||||||
|
Tries: 1. explicit home_dir argument, 2. HOME_DIR env var, 3. HOME env var, 4. os.path.expanduser('~').
|
||||||
|
Raises ValueError if result is empty or filesystem root ('/'), preventing root-relative silent fail-close.
|
||||||
|
"""
|
||||||
|
h = home_dir or os.environ.get("HOME_DIR") or os.environ.get("HOME") or os.path.expanduser("~")
|
||||||
|
if not h or h == "/":
|
||||||
|
raise ValueError("HOME_DIR unresolvable — refusing to build paths from the filesystem root")
|
||||||
|
return h
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# state.py — Python direct state loader for agent-sessions.yaml / agent-sessions.db (N7)
|
||||||
|
|
||||||
|
import os, json, sqlite3
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
def load_state_json(yaml_path: str = None) -> Dict[str, Any]:
|
||||||
|
if not yaml_path:
|
||||||
|
yaml_path = os.environ.get('AGENT_SESSIONS_YAML') or os.environ.get('YAML_PATH', '')
|
||||||
|
if not yaml_path:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
||||||
|
d = {}
|
||||||
|
db_sessions = []
|
||||||
|
try:
|
||||||
|
if os.path.exists(db_path):
|
||||||
|
conn = sqlite3.connect(db_path, timeout=60.0)
|
||||||
|
conn.execute('PRAGMA busy_timeout = 60000')
|
||||||
|
try:
|
||||||
|
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
||||||
|
if row:
|
||||||
|
d = json.loads(row[0])
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
cursor = conn.execute('SELECT data FROM sessions')
|
||||||
|
for r in cursor.fetchall():
|
||||||
|
db_sessions.append(json.loads(r[0]))
|
||||||
|
d['herdr_sessions'] = db_sessions
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
conn.close()
|
||||||
|
elif os.path.exists(yaml_path):
|
||||||
|
import yaml
|
||||||
|
with open(yaml_path) as f:
|
||||||
|
d = yaml.safe_load(f) or {}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def clean_surrogates(obj):
|
||||||
|
if isinstance(obj, str):
|
||||||
|
return obj.encode('utf-8', errors='replace').decode('utf-8')
|
||||||
|
elif isinstance(obj, dict):
|
||||||
|
return {k: clean_surrogates(v) for k, v in obj.items()}
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
return [clean_surrogates(x) for x in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
return clean_surrogates(d)
|
||||||
@@ -79,9 +79,11 @@ def workspace_key(path):
|
|||||||
p = path
|
p = path
|
||||||
return p.replace("/", "-").replace("_", "-")
|
return p.replace("/", "-").replace("_", "-")
|
||||||
|
|
||||||
|
from lib_py.paths import resolve_home
|
||||||
|
|
||||||
def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=None, mode="discover"):
|
def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=None, mode="discover"):
|
||||||
import os, json, sqlite3
|
import os, json, sqlite3
|
||||||
home = home_dir or os.environ.get("HOME_DIR", "")
|
home = resolve_home(home_dir)
|
||||||
c_dir = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR", f"{home}/.claude/projects")
|
c_dir = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR", f"{home}/.claude/projects")
|
||||||
row = row or {}
|
row = row or {}
|
||||||
_iso = row.get("isolation")
|
_iso = row.get("isolation")
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ def iso_root_of(s):
|
|||||||
iso = s.get('isolation')
|
iso = s.get('isolation')
|
||||||
return iso.get('root') if isinstance(iso, dict) else None
|
return iso.get('root') if isinstance(iso, dict) else None
|
||||||
|
|
||||||
|
from lib_py.paths import resolve_home
|
||||||
|
|
||||||
def find_workspace_uuid_main():
|
def find_workspace_uuid_main():
|
||||||
ws = os.environ['WS_ABS']
|
ws = os.environ['WS_ABS']
|
||||||
agent = os.environ['AGENT']
|
agent = os.environ['AGENT']
|
||||||
home = os.environ['HOME_DIR']
|
home = resolve_home()
|
||||||
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
|
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
|
||||||
target = os.environ.get('TARGET_SESSION', '')
|
target = os.environ.get('TARGET_SESSION', '')
|
||||||
|
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ import os, json, glob, subprocess, time, sqlite3
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
exec(os.environ['MAM_VERIFY_PY'])
|
from lib_py.verify_session import verify_session_uuid, workspace_key
|
||||||
|
|
||||||
yaml_path = os.environ['YAML_PATH']
|
yaml_path = os.environ['YAML_PATH']
|
||||||
home = os.environ['HOME_DIR']
|
home = os.environ['HOME_DIR']
|
||||||
@@ -344,14 +344,18 @@ if not lib_sh:
|
|||||||
try:
|
try:
|
||||||
d
|
d
|
||||||
except NameError:
|
except NameError:
|
||||||
import subprocess
|
|
||||||
d = {}
|
|
||||||
try:
|
try:
|
||||||
script = f"source '{lib_sh}' && load_state_json"
|
from lib_py.state import load_state_json
|
||||||
out = subprocess.check_output(['bash', '-c', script], stderr=subprocess.DEVNULL)
|
d = load_state_json(yaml_path)
|
||||||
d = json.loads(out.decode('utf-8'))
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
import subprocess
|
||||||
|
d = {}
|
||||||
|
try:
|
||||||
|
script = f"source '{lib_sh}' && load_state_json"
|
||||||
|
out = subprocess.check_output(['bash', '-c', script], stderr=subprocess.DEVNULL)
|
||||||
|
d = json.loads(out.decode('utf-8'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Any herdr_session_epoch at or below this is not a real session time.
|
# Any herdr_session_epoch at or below this is not a real session time.
|
||||||
# 1000000000 = 2001-09-09; it is below every plausible MAM session and far
|
# 1000000000 = 2001-09-09; it is below every plausible MAM session and far
|
||||||
@@ -859,7 +863,7 @@ if not actions:
|
|||||||
PYEOF
|
PYEOF
|
||||||
|
|
||||||
if [ "$DRY_RUN" = "1" ]; then
|
if [ "$DRY_RUN" = "1" ]; then
|
||||||
printf '%s' "$RECON_SRC" | LIB_SH="$LIB_SH" MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" env_python "$AGENT_SESSIONS_YAML"
|
printf '%s' "$RECON_SRC" | LIB_SH="$LIB_SH" env_python "$AGENT_SESSIONS_YAML"
|
||||||
else
|
else
|
||||||
printf '%s' "$RECON_SRC" | LIB_SH="$LIB_SH" MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" atomic_dump_yaml "$AGENT_SESSIONS_YAML"
|
printf '%s' "$RECON_SRC" | LIB_SH="$LIB_SH" atomic_dump_yaml "$AGENT_SESSIONS_YAML"
|
||||||
fi
|
fi
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# 🛠️ Multi-Agent Mux 종합 개선 및 미해결 과제 백로그 (`IMPROVEMENTS.md`)
|
# 🛠️ Multi-Agent Mux 종합 개선 및 미해결 과제 백로그 (`IMPROVEMENTS.md`)
|
||||||
|
|
||||||
- **최종 갱신일**: 2026-08-12 (multi-agent-mux-orc-onboard 스킬 구현 및 orchestrator_uuids 배제 게이트 조치 완료 반영)
|
- **최종 갱신일**: 2026-08-14 (A-4 M0~M1 BaseAgentAdapter 도입 1단계 완료 및 계약 검증 반영)
|
||||||
- **통합 관리 대상**: 기존 `CODEBASE_REVIEW_REPORT.md` + `OPTIMIZATION.md`
|
- **통합 관리 대상**: 기존 `CODEBASE_REVIEW_REPORT.md` + `OPTIMIZATION.md`
|
||||||
- **총 추적 미해결 과제**: **10건** (아키텍처 2건, 엣지케이스 5건, 오케스트레이션 0건, 레거시 잔재 3건)
|
- **총 추적 미해결 과제**: **10건** (아키텍처 2건, 엣지케이스 5건, 오케스트레이션 0건, 레거시 잔재 3건)
|
||||||
- **완료된 과제**: **13건** (A-1, A-3, A-5, B-1, B-3, B-4, B-7, C-1, C-2, O-1, O-2, O-3, O-4-OrcOnboard)
|
- **완료된 과제**: **13건** (A-1, A-3, A-5, B-1, B-3, B-4, B-7, C-1, C-2, O-1, O-2, O-3, O-4-OrcOnboard)
|
||||||
@@ -217,7 +217,7 @@
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| **P0-1** | **B-7** | 저장소 밖 기동 시 리뷰어가 문자열 `"No git diff available"` 로 `[VERDICT: PASS]` 를 냄. 신규(미추적) 파일은 리뷰 대상 밖. **(✅ 완료 — tests/test_b7_diff_untracked.py 20/20 PASS)** | 소 (1파일) | — |
|
| **P0-1** | **B-7** | 저장소 밖 기동 시 리뷰어가 문자열 `"No git diff available"` 로 `[VERDICT: PASS]` 를 냄. 신규(미추적) 파일은 리뷰 대상 밖. **(✅ 완료 — tests/test_b7_diff_untracked.py 20/20 PASS)** | 소 (1파일) | — |
|
||||||
| **P0-2** | **O-2** | 마커를 조건 없이 덮어쓰고 종료 트랩이 **타 인스턴스의 마커까지 삭제** → 완료 처리된 **O-3 가드가 조용히 무력화**됨 **(✅ 완료 — tests/test_o2_race_free_lock.py 22/22 PASS)** | 소~중 (1파일) | — |
|
| **P0-2** | **O-2** | 마커를 조건 없이 덮어쓰고 종료 트랩이 **타 인스턴스의 마커까지 삭제** → 완료 처리된 **O-3 가드가 조용히 무력화**됨 **(✅ 완료 — tests/test_o2_race_free_lock.py 22/22 PASS)** | 소~중 (1파일) | — |
|
||||||
| **P1-1** | **A-4 M0~M1** | `PYTHONPATH` 부트스트랩·배포/CI 등록·`own_key` 이관. B-8/B-10/C-3b 로직을 싸게 만듦 | 중 | B-7 |
|
| **P1-1** | **A-4 M0~M1** | `PYTHONPATH` 부트스트랩·배포/CI 등록·`own_key` 이관. B-8/B-10/C-3b 로직을 싸게 만듦 **(✅ 완료 — tests/test_a4_adapter_contract.py 3/3 PASS)** | 중 | B-7 |
|
||||||
| **P1-2** | **B-8** | agy 주입이 검증 없이 `return 0` → 아무것도 전달되지 않은 잡이 `started` 로 기록됨. A-4 M0(`_pane_capture` 디코딩) 이후엔 **회피책 제거**로 축소 | 소 | A-4 M0 |
|
| **P1-2** | **B-8** | agy 주입이 검증 없이 `return 0` → 아무것도 전달되지 않은 잡이 `started` 로 기록됨. A-4 M0(`_pane_capture` 디코딩) 이후엔 **회피책 제거**로 축소 | 소 | A-4 M0 |
|
||||||
| **P2-1** | **B-6** | 버전 관리 트리 오염 + rsync 배포 유출. `mktemp -d` 로 옮기는 1~2줄 | 소 | — |
|
| **P2-1** | **B-6** | 버전 관리 트리 오염 + rsync 배포 유출. `mktemp -d` 로 옮기는 1~2줄 | 소 | — |
|
||||||
| **P2-2** | **C-3a + C-4** | 빈 스텁 4종 + 이를 고정하던 **공허한 테스트 5건** + 죽은 심볼 3종 제거 (회귀 시간 단축 효과) | 소 | — |
|
| **P2-2** | **C-3a + C-4** | 빈 스텁 4종 + 이를 고정하던 **공허한 테스트 5건** + 죽은 심볼 3종 제거 (회귀 시간 단축 효과) | 소 | — |
|
||||||
|
|||||||
+1
-1
@@ -76,7 +76,7 @@ jobs:
|
|||||||
- name: Run Python Syntax Check (Compile test)
|
- name: Run Python Syntax Check (Compile test)
|
||||||
run: |
|
run: |
|
||||||
echo "🔍 Verifying Python file compilation..."
|
echo "🔍 Verifying Python file compilation..."
|
||||||
python -m py_compile .agents/skills/multi-agent-mux-delegate-job/scripts/*.py .agents/skills/lib_py/*.py
|
python -c "import py_compile, glob; [py_compile.compile(f, doraise=True) for f in glob.glob('.agents/skills/lib_py/**/*.py', recursive=True) + glob.glob('.agents/skills/multi-agent-mux-delegate-job/scripts/*.py')]"
|
||||||
echo "✅ All Python files compiled successfully."
|
echo "✅ All Python files compiled successfully."
|
||||||
|
|
||||||
test:
|
test:
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# test_a4_adapter_contract.py — Contract tests for BaseAgentAdapter and resolve_home
|
||||||
|
|
||||||
|
import os, pytest
|
||||||
|
from lib_py.paths import resolve_home
|
||||||
|
from lib_py.agents.registry import get_adapter, own_key, agent_of_row
|
||||||
|
|
||||||
|
def test_resolve_home_contract():
|
||||||
|
# 1. When explicit home_dir is passed
|
||||||
|
assert resolve_home('/custom/home') == '/custom/home'
|
||||||
|
|
||||||
|
# 2. When HOME_DIR env var is set
|
||||||
|
orig = os.environ.get('HOME_DIR')
|
||||||
|
try:
|
||||||
|
os.environ['HOME_DIR'] = '/env/home'
|
||||||
|
assert resolve_home() == '/env/home'
|
||||||
|
finally:
|
||||||
|
if orig is None:
|
||||||
|
os.environ.pop('HOME_DIR', None)
|
||||||
|
else:
|
||||||
|
os.environ['HOME_DIR'] = orig
|
||||||
|
|
||||||
|
# 3. When HOME_DIR env var is NOT set, falls back to HOME / expanduser (~), NOT '' or '/'
|
||||||
|
env_copy = os.environ.copy()
|
||||||
|
env_copy.pop('HOME_DIR', None)
|
||||||
|
home_val = env_copy.get('HOME') or os.path.expanduser('~')
|
||||||
|
assert resolve_home() == home_val
|
||||||
|
|
||||||
|
def test_agent_adapter_registry():
|
||||||
|
for agent in ('claude', 'agy', 'hermes', 'cline'):
|
||||||
|
adapter = get_adapter(agent)
|
||||||
|
assert adapter is not None
|
||||||
|
assert adapter.name == agent
|
||||||
|
assert adapter.own_key == f"{agent}_{'session' if agent == 'claude' else 'conversation'}_id_own"
|
||||||
|
assert own_key(agent) == adapter.own_key
|
||||||
|
|
||||||
|
def test_agent_of_row_priority():
|
||||||
|
# Priority 1: Explicit 'agent' field
|
||||||
|
row1 = {'agent': 'agy', 'name': 'some-name-creator-claude'}
|
||||||
|
assert agent_of_row(row1) == 'agy'
|
||||||
|
|
||||||
|
# Priority 2: Session name suffix
|
||||||
|
row2 = {'name': 'my-workspace-planner-hermes'}
|
||||||
|
assert agent_of_row(row2) == 'hermes'
|
||||||
|
|
||||||
|
# Priority 3: pane.cmd exact match
|
||||||
|
row3 = {'pane': {'cmd': 'cline'}}
|
||||||
|
assert agent_of_row(row3) == 'cline'
|
||||||
Reference in New Issue
Block a user