From caffd04f362134be1678ad718492507aba06c594 Mon Sep 17 00:00:00 2001 From: Godopu Date: Fri, 14 Aug 2026 08:44:36 +0900 Subject: [PATCH] feat(lib): implement A-4 M0~M1 BaseAgentAdapter pattern and resolve_home contract --- .../plan-5af41284.md | 242 ++++++++++++++++++ .../report-98e3986f.md | 186 ++++++++++++++ .agents/skills/lib.sh | 3 +- .agents/skills/lib_py/agents/__init__.py | 1 + .agents/skills/lib_py/agents/__main__.py | 35 +++ .../skills/lib_py/agents/adapters/__init__.py | 1 + .agents/skills/lib_py/agents/adapters/agy.py | 12 + .../skills/lib_py/agents/adapters/claude.py | 12 + .../skills/lib_py/agents/adapters/cline.py | 12 + .../skills/lib_py/agents/adapters/hermes.py | 12 + .agents/skills/lib_py/agents/base.py | 41 +++ .agents/skills/lib_py/agents/registry.py | 55 ++++ .agents/skills/lib_py/paths.py | 14 + .agents/skills/lib_py/state.py | 49 ++++ .agents/skills/lib_py/verify_session.py | 4 +- .agents/skills/lib_py/workspace_uuid.py | 4 +- .../scripts/reconcile.sh | 22 +- IMPROVEMENTS.md | 4 +- deploy/gitea-ci.yml | 2 +- tests/test_a4_adapter_contract.py | 47 ++++ 20 files changed, 743 insertions(+), 15 deletions(-) create mode 100644 .agents/reports/canary-projects-multi-agent-mux-creator-claude/plan-5af41284.md create mode 100644 .agents/reports/canary-projects-multi-agent-mux-creator-cline/report-98e3986f.md create mode 100644 .agents/skills/lib_py/agents/__init__.py create mode 100644 .agents/skills/lib_py/agents/__main__.py create mode 100644 .agents/skills/lib_py/agents/adapters/__init__.py create mode 100644 .agents/skills/lib_py/agents/adapters/agy.py create mode 100644 .agents/skills/lib_py/agents/adapters/claude.py create mode 100644 .agents/skills/lib_py/agents/adapters/cline.py create mode 100644 .agents/skills/lib_py/agents/adapters/hermes.py create mode 100644 .agents/skills/lib_py/agents/base.py create mode 100644 .agents/skills/lib_py/agents/registry.py create mode 100644 .agents/skills/lib_py/paths.py create mode 100644 .agents/skills/lib_py/state.py create mode 100644 tests/test_a4_adapter_contract.py diff --git a/.agents/reports/canary-projects-multi-agent-mux-creator-claude/plan-5af41284.md b/.agents/reports/canary-projects-multi-agent-mux-creator-claude/plan-5af41284.md new file mode 100644 index 0000000..d75d955 --- /dev/null +++ b/.agents/reports/canary-projects-multi-agent-mux-creator-claude/plan-5af41284.md @@ -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 )"` **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)** 이다.)* diff --git a/.agents/reports/canary-projects-multi-agent-mux-creator-cline/report-98e3986f.md b/.agents/reports/canary-projects-multi-agent-mux-creator-cline/report-98e3986f.md new file mode 100644 index 0000000..fe66861 --- /dev/null +++ b/.agents/reports/canary-projects-multi-agent-mux-creator-cline/report-98e3986f.md @@ -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 ` | +| `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] \ No newline at end of file diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index bf8302e..b2f86bf 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -1120,7 +1120,8 @@ PYEOF if [ -f "$SKILL_DIR/lib_py/verify_session.py" ]; then VERIFY_SESSION_PYTHON="$(cat "$SKILL_DIR/lib_py/verify_session.py")" else - VERIFY_SESSION_PYTHON="" + echo "ERROR: lib_py/verify_session.py missing" >&2 + return 1 2>/dev/null || exit 1 fi verify_session_uuid() { diff --git a/.agents/skills/lib_py/agents/__init__.py b/.agents/skills/lib_py/agents/__init__.py new file mode 100644 index 0000000..07a7de7 --- /dev/null +++ b/.agents/skills/lib_py/agents/__init__.py @@ -0,0 +1 @@ +# lib_py.agents package initialization — BaseAgentAdapter and static registry diff --git a/.agents/skills/lib_py/agents/__main__.py b/.agents/skills/lib_py/agents/__main__.py new file mode 100644 index 0000000..ee7da18 --- /dev/null +++ b/.agents/skills/lib_py/agents/__main__.py @@ -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 [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() diff --git a/.agents/skills/lib_py/agents/adapters/__init__.py b/.agents/skills/lib_py/agents/adapters/__init__.py new file mode 100644 index 0000000..749cd9b --- /dev/null +++ b/.agents/skills/lib_py/agents/adapters/__init__.py @@ -0,0 +1 @@ +# Adapters package init diff --git a/.agents/skills/lib_py/agents/adapters/agy.py b/.agents/skills/lib_py/agents/adapters/agy.py new file mode 100644 index 0000000..b108753 --- /dev/null +++ b/.agents/skills/lib_py/agents/adapters/agy.py @@ -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' diff --git a/.agents/skills/lib_py/agents/adapters/claude.py b/.agents/skills/lib_py/agents/adapters/claude.py new file mode 100644 index 0000000..1e61a7c --- /dev/null +++ b/.agents/skills/lib_py/agents/adapters/claude.py @@ -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' diff --git a/.agents/skills/lib_py/agents/adapters/cline.py b/.agents/skills/lib_py/agents/adapters/cline.py new file mode 100644 index 0000000..ad6629c --- /dev/null +++ b/.agents/skills/lib_py/agents/adapters/cline.py @@ -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' diff --git a/.agents/skills/lib_py/agents/adapters/hermes.py b/.agents/skills/lib_py/agents/adapters/hermes.py new file mode 100644 index 0000000..eba7cd9 --- /dev/null +++ b/.agents/skills/lib_py/agents/adapters/hermes.py @@ -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' diff --git a/.agents/skills/lib_py/agents/base.py b/.agents/skills/lib_py/agents/base.py new file mode 100644 index 0000000..9088846 --- /dev/null +++ b/.agents/skills/lib_py/agents/base.py @@ -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) diff --git a/.agents/skills/lib_py/agents/registry.py b/.agents/skills/lib_py/agents/registry.py new file mode 100644 index 0000000..accf224 --- /dev/null +++ b/.agents/skills/lib_py/agents/registry.py @@ -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}- or *-) + 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 diff --git a/.agents/skills/lib_py/paths.py b/.agents/skills/lib_py/paths.py new file mode 100644 index 0000000..8068dac --- /dev/null +++ b/.agents/skills/lib_py/paths.py @@ -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 diff --git a/.agents/skills/lib_py/state.py b/.agents/skills/lib_py/state.py new file mode 100644 index 0000000..4bde815 --- /dev/null +++ b/.agents/skills/lib_py/state.py @@ -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) diff --git a/.agents/skills/lib_py/verify_session.py b/.agents/skills/lib_py/verify_session.py index b40627f..67433c7 100644 --- a/.agents/skills/lib_py/verify_session.py +++ b/.agents/skills/lib_py/verify_session.py @@ -79,9 +79,11 @@ def workspace_key(path): p = path 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"): 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") row = row or {} _iso = row.get("isolation") diff --git a/.agents/skills/lib_py/workspace_uuid.py b/.agents/skills/lib_py/workspace_uuid.py index 50921e5..6576969 100644 --- a/.agents/skills/lib_py/workspace_uuid.py +++ b/.agents/skills/lib_py/workspace_uuid.py @@ -15,10 +15,12 @@ def iso_root_of(s): iso = s.get('isolation') return iso.get('root') if isinstance(iso, dict) else None +from lib_py.paths import resolve_home + def find_workspace_uuid_main(): ws = os.environ['WS_ABS'] 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") target = os.environ.get('TARGET_SESSION', '') diff --git a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh index e83f842..85cd54f 100755 --- a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh +++ b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh @@ -320,7 +320,7 @@ import os, json, glob, subprocess, time, sqlite3 from datetime import datetime, timezone 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'] home = os.environ['HOME_DIR'] @@ -344,14 +344,18 @@ if not lib_sh: try: d except NameError: - 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')) + from lib_py.state import load_state_json + d = load_state_json(yaml_path) 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. # 1000000000 = 2001-09-09; it is below every plausible MAM session and far @@ -859,7 +863,7 @@ if not actions: PYEOF 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 - 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 diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md index 6012ae1..506bd07 100644 --- a/IMPROVEMENTS.md +++ b/IMPROVEMENTS.md @@ -1,6 +1,6 @@ # 🛠️ 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` - **총 추적 미해결 과제**: **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) @@ -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-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 | | **P2-1** | **B-6** | 버전 관리 트리 오염 + rsync 배포 유출. `mktemp -d` 로 옮기는 1~2줄 | 소 | — | | **P2-2** | **C-3a + C-4** | 빈 스텁 4종 + 이를 고정하던 **공허한 테스트 5건** + 죽은 심볼 3종 제거 (회귀 시간 단축 효과) | 소 | — | diff --git a/deploy/gitea-ci.yml b/deploy/gitea-ci.yml index 014ce39..40f0340 100644 --- a/deploy/gitea-ci.yml +++ b/deploy/gitea-ci.yml @@ -76,7 +76,7 @@ jobs: - name: Run Python Syntax Check (Compile test) run: | 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." test: diff --git a/tests/test_a4_adapter_contract.py b/tests/test_a4_adapter_contract.py new file mode 100644 index 0000000..5afa12f --- /dev/null +++ b/tests/test_a4_adapter_contract.py @@ -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'