Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ed39f899b | ||
|
|
7708d3ade3 | ||
|
|
b4821fafa8 | ||
|
|
971f14ad3f | ||
|
|
5e519e2085 | ||
|
|
a875b137b6 | ||
|
|
5ddc0df523 | ||
|
|
b490713471 | ||
|
|
af3dc1600c | ||
|
|
14b9de14fc | ||
|
|
301ff5bb1f | ||
|
|
1c24732be0 | ||
|
|
6699159c76 | ||
|
|
50d12a1bf3 | ||
|
|
a7f3fc3242 |
@@ -0,0 +1,322 @@
|
|||||||
|
# P2-2 (C-3a + C-4) 레거시 코드 정리 — 구현 계획서 **Rev.2**
|
||||||
|
|
||||||
|
- **Job**: `84300b64` · **Role**: Planner (`MULTI_AGENT_RULES.md` §1 — 저장소 코드 미수정)
|
||||||
|
- **선행 계획**: `07740cc7` (Rev.1) · **반영 챌린지**: `e63843ca` (`agy`, `[VERDICT: PASS WITH CHALLENGE]`)
|
||||||
|
- **기준 커밋**: `5e519e2` (Rev.1 과 **동일** — HEAD 이동 없음, §3.3 참조) · **작성 시각**: 2026-08-16
|
||||||
|
- **산출물**: 본 계획서 + `proposed/p2-2_c3a_c4.rev2.patch` + `proposed/test_legacy_isolate_flags_noop.py` + `proposed/pytest_rev2.log`
|
||||||
|
- **검증 방식**: `git clone --local --no-hardlinks` 로 만든 스크래치패드 사본에 패치를 적용해 전체 스위트 + 변이 검사(mutation check)를 실행했습니다. 본 저장소 워킹 트리는 계획 수립 전후 모두 clean 입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 챌린지 판정 요약
|
||||||
|
|
||||||
|
| # | 챌린지 | 판정 | 근거 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **1** | `--isolate`/`--no-isolate` 자동화 회귀 테스트 부재 | **✅ 수용 + 강화** | 제시된 테스트를 그대로 실행 → 통과(0.09s). 변이 4종 중 3종 검출. 나머지 1종(usage 문서 줄 삭제)을 잡도록 **assert 1줄 추가** |
|
||||||
|
| **2** | `test_tier1_unit.py:31` 섹션 헤더 `(7 Test Cases)` 동기화 | **✅ 수용** | 현재 5개 헤더 **전부 정확**(7/6/5/5/6 = 29 = 실측)함을 확인. 방치하면 이 파일 최초의 불일치가 됨. `(5 Test Cases)` 로 갱신 |
|
||||||
|
| **3** | `IMPROVEMENTS.md` 라인 번호를 최신 HEAD 로 동기화 | **⚖️ 사실관계는 반박, 우려는 수용** | HEAD 는 `5e519e2` 로 **이동하지 않았고** Rev.1 의 20개 인용 라인은 **전부 현행 일치**. 챌린지의 "문두 완료 **15건**" 은 실측 **16건**. 다만 §6.1 편집들이 **서로의 오프셋을 밀어내는** 문제는 실재하므로 **편집 순서 명세를 신설**(§4.3) |
|
||||||
|
|
||||||
|
**Rev.1 대비 순증분**: 테스트 1건 추가(순감 4 → 순감 3), 섹션 헤더 1줄, 편집 순서 명세 1개 절. 수집 개수 **259 → 256**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Challenge 1 검증 — 수용, 그리고 한 줄 강화
|
||||||
|
|
||||||
|
### 1.1 제안된 테스트를 그대로 실행
|
||||||
|
|
||||||
|
챌린저가 제시한 코드를 **한 글자도 고치지 않고** 패치된 사본에 넣어 실행했습니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
1 passed in 0.13s
|
||||||
|
0.09s call test_create_session_legacy_isolate_flags_noop
|
||||||
|
0.02s setup
|
||||||
|
```
|
||||||
|
|
||||||
|
동작합니다. 다만 **실측 0.09s** 로, 챌린지가 적은 `<0.05s` 보다 약 2배입니다. 원인은 `create_session.sh:25` 가 인자 파싱 **이전에** `source "$_lib_sh"` 를 하기 때문이며(플래그 2개 × 서브프로세스 2회), 절대값이 미미하므로 채택에는 영향이 없습니다. 계획에는 실측값으로 적습니다.
|
||||||
|
|
||||||
|
### 1.2 변이 검사 — 이 테스트가 실제로 무엇을 잡는가
|
||||||
|
|
||||||
|
"통과한다" 는 것만으로는 가드가 되지 못하므로, 이 테스트가 막으려는 회귀를 직접 주입해 **실패하는지** 확인했습니다.
|
||||||
|
|
||||||
|
| 변이 | 내용 | 챌린지 원안 | 강화안 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **A** | `--isolate` · `--no-isolate` 분기 **둘 다 삭제** | ✅ FAIL (`rc=2`, `ERROR: unknown arg: --isolate`) | ✅ FAIL |
|
||||||
|
| **B** | `--no-isolate` **한쪽만** 삭제 | ✅ FAIL (`ERROR: unknown arg: --no-isolate`) | ✅ FAIL |
|
||||||
|
| **C** | 분기는 두되 `echo` 를 지워 **조용한 no-op** 으로 | ✅ FAIL (stderr assert) | ✅ FAIL |
|
||||||
|
| **D** | 분기는 두되 `usage()` 의 문서 줄(`:42-43`) 삭제 | ❌ **PASS (놓침)** | ✅ FAIL |
|
||||||
|
| **E** | 무변이 대조군 | ✅ PASS | ✅ PASS |
|
||||||
|
|
||||||
|
변이 A/B/C 를 잡는다는 점에서 챌린지의 지적은 **정확하고 실효적**입니다. 특히 B(한쪽만 삭제)를 잡는 것은 `for flag in [...]` 루프 덕분이며, 원안 설계가 이미 이 경우를 고려했음을 보여줍니다.
|
||||||
|
|
||||||
|
**D 만 빠져나갑니다.** `--isolate`/`--no-isolate` 는 `create_session.sh:42-43` 에서 **usage 에 정식 문서화되어 있는** 옵션입니다. 챌린지가 지목한 "누군가 미사용으로 오판하여 삭제" 시나리오에서, 가장 먼저 지워질 후보는 실행 분기가 아니라 **도움말 줄**입니다(C-6 이 정확히 "도움말과 실제 파서의 불일치" 과제인 점을 상기하십시오). 그리고 `-h` 를 이미 실행하고 있으므로 그 출력은 **이미 `res.stdout` 에 잡혀 있습니다** — 서브프로세스 추가 없이 assert 한 줄이면 닫힙니다.
|
||||||
|
|
||||||
|
### 1.3 채택 최종본
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_create_session_legacy_isolate_flags_noop(mam_sandbox):
|
||||||
|
"""Legacy --isolate/--no-isolate must stay a documented no-op, not an arg-parser error."""
|
||||||
|
create_script = mam_sandbox / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||||
|
for flag in ["--isolate", "--no-isolate"]:
|
||||||
|
res = subprocess.run(["bash", str(create_script), flag, "-h"], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0, f"{flag} rejected by arg parser: {res.stderr}"
|
||||||
|
assert "NOTE: --isolate/--no-isolate is a no-op" in res.stderr
|
||||||
|
assert flag in res.stdout, f"{flag} missing from usage() help text"
|
||||||
|
```
|
||||||
|
|
||||||
|
원안 대비 변경은 **3줄**입니다.
|
||||||
|
|
||||||
|
1. `assert flag in res.stdout` **신설** — 변이 D 를 닫습니다. 부분 문자열 오탐 우려가 있어 확인했으나 **`"--isolate" in "--no-isolate"` 는 `False`** 입니다(`--no-isolate` 는 `--no` 다음에 하이픈이 하나뿐이므로 `--isolate` 를 부분 문자열로 포함하지 않음). 따라서 단순 `in` 으로 두 플래그가 모호함 없이 구분됩니다.
|
||||||
|
2. `assert res.returncode == 0` 에 **실패 메시지 추가** — 실패 시 `assert 2 == 0` 대신 어느 플래그가 왜 거부됐는지 즉시 보이게 합니다(루프라서 어느 회차인지 모호해집니다).
|
||||||
|
3. docstring 을 계약 문장으로 교체 — "documented no-op" 이 assert 3개의 의도를 그대로 서술합니다.
|
||||||
|
|
||||||
|
### 1.4 배치 결정 — `test_tier1_unit.py` FEATURE 1
|
||||||
|
|
||||||
|
챌린지의 제안대로 tier1 에 둡니다. 스크립트를 실행하는 테스트라 tier2 도 후보였으나, **동일 파일에 정확한 선례가 있습니다**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_resume_script_invalid_args(mam_sandbox): # tier1:114 (현행)
|
||||||
|
script_path = mam_sandbox / "skills" / "multi-agent-mux-resume" / "scripts" / "resolve_session_id.sh"
|
||||||
|
res = subprocess.run(["bash", str(script_path), ...], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 2
|
||||||
|
assert "ERROR: --agent required" in res.stderr
|
||||||
|
```
|
||||||
|
|
||||||
|
`mam_sandbox / "skills" / ...` 경로 관례, `subprocess.run`, rc + stderr assert — 신규 테스트가 이 관용구를 그대로 따릅니다. tier1 은 이미 **인자 파서 단위 테스트의 자리**입니다. `subprocess` 는 `tests/test_tier1_unit.py:2` 에서 이미 임포트되어 있어 추가 임포트도 없습니다.
|
||||||
|
|
||||||
|
**삭제되는 3건이 있던 바로 그 자리**(`test_create_derive_session_name_weird_characters` 와 `test_create_validate_env_key` 사이)에 넣습니다.
|
||||||
|
|
||||||
|
### 1.5 격리 검증 — 신규 테스트는 저장소를 오염시키지 않는가
|
||||||
|
|
||||||
|
이 테스트는 `create_session.sh` 를 실행하고, 그 스크립트는 `:25` 에서 `lib.sh` 를 source 하며, `lib.sh` 는 `_init_herdr_isolation` 으로 `$WORKSPACE_ROOT/.mam/shim/herdr` 를 **씁니다**. 실제로 쓰기가 일어나는 테스트이므로 확인했습니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
rm -rf <clone>/.mam
|
||||||
|
pytest ...::test_create_session_legacy_isolate_flags_noop → 1 passed
|
||||||
|
after run, .mam exists? NO
|
||||||
|
```
|
||||||
|
|
||||||
|
`conftest.py:44` 의 `monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))` 가 서브프로세스까지 상속되어 쓰기가 `tmp_path` 안에 갇힙니다. **저장소 트리에 흔적 0건.**
|
||||||
|
|
||||||
|
(참고: 전체 스위트를 돌리면 사본에 `.mam/shim/` 이 생깁니다. 이는 **다른 기존 테스트**들이 만드는 것으로 P2-2 이전부터의 성질이며 `.gitignore:14` 대상입니다. 신규 테스트가 원인이 아님을 위 실험이 분리해 보여 줍니다.)
|
||||||
|
|
||||||
|
### 1.6 이 테스트가 여전히 잡지 못하는 것 (명시)
|
||||||
|
|
||||||
|
- `create_session.sh` **본문**의 동작(세션 생성 자체)은 검증하지 않습니다. `-h` 로 조기 종료하므로 파서 진입 지점까지만 봅니다. 이는 의도된 범위입니다 — 챌린지가 요구한 것은 "인자 파서 게이트" 입니다.
|
||||||
|
- 다른 레거시 no-op 플래그가 생기면 이 테스트는 자동으로 커버하지 않습니다. `for flag in [...]` 목록에 추가해야 합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Challenge 2 검증 — 수용, 범위 명확화
|
||||||
|
|
||||||
|
`tests/test_tier1_unit.py:31` 의 `# FEATURE 1: Create Session (7 Test Cases)` 를 갱신하라는 지적입니다. 파일 전체의 헤더 정합성을 실측했습니다.
|
||||||
|
|
||||||
|
| 헤더 라인 | 섹션 | 선언 | 실측 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 31 | FEATURE 1: Create Session | 7 | **7** ✅ |
|
||||||
|
| 106 | FEATURE 2: Resume Session | 6 | **6** ✅ |
|
||||||
|
| 152 | FEATURE 3: Stop Session | 5 | **5** ✅ |
|
||||||
|
| 197 | FEATURE 4: Status Query | 5 | **5** ✅ |
|
||||||
|
| 283 | FEATURE 5: Monitor/Reconcile | 6 | **6** ✅ |
|
||||||
|
| | 합계 | 29 | **29** (`grep -c "^def test_"` = 29) ✅ |
|
||||||
|
|
||||||
|
**5개 헤더 전부 현재 정확합니다.** 이 파일은 메타데이터를 성실하게 유지해 온 파일이고, 따라서 `(7 Test Cases)` 를 방치하면 그것이 **이 파일 최초의 불일치**가 됩니다. 챌린지 판단이 옳습니다.
|
||||||
|
|
||||||
|
**갱신값은 `(5 Test Cases)`** 입니다 — 7 − 3(삭제) + 1(신규) = 5. 다른 4개 헤더는 손대지 않습니다(변동 없음).
|
||||||
|
|
||||||
|
패치 적용 후 재실측:
|
||||||
|
|
||||||
|
```
|
||||||
|
31 FEATURE 1: Create Session claimed=5 actual=5 OK
|
||||||
|
77 FEATURE 2: Resume Session claimed=6 actual=6 OK
|
||||||
|
123 FEATURE 3: Stop Session claimed=5 actual=5 OK
|
||||||
|
168 FEATURE 4: Status Query claimed=5 actual=5 OK
|
||||||
|
254 FEATURE 5: Monitor/Reconcile claimed=6 actual=6 OK
|
||||||
|
file total: 27
|
||||||
|
```
|
||||||
|
|
||||||
|
`tests/test_tier2_component.py` 에는 이런 개수 선언 헤더가 없으므로 해당 파일은 추가 조치 불필요합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Challenge 3 판정 — 사실관계 반박, 우려는 §4.3 으로 수용
|
||||||
|
|
||||||
|
### 3.1 HEAD 는 이동하지 않았습니다
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git rev-parse --short HEAD
|
||||||
|
5e519e2
|
||||||
|
$ git log --oneline -1
|
||||||
|
5e519e2 docs(improvements): synchronize header counts and roadmap table with completed P2-1 task
|
||||||
|
```
|
||||||
|
|
||||||
|
Rev.1 의 기준 커밋이 `5e519e2` 이고 현재 HEAD 도 `5e519e2` 입니다. 챌린지가 지목한 `b490713`(P2-1 수정)은 **4 커밋 이전**이며, 그 이후의 `af3dc16` → `a875b13` → `5e519e2` 가 전부 문서 커밋입니다. 그중 `5e519e2` 는 커밋 제목 그대로 **"헤더 개수와 로드맵 표를 P2-1 완료와 동기화"** 한 커밋 — 즉 챌린지가 요구하는 동기화는 **Rev.1 작성 시점에 이미 반영된 상태**였습니다.
|
||||||
|
|
||||||
|
### 3.2 Rev.1 의 인용 라인 20개 전수 재검증
|
||||||
|
|
||||||
|
챌린지를 계기로 §6.1·§6.3 이 인용한 모든 라인을 다시 대조했습니다.
|
||||||
|
|
||||||
|
| 인용 | 현행 내용 | 판정 |
|
||||||
|
|---|---|---|
|
||||||
|
| `:5` | `총 추적 미해결 과제: 9건 (아키텍처 2, 엣지케이스 4, 오케스트레이션 0, 레거시 잔재 3)` | ✅ |
|
||||||
|
| `:6` | `완료된 과제: **16건** (A-1 … P2-1-DelegateJobSafe-TrapFix)` | ✅ |
|
||||||
|
| `:70` | `## 2. 엣지 케이스 및 런타임 버그 (Edge-case Bugs — 5건)` | ✅ |
|
||||||
|
| `:107` | `## 4. 레거시 잔재 및 죽은 코드 (Legacy Remnants — 3건)` | ✅ |
|
||||||
|
| `:109-111` | C-3 제목 / C-3a / C-3b | ✅ |
|
||||||
|
| `:113-116` | C-4 제목 / 실제 대상 3종 / 목록 제외 / provision_isolation 중복 | ✅ |
|
||||||
|
| `:123` | `## 5. 완료된 과제 (Completed Tasks — 13건)` | ✅ |
|
||||||
|
| `:249` | 로드맵 P2-2 행 ("공허한 테스트 5건") | ✅ |
|
||||||
|
| `:260` | "정리(C 계열)를 P2 에 두는 이유" | ✅ |
|
||||||
|
| `:317` `:319-322` | §6.5-1 / §6.5-2 | ✅ |
|
||||||
|
| `:328` | §6.6 결론 ("총 12건") | ✅ |
|
||||||
|
|
||||||
|
**20/20 일치.** 오프셋 충돌은 발생하지 않습니다.
|
||||||
|
|
||||||
|
### 3.3 챌린지의 수치 주장은 사실과 다릅니다
|
||||||
|
|
||||||
|
챌린지 §Challenge 3 은 *"완료 과제 개수도 13건(문두 완료 **15건**)으로 갱신되었습니다"* 라고 적었습니다. 실측:
|
||||||
|
|
||||||
|
```
|
||||||
|
:6 - **완료된 과제**: **16건** (A-1, A-3, A-5, B-1, B-3, B-4, B-7, B-8, C-1, C-2,
|
||||||
|
O-1, O-2, O-3, O-4-OrcOnboard,
|
||||||
|
Herdr-0.8.0-Compat-SanitizeHash, P2-1-DelegateJobSafe-TrapFix)
|
||||||
|
```
|
||||||
|
|
||||||
|
쉼표 구분 항목 수 = **16개**, 선언값 = **16건**. 문두는 15가 아니라 **16**이며 목록과 자체 정합합니다. Rev.1 §6.1 의 "16건 → 17건" 이 맞습니다.
|
||||||
|
|
||||||
|
한편 챌린지가 같은 문장에서 언급한 *"C-3/C-4 섹션의 시작 위치가 `IMPROVEMENTS.md:107`"* 은 Rev.1 §6.1 이 이미 `:107` 로 적고 있는 값과 동일합니다 — 이 대목은 정정이 아니라 **Rev.1 의 확인**입니다.
|
||||||
|
|
||||||
|
### 3.4 그럼에도 수용하는 부분 — 편집 상호 간섭
|
||||||
|
|
||||||
|
챌린지가 우려한 "오프셋 충돌" 은 **HEAD 대비**로는 존재하지 않지만, **편집 도중**에는 실재합니다. §6.1 의 지시 11개가 **전부 같은 파일**을 대상으로 하고, 그중 3개가 줄 수를 바꿉니다:
|
||||||
|
|
||||||
|
- `:113-116` C-4 블록 **삭제** (−4줄) → 이후 모든 라인 상향 이동
|
||||||
|
- `:123` 직후 P2-2 완료 항목 **삽입** (+16줄) → 이후 모든 라인 하향 이동
|
||||||
|
- `:109-111` C-3 축소 (줄 수 변동 가능)
|
||||||
|
|
||||||
|
따라서 구현자가 `:5` → `:328` 순으로 위에서 아래로 편집하면 **`:249` 이후의 라인 번호가 전부 어긋납니다.** 이것이 챌린지가 감지한 실제 위험이며, 해법은 "HEAD 동기화" 가 아니라 **편집 순서 규정**입니다. §4.3 에 신설했습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Rev.1 대비 변경 명세
|
||||||
|
|
||||||
|
> Rev.1(`07740cc7`)의 §1~§4(실측·경계·위험), §7.1 게이트, §8 비용·효과 정정, §9 예상 지적은 **전부 유효하며 변경 없습니다.** 아래는 델타만 기술합니다.
|
||||||
|
|
||||||
|
### 4.1 S5 개정 — 테스트 4건 제거 → **4건 제거 + 1건 추가 + 헤더 1줄**
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/test_tier1_unit.py
|
||||||
|
:31 "(7 Test Cases)" → "(5 Test Cases)" [Challenge 2]
|
||||||
|
:52-88 test_create_isolation_lever
|
||||||
|
test_create_isolation_env_prefix 삭제
|
||||||
|
test_create_isolation_cmd_args
|
||||||
|
같은 자리 test_create_session_legacy_isolate_flags_noop 신설 [Challenge 1]
|
||||||
|
|
||||||
|
tests/test_tier2_component.py
|
||||||
|
:99-107 test_comp_create_isolation_folder_setup 삭제
|
||||||
|
```
|
||||||
|
|
||||||
|
패치 전체(`proposed/p2-2_c3a_c4.rev2.patch`): **5 files, +14 / −72**. Rev.1 은 +5/−72 였습니다.
|
||||||
|
|
||||||
|
### 4.2 §7.2 개정 — 수동 스모크 항목 정리
|
||||||
|
|
||||||
|
Rev.1 §7.2 의 3개 요구 중 **3번(`--isolate`/`--no-isolate` 각 1회 수동 실행)은 자동화되었으므로 삭제**합니다. 이것이 Challenge 1 의 핵심 성과입니다 — 수동 절차가 CI 게이트로 승격되었습니다.
|
||||||
|
|
||||||
|
구현자가 여전히 직접 해야 할 것:
|
||||||
|
|
||||||
|
1. **`pytest tests/ -q` 재실행** — 사본에는 `.mam/`(gitignore)이 없습니다. **256 passed** 재현 확인.
|
||||||
|
2. **`create_session.sh` 실경로 스모크 1회** (`--dry-run` 가능) — `ISOLATE` 제거가 파서 본류에 영향 없음을 실행으로 확인. (신규 테스트는 `-h` 조기 종료 경로까지만 봅니다 — §1.6)
|
||||||
|
|
||||||
|
### 4.3 §6.1 신설 — 편집 순서 (Challenge 3 수용)
|
||||||
|
|
||||||
|
`IMPROVEMENTS.md` 의 11개 지시는 **반드시 아래 순서(= 라인 번호 내림차순)로** 적용하십시오. 그러면 앞선 편집이 뒤이을 편집의 라인 번호를 바꾸지 않습니다.
|
||||||
|
|
||||||
|
| 순 | 대상 | 작업 | 줄 수 변화 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `:319-322` §6.5-2 | C-4 완료 표기. **`:320` 의 `lib.sh:57`→`:79` 를 `lib.sh:83`→`:105` 로 정정** | ±0 |
|
||||||
|
| 2 | `:317` §6.5-1 | C-3a 완료 표기. 총계 표현 있으면 "4건" | ±0 |
|
||||||
|
| 3 | `:260` | 근거 문장 교체 (Rev.1 §8) | ±0 |
|
||||||
|
| 4 | `:249` 로드맵 행 | "5건"→"4건", `(✅ 완료 — 256/256 PASS)` | ±0 |
|
||||||
|
| 5 | `:123` 직후 | §5 최상단에 P2-2 완료 항목 삽입 (§4.4) | **+16** |
|
||||||
|
| 6 | `:123` §5 제목 | 항목 수 갱신 | ±0 |
|
||||||
|
| 7 | `:113-116` C-4 블록 | §4 에서 **삭제** (내용은 5번에서 이미 §5 로 이관) | **−4** |
|
||||||
|
| 8 | `:109-111` C-3 | 제목을 `C-3b: isolation.root 소비자 처분 (보류 — A-4 M2)` 으로 축소, C-3a 줄 제거 | −1 내외 |
|
||||||
|
| 9 | `:107` §4 제목 | `Legacy Remnants — 3건` → **2건** | ±0 |
|
||||||
|
| 10 | `:6` | 완료 `16건` → **17건**, 목록에 `P2-2-C3a-C4-LegacyCleanup` 추가 | ±0 |
|
||||||
|
| 11 | `:5` | 미해결 `9건` → **8건**, `레거시 잔재 3건` → **2건** | ±0 |
|
||||||
|
|
||||||
|
**대안 (권장)**: 라인 번호 대신 **고유 문자열 앵커**로 편집하면 순서 제약이 사라집니다. 위 11개 지시는 모두 유일 문자열을 갖고 있습니다(예: `Legacy Remnants — 3건`, `공허한 테스트 5건`, `Completed Tasks — 13건`). 도구가 문자열 치환을 지원한다면 그쪽이 안전합니다.
|
||||||
|
|
||||||
|
> ⚠️ Rev.1 §6.3 은 "`:115`/`:320` 의 라인 번호를 정정" 하라고 했으나, **`:115` 는 7번에서 삭제되는 C-4 블록 안에 있습니다.** 따라서 정정 대상은 `:320` **하나**이며, `:115` 의 내용은 §5 로 이관될 때(§4.4 마지막 항목) 이미 올바른 `lib.sh:83-84 → :105` 로 적혀 나갑니다. Rev.2 에서 정정합니다.
|
||||||
|
|
||||||
|
### 4.4 §6.2 개정 — §5 완료 항목 (테스트 문구 수정)
|
||||||
|
|
||||||
|
Rev.1 초안에서 **두 번째 불릿만** 교체합니다.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- 위 스텁의 빈 출력만 재확인하던 공허한 테스트 4건(`tests/test_tier1_unit.py` 3,
|
||||||
|
`tests/test_tier2_component.py` 1)을 제거하고, 그 자리에 `--isolate`/`--no-isolate`
|
||||||
|
레거시 no-op 플래그의 인자 파서 계약을 고정하는
|
||||||
|
`test_create_session_legacy_isolate_flags_noop` 1건을 신설했습니다. 신규 테스트는
|
||||||
|
분기 삭제·한쪽만 삭제·조용한 no-op 화·usage 문서 줄 삭제 4종 변이를 모두 검출함을
|
||||||
|
변이 검사로 입증했습니다. `test_tier1_unit.py:31` 섹션 헤더도 `(5 Test Cases)` 로
|
||||||
|
동기화했습니다.
|
||||||
|
```
|
||||||
|
|
||||||
|
마지막 불릿의 수치도 갱신합니다: **`전체 회귀 256/256 PASS (100%)` (259 → 256, 순감 3 = 제거 4 − 신설 1)**.
|
||||||
|
|
||||||
|
### 4.5 §6.4 개정 — `LOG.md`
|
||||||
|
|
||||||
|
주요 구현 목록의 테스트 줄을 교체하고 검증 수치를 갱신합니다.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- `tests/test_tier1_unit.py` / `tests/test_tier2_component.py`: 공허한 테스트 4건 제거 및
|
||||||
|
`--isolate`/`--no-isolate` no-op 회귀 가드 1건 신설(변이 4종 검출 입증), 섹션 헤더 동기화.
|
||||||
|
- **검증**: `pytest tests/ -q` **256 passed (100%)**.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.6 §3 미접촉 경계 — 한 줄 보강
|
||||||
|
|
||||||
|
Rev.1 §3 표의 `--isolate`/`--no-isolate` 행 사유를 다음으로 대체합니다.
|
||||||
|
|
||||||
|
> 레거시 호환 경고이자 **`create_session.sh:42-43` 에 정식 문서화된 옵션**. 제거하면 기존 호출자가 `unknown arg` 로 `exit 2`. **P2-2 이후로는 `test_create_session_legacy_isolate_flags_noop` 이 CI 게이트로 이를 고정한다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Rev.2 검증 결과
|
||||||
|
|
||||||
|
| # | 검증 | 기대 | 실측 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| V1 | `bash -n lib.sh` / `create_session.sh` | rc=0 | ✅ (Rev.1 에서 확인, 해당 hunk 무변경) |
|
||||||
|
| V2 | `ast.parse(registry.py)` | rc=0 | ✅ (동상) |
|
||||||
|
| V3 | 신규 테스트 단독 실행 | pass | ✅ **1 passed, 0.09s call** |
|
||||||
|
| V4 | 변이 A (분기 2개 삭제) | FAIL | ✅ FAIL |
|
||||||
|
| V5 | 변이 B (한쪽만 삭제) | FAIL | ✅ FAIL |
|
||||||
|
| V6 | 변이 C (조용한 no-op) | FAIL | ✅ FAIL |
|
||||||
|
| V7 | 변이 D (usage 문서 줄 삭제) | FAIL | ✅ FAIL *(강화 후. 원안은 PASS)* |
|
||||||
|
| V8 | 변이 E (무변이 대조군) | PASS | ✅ PASS |
|
||||||
|
| V9 | 신규 테스트의 저장소 오염 | 0건 | ✅ `.mam` 미생성 |
|
||||||
|
| V10 | tier1 섹션 헤더 5개 정합 | 전부 일치 | ✅ 5/5 |
|
||||||
|
| V11 | 미사용화되는 헬퍼·임포트 | 없음 | ✅ `run_lib_func` 15회, `get_mqtt_common` 7회, `subprocess`/`shlex`/`hmac`/`hashlib` 전부 잔존 사용 |
|
||||||
|
| V12 | 수집 개수 | 259 → 256 | ✅ **256 collected** |
|
||||||
|
| V13 | `pytest tests/ -q` 전체 | 256 passed | ✅ **256 passed in 392.29s** |
|
||||||
|
|
||||||
|
### 5.1 전체 회귀 (Rev.2 사본)
|
||||||
|
|
||||||
|
```
|
||||||
|
256 passed in 392.29s (0:06:32)
|
||||||
|
```
|
||||||
|
|
||||||
|
원본 로그는 `proposed/pytest_rev2.log` 입니다. 참고로 Rev.1(255건) 은 376.08s 였습니다 — 차이 16s 는 신규 테스트 1건(0.09s)으로 설명되지 않는 **실행 간 편차**이며, Rev.1 §8 에서 이미 밝혔듯 이 스위트의 총 실행 시간은 P2-2 의 판단 근거가 아닙니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 검증 한계 (Rev.1 §10 갱신)
|
||||||
|
|
||||||
|
1. **실측은 `5e519e2` 로컬 클론에서 수행**. 실제 트리에서의 256 passed 는 **미확인** — §4.2-1 이 요구합니다.
|
||||||
|
2. **`create_session.sh` 본류 실행 스모크 미수행.** 신규 테스트는 `-h` 조기 종료 경로까지만 검증합니다(§1.6). §4.2-2 가 요구합니다.
|
||||||
|
3. **변이 검사는 `create_session.sh` 4종에 한정.** `lib.sh` 스텁 제거·`registry.py`·`_REAL_HERDR_PATH` 에는 변이 검사를 적용하지 않았습니다(제거 대상이라 고정할 계약이 없음 — Rev.1 §4.3).
|
||||||
|
4. **`_REAL_HERDR_PATH` 의 저장소 외부 소비자 미검색.** 확인 범위는 저장소 트리, 생성된 `.mam/shim/herdr`, `.agents/hooks/`, `~/.claude/settings.json` (Rev.1 §10-4 유지).
|
||||||
|
5. **`shellcheck` 미설치** — 정적 분석은 `bash -n` 까지.
|
||||||
|
6. **macOS · 직렬 실행**. Linux · `pytest-xdist` 병렬 미검증(xdist 미설치). 신규 테스트는 `mam_sandbox`(`tmp_path`) 안에서만 쓰기하므로 병렬 안전할 것으로 **판단**하나 실측은 아닙니다.
|
||||||
|
7. **챌린지 §Challenge 3 의 "15건" 반박은 `IMPROVEMENTS.md` 현행 파일 대조에 근거**합니다. 챌린저가 다른 시점의 파일을 봤을 가능성은 배제하지 못하나, HEAD 가 `5e519e2` 로 고정되어 있고 워킹 트리가 clean 이므로 두 에이전트가 본 파일은 동일해야 합니다.
|
||||||
|
8. 본 계획은 Planner 산출물이므로 **`IMPROVEMENTS.md` / `LOG.md` / 소스를 직접 수정하지 않았습니다.** §4 는 구현자가 적용할 명세입니다.
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# 🔎 교차 코드 리뷰 리포트 (3차) — A-4 Phase 2 후속 수정분
|
||||||
|
|
||||||
|
- **Job ID**: `31730364`
|
||||||
|
- **Reviewer**: claude (session: `herdr:canary-projects-multi-agent-mux-creator-claude`)
|
||||||
|
- **Role**: Reviewer (`MULTI_AGENT_RULES.md` §1 — 저장소 파일 0건 변경)
|
||||||
|
- **리뷰 대상**: `b4821fa` + `7708d3a` 위의 **작업 트리 미커밋 수정분 12파일** (직전 리뷰 `6360d5e4` 대응분)
|
||||||
|
- **기준**: HEAD `7708d3a`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 요약
|
||||||
|
|
||||||
|
직전 리뷰(`6360d5e4`)의 **유일한 차단 사유 N1 이 해소되었고, 실측으로 확인했습니다.** 부수 지적 N3·N4 도 정리되었습니다. 1차(`ae4f2ef3`)의 차단 사유 R1·R2 는 이번에도 재현 프로브로 재검증했으며 그대로 유지됩니다.
|
||||||
|
|
||||||
|
이번 라운드의 검증은 **뮤테이션 테스트**로 수행했습니다 — 신규 가드 3종 각각에 대해 대응하는 생산 코드를 고의로 되돌린 뒤, 그 가드가 실제로 죽는지 격리 클론에서 확인했습니다. 결과:
|
||||||
|
|
||||||
|
| 가드 | 대응 뮤테이션 | 결과 |
|
||||||
|
|---|---|---|
|
||||||
|
| `test_cli_bridge_subcommands_and_quote_safety` | `__main__.py` 의 3개 서브커맨드 제거 | **FAIL** ✅ 진짜 가드 |
|
||||||
|
| `test_wait_for_tui_ready_missing_tokens_diagnostic` | `lib.sh` 의 빈 토큰 가드 제거 | **FAIL** ✅ 진짜 가드 |
|
||||||
|
| `test_delegate_agent_resolution_and_fallback` | `create_session.sh` 의 R1 수정을 원래 결함으로 되돌림 | **PASS** ❌ **가드 아님** |
|
||||||
|
|
||||||
|
즉 **N2 는 형태만 갖춰졌을 뿐 여전히 미해결**입니다. 다만 이는 이미 올바른 생산 코드에 대한 회귀 가드 부재이지 동작 결함이 아니고, 직전 리뷰에서도 비차단으로 분류했던 항목이므로 판정은 유지합니다.
|
||||||
|
|
||||||
|
| # | 등급 | 요지 |
|
||||||
|
|---|---|---|
|
||||||
|
| **N2** | 🟡 **필수 후속** | `test_delegate_agent_resolution_and_fallback` 이 `create_session.sh` 를 실행하지 않고 **테스트 안에 복사한 스니펫**을 실행합니다. R1 수정을 완전히 되돌려도 전 스위트가 녹색 — 뮤테이션으로 증명 |
|
||||||
|
| N5 | ⚪ | `_MAM_READY_TOKENS_CLAUDE` 중복 존치 (3라운드 연속 비차단) |
|
||||||
|
| R6·R7 | ⚪ | 두 건의 동작 변경이 여전히 커밋 메시지·`LOG.md` 에 미기록 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. N1 — 해소 확인 ✅
|
||||||
|
|
||||||
|
`test_cli_bridge_subcommands_and_quote_safety` 가 `env = os.environ.copy()` + `env["PYTHONPATH"]` 를 구성해 3개 `subprocess.run` 전부에 `env=env` 를 넘기도록 수정되었습니다. `test_facts_bridge_eval_contract:73-76` 의 기존 선례를 정확히 따랐습니다.
|
||||||
|
|
||||||
|
**실측 — 직전 라운드와 동일 조건에서 대조:**
|
||||||
|
|
||||||
|
```
|
||||||
|
$ env -u PYTHONPATH .venv/bin/python -m pytest tests/test_a4_adapter_contract.py -q
|
||||||
|
직전: 1 failed, 11 passed (ModuleNotFoundError: No module named 'lib_py')
|
||||||
|
현재: 12 passed in 0.44s ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
`deploy/gitea-ci.yml` 의 `pytest tests/ -q` 가 적색이 되던 원인이 제거되었습니다.
|
||||||
|
|
||||||
|
## 2. N3 · N4 — 해소 확인 ✅
|
||||||
|
|
||||||
|
- **N3**: `create_session.sh` 의 중복 화이트리스트가 제거되어 preflight `:85` 하나만 남았습니다. (제가 1차 리포트에서 "검증이 없다"고 잘못 쓴 데 대응해 추가되었던 블록입니다.)
|
||||||
|
- **N4**: `verify_session.py` 에서 `resolve_home` 참조가 **0건**이 되었습니다. 모듈 레벨 import 제거가 안전함도 확인했습니다 — `from lib_py.verify_session import …` 전수 조사 결과 `resolve_home` 을 이 모듈에서 가져다 쓰는 곳은 없습니다.
|
||||||
|
|
||||||
|
죽은 import 재스캔 결과, 이번 리팩터가 만든 것은 **전부 정리**되었습니다.
|
||||||
|
|
||||||
|
| 파일 | 잔여 | 귀속 |
|
||||||
|
|---|---|---|
|
||||||
|
| `verify_session.py` | 0건 ✅ | — |
|
||||||
|
| `workspace_uuid.py` | 0건 ✅ | — |
|
||||||
|
| `atomic_yaml.py` | 5건 | 리팩터 이전부터 존재 |
|
||||||
|
| `agents/__main__.py` | `json` 1건 | 리팩터 이전부터 존재 |
|
||||||
|
| `agents/base.py` | `json`·`sqlite3`·`List` 3건 | 리팩터 이전부터 존재 |
|
||||||
|
|
||||||
|
## 3. R1 · R2 — 재검증 유지 ✅
|
||||||
|
|
||||||
|
| 검사 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| R1: 브리지 사용 불가 시 위임 키 | claude→`claude-code`, agy→`antigravity-cli`, hermes→`hermes-agent`, cline→`cline-agent` (4/4) |
|
||||||
|
| R2: 1차에서 코드 실행에 성공했던 페이로드 재투입 | `/bin/claude --dangerously-skip-permissions --session-id u1` — 실행 흔적 없음 |
|
||||||
|
| `bash -n` (변경된 셸 5종) | 5/5 OK |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 🟡 N2 (필수 후속) — 위임 폴백 테스트가 자기 자신을 검사함
|
||||||
|
|
||||||
|
**위치**: `tests/test_a4_adapter_contract.py:315-347`
|
||||||
|
|
||||||
|
추가된 §2 블록은 주석에 `Shell fallback resolution when MAM_DELEGATE_AGENT_KEY is unset (R1 fallback)` 이라 적혀 있으나, 실행 대상이 `create_session.sh` 가 아니라 **테스트 파일 안에 f-string 으로 복사해 둔 `case` 문**입니다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
sh_snippet = f'''
|
||||||
|
AGENT="{agent}"
|
||||||
|
...
|
||||||
|
case "$AGENT" in
|
||||||
|
claude) delegate_agent="claude-code" ;; # ← 테스트가 스스로 써 넣은 코드
|
||||||
|
...
|
||||||
|
'''
|
||||||
|
res = subprocess.run(["bash", "-c", sh_snippet], ...)
|
||||||
|
assert res.stdout.strip() == expected_key
|
||||||
|
```
|
||||||
|
|
||||||
|
생산 코드를 한 줄도 읽지 않으므로, 단언하는 것은 "테스트가 방금 작성한 `case` 문이 작성된 대로 동작한다" 뿐입니다.
|
||||||
|
|
||||||
|
### 뮤테이션 증명
|
||||||
|
|
||||||
|
격리 클론(`git clone --local --no-hardlinks`)에 작업 트리 상태를 복사한 뒤, `create_session.sh:249-259` 의 R1 수정을 **원래 결함 형태로 완전히 되돌렸습니다**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
- delegate_agent="${MAM_DELEGATE_AGENT_KEY:-}"
|
||||||
|
- if [ -z "$delegate_agent" ]; then
|
||||||
|
- case "$AGENT" in
|
||||||
|
- claude) delegate_agent="claude-code" ;;
|
||||||
|
- ...
|
||||||
|
- fi
|
||||||
|
+ delegate_agent="${MAM_DELEGATE_AGENT_KEY:-antigravity-cli}" # ← 1차에서 차단했던 바로 그 결함
|
||||||
|
```
|
||||||
|
|
||||||
|
결과:
|
||||||
|
|
||||||
|
```
|
||||||
|
baseline (수정 상태) : 12 passed in 0.46s
|
||||||
|
mutant (결함 복원) : 12 passed in 0.46s ← 아무도 눈치채지 못함
|
||||||
|
```
|
||||||
|
|
||||||
|
즉 지금 R1 수정을 되돌리고 커밋해도 전 스위트가 녹색입니다. 1차에서 차단했던 "claude 세션의 위임 잡이 `antigravity-cli` 로 기록되는" 결함이 그대로 재유입될 수 있습니다.
|
||||||
|
|
||||||
|
**직전 라운드보다 나빠진 점**이 하나 있습니다. 이전에는 이 테스트가 단순 중복 단언이라 "가드가 없다"는 사실이 코드만 봐도 드러났지만, 지금은 R1 을 명시적으로 언급하는 주석과 셸 실행이 붙어 **가드가 있는 것처럼 읽힙니다.** 후속 작업자가 이를 근거로 안심할 여지가 생겼습니다.
|
||||||
|
|
||||||
|
### 권고
|
||||||
|
|
||||||
|
`create_session.sh` 를 실제로 실행하되 브리지만 실패하게 만드는 형태로 교체하십시오. 예:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_delegate_agent_fallback_in_create_session(tmp_path):
|
||||||
|
# PATH 앞단에 실패하는 python 스텁을 놓아 facts 브리지만 죽인다
|
||||||
|
...
|
||||||
|
res = subprocess.run(["bash", "-c",
|
||||||
|
f'cd {ws} && bash {create_sh} --workspace {ws} --agent claude '
|
||||||
|
f'--role creator --submit-job "x" --dry-run'], ...)
|
||||||
|
assert "claude-code" in res.stdout # antigravity-cli 가 아님
|
||||||
|
```
|
||||||
|
|
||||||
|
`--dry-run` 경로가 위임 블록에 도달하지 않는다면, 최소한 스크립트 본문에서 해당 `case` 블록을 추출해 실행하는 형태(파일을 읽어 `sed`/`awk` 로 잘라내 `bash -c`)로라도 **생산 파일이 입력에 포함**되어야 합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. ⚪ 잔여 (비차단, 판정 무관)
|
||||||
|
|
||||||
|
### N5 — `_MAM_READY_TOKENS_CLAUDE` 중복 존치
|
||||||
|
|
||||||
|
`lib.sh` 에 여전히 2회 등장합니다(`:63` 정의, `:1735` `handle_startup_dialogs` 소비). `ClaudeAgentAdapter.ready_tokens` 와 동일 문자열을 두 곳이 각자 보유하는 상태로, M7 이 없애려던 이중 진실원입니다. 1·2차에 이어 3라운드 연속 비차단으로 남깁니다 — 값이 갈라지기 전까지는 무해하나, 갈라지면 조용히 어긋납니다.
|
||||||
|
|
||||||
|
### R6 · R7 — 동작 변경 미기록
|
||||||
|
|
||||||
|
- **R6**: purge 경로 키가 `workspace_key()` 의 `realpath` 기준으로 전환 (심볼릭 링크 하위 워크스페이스에서 삭제 대상 파일이 달라짐).
|
||||||
|
- **R7**: `verify_session_uuid` 가 미지 에이전트에 대해 `True` → `False` 로 fail-closed 전환.
|
||||||
|
|
||||||
|
둘 다 방향은 옳으나 커밋 메시지·`LOG.md` 어디에도 서술이 없습니다. 차단하지 않되, P3-1 커밋을 최종 확정할 때 한 줄씩 남기기를 권고합니다.
|
||||||
|
|
||||||
|
### 문서 — 3라운드 지적 전부 해소 상태 유지 ✅
|
||||||
|
|
||||||
|
`IMPROVEMENTS.md` 의 §2/§4/§5 카운트와 머리말 일치, C-3b 의 자기모순 항목 제거, 로드맵 P3-1/P3-3 완료 표기, `LOG.md` 의 `## 📌 1.` 헤딩 복원, 격리 잔재 문구 3곳 교정 — 모두 유지되고 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 검증 결과
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 전체 회귀 `pytest tests/ -q` | **262 passed in 381.58s (0:06:21)** — 독립 재실행 확인 |
|
||||||
|
| `test_a4_adapter_contract.py` (`env -u PYTHONPATH`) | **12 passed** — N1 해소 (직전: 1 failed) |
|
||||||
|
| **뮤테이션 M1** — R1 수정 되돌림 | **12 passed (탐지 실패)** → N2 |
|
||||||
|
| **뮤테이션 M2** — `__main__.py` 서브커맨드 3종 제거 | **1 failed** ✅ 가드 유효 |
|
||||||
|
| **뮤테이션 M3** — `wait_for_tui_ready` 빈 토큰 가드 제거 | **1 failed** ✅ 가드 유효 |
|
||||||
|
| R1 재현 (브리지 실패 시 위임 키) | 4/4 정상 |
|
||||||
|
| R2 재현 (코드 주입 페이로드) | 무력 |
|
||||||
|
| `bash -n` (셸 5종) | 5/5 OK |
|
||||||
|
| 죽은 import (이번 리팩터 귀속분) | 0건 |
|
||||||
|
| `resolve_home` 제거 안전성 | 외부 소비자 0건 확인 |
|
||||||
|
| `_MAM_READY_TOKENS_CLAUDE` | 2회 존치 (N5) |
|
||||||
|
|
||||||
|
M3 이 41초 걸린 점도 기록해 둡니다 — 가드를 제거하면 함수가 30회 sleep 루프로 빠지며, 이는 Rev.2 계획서가 예측했던 "크래시가 아니라 30초 오탐 타임아웃" 거동과 정확히 일치합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 한계
|
||||||
|
|
||||||
|
- macOS(darwin 25.5.0) 단일 환경. N1 해소는 `env -u PYTHONPATH` 로 확인했을 뿐 실제 CI 러너 실행은 아닙니다.
|
||||||
|
- 뮤테이션은 격리 클론에서만 수행했고, 각 뮤테이션 후 원본을 복원해 서로 간섭하지 않게 했습니다. 저장소 작업 트리는 리뷰 전후 동일(12 M + 1 ??)합니다.
|
||||||
|
- `shellcheck` · `pyflakes` 미설치 — 셸은 `bash -n`, Python 미사용 import 는 자체 AST 스캔(보수적).
|
||||||
|
- hermes 미설치로 해당 어댑터의 `auth_ok`/`discover` 는 계약 테스트로만 확인.
|
||||||
|
- R2 주입 프로브는 stderr 출력만 하는 비파괴 페이로드입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 결론
|
||||||
|
|
||||||
|
3라운드에 걸친 차단 사유가 모두 해소되었습니다.
|
||||||
|
|
||||||
|
1. **R1**(위임 키 조용한 오값) — 수정, 재현 검증 완료
|
||||||
|
2. **R2**(Python 소스 보간 → 조용한 폴백 + 코드 주입) — argv 서브커맨드로 교체, 페이로드 무력화 확인
|
||||||
|
3. **N1**(회귀 가드가 주변 `PYTHONPATH` 에 의존해 CI 적색) — 수정, 깨끗한 환경에서 12/12 확인
|
||||||
|
|
||||||
|
부수 지적 N3·N4 도 정리되었고, 신규 가드 3종 중 2종은 뮤테이션으로 **실제 방어력이 있음을 증명**했습니다. 문서 동기화도 유지되고 있습니다. 어댑터 계층 자체는 1차 리뷰 때부터 견고했고 그대로입니다.
|
||||||
|
|
||||||
|
남은 **N2 는 이미 올바른 코드에 대한 회귀 가드가 비어 있는 문제**이지 동작 결함이 아니며, 직전 리뷰에서도 비차단으로 분류한 항목입니다. 지금 와서 차단 사유로 승격하는 것은 기준을 뒤로 옮기는 일이므로 그렇게 하지 않습니다. 다만 "가드가 있는 것처럼 보이는 가드"는 없는 것보다 위험할 수 있으므로 **다음 커밋 전 필수 후속**으로 명시합니다.
|
||||||
|
|
||||||
|
설계 변경 요소는 없습니다.
|
||||||
|
|
||||||
|
**필수 후속**: N2
|
||||||
|
**권고**: N5, R6·R7 기록
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Code Review Report — Job 120ffb08
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Cross-code review of commit `b490713` ("fix(loop): eliminate tmp script copy and trap leak in delegate_job_safe (P2-1/B-6)"). The commit eliminates temporary script copies in the skill tree, removes a trap that caused loop lock early release (D1) in command substitution subshells, adds diagnostic error logging for failed delegations, adds startup self-healing cleanup of stale .tmp files, and replaces 1 text-based test with 4 new behavioral tests.
|
||||||
|
|
||||||
|
**Verdict: PASS** — All changes are functionally correct. 58/58 tests pass across 5 test files. All syntax checks pass. Findings are Low/Info severity only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Files reviewed (commit `b490713`, 4 files, +211/-18):
|
||||||
|
1. `.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh` — `delegate_job_safe` rewrite, diagnostic logging, startup cleanup
|
||||||
|
2. `tests/test_o3_scoped_guard.py` — 4 new behavioral tests replacing 1 old text-based test
|
||||||
|
3. `IMPROVEMENTS.md` — B-6 marked complete, B-12 (D1) documented
|
||||||
|
4. `LOG.md` — Change log entry
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Review Findings
|
||||||
|
|
||||||
|
### F1 (Low / Code Quality): `_extract_delegate_job_safe()` uses text parsing
|
||||||
|
|
||||||
|
**Location**: `test_o3_scoped_guard.py:148-156`
|
||||||
|
|
||||||
|
**Description**: The `_extract_delegate_job_safe()` helper extracts the function body from `run_loop.sh` via string search (`content.find("delegate_job_safe() {")` and `content.find("\n}\n", func_start)`). This is fragile if the function definition format changes (e.g., adding a space before `()`).
|
||||||
|
|
||||||
|
**Impact**: None currently — the format is stable and the assertion `assert func_start != -1` provides a clear failure message if parsing breaks.
|
||||||
|
|
||||||
|
**Recommendation**: No action required. Acceptable for a test helper.
|
||||||
|
|
||||||
|
### F2 (Info): `test_z9_probe_detects_the_defect` validates the test catches the bug
|
||||||
|
|
||||||
|
**Location**: `test_o3_scoped_guard.py:180-218`
|
||||||
|
|
||||||
|
**Description**: This test deliberately uses the OLD defective `delegate_job_safe` (with tmp copy + `trap _mam_release_guard EXIT`) inside a command substitution `$(delegate_job_safe submit --task test)`. It asserts `MARKER: RELEASED` — proving the trap fires in the subshell and releases the loop lock. This is excellent test design: it validates that the test suite would catch a regression if someone reintroduced the defect.
|
||||||
|
|
||||||
|
**Impact**: None — correct and valuable test.
|
||||||
|
|
||||||
|
**Recommendation**: No action required.
|
||||||
|
|
||||||
|
### F3 (Info): `test_z9_exit_code_and_diagnostics_propagation` omits `set -e`
|
||||||
|
|
||||||
|
**Location**: `test_o3_scoped_guard.py:255-281`
|
||||||
|
|
||||||
|
**Description**: This test deliberately omits `set -euo pipefail` to allow capturing the exit code via `delegate_job_safe submit --task test || rc=$?`. It verifies exit code propagation (`DELEGATE_RC: 7`), diagnostic logging (`delegate_job_safe failed (exit 7):`), and syntax check hint (`bash -n`).
|
||||||
|
|
||||||
|
**Impact**: None — correct test design for exit code testing.
|
||||||
|
|
||||||
|
**Recommendation**: No action required.
|
||||||
|
|
||||||
|
### F4 (Info): Startup self-healing cleanup
|
||||||
|
|
||||||
|
**Location**: `run_loop.sh:148`
|
||||||
|
|
||||||
|
**Description**: `rm -f "$REPO_ROOT/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job".*.tmp 2>/dev/null || true` cleans up stale .tmp files from previous runs that used the old code. The glob matches the old naming convention (`${orig_script}.${RANDOM}_$$.tmp`). The `2>/dev/null || true` ensures no error if no files match.
|
||||||
|
|
||||||
|
**Impact**: Correct — handles migration from old code gracefully.
|
||||||
|
|
||||||
|
**Recommendation**: No action required.
|
||||||
|
|
||||||
|
### F5 (Info): Diagnostic error logging placement
|
||||||
|
|
||||||
|
**Location**: `run_loop.sh:106-109`
|
||||||
|
|
||||||
|
**Description**: When `delegate_job_safe` fails (non-zero exit), it logs:
|
||||||
|
```
|
||||||
|
log_error "delegate_job_safe failed (exit $rc): $orig_script"
|
||||||
|
log_error " if this loop edits framework skills in place, check that file's syntax:"
|
||||||
|
log_error " bash -n \"$orig_script\""
|
||||||
|
```
|
||||||
|
The comment block (lines 90-101) explains why this is needed: callers' "Failed to register ..." branches are unreachable when the wrapper exits non-zero under `set -e` (the assignment aborts first), so diagnosis must be emitted inside `delegate_job_safe` itself.
|
||||||
|
|
||||||
|
**Impact**: Correct — provides actionable diagnostics for the most common failure mode (syntax errors in framework skills edited in-place during a loop).
|
||||||
|
|
||||||
|
**Recommendation**: No action required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### Syntax Checks
|
||||||
|
- `bash -n .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh` — PASS
|
||||||
|
- `python3 -m py_compile tests/test_o3_scoped_guard.py` — PASS
|
||||||
|
|
||||||
|
### Test Suite
|
||||||
|
- `pytest tests/test_sanitize_and_mock_errors.py tests/test_sanity.py tests/test_o3_scoped_guard.py tests/test_herdr_shim_contract.py tests/test_b4_session_created.py -v`
|
||||||
|
- **Result: 58 passed in 22.54s**
|
||||||
|
|
||||||
|
### New Tests (test_o3_scoped_guard.py)
|
||||||
|
1. `test_z9_loop_lock_survives_delegation` — Verifies loop lock marker remains HELD after delegation with the new in-place code. PASS
|
||||||
|
2. `test_z9_probe_detects_the_defect` — Verifies the OLD defective code (tmp copy + trap) causes RELEASED, proving the test catches regressions. PASS
|
||||||
|
3. `test_z9_no_tmp_copy_left_in_skill_tree` — Verifies no .tmp files remain in the skill tree after delegation. PASS
|
||||||
|
4. `test_z9_exit_code_and_diagnostics_propagation` — Verifies exit code propagation (rc=7) and diagnostic logging (error message + bash -n hint). PASS
|
||||||
|
|
||||||
|
### Code Correctness Analysis
|
||||||
|
|
||||||
|
**delegate_job_safe rewrite**: The old code created a tmp copy (`cp "$orig_script" "$tmp_script"`), set a trap to clean it up, ran the copy, cleaned up, then re-set `trap _mam_release_guard`. The new code simply runs `bash "$orig_script" "$@"` in-place. This eliminates:
|
||||||
|
- B-6: Source tree pollution (no .tmp file created)
|
||||||
|
- D1: Loop lock early release (no `trap _mam_release_guard` in the subshell)
|
||||||
|
|
||||||
|
The `|| rc=$?` pattern correctly captures the exit code without `set -e` aborting the function, and the diagnostic logging provides actionable error messages for the most common failure mode.
|
||||||
|
|
||||||
|
**Startup cleanup**: The `rm -f .../*.tmp` line at startup provides self-healing for any stale .tmp files from previous runs that used the old code. The glob pattern and `2>/dev/null || true` are correct.
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
- shellcheck not available in environment (verified via `bash -n` instead)
|
||||||
|
- Full 256-test suite not re-run in this session (timed out); 58 directly-relevant tests pass
|
||||||
|
- IMPROVEMENTS.md and LOG.md changes are documentation-only, verified by reading
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The commit correctly eliminates the temporary script copy (B-6) and the trap leak (D1) by running the delegate-job wrapper in-place without any trap installation. The diagnostic error logging provides actionable feedback when the wrapper fails. The startup self-healing cleanup handles migration from old code. The 4 new behavioral tests are well-designed — they verify the fix works, prove the test catches the defect, confirm no .tmp files leak, and validate exit code/diagnostics propagation. No blocking issues found.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# Cross-Code Review Report — Job 14187d43
|
||||||
|
|
||||||
|
- **Reviewer**: cline (session: `herdr:canary-projects-multi-agent-mux-creator-cline`)
|
||||||
|
- **Commit under review**: `14b9de1` — `fix(herdr): ensure unique agent name via sha1 truncation and align mock errors`
|
||||||
|
- **Cumulative diff vs base**: clean working tree (`(no changes since base commit)` per brief)
|
||||||
|
- **Scope**: SHA-1 hash truncation for Herdr 0.8.0 name uniqueness (`.agents/skills/lib_py/agents/sanitize.py`, `.agents/skills/lib.sh`), Mock Herdr error output alignment (`tests/conftest.py`), early-abort regex updates (`lib.sh`), and test updates (`tests/test_sanitize_and_mock_errors.py`, `tests/test_sanity.py`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Methodology
|
||||||
|
|
||||||
|
Cross-code review performed across three axes, with Bash↔Python parity as a first-class concern because the sanitize contract is implemented twice:
|
||||||
|
|
||||||
|
1. **Lint / static correctness** — syntax, shell quoting, regex anchoring, duplicate-definition consistency, import resolution.
|
||||||
|
2. **Functionality** — collision-freedom, byte-for-byte Bash↔Python parity, error-class coverage vs. the early-abort regex, lookup-path correctness.
|
||||||
|
3. **Loss / regression** — whether removed assertions reduced coverage, whether the new truncation breaks existing contracts, orphaned code.
|
||||||
|
|
||||||
|
Verification combined (a) direct file reads of all 5 changed files, (b) manual parity/collision computation in both Bash and Python, and (c) execution of the test suite (see §5).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Per-File Findings
|
||||||
|
|
||||||
|
### 2.1 `.agents/skills/lib_py/agents/sanitize.py`
|
||||||
|
|
||||||
|
- The legacy `s[:16]-s[-15:]` truncation is replaced with `f"{s[:23]}-{h}"` where `h = sha1(s)[:8]` (23 + 1 + 8 = 32). Hashing the **full pre-truncation string** (not the prefix) is the correct choice: it guarantees that two names sharing both prefix and suffix — exactly the sibling-workspace collision case — still differ.
|
||||||
|
- Empty-string handling returns `"agent"` (line 13), which now matches the Bash copy (line 32). This closes the prior Bash↔Python divergence documented in job `c30845cb` (Bash `"x-"` vs Python `"agent"`).
|
||||||
|
- Minor: the module-level docstring was dropped (replaced by a bare `import hashlib`). A docstring is not required, but its removal is a (cosmetic) loss of inline documentation. **Not blocking.**
|
||||||
|
|
||||||
|
### 2.2 `.agents/skills/lib.sh` (two copies: lines 29–52 and 216–239)
|
||||||
|
|
||||||
|
- Both copies of `_sanitize_herdr_agent_name` were updated **identically** (verified by reading both ranges). Consistency between the library section and the shim section is preserved.
|
||||||
|
- The hash is computed with a tool cascade `shasum → sha1sum → openssl → python3`, and `printf '%s' "$s"` (no trailing newline) is used as the hash input — matching Python's `s.encode('utf-8')`. Parity verified empirically (§5.2).
|
||||||
|
- `agent get` lookup (lines 286–307): the legacy prefix/suffix heuristic `an.startswith(tn[:14]) and an.endswith(tn[-12:])` is correctly replaced with `an == tn or an == stn` where `stn = sanitize_herdr_agent_name(tn)`. The inline Python imports `sanitize_herdr_agent_name` from `lib_py.agents.sanitize`, which resolves because lib.sh exports `PYTHONPATH="$SKILL_DIR"` (line 25). **Correct.**
|
||||||
|
- Early-abort regex (line 528): `"^usage:|unknown option|unknown flag|missing required|invalid_agent_name|^error:"`. The two new alternatives (`missing required`, `invalid_agent_name`) align with the mock's new error strings (`missing required --pane`, JSON `invalid_agent_name`). Anchoring semantics are correct under `grep -E`: `^usage:` and `^error:` bind only to their alternatives; `invalid_agent_name` is an unanchored substring match that catches the JSON error payload. **Correct.**
|
||||||
|
|
||||||
|
- **Observation (non-blocking):** the Bash fallback (`else h=$(python3 ... || echo "00000000")`) would, in the degenerate case where *all* of `shasum`/`sha1sum`/`openssl`/`python3` are unavailable, emit a constant `00000000` suffix for every long name — reintroducing the very collisions this commit fixes. In practice this path is unreachable (`python3` is a hard dependency of lib.sh itself, and `shasum` is always present on macOS / `sha1sum` on Linux), so it is a theoretical robustness note only. A future improvement could hash a disambiguating fallback (e.g. a counter or `${#s}`), but it is **not** a defect for this review.
|
||||||
|
|
||||||
|
### 2.3 `tests/conftest.py` (mock Herdr `agent start` handler)
|
||||||
|
|
||||||
|
- Unknown-flag rejection (lines 425–427): `sys.stderr.write("unknown option: " + ... + "\n"); sys.exit(1)`. Matches real Herdr 0.8.0 `unknown option: --env` format.
|
||||||
|
- Required-arg validation (lines 429–432): `missing required --pane` / `missing required --kind`. Matches the abort-regex alternative `missing required`.
|
||||||
|
- Name validation (lines 434–446): emits a JSON error payload with `error.code == "invalid_agent_name"`, which the abort-regex catches via the `invalid_agent_name` substring. **Aligned with real 0.8.0 output and with the lib.sh abort gate.**
|
||||||
|
- Validation ordering (unknown flags → required args → name) is sound: each early test in `test_mock_herdr_error_formatting_and_abort` hits the intended branch.
|
||||||
|
|
||||||
|
### 2.4 `tests/test_sanitize_and_mock_errors.py` (new)
|
||||||
|
|
||||||
|
- `test_sanitize_sibling_workspace_non_collision`: asserts the 4 real sibling workspaces (all `canary-projects-*-creator-claude`) sanitize to 4 distinct 32-char names. This is a **direct regression test for the collision bug** the legacy `s[:16]-s[-15:]` rule caused. Verified manually (§5.2): the 4 outputs are distinct with differing SHA-1 suffixes.
|
||||||
|
- `test_sanitize_bash_python_parity`: 11 edge cases (incl. empty, digit-leading, underscore-leading, 32- and 33-char boundaries) assert byte-for-byte Bash↔Python equality. **Strong contract test.** All pass.
|
||||||
|
- `test_mock_herdr_error_formatting_and_abort`: exercises all three new error classes and asserts both the exact stderr substring and that it matches the abort regex. **Correct and complete.**
|
||||||
|
|
||||||
|
### 2.5 `tests/test_sanity.py`
|
||||||
|
|
||||||
|
- The old `assert session_name.endswith("-creator-claude")` (herdr-registered name) was removed and replaced with:
|
||||||
|
- `assert len(session_name) <= 32`
|
||||||
|
- `assert session_name == sanitize_herdr_agent_name(sessions[0]["name"])`
|
||||||
|
- **Not a coverage loss.** The old `endswith("-creator-claude")` on the *herdr-registered* name is incompatible with the new (correct) truncation — a long workspace name truncates to `canary-projects-multi-a-039bb460`, which legitimately no longer ends with `-creator-claude`. The replacement assertion is *stronger*: it verifies the herdr agent key equals the sanitized form of the yaml session name, i.e. it pins the sanitize contract across the two stores. The unsanitized yaml name is still checked for `endswith("-creator-claude")` on line 72, so the semantic suffix check is retained where it is actually valid.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Cross-Cutting Consistency Checks
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| Bash `_sanitize_herdr_agent_name` == Python `sanitize_herdr_agent_name` (11 cases incl. empty, digit/underscore leading, 32/33-char boundaries) | ✅ Identical (verified by `test_sanitize_bash_python_parity` + manual run) |
|
||||||
|
| Empty-string divergence (job `c30845cb`) resolved | ✅ Both return `"agent"` |
|
||||||
|
| Sibling-workspace collision (the root cause) eliminated | ✅ 4/4 unique 32-char names |
|
||||||
|
| Mock error strings ⊆ lib.sh abort regex | ✅ `unknown option`, `missing required`, `invalid_agent_name` all match |
|
||||||
|
| `agent get` lookup uses sanitized name (no legacy heuristic) | ✅ `an == tn or an == stn` |
|
||||||
|
| Two Bash copies of the function are identical | ✅ Lines 29–52 == 216–239 |
|
||||||
|
| Removed `endswith` assertion compensated by stronger contract assertion | ✅ |
|
||||||
|
| PYTHONPATH for inline `from lib_py.agents.sanitize import ...` | ✅ Set at lib.sh:25 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Issues Identified
|
||||||
|
|
||||||
|
**Blocking issues:** none.
|
||||||
|
|
||||||
|
**Non-blocking observations:**
|
||||||
|
1. **(Robustness, theoretical)** The Bash hash fallback `echo "00000000"` would collapse all long names to the same suffix if every hash tool were unavailable. Unreachable in any supported environment (macOS has `shasum`; Linux has `sha1sum`; `python3` is itself a lib.sh dependency), so not a defect — but a future hardening could disambiguate the fallback.
|
||||||
|
2. **(Maintainability, pre-existing)** `_sanitize_herdr_agent_name` is duplicated in lib.sh (library section + shim section). Both copies are consistent after this commit, so no action is required here, but the duplication remains a drift risk.
|
||||||
|
3. **(Cosmetic)** `sanitize.py` lost its module docstring; behavior is unaffected.
|
||||||
|
|
||||||
|
None of these rise to the level of requiring a fix, and none warrant replanning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Test Verification
|
||||||
|
|
||||||
|
### 5.1 Directly-affected code paths (regression baseline + new tests)
|
||||||
|
```
|
||||||
|
tests/test_herdr_shim_contract.py
|
||||||
|
tests/test_tier1_unit.py
|
||||||
|
tests/test_sanity.py
|
||||||
|
tests/test_sanitize_and_mock_errors.py
|
||||||
|
=> 39 passed in 19.54s
|
||||||
|
```
|
||||||
|
This covers the documented regression baseline (36, per prior jobs `cdd44bb3`/`1c80f10e`) plus the 3 new tests introduced by this commit.
|
||||||
|
|
||||||
|
### 5.2 Manual parity / collision verification (Bash + Python)
|
||||||
|
```
|
||||||
|
canary-projects-educative-export-tools-creator-claude -> canary-projects-educati-9da57e6e (32)
|
||||||
|
canary-projects-getting-started-a2a-creator-claude -> canary-projects-getting-48a65fbb (32)
|
||||||
|
canary-projects-multi-agent-mux-creator-claude -> canary-projects-multi-a-039bb460 (32)
|
||||||
|
canary-projects-pu-riverpod-cookbook-creator-claude -> canary-projects-pu-rive-5e30d500 (32)
|
||||||
|
unique: True (collision eliminated)
|
||||||
|
"" -> "agent" (both Bash and Python; divergence resolved)
|
||||||
|
"exact-32-chars-long-name-1234567" -> unchanged (32, not truncated; both)
|
||||||
|
"123_starts_digit" -> "x-123_starts_digit" (both)
|
||||||
|
```
|
||||||
|
Bash and Python outputs are byte-for-byte identical across all cases.
|
||||||
|
|
||||||
|
### 5.3 Remaining (non-e2e) test files
|
||||||
|
```
|
||||||
|
tests/test_deploy_freshness.py test_deploy_layout.py test_deploy_registry_merge.py
|
||||||
|
tests/test_a4_adapter_contract.py test_b4_session_created.py test_b7_diff_untracked.py
|
||||||
|
tests/test_uuid_target.py test_workspace_scope.py test_o1_rebuttal.py
|
||||||
|
tests/test_o3_scoped_guard.py test_orc_onboard.py test_b8_send_keys_verification.py
|
||||||
|
tests/test_o2_race_free_lock.py
|
||||||
|
=> 181 passed in 172.03s
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 e2e / integration suites (tier2/3/4)
|
||||||
|
```
|
||||||
|
tests/test_tier2_component.py test_tier3_integration.py test_tier4_e2e.py
|
||||||
|
=> 36 passed in 192.70s (0:03:12)
|
||||||
|
```
|
||||||
|
These suites spawn `reconcile.sh --subscribe --idle-timeout 0` loops and are inherently long-running (~3 min). **All 36 pass with 0 failures / 0 errors.** These suites exercise the monitor/reconcile/e2e subsystems, none of which are touched by commit `14b9de1`; their clean pass confirms no collateral regression.
|
||||||
|
|
||||||
|
### 5.5 Aggregate
|
||||||
|
- **Full suite: 256 tests pass** (39 + 181 + 36), with **0 failures, 0 errors** — matching the commit message's "256/256 passed" claim.
|
||||||
|
- Coverage spans every module, including all changed code paths (sanitize truncation, mock errors, abort regex, `agent get` lookup) and all unaffected subsystems (deploy, adapters, reconcile, e2e).
|
||||||
|
- All assertions in the new `test_sanitize_and_mock_errors.py` pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Verdict
|
||||||
|
|
||||||
|
The commit correctly replaces the collision-prone legacy truncation with a collision-free SHA-1 suffix scheme, keeps the Bash and Python implementations byte-for-byte identical (including the previously-divergent empty-string case), aligns the mock Herdr error output with real Herdr 0.8.0 and with the lib.sh early-abort regex, and simplifies the `agent get` lookup to use the sanitized name. Test changes replace a now-invalid `endswith` assertion with a stronger sanitize-contract assertion rather than weakening coverage. No blocking issues were found; the only notes are theoretical/non-blocking.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Cross-Code Review Report — Job 143de35c
|
||||||
|
|
||||||
|
- **Job ID**: 143de35c
|
||||||
|
- **Reviewer**: cline
|
||||||
|
- **Scope**: Verify P2-1 (B-6: Eliminate temporary script copy and trap leak in `delegate_job_safe`, commit `b490713`) is 100% completed and validated; audit `IMPROVEMENTS.md` backlog for remaining prioritized improvement opportunities (P2-2: C-3a + C-4, P2-3: C-6, P3-1: A-4 M2~M7).
|
||||||
|
- **Changes under review**: `(no changes since base commit)` — base commit is `b490713` (the P2-1 fix). Only delta since base is an archived review-report doc (`report-120ffb08.md`, +118 lines, non-code).
|
||||||
|
- **Date**: 2026-08-15
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. P2-1 (B-6 / B-12) Verification — ✅ 100% Complete & Validated
|
||||||
|
|
||||||
|
### 1.1 Source Code (`run_loop.sh`)
|
||||||
|
|
||||||
|
`delegate_job_safe()` at `.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh:102-112` implements the fix exactly as documented:
|
||||||
|
|
||||||
|
- **No temporary copy**: The wrapper is executed in-place via `bash "$orig_script" "$@"` (line 105). The previous `.tmp` copy-into-`.agents/skills/...` tree logic is fully removed.
|
||||||
|
- **No `trap`**: No `trap _mam_release_guard EXIT INT TERM HUP` exists inside `delegate_job_safe`. The explanatory comment (lines 90-101) documents *why* both the copy and the trap were removed (B-6 tree pollution + B-12 subshell trap-leak firing on `$(...)` command-substitution subshell exit, dropping the loop lock after the first delegated job).
|
||||||
|
- **Diagnostics**: A local `rc=0` captures the wrapper's exit code; on non-zero, `log_error` emits the previously-unreachable failure diagnosis (lines 106-110). Callers' own "Failed to register …" branches remain reachable for non-`set -e` paths.
|
||||||
|
- **Residual `.tmp` sweep**: Startup sweep of `.../multi-agent-mux-delegate-job.*.tmp` is retained for backwards cleanup of any pre-fix leftovers (confirmed in commit `b490713` diff, `run_loop.sh` +26 lines).
|
||||||
|
|
||||||
|
### 1.2 Commit Hygiene (`b490713`)
|
||||||
|
|
||||||
|
`git show b490713 --stat` — surgical, 4 files only:
|
||||||
|
| File | Δ |
|
||||||
|
|---|---|
|
||||||
|
| `run_loop.sh` | +26/-… |
|
||||||
|
| `IMPROVEMENTS.md` | +24 |
|
||||||
|
| `LOG.md` | +12 |
|
||||||
|
| `tests/test_o3_scoped_guard.py` | +167 |
|
||||||
|
|
||||||
|
No unrelated files touched. Every changed line traces to B-6/B-12. ✅
|
||||||
|
|
||||||
|
### 1.3 Tests
|
||||||
|
|
||||||
|
- `tests/test_o3_scoped_guard.py` + `tests/test_a4_adapter_contract.py` → **30 passed** (includes the 4 new Z-9 behavior-based tests: `test_z9_loop_lock_survives_delegation`, `test_z9_probe_detects_the_defect`, `test_z9_no_tmp_copy_left_in_skill_tree`, `test_z9_exit_code_and_diagnostics_propagation`).
|
||||||
|
- `tests/test_b7_diff_untracked.py` → **20 passed** (same `run_loop.sh` slot, confirms no regression).
|
||||||
|
- All relevant tests green; the Z-9 suite directly asserts both the "no tmp copy" (B-6) and "loop lock survives delegation" (B-12/D1) behaviors.
|
||||||
|
|
||||||
|
### 1.4 Syntax
|
||||||
|
|
||||||
|
- `bash -n run_loop.sh` → OK
|
||||||
|
- `bash -n lib.sh` → OK
|
||||||
|
- `bash -n stop_session.sh` → OK
|
||||||
|
|
||||||
|
### 1.5 Documentation (`IMPROVEMENTS.md`)
|
||||||
|
|
||||||
|
- `B-6` section (line 71): marked **✅ 완료 (Stage 1)** — describes in-place execution + residual `.tmp` sweep + failure diagnostics.
|
||||||
|
- `B-12` section (line 79): marked **✅ 완료 (P0)** — describes trap removal + Z-9 test replacement.
|
||||||
|
- `P2-1` consolidated section (line 125): marked **✅ 완료** — merges B-6 + B-12 with full rationale.
|
||||||
|
- Priority table 6.2 row `P2-1 | B-6` aligns with the completed state.
|
||||||
|
|
||||||
|
### 1.6 P2-1 Verdict
|
||||||
|
|
||||||
|
**P2-1 is 100% completed and validated.** Code, tests, syntax, and documentation are consistent and self-corroborating. The fix is minimal, surgical, and behaviorally proven by the Z-9 regression suite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. IMPROVEMENTS.md Backlog Audit — Remaining Prioritized Opportunities
|
||||||
|
|
||||||
|
Per the brief, the remaining backlog was audited against the live codebase. The header states **10 unresolved items** (Architecture 2, Edge-cases 5, Orchestration 0, Legacy 3); §6.2 enumerates 12 roadmap rows (some are sub-items / decisions). Current status:
|
||||||
|
|
||||||
|
| Priority | Item | Status (live code audit) | Evidence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **P2-2** | **C-3a** (4 empty isolation stubs) + **C-4** (dead symbols) | 🔴 **Not started** — all targets confirmed present | See §2.1, §2.2 |
|
||||||
|
| **P2-3** | **C-6** (`stop_session.sh` stale help text) | 🔴 **Not started** — defect confirmed live | See §2.3 |
|
||||||
|
| **P3-1** | **A-4 M2~M7** (adapter main migration) | 🟡 **Partially done** — only M0~M1 complete | See §2.4 |
|
||||||
|
| **P3-2** | **B-10** (`agent_identities` write path + PyYAML dep) | 🔴 Not started | §6.2 row; depends on A-4 M2 |
|
||||||
|
| **P3-3** | **C-3b** (`isolation.root` consumer disposition) | 🔴 Not started | §6.2 row; depends on A-4 M2 |
|
||||||
|
| **P4-1** | **B-9** (`LOGS_DIR` import-time cwd) | 🔴 Not started (low impact; 2 mitigations exist) | §6.2 row |
|
||||||
|
| **P5-1** | **A-2** (public broker + HMAC) | 🔴 Deferred by user instruction (P5) | §6.2 note |
|
||||||
|
| **종결 권고** | **B-5** (`df --output` GNU flag) | ⚪ Recommend close (fallback `df -P` works) | Line 75 |
|
||||||
|
| **—** | **B-11** (mount-point ERE interpolation) | 🟡 Split-off recommendation from B-5 residual | Line 75 |
|
||||||
|
| **—** | **B-13** (in-flight tooling mutation, Stage 2) | 🟡 Separated Stage 2 task | Line ~85 |
|
||||||
|
|
||||||
|
### 2.1 P2-2 / C-3a — 4 Empty Isolation Stubs (NOT done)
|
||||||
|
|
||||||
|
All 4 stubs remain in `lib.sh` with empty bodies, zero production callers:
|
||||||
|
- `provision_isolation()` — `lib.sh:1369`
|
||||||
|
- `isolation_lever()` — `lib.sh:1374`
|
||||||
|
- `isolation_env_prefix()` — `lib.sh:1381`
|
||||||
|
- `isolation_cmd_args()` — `lib.sh:1385`
|
||||||
|
|
||||||
|
Per §6.5, the 5 vacuous tests pinning these stubs (`test_tier1_unit.py` ×3, `test_tier2_component.py` ×1 + 1) are co-removal targets. **C-3b must NOT be touched** (intentionally revived in `b4a1d094`/`44062a63`). ✅ Audit consistent with live code.
|
||||||
|
|
||||||
|
### 2.2 P2-2 / C-4 — Dead Symbols (NOT done, list corrected to 3)
|
||||||
|
|
||||||
|
Live confirmation of the corrected 3-symbol target list:
|
||||||
|
- `_REAL_HERDR_PATH` — `lib.sh:126` (assignment + `export` only, no read) ✅ present
|
||||||
|
- `TERMINAL_STATUSES` — `multi-agent-mux-delegate-job/scripts/registry.py:38` (definition only, no reference) ✅ present
|
||||||
|
- `ISOLATE` — `multi-agent-mux-create/scripts/create_session.sh:57` (assignment only) ✅ present
|
||||||
|
|
||||||
|
**Excluded (per §6.5 correction, correctly left alone):** `_HERDR_SHIM_DIR_PATTERN` is *in use* (`lib.sh:57` → `lib.sh:79`); `local_herdr` already removed. Audit confirms the corrected list matches live code. ⚠️ Risk note: a naive "delete all 7" execution would break shim-path detection — §6.5 correction must be honored.
|
||||||
|
|
||||||
|
### 2.3 P2-3 / C-6 — `stop_session.sh` Stale Help (NOT done, defect live)
|
||||||
|
|
||||||
|
`.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh`:
|
||||||
|
- Header comment (line 5): advertises `[--mode soft|hard]`
|
||||||
|
- `usage()` (line 41): does **not** list `--mode`
|
||||||
|
- Option parser (line 65): `--mode|--capture-id|--graceful)` → falls through to `exit 2` ("unknown arg")
|
||||||
|
|
||||||
|
So the documented `--mode soft|hard` is rejected at runtime. ~3-line fix. ✅ Audit consistent — defect is live and reproducible.
|
||||||
|
|
||||||
|
### 2.4 P3-1 / A-4 — Adapter Layer (PARTIALLY done: M0~M1 only)
|
||||||
|
|
||||||
|
- **M0~M1 ✅ done**: `tests/test_a4_adapter_contract.py` → **3/3 PASS** (PYTHONPATH bootstrap, deploy/CI registration, `own_key` migration; fanout 34→29 per prototype).
|
||||||
|
- **M2~M7 🔴 not started**: `artifact_path`/`verify_artifact`, `spawn_spec`/`resume_spec`/`auth_ok`, `discover()`, `stop_session.sh` purge path, `ready_tokens` migration, claude `projects` removal. This is the large (大) remaining structural work; gating decision for B-10 / C-3b disposition happens here.
|
||||||
|
|
||||||
|
### 2.5 Backlog Audit Verdict
|
||||||
|
|
||||||
|
The backlog is **accurate and up-to-date** as of 2026-08-15. All "not started" items were confirmed present in live code; the §6.5 corrections (C-3 split, C-4 list reduction to 3, A-2 cause rewording, B-5/B-11 split) are reflected. No stale/false "completed" claims found. The next executable, dependency-free items are **P2-2 (C-3a + C-4)** and **P2-3 (C-6)** — both small, both reduce regression time / risk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Lint / Behavioral / Loss Review
|
||||||
|
|
||||||
|
Since `(no changes since base commit)`, the review is a re-verification of `b490713` plus a non-code backlog audit:
|
||||||
|
|
||||||
|
- **Lint**: `bash -n` passes on all 3 touched/relevant shell scripts (`run_loop.sh`, `lib.sh`, `stop_session.sh`); no `py_compile` needed (no `.py` changed in b490713 except the test file). ✅
|
||||||
|
- **Behavioral**: Z-9 tests (30 passed) + B-7 tests (20 passed) prove no tmp copy, no trap leak, loop-lock survival, and exit-code/diagnostic propagation. ✅
|
||||||
|
- **Loss (regression/orphan check)**: `git diff b490713..HEAD --stat` shows only `report-120ffb08.md` (+118) — no code drift, no orphaned symbols introduced, no accidental removals. The b490713 commit removed the copy+trap and added diagnostics + tests; nothing was orphaned by it (the `_mam_release_guard` trap is still installed at loop scope, not inside `delegate_job_safe`). ✅
|
||||||
|
|
||||||
|
No `[ESCALATE: PLANNER]` warranted: P2-1 is a complete bug fix, and the remaining backlog items are already planned and prioritized in `IMPROVEMENTS.md` §6.2 — no re-planning/design-change needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Summary
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| P2-1 (B-6) tmp-copy elimination | ✅ Complete — in-place `bash "$orig_script"` |
|
||||||
|
| P2-1 (B-12) subshell trap-leak fix | ✅ Complete — no `trap` in `delegate_job_safe` |
|
||||||
|
| P2-1 tests (Z-9) | ✅ 30 passed |
|
||||||
|
| P2-1 syntax (`bash -n`) | ✅ OK |
|
||||||
|
| P2-1 commit hygiene | ✅ Surgical (4 files) |
|
||||||
|
| Backlog P2-2 (C-3a + C-4) | 🔴 Not started — targets confirmed live (corrected to 3+4) |
|
||||||
|
| Backlog P2-3 (C-6) | 🔴 Not started — stale `--mode` help confirmed live |
|
||||||
|
| Backlog P3-1 (A-4 M2~M7) | 🟡 M0~M1 done (3/3); M2~M7 pending |
|
||||||
|
| Backlog accuracy | ✅ Matches live code; §6.5 corrections honored |
|
||||||
|
| Lint / Behavior / Loss | ✅ Clean |
|
||||||
|
|
||||||
|
P2-1 is fully implemented, tested, and documented. The remaining backlog is accurately tracked and correctly prioritized; the next low-cost, dependency-free items are P2-2 and P2-3.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# 📋 Cross Review Report — Job 8fc5b0bd (P3-1 / A-4 Phase 2 Reviewer-feedback fix)
|
||||||
|
|
||||||
|
- **Reviewer**: cline (session: herdr:canary-projects-multi-agent-mux-creator-cline)
|
||||||
|
- **Job**: 8fc5b0bd (follow-up to Job 59467505 NOT PASS)
|
||||||
|
- **Scope**: Verify the implementation addressed the 4 blocking issues from the prior NOT PASS review.
|
||||||
|
- **Date**: 2026-08-16
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
The implementer addressed **all 4 blocking issues** raised in the prior NOT PASS review
|
||||||
|
(Job 59467505). The `cline resume_spec --resume` bug is fixed (`--id`), `auth_ok`/`discover`
|
||||||
|
are implemented in all 4 adapters, `resume_session.sh` and `reconcile.sh` are migrated to the
|
||||||
|
adapter layer, and contract tests for `spawn_spec`/`resume_spec`/`auth_ok`/`discover` values
|
||||||
|
were added and pass. 132 change-relevant tests pass with 0 failures; `py_compile` is clean;
|
||||||
|
`IMPROVEMENTS.md`/`LOG.md` are synchronized. One minor non-blocking observation remains
|
||||||
|
(create_session.sh auth not yet wired to `adapter.auth_ok`), which is out of the brief's
|
||||||
|
explicit scope.
|
||||||
|
|
||||||
|
**Verdict: PASS.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Prior NOT PASS Issues — Resolution Status
|
||||||
|
|
||||||
|
### 2.1 [FIXED] cline `resume_spec` used non-existent `--resume` flag
|
||||||
|
- **Prior**: `cline.py:76` emitted `--resume`, but `cline --help` only exposes `--id`.
|
||||||
|
- **Now**: `cline.py:75-78` emits `f"{binary} -i --id {session_uuid}"` (materialized) /
|
||||||
|
`f"{binary} -i"` (non-materialized).
|
||||||
|
- **Verification**: `cline --help` → `--id <session-id> Resume an existing session by ID`
|
||||||
|
(no `--resume`). Contract test `test_adapter_spawn_and_resume_specs` (line 181-182)
|
||||||
|
asserts `cline -i --id u1` (materialized) and `cline -i` (non-materialized). ✅
|
||||||
|
|
||||||
|
### 2.2 [FIXED] `auth_ok` / `discover` unimplemented (2 of 7 adapter methods)
|
||||||
|
- **Prior**: `auth_ok` and `discover` were absent from `base.py` and all adapters.
|
||||||
|
- **Now**:
|
||||||
|
- `base.py:100-104` declares both as abstract (`raise NotImplementedError`).
|
||||||
|
- `claude.py:99-110` — `auth_ok` dual-mode (`run_cmd` callable for test injection /
|
||||||
|
`subprocess` for prod; checks `claude auth status` → `"loggedIn":true`).
|
||||||
|
- `agy.py:94-96` — `auth_ok` checks `~/.gemini/oauth_creds.json` or antigravity-oauth-token.
|
||||||
|
- `hermes.py:80-81` / `cline.py:80-81` — `auth_ok` returns `True` (no auth gate).
|
||||||
|
- `claude.py:112-121` — `discover` globs `{claude_dir}/{ws_key}/*.jsonl`, verifies each.
|
||||||
|
- `agy.py:98-108` — `discover` reads `last_conversations.json[ws]`, verifies artifact.
|
||||||
|
- `hermes.py:83-96` — `discover` queries `state.db` sessions by `cwd`.
|
||||||
|
- `cline.py:83-100` — `discover` scans `~/.cline/data/sessions/*`, verifies each.
|
||||||
|
- `workspace_uuid.py:74-81` — disk-scan fan-out replaced by `adapter.discover(ctx)`.
|
||||||
|
- **Contract tests**: `test_adapter_auth_ok` (line 184-201) and `test_adapter_discover`
|
||||||
|
(line 203-256) verify all 4 agents. Both pass. ✅
|
||||||
|
|
||||||
|
### 2.3 [FIXED] `resume_session.sh` / `reconcile.sh` not migrated to adapters
|
||||||
|
- **resume_session.sh** (line 83-93): `CMD_FULL` now computed via
|
||||||
|
`adapter.resume_spec('$RESOLVED_BIN', '$UUID', mat)` where `mat = adapter.verify_artifact(...)`.
|
||||||
|
The `materialized` flag (artifact exists on disk) selects `-r`/`--session-id` (claude) or
|
||||||
|
`--id`/bare (cline) — a behavioral improvement: do not attempt to resume a session whose
|
||||||
|
artifact is absent. Hardcoded fallback case retained as a safety net. `_iso_root` branch
|
||||||
|
fully removed. ✅
|
||||||
|
- **reconcile.sh**:
|
||||||
|
- `row_agent(s)` (line 590-591) delegates to `agent_of_row(s)` from registry.
|
||||||
|
- `_pin_and_verify_resume` (line 438-441) uses `_get_own_key(agent)` from registry.
|
||||||
|
- `OWN_KEY_BY_AGENT` (line 593-595) built from `_get_own_key(a)` for all 4 agents.
|
||||||
|
- Auto-register `cmd_full` (line 540-541) uses `_adapter.spawn_spec(agent)`.
|
||||||
|
- The 4-way hardcoded spawn/own-key fan-outs are now adapter-driven. ✅
|
||||||
|
|
||||||
|
### 2.4 [FIXED] No contract tests for `spawn_spec` / `resume_spec` values
|
||||||
|
- **Now**: `test_adapter_spawn_and_resume_specs` (line 164-182) asserts exact output strings
|
||||||
|
for all 4 agents' `spawn_spec` and `resume_spec` (materialized + non-materialized):
|
||||||
|
- claude: `--dangerously-skip-permissions --session-id u1` (spawn) / `-r u1` (resume,mat)
|
||||||
|
- agy: `--dangerously-skip-permissions` (spawn) / `--conversation u1` (resume,mat)
|
||||||
|
- hermes: `hermes` (spawn) / `hermes --resume u1` (resume,mat)
|
||||||
|
- cline: `cline -i` (spawn) / `cline -i --id u1` (resume,mat) / `cline -i` (resume,!mat)
|
||||||
|
- Plus `test_adapter_auth_ok` and `test_adapter_discover`. Total: 9 contract tests, all pass. ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Test Execution (Independent)
|
||||||
|
|
||||||
|
| Group | Files | Result | Time |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Contract | test_a4_adapter_contract.py | **9 passed** | 0.13s |
|
||||||
|
| Unit | test_tier1_unit.py, test_orc_onboard.py | **66 passed** | 13.48s |
|
||||||
|
| UUID | test_uuid_target.py | **12 passed** | 78.35s |
|
||||||
|
| Tier2 | test_tier2_component.py, test_b4_session_created.py | **45 passed** | 56.05s |
|
||||||
|
| **Total** | | **132 passed, 0 failed** | — |
|
||||||
|
|
||||||
|
- `py_compile` clean on all 9 changed `.py` files.
|
||||||
|
- Removed tests (`test_t11_legacy_isolation_row`, `test_comp_stop_safe_path_checking`)
|
||||||
|
correctly tested the now-deprecated `isolation.root` feature — removals are justified.
|
||||||
|
- Full-suite count per LOG.md: 259 passed (consistent with +3 new contract tests over prior 256).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Documentation Sync
|
||||||
|
|
||||||
|
- `IMPROVEMENTS.md`: A-4 marked ✅완료 (P3-1), C-3b ✅완료; completed 17→19, pending 8→6.
|
||||||
|
- `LOG.md`: New P3-1 section documents every migrated file (base/adapters/__main__/verify_session/
|
||||||
|
workspace_uuid/atomic_yaml/lib.sh/create/resume/reconcile/stop/tests), records the
|
||||||
|
`cline resume_spec -i --id` fix, and the 259-pass result.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Non-Blocking Observations
|
||||||
|
|
||||||
|
### 5.1 `create_session.sh` auth not yet wired to `adapter.auth_ok`
|
||||||
|
`create_session.sh:96-119` still contains a 4-way hardcoded auth fan-out (claude/agy/hermes/cline).
|
||||||
|
The `auth_ok` adapter method is now implemented and tested but is **not yet invoked** from this
|
||||||
|
script, leaving two sources of truth for auth logic. The brief explicitly scoped shell-script
|
||||||
|
migration to `resume_session.sh` and `reconcile.sh` only, so this is **out of scope for this round**
|
||||||
|
and not a blocker. Recommendation: wire `create_session.sh` auth to `adapter.auth_ok` in a future
|
||||||
|
increment to close the last auth fan-out.
|
||||||
|
|
||||||
|
### 5.2 reconcile.sh entry-field metadata still agent-branched
|
||||||
|
`reconcile.sh:564-583` still branches on agent for entry metadata (claude `tui` block, agy
|
||||||
|
`mcp_attachments`, `child_pid`). These are agent-specific *metadata* with no corresponding adapter
|
||||||
|
method (no `entry_metadata` defined), so they are arguably not "agent command knowledge" and
|
||||||
|
remain acceptable. Not a blocker.
|
||||||
|
|
||||||
|
### 5.3 Environmental e2e hang (pre-existing, not a regression)
|
||||||
|
Orphaned `reconcile.sh --subscribe --idle-timeout 0` processes accumulate from the
|
||||||
|
subprocess-spawning test suites (test_tier2/test_b4/test_uuid). These caused the prior review's
|
||||||
|
environmental hang and are a pre-existing infrastructure issue, **not** a regression introduced by
|
||||||
|
this change. All orphans were cleaned (0 remaining) before final test runs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Lint / Compile / Loss Checks
|
||||||
|
|
||||||
|
- **Lint/compile**: `py_compile` clean on `base.py`, all 4 adapters, `__main__.py`,
|
||||||
|
`verify_session.py`, `workspace_uuid.py`, `atomic_yaml.py`.
|
||||||
|
- **No lost functionality**: removed `mam_session_iso_root` (lib.sh), `iso_root_of`
|
||||||
|
(workspace_uuid.py), isolation validity check (atomic_yaml.py) — all consumers of the
|
||||||
|
deprecated `isolation.root` row; removed tests aligned with removed features.
|
||||||
|
- **No orphaned imports**: adapters import `os/json/glob/sqlite3/subprocess/shutil` as needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Behavioral-Change Assessment
|
||||||
|
|
||||||
|
The `materialized` parameter in `resume_spec` is a deliberate, contract-tested behavioral
|
||||||
|
improvement: when the session artifact is absent (`verify_artifact` False), the adapter starts a
|
||||||
|
fresh session bound to the UUID (`--session-id` for claude, bare `-i` for cline) instead of
|
||||||
|
attempting to resume a non-existent history (`-r`/`--id`). This avoids resume failures on missing
|
||||||
|
artifacts. The fallback case in `resume_session.sh:86-92` preserves the materialized forms, so the
|
||||||
|
shell and adapter agree when artifacts exist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Verdict
|
||||||
|
|
||||||
|
All 4 prior blocking issues are resolved with verified code + passing contract tests. 132
|
||||||
|
change-relevant tests pass (0 failures). Documentation is synchronized. The one remaining item
|
||||||
|
(create_session.sh auth wiring) is explicitly out of the brief's scope and non-blocking. No
|
||||||
|
design-level rework is needed.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# 📋 Cross-Code Review Report: P2-2 (C-3a + C-4 Legacy Code Cleanup)
|
||||||
|
|
||||||
|
- **Job ID**: `34b928df`
|
||||||
|
- **Reviewer**: cline (session: herdr:canary-projects-multi-agent-mux-creator-cline)
|
||||||
|
- **Role**: Reviewer
|
||||||
|
- **Review Subject**: P2-2 implementation — C-3a (4 isolation stub removal) + C-4 (3 dead symbol removal)
|
||||||
|
- **Base Commit**: `5e519e2` (HEAD)
|
||||||
|
- **Working Tree**: 7 modified files (uncommitted)
|
||||||
|
- **Report Path**: `.mam/jobs/34b928df/cline-reports/report-final.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
The P2-2 implementation is a **clean, surgical legacy code cleanup** that precisely matches the task specification. All 4 isolation stubs (C-3a) and all 3 dead symbols (C-4) were removed with zero collateral damage. The C-3b boundary (`isolation.root` consumers) was respected — no touched lines. A thoughtful regression guard test (`test_create_session_legacy_isolate_flags_noop`) was added to replace the 4 removed vacuous tests, pinning the `--isolate`/`--no-isolate` no-op contract against future arg-parser regressions.
|
||||||
|
|
||||||
|
**No lint, operability, or loss issues found.** Documentation (IMPROVEMENTS.md, LOG.md) updated with accurate counts and descriptions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Scope — Files Changed (7 files, +46/-94 lines)
|
||||||
|
|
||||||
|
| File | Change | Lines |
|
||||||
|
|---|---|---|
|
||||||
|
| `.agents/skills/lib.sh` | Removed 4 stubs + `_REAL_HERDR_PATH` assignment/export; updated comment | 28 changed |
|
||||||
|
| `.agents/skills/multi-agent-mux-create/scripts/create_session.sh` | Removed `ISOLATE=1` | 1 removed |
|
||||||
|
| `.agents/skills/multi-agent-mux-delegate-job/scripts/registry.py` | Removed `TERMINAL_STATUSES` | 1 removed |
|
||||||
|
| `tests/test_tier1_unit.py` | Removed 3 vacuous tests, added 1 regression guard, synced header | 47 changed |
|
||||||
|
| `tests/test_tier2_component.py` | Removed 1 vacuous test | 10 removed |
|
||||||
|
| `IMPROVEMENTS.md` | C-3a/C-4 completion, counts updated (9→8 open, 16→17 done) | 39 changed |
|
||||||
|
| `LOG.md` | P2-2 session entry added | 14 added |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. C-3a Verification — 4 Isolation Stub Removal
|
||||||
|
|
||||||
|
### 3.1 Stubs Removed ✅
|
||||||
|
All 4 empty stubs removed from `lib.sh` (was at lines 1369-1385, now gone):
|
||||||
|
- `provision_isolation()` — was `printf ''` (no-op)
|
||||||
|
- `isolation_lever()` — was `echo "none"` for all agents (no consumer read the output)
|
||||||
|
- `isolation_env_prefix()` — was `:` (true no-op)
|
||||||
|
- `isolation_cmd_args()` — was `:` (true no-op)
|
||||||
|
|
||||||
|
**Orphan check**: `grep -rn` across `.agents/`, `deploy/`, `tests/` for all 4 function names returns **zero production-code references** (only historical reports in `.mam/` and the new explanatory comment in `lib.sh:1364-1368`). ✅
|
||||||
|
|
||||||
|
### 3.2 Comment Block Updated ✅
|
||||||
|
The old "Stubbed isolation functions kept for backward compatibility" comment was replaced with an accurate removal record that explicitly names the C-3b boundary:
|
||||||
|
|
||||||
|
```
|
||||||
|
# The backward-compat stubs (provision_isolation / isolation_lever /
|
||||||
|
# isolation_env_prefix / isolation_cmd_args) were removed in P2-2 (C-3a);
|
||||||
|
# they had zero production callers. The `isolation.root` row field is still
|
||||||
|
# consumed (C-3b) — see verify_session_uuid / find_workspace_uuid /
|
||||||
|
# mam_session_iso_root / stop_session.sh purge guard.
|
||||||
|
```
|
||||||
|
|
||||||
|
All 4 referenced C-3b consumers confirmed present in live code:
|
||||||
|
- `verify_session_uuid` — `lib.sh:1260` (via Python import) ✅
|
||||||
|
- `find_workspace_uuid` — `lib.sh:1331` ✅
|
||||||
|
- `mam_session_iso_root` — `lib.sh:1103` ✅
|
||||||
|
- `stop_session.sh` purge guard — `stop_session.sh:62` (`--purge-conversation`) ✅
|
||||||
|
|
||||||
|
### 3.3 Tests Removed (4) ✅
|
||||||
|
- `test_create_isolation_lever` (test_tier1_unit.py) — vacuous: asserted `isolation_lever` returns "none"
|
||||||
|
- `test_create_isolation_env_prefix` (test_tier1_unit.py) — vacuous: asserted empty stdout
|
||||||
|
- `test_create_isolation_cmd_args` (test_tier1_unit.py) — vacuous: asserted empty stdout
|
||||||
|
- `test_comp_create_isolation_folder_setup` (test_tier2_component.py) — vacuous: asserted `provision_isolation` returns empty stdout
|
||||||
|
|
||||||
|
**Note on "5 tests" in brief**: The brief mentions "5 vacuous tests" but only 4 existed. The 5th was a non-existent test — the remaining `isolation` hits in `tests/` are all C-3b contract verifications (which must NOT be touched). This discrepancy was pre-acknowledged in the planner's Rev.2 document (§1.2). ✅
|
||||||
|
|
||||||
|
### 3.4 Regression Guard Added (1) ✅
|
||||||
|
New test `test_create_session_legacy_isolate_flags_noop` replaces the 4 removed vacuous tests with a meaningful contract: `--isolate` and `--no-isolate` must remain accepted no-op flags (rc=0, stderr notice, present in usage help). This prevents future arg-parser refactors from silently breaking legacy callers.
|
||||||
|
|
||||||
|
**Test verified**: `pytest tests/test_tier1_unit.py::test_create_session_legacy_isolate_flags_noop` → **PASSED** (0.12s) ✅
|
||||||
|
|
||||||
|
### 3.5 Section Header Sync ✅
|
||||||
|
`test_tier1_unit.py:31` header updated: `(7 Test Cases)` → `(5 Test Cases)`. Verified: 7 - 3 removed + 1 added = 5. ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. C-4 Verification — 3 Dead Symbol Removal
|
||||||
|
|
||||||
|
### 4.1 `_REAL_HERDR_PATH` (lib.sh) ✅
|
||||||
|
- **Removed**: Lines 126-127 (`_REAL_HERDR_PATH="$real_path"` + `export _REAL_HERDR_PATH`)
|
||||||
|
- **Function invariant**: `_resolve_real_herdr_path()` (lib.sh:111-127) still returns the resolved path via **stdout** (`printf '%s\n' "$real_path"`) and **exit code** (`return 1` on not found). The removed global variable was a write-only side-effect — no consumer ever read `$_REAL_HERDR_PATH`.
|
||||||
|
- **`has_real_herdr()`** (lib.sh:129-131) calls `_resolve_real_herdr_path >/dev/null 2>&1` — uses exit code only, not the variable. ✅
|
||||||
|
- **Orphan check**: `grep -rn '_REAL_HERDR_PATH'` across `.agents/`, `deploy/`, `tests/` → zero production-code references (only historical reports). ✅
|
||||||
|
- **`_` prefix**: Denotes private/internal symbol. External consumers outside repo not searched, but `_resolve_real_herdr_path` is the public contract, not the variable.
|
||||||
|
|
||||||
|
### 4.2 `TERMINAL_STATUSES` (registry.py) ✅
|
||||||
|
- **Removed**: Line 38 (`TERMINAL_STATUSES = ("completed", "error", "cancelled")`)
|
||||||
|
- **`__all__` check**: `registry.py:175-178` — `TERMINAL_STATUSES` is **NOT** in `__all__`. `from registry import *` contract is invariant. ✅
|
||||||
|
- **`VALID_STATUSES`** (now line 38) — still present and used at lines 149-150. **Not touched**. ✅
|
||||||
|
- **Orphan check**: `grep -rn 'TERMINAL_STATUSES'` in registry.py → not found (exit code 1). Zero references in production code. ✅
|
||||||
|
|
||||||
|
### 4.3 `ISOLATE` (create_session.sh) ✅
|
||||||
|
- **Removed**: Line 57 (`ISOLATE=1`)
|
||||||
|
- **`set -euo pipefail`** at line 20 — if any code referenced `$ISOLATE` after removal, the script would fail with "unbound variable". No such reference exists. ✅
|
||||||
|
- **`--isolate`/`--no-isolate` arg parsing** (lines 70-71) — these are **separate no-op branches** that echo a notice to stderr and `shift`. They never set or read `$ISOLATE`. They remain untouched and functional. ✅
|
||||||
|
- **Usage help** (lines 42-43) — `--isolate` and `--no-isolate` documented as legacy no-op flags. Still present. ✅
|
||||||
|
- **Deploy scripts** (`deploy/install_mam.sh:326`, `deploy/install.sh:613`) — reference `--isolate` in example commands. Since `--isolate` is still accepted as a no-op, these examples still work correctly. ✅
|
||||||
|
|
||||||
|
### 4.4 `_HERDR_SHIM_DIR_PATTERN` NOT Touched ✅
|
||||||
|
Confirmed: `_HERDR_SHIM_DIR_PATTERN` (lib.sh:83) and `_HERDR_SKILLS_BIN_PATTERN` (lib.sh:84) are **not in the diff**. Both are still defined and used at lib.sh:105 (`_is_shim_path`). ✅
|
||||||
|
|
||||||
|
## 5. Syntax & Static Analysis
|
||||||
|
|
||||||
|
| Check | Command | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| Shell syntax (lib.sh) | `bash -n .agents/skills/lib.sh` | ✅ SYNTAX OK |
|
||||||
|
| Shell syntax (create_session.sh) | `bash -n .../create_session.sh` | ✅ SYNTAX OK |
|
||||||
|
| Python AST (registry.py) | `python3 -c "import ast; ast.parse(...)"` | ✅ AST OK |
|
||||||
|
| `shellcheck` | Not installed in environment | ⚠️ Not available (same as prior jobs) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Test Verification
|
||||||
|
|
||||||
|
| Check | Expected | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| Collection count | 256 (259 → 256, net -3 = 4 removed - 1 added) | ✅ **256 tests collected** |
|
||||||
|
| test_tier1_unit.py full | All pass | ✅ **27 passed in 6.22s** |
|
||||||
|
| New test standalone | PASS | ✅ **1 passed in 0.12s** |
|
||||||
|
| test_tier2_component.py collection | 25 (was 26, -1 removed) | ✅ **25 collected** |
|
||||||
|
| test_tier2_component.py adjacent test | PASS | ✅ `test_comp_create_sqlite_tables_created` passed (12.67s) |
|
||||||
|
| Full 256-test suite | 256 passed | ⚠️ Not run to completion — timeout in review environment (same limitation as prior jobs 143de35c, 120ffb08) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Documentation Review (IMPROVEMENTS.md / LOG.md)
|
||||||
|
|
||||||
|
### 7.1 IMPROVEMENTS.md ✅
|
||||||
|
- **Header counts**: Open tasks 9→8 (레거시 3→2), Completed 16→17. Arithmetic verified: 2+4+0+2=8 ✅
|
||||||
|
- **Section 4 title**: "3건 → 2건" (C-3a completed, C-4 completed, C-3b + C-6 remain = 2) ✅
|
||||||
|
- **Section 5 title**: "13건 → 14건" (P2-2 added) ✅
|
||||||
|
- **New P2-2 section**: Accurately describes all changes including mutation-test verification of the new regression guard.
|
||||||
|
- **Pre-existing discrepancy**: Header says 17 completed but Section 5 says 14 (gap of 3). This gap was pre-existing (was 16 vs 13 = 3) and is **not introduced by P2-2**. Both counts incremented by exactly +1.
|
||||||
|
|
||||||
|
### 7.2 LOG.md ✅
|
||||||
|
- P2-2 entry added with implementation summary and "256 passed (100%)" verification claim.
|
||||||
|
- Date updated: 2026-08-15 → 2026-08-16.
|
||||||
|
- Previous P2-1 entry renumbered from "1)" to "2)".
|
||||||
|
|
||||||
|
---
|
||||||
|
## 8. Lint / Operability / Loss Analysis
|
||||||
|
|
||||||
|
### 8.1 Lint ✅
|
||||||
|
- No syntax errors in any modified file.
|
||||||
|
- No unused imports/variables introduced (removals only made the code cleaner).
|
||||||
|
- `run_lib_func` helper still used 15× in test_tier1_unit.py — not orphaned by test removals.
|
||||||
|
- `subprocess` import in test_tier1_unit.py — still used by new test and other existing tests. ✅
|
||||||
|
|
||||||
|
### 8.2 Operability ✅
|
||||||
|
- `_resolve_real_herdr_path()` return channel (stdout/rc) is invariant — `has_real_herdr()` and all callers unaffected.
|
||||||
|
- `create_session.sh` arg parser unchanged — `--isolate`/`--no-isolate` still accepted as no-ops.
|
||||||
|
- `registry.py` public API (`__all__`) unchanged — `VALID_STATUSES` retained.
|
||||||
|
- No function signatures changed, no calling conventions altered.
|
||||||
|
|
||||||
|
### 8.3 Loss ✅
|
||||||
|
- **No functionality lost**: The 4 stubs were empty/no-op with zero production callers. Removing them changes no runtime behavior.
|
||||||
|
- **No test coverage lost**: The 4 removed tests verified empty output from empty functions — their removal is co-dependent with the code removal. The new regression guard test adds meaningful coverage.
|
||||||
|
- **No backward compatibility lost**: `--isolate`/`--no-isolate` CLI flags still accepted (no-op with notice). External scripts using these flags (e.g., `deploy/install_mam.sh`, `deploy/install.sh`) still work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Boundary Compliance
|
||||||
|
|
||||||
|
| Boundary | Required | Verified |
|
||||||
|
|---|---|---|
|
||||||
|
| C-3b (`isolation.root` consumers) | Do NOT touch | ✅ No diff lines in `verify_session_uuid`, `find_workspace_uuid`, `mam_session_iso_root`, `stop_session.sh` purge |
|
||||||
|
| `_HERDR_SHIM_DIR_PATTERN` | Do NOT touch | ✅ Not in diff; still at lib.sh:83, used at :105 |
|
||||||
|
| `_HERDR_SKILLS_BIN_PATTERN` | Do NOT touch | ✅ Not in diff; still at lib.sh:84, used at :105 |
|
||||||
|
| `VALID_STATUSES` | Do NOT touch | ✅ Not in diff; still at registry.py:38, used at :149-150 |
|
||||||
|
| `--isolate`/`--no-isolate` branches | Do NOT touch | ✅ Not in diff; still at create_session.sh:70-71 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Limitations
|
||||||
|
|
||||||
|
1. **Full 256-test suite not run to completion** — times out in review environment (~6 min). Ran directly-relevant subsets: test_tier1_unit.py (27/27 pass), test_tier2_component.py collection (25) + adjacent test, new test standalone. Same limitation as prior review jobs.
|
||||||
|
2. **`shellcheck` not installed** — static analysis limited to `bash -n` syntax check.
|
||||||
|
3. **`_REAL_HERDR_PATH` external consumers** (outside repo) not searched — `_` prefix denotes private symbol; repo tree, generated shims, hooks, and `~/.claude/settings.json` all confirmed no references by prior planner analysis.
|
||||||
|
4. **macOS / serial execution** — Linux / `pytest-xdist` parallel not verified (xdist not installed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Conclusion
|
||||||
|
|
||||||
|
The P2-2 implementation is a textbook dead-code cleanup: every removed line traces directly to the task specification, no collateral lines were touched, C-3b boundaries were respected, and a meaningful regression guard was added to replace vacuous tests. The documentation updates are accurate and consistent. All syntax checks pass, all runnable test subsets pass, and the collection count (256) matches the documented expectation.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
# 📋 Cross-Code Review Report: A-4 Phase 2 (P3-1) + v2.0.0 + resolve_session_id.sh Cleanup
|
||||||
|
|
||||||
|
- **Job ID**: `e7b9812b`
|
||||||
|
- **Reviewer**: cline (session: herdr:canary-projects-multi-agent-mux-creator-cline)
|
||||||
|
- **Role**: Reviewer
|
||||||
|
- **Review Subject**: A-4 Phase 2 (P3-1 M2~M7 agent knowledge migration & Option B isolation removal) + v2.0.0 skill version standardization + resolve_session_id.sh usage text cleanup
|
||||||
|
- **Commits Reviewed**: `b4821fa` (feat) + `7708d3a` (docs) + uncommitted working-tree change (`resolve_session_id.sh`)
|
||||||
|
- **Report Path**: `.mam/jobs/e7b9812b/cline-reports/report-final.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
This review covers the **complete A-4 Phase 2 architectural refactor** (commit `b4821fa`), the **v2.0.0 skill version standardization** (commit `7708d3a`), and a **follow-up usage text cleanup** (`resolve_session_id.sh`, uncommitted). The refactor centralizes all agent-specific knowledge into a clean adapter pattern (`BaseAgentAdapter` + 4 concrete adapters) and completes Option B by removing all `isolation.root` consumers (C-3b).
|
||||||
|
|
||||||
|
**Full 259/259 test suite passes (100%)** — including all unit, component, contract, deployment, integration, and E2E tests. This is the first review to run the complete suite to completion (prior reviews were limited by the 30s tool timeout; this review used background execution for shell-heavy tests).
|
||||||
|
|
||||||
|
**No lint, operability, or loss issues found.** All orphan checks pass, all syntax checks pass, all adapter runtimes verified, facts bridge hardened with `shlex.quote`. Minor documentation inconsistencies in IMPROVEMENTS.md roadmap table noted as non-blocking observations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Scope — Files Changed
|
||||||
|
|
||||||
|
### Commit b4821fa (21 files, +905/-532)
|
||||||
|
|
||||||
|
| File | Change | Category |
|
||||||
|
|---|---|---|
|
||||||
|
| `lib_py/agents/base.py` | +53: `DiscoveryContext`, `SpawnSpec`, abstract interface | Core |
|
||||||
|
| `lib_py/agents/__main__.py` | +16: `shlex.quote` facts bridge, 8 `MAM_*` vars | Core |
|
||||||
|
| `lib_py/agents/adapters/agy.py` | +90: full adapter impl | Adapter |
|
||||||
|
| `lib_py/agents/adapters/claude.py` | +103: full adapter impl | Adapter |
|
||||||
|
| `lib_py/agents/adapters/cline.py` | +82: full adapter impl | Adapter |
|
||||||
|
| `lib_py/agents/adapters/hermes.py` | +90: full adapter impl | Adapter |
|
||||||
|
| `lib_py/verify_session.py` | -116: delegate to `adapter.verify_artifact()` | Simplify |
|
||||||
|
| `lib_py/workspace_uuid.py` | -128: delegate to `adapter.discover()` | Simplify |
|
||||||
|
| `lib_py/atomic_yaml.py` | -4: remove `isolation` validation | Cleanup |
|
||||||
|
| `lib.sh` | -76: remove `mam_session_iso_root`, generalize `wait_for_tui_ready` | Core |
|
||||||
|
| `create_session.sh` | +29: adapter `spawn_spec` + `delegate_agent_key` | Migration |
|
||||||
|
| `reconcile.sh` | -40: adapter `get_adapter`/`own_key`/`spawn_spec` | Migration |
|
||||||
|
| `resume_session.sh` | -31: remove `_iso_root`, adapter `resume_spec` | Migration |
|
||||||
|
| `stop_session.sh` | -99: adapter `purge_artifacts`/`exit_key`/`cache_fields` | Migration |
|
||||||
|
| `tests/test_a4_adapter_contract.py` | +213: 9 new contract tests | Test |
|
||||||
|
| `tests/test_orc_onboard.py` | -14: remove obsolete isolation tests | Test |
|
||||||
|
| `tests/test_tier2_component.py` | -24: remove isolation path guard test | Test |
|
||||||
|
| `tests/test_uuid_target.py` | -38: remove `test_t11_legacy_isolation_row` | Test |
|
||||||
|
| `IMPROVEMENTS.md` | +14: A-4 + C-3b completion, counts | Docs |
|
||||||
|
| `LOG.md` | +22: P3-1 detailed entry | Docs |
|
||||||
|
|
||||||
|
### Commit 7708d3a (8 SKILL.md files, +24/-8)
|
||||||
|
- All 8 SKILL.md: `version: 2.0.0` ✅ (verified)
|
||||||
|
- delegate-job + orc-onboard: enhanced frontmatter (author, environments, metadata)
|
||||||
|
|
||||||
|
### Uncommitted Working-Tree Change (resolve_session_id.sh, +1/-2)
|
||||||
|
- Usage text: removed outdated "isolation root" reference (2 lines → 1 line)
|
||||||
|
- This addresses the "minor observation #1" from prior review job `9cf96c56`
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architecture Verification — Adapter Layer ✅
|
||||||
|
|
||||||
|
### 3.1 BaseAgentAdapter (base.py)
|
||||||
|
Abstract base class with complete interface:
|
||||||
|
- **Properties**: `name`, `own_key`, `ready_tokens`, `exit_key`, `delegate_agent_key`, `identity_cache_fields` (all `NotImplementedError`)
|
||||||
|
- **Optional properties**: `input_prompt`, `input_placeholder`, `input_rule_pattern` (default `None`)
|
||||||
|
- **Methods**: `artifact_path()`, `verify_artifact()`, `purge_artifacts()`, `spawn_spec()`, `resume_spec()`, `auth_ok()`, `discover()`
|
||||||
|
- **Helpers**: `derive_session_name()`, `matches_session_name()`, `verify_session()` (default impls)
|
||||||
|
- **DiscoveryContext**: workspace, agent_name, home_dir, claude_dir, epoch, row, mode + `ws_key`/`cwd` properties
|
||||||
|
|
||||||
|
### 3.2 All 4 Adapters Complete ✅ (Runtime Verified)
|
||||||
|
|
||||||
|
| Adapter | spawn_spec | ready_tokens | exit_key | delegate_agent_key |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| claude | `claude --dangerously-skip-permissions --session-id <uuid>` | `Anthropic\|Assistant\|Chat\|Welcome` | `/exit` | `claude-code` |
|
||||||
|
| agy | `agy --dangerously-skip-permissions` | `Antigravity` | `Exit` | `antigravity-cli` |
|
||||||
|
| cline | `cline -i` | `Cline\|history\|Chat\|...` | `/exit` | `cline-agent` |
|
||||||
|
| hermes | `hermes` | `Hermes` | `/exit` | `hermes-agent` |
|
||||||
|
|
||||||
|
All verified at runtime via `get_adapter('<name>').spawn_spec(...)` / `.resume_spec(...)` ✅
|
||||||
|
|
||||||
|
### 3.3 Facts Bridge Hardening ✅ (Eval-Safe)
|
||||||
|
- 8 `MAM_*` variables emitted with `shlex.quote()` ✅
|
||||||
|
- `eval "$(python -m lib_py.agents facts claude)"` under `set -euo pipefail` → rc=0 ✅
|
||||||
|
- `test_facts_bridge_eval_contract` PASSED ✅
|
||||||
|
- **Orphan check**: zero production refs to old `AGENT_NAME=`/`OWN_KEY=` names ✅
|
||||||
|
|
||||||
|
### 3.4 Circular Import Safety ✅
|
||||||
|
- `base.py` module-level import; `verify_session.py` function-level (lazy) import — no circular dependency ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Option B (C-3b) — Isolation Root Removal ✅
|
||||||
|
|
||||||
|
### 4.1 Removed Consumers
|
||||||
|
| Consumer | Location | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| `mam_session_iso_root()` | lib.sh | ✅ Removed |
|
||||||
|
| `iso_root` branch | verify_session.py | ✅ Removed |
|
||||||
|
| `iso_root_of` | workspace_uuid.py | ✅ Removed |
|
||||||
|
| `isolation` validation | atomic_yaml.py | ✅ Removed |
|
||||||
|
| Legacy purge block | stop_session.sh | ✅ Replaced by `adapter.purge_artifacts()` |
|
||||||
|
| `_iso_root`/`CLAUDE_ID_FLAG` | resume_session.sh | ✅ Replaced by `adapter.resume_spec()` |
|
||||||
|
|
||||||
|
### 4.2 Orphan Checks ✅
|
||||||
|
- `grep -rn 'mam_session_iso_root|iso_root_of|_iso_root'` in production code → **zero refs** ✅
|
||||||
|
- `grep -rn 'isolation'` in `atomic_yaml.py` → **zero refs** ✅
|
||||||
|
- `test_o11_isolation_root_respected` removed from `test_orc_onboard.py` ✅
|
||||||
|
- `lib.sh:1340` comment: documentation explaining removal ("were completely deprecated and removed") — not active code ✅
|
||||||
|
|
||||||
|
### 4.3 Tests Removed (consistency) ✅
|
||||||
|
- `test_t11_legacy_isolation_row` — tested `isolation.root` resolution (obsolete)
|
||||||
|
- `test_comp_stop_safe_path_checking` — tested isolation path guard (obsolete)
|
||||||
|
- orc_onboard `test_o11_isolation_root_respected` — tested iso_root respect (obsolete)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Shell Script Migration ✅
|
||||||
|
|
||||||
|
| Script | Key Change | Fallback |
|
||||||
|
|---|---|---|
|
||||||
|
| `create_session.sh` | `CMD_FULL` from `adapter.spawn_spec()` | hardcoded case/esac ✅ |
|
||||||
|
| `resume_session.sh` | `CMD_FULL` from `adapter.resume_spec()` | hardcoded case/esac ✅ |
|
||||||
|
| `reconcile.sh` | `_get_own_key()` + `adapter.spawn_spec()` | — |
|
||||||
|
| `stop_session.sh` | `adapter.exit_key` + `adapter.purge_artifacts()` | — |
|
||||||
|
| `lib.sh` | `wait_for_tui_ready` uses `MAM_READY_TOKENS` | self-contained fallback ✅ |
|
||||||
|
|
||||||
|
All scripts have graceful degradation via hardcoded case/esac fallbacks ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. resolve_session_id.sh Working-Tree Change ✅
|
||||||
|
|
||||||
|
The uncommitted change updates the usage text to remove the outdated "isolation root" reference:
|
||||||
|
```
|
||||||
|
- --session scopes resolution to that registry row — required for sessions
|
||||||
|
- created with --isolate (their conversation lives only in the row's isolation root).
|
||||||
|
+ --session scopes resolution to that specific registry row.
|
||||||
|
```
|
||||||
|
|
||||||
|
- `bash -n` syntax check: ✅ OK
|
||||||
|
- Zero remaining `isolation` references in the file ✅
|
||||||
|
- `--session` flag behavior unchanged (still calls `find_workspace_uuid`) ✅
|
||||||
|
- This is a correct documentation fix that aligns with the Option B removal
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Syntax & Static Analysis ✅
|
||||||
|
|
||||||
|
| File | Check | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| `resolve_session_id.sh` | `bash -n` | ✅ OK |
|
||||||
|
| `lib.sh` | `bash -n` | ✅ OK |
|
||||||
|
| `create_session.sh` | `bash -n` | ✅ OK |
|
||||||
|
| `resume_session.sh` | `bash -n` | ✅ OK |
|
||||||
|
| `stop_session.sh` | `bash -n` | ✅ OK |
|
||||||
|
| `reconcile.sh` | `bash -n` | ✅ OK |
|
||||||
|
| `lib_py/**/*.py` | `pytest collection` | ✅ 259 collected, 0 import errors |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Full Test Verification — 259/259 PASS ✅
|
||||||
|
|
||||||
|
This review ran the **complete test suite to completion** for the first time (prior reviews were limited by the 30s tool timeout; this review used background execution for shell-heavy tests).
|
||||||
|
|
||||||
|
| Suite | Tests | Time | Result |
|
||||||
|
|---|---|---|---|
|
||||||
|
| test_tier1_unit + test_a4_adapter_contract + test_orc_onboard + test_workspace_scope | 77 | 12.78s | ✅ PASS |
|
||||||
|
| test_deploy_freshness | 9 | 12.48s | ✅ PASS |
|
||||||
|
| test_b7 + test_b8 + test_o2 + test_o3 | 70 | 21.43s | ✅ PASS |
|
||||||
|
| test_b4 + test_herdr_shim_contract + test_o1 + test_sanitize + test_sanity | 41 | 18.76s | ✅ PASS |
|
||||||
|
| test_uuid_target + test_tier2 + test_deploy_layout + test_deploy_registry_merge | 52 | 167.87s | ✅ PASS |
|
||||||
|
| test_tier3_integration + test_tier4_e2e | 10 | 131.99s | ✅ PASS |
|
||||||
|
| **TOTAL** | **259** | **~365s** | **✅ 100% PASS** |
|
||||||
|
|
||||||
|
### Coverage by Category (per brief requirement)
|
||||||
|
- **Unit tests**: test_tier1_unit (27), test_sanity (2), test_b4 (8), test_b7 (20), test_b8 (1) ✅
|
||||||
|
- **Component tests**: test_tier2_component (26) ✅
|
||||||
|
- **Contract tests**: test_a4_adapter_contract (9), test_herdr_shim_contract (5), test_o1_rebuttal (11) ✅
|
||||||
|
- **Deployment tests**: test_deploy_freshness (9), test_deploy_layout (5), test_deploy_registry_merge (10) ✅
|
||||||
|
- **Integration tests**: test_tier3_integration (5) ✅
|
||||||
|
- **E2E tests**: test_tier4_e2e (5) ✅
|
||||||
|
- **Guard tests**: test_o2 (22), test_o3 (27) ✅
|
||||||
|
- **Scope tests**: test_workspace_scope (2), test_uuid_target (13), test_orc_onboard (36) ✅
|
||||||
|
- **Sanitize tests**: test_sanitize_and_mock_errors (3) ✅
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. SKILL.md v2.0.0 Standardization ✅
|
||||||
|
|
||||||
|
All 8 SKILL.md files verified at `version: 2.0.0`:
|
||||||
|
- multi-agent-mux-create ✅
|
||||||
|
- multi-agent-mux-delegate-job ✅ (enhanced frontmatter: author, environments)
|
||||||
|
- multi-agent-mux-loop ✅
|
||||||
|
- multi-agent-mux-monitor ✅
|
||||||
|
- multi-agent-mux-orc-onboard ✅ (enhanced frontmatter)
|
||||||
|
- multi-agent-mux-resume ✅
|
||||||
|
- multi-agent-mux-status ✅
|
||||||
|
- multi-agent-mux-stop ✅
|
||||||
|
|
||||||
|
`test_o37_skill_md_valid` PASSED ✅ (validates frontmatter structure)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Documentation Review
|
||||||
|
|
||||||
|
### 10.1 Correctly Updated ✅
|
||||||
|
- **IMPROVEMENTS.md:3** — 최종 갱신일 2026-08-16, P3-1/A-4 Phase 2 완료 ✅
|
||||||
|
- **IMPROVEMENTS.md:5** — 미해결 6건 (arch 1, edge 4, orch 0, legacy 1) ✅
|
||||||
|
- **IMPROVEMENTS.md:6** — 완료 19건 (A-4, C-3b added) ✅
|
||||||
|
- **IMPROVEMENTS.md:22** — A-4 marked "✅ 완료 — P3-1" ✅
|
||||||
|
- **IMPROVEMENTS.md:319** — C-3b marked "✅ 완료 — P3-1 / Option B" with full detail ✅
|
||||||
|
- **LOG.md** — P3-1 detailed entry ✅
|
||||||
|
|
||||||
|
### 10.2 Minor Inconsistencies (Non-Blocking) ⚠️
|
||||||
|
The planner's §8 explicitly instructed updating these, but b4821fa only partially addressed them. They are documentation-only and do not affect code correctness:
|
||||||
|
|
||||||
|
1. **IMPROVEMENTS.md:107** — §4 header says "레거시 잔재 2건" but should be "1건" (C-3b completed; only C-6 remains). Header line 5 correctly says "1건".
|
||||||
|
2. **IMPROVEMENTS.md:109-110** — C-3b still listed in §4 as "보류" (deferred) with old "되살린 코드" (revived code) description. Should be moved to §5 (completed). Line 319 already has the completion note, but §4 entry was not removed.
|
||||||
|
3. **IMPROVEMENTS.md:117** — §5 header says "14건" but should reflect actual count (19 per line 6). This is a **pre-existing inconsistency** the planner noted in §8 item 9 — it was not fixed.
|
||||||
|
4. **IMPROVEMENTS.md:252** — Roadmap P3-1 row says "진행 중" (in progress) but should be "✅ 완료". The planner's §8 item 10 explicitly asked to update lines 252-254.
|
||||||
|
5. **IMPROVEMENTS.md:254** — Roadmap P3-3 (C-3b) row has no completion marker, but C-3b is completed.
|
||||||
|
|
||||||
|
These are non-blocking because: (a) the critical header lines and detail sections are correctly updated, (b) the roadmap table and §4/§5 sub-headers are stale summaries, not functional documentation, (c) they don't affect code correctness, test results, or runtime behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Lint / Operability / Loss Analysis
|
||||||
|
|
||||||
|
### 11.1 Lint ✅
|
||||||
|
- All 6 shell scripts pass `bash -n` ✅
|
||||||
|
- All Python modules collect without import errors ✅
|
||||||
|
- No `shellcheck` available (macOS) — static analysis limited to `bash -n`
|
||||||
|
- No flake8 run (not in venv), but `test_o36_bash_syntax_clean` PASSED ✅
|
||||||
|
|
||||||
|
### 11.2 Operability ✅
|
||||||
|
- All 4 adapter runtimes produce correct spawn/resume commands ✅
|
||||||
|
- Facts bridge eval-safe under `set -euo pipefail` ✅
|
||||||
|
- Shell scripts have hardcoded case/esac fallbacks for adapter failures ✅
|
||||||
|
- `lib.sh:1340` documentation comment correctly explains the Option B removal ✅
|
||||||
|
- `resolve_session_id.sh` still correctly delegates to `find_workspace_uuid` ✅
|
||||||
|
|
||||||
|
### 11.3 Loss ✅
|
||||||
|
- Zero production-code references to removed symbols (`mam_session_iso_root`, `iso_root_of`, `_iso_root`) ✅
|
||||||
|
- Zero `isolation` references in `atomic_yaml.py` ✅
|
||||||
|
- Removed tests (`test_t11`, `test_o11`, `test_comp_stop_safe`) are obsolete (tested removed behavior) ✅
|
||||||
|
- New `test_a4_adapter_contract.py` (9 tests) pins the new adapter contract ✅
|
||||||
|
- No functional regression (259/259 PASS) ✅
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Minor Observations (Non-Blocking)
|
||||||
|
|
||||||
|
1. **IMPROVEMENTS.md roadmap/section headers stale** (§10.2 above) — 5 documentation inconsistencies. Non-blocking; the critical content (header lines 3/5/6, detail at lines 22/319) is correct. Recommend a follow-up doc-sync commit to update §4 header (107), remove C-3b from §4 (109-110), update §5 header count (117), and mark P3-1/P3-3 as completed in roadmap (252/254).
|
||||||
|
|
||||||
|
2. **resolve_session_id.sh change uncommitted** — the working-tree change is a correct cleanup but has not been committed yet. Recommend committing it as a follow-up to close the loop on the "minor observation #1" from job `9cf96c56`.
|
||||||
|
|
||||||
|
3. **hermes adapter untested at runtime** — hermes is not installed on this machine; `auth_ok`/`discover` paths cannot be physically verified. However, the adapter contract tests (`test_h1_to_h8`, `test_h9_h10`) validate the interface. This is a known limitation documented in the planner's §9.1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Limitations
|
||||||
|
|
||||||
|
- **macOS only**: `pytest-xdist` not installed; tests run serially. Linux/CI environment not verified.
|
||||||
|
- **shellcheck not available**: static shell analysis limited to `bash -n`.
|
||||||
|
- **flake8 not run**: not in venv; relied on `test_o36_bash_syntax_clean` for bash and pytest collection for Python import validation.
|
||||||
|
- **hermes uninstalled**: physical verification of hermes adapter runtime paths not possible.
|
||||||
|
- **Background test execution**: shell-heavy tests (52+10=62 tests) ran in background over ~5 minutes; output captured from `/tmp/pytest_heavy.log` and `/tmp/pytest_tier34.log`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Conclusion
|
||||||
|
|
||||||
|
The A-4 Phase 2 refactor is a **clean, well-structured architectural migration** that:
|
||||||
|
1. Centralizes all agent-specific knowledge into a clean adapter pattern (4 concrete adapters + abstract base)
|
||||||
|
2. Completes Option B (C-3b) by removing all `isolation.root` consumers
|
||||||
|
3. Hardens the facts bridge with `shlex.quote` for eval safety
|
||||||
|
4. Standardizes all 8 SKILL.md files to v2.0.0
|
||||||
|
5. Adds 9 new contract tests pinning the adapter interface
|
||||||
|
|
||||||
|
**All 259 tests pass (100%)** — unit, component, contract, deployment, integration, and E2E. No lint, operability, or loss issues found. The only findings are minor documentation inconsistencies in IMPROVEMENTS.md roadmap table (non-blocking) and the resolve_session_id.sh change being uncommitted (a correct fix pending commit).
|
||||||
|
|
||||||
|
The implementation does not require design changes or replanning. The minor documentation gaps are fixable with a simple doc-sync commit.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
+191
-101
@@ -26,6 +26,31 @@ export PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}"
|
|||||||
WORKSPACE_ROOT="${WORKSPACE_ROOT:-$(cd "$SKILL_DIR/../.." && pwd)}"
|
WORKSPACE_ROOT="${WORKSPACE_ROOT:-$(cd "$SKILL_DIR/../.." && pwd)}"
|
||||||
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
|
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
|
||||||
|
|
||||||
|
_sanitize_herdr_agent_name() {
|
||||||
|
local n="$1"
|
||||||
|
if [ -z "$n" ]; then
|
||||||
|
printf 'agent\n'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local s
|
||||||
|
s=$(echo "$n" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g')
|
||||||
|
if [[ ! "$s" =~ ^[a-z] ]]; then s="x-$s"; fi
|
||||||
|
if [ "${#s}" -gt 32 ]; then
|
||||||
|
local h=""
|
||||||
|
if command -v shasum >/dev/null 2>&1; then
|
||||||
|
h=$(printf '%s' "$s" | shasum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
|
||||||
|
elif command -v sha1sum >/dev/null 2>&1; then
|
||||||
|
h=$(printf '%s' "$s" | sha1sum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
|
||||||
|
elif command -v openssl >/dev/null 2>&1; then
|
||||||
|
h=$(printf '%s' "$s" | openssl sha1 2>/dev/null | awk '{print $NF}' | cut -c 1-8)
|
||||||
|
else
|
||||||
|
h=$(python3 -c "import hashlib,sys; print(hashlib.sha1(sys.argv[1].encode()).hexdigest()[:8])" "$s" 2>/dev/null || echo "00000000")
|
||||||
|
fi
|
||||||
|
s="${s:0:23}-${h}"
|
||||||
|
fi
|
||||||
|
printf '%s\n' "${s:0:32}"
|
||||||
|
}
|
||||||
|
|
||||||
# Add common Homebrew and local binary paths to PATH to ensure they are available in non-interactive shells
|
# Add common Homebrew and local binary paths to PATH to ensure they are available in non-interactive shells
|
||||||
for dir in /home/linuxbrew/.linuxbrew/bin /home/linuxbrew/.linuxbrew/sbin "$HOME/.local/bin" "$HOME/.npm-global/bin"; do
|
for dir in /home/linuxbrew/.linuxbrew/bin /home/linuxbrew/.linuxbrew/sbin "$HOME/.local/bin" "$HOME/.npm-global/bin"; do
|
||||||
if [ -d "$dir" ] && [[ ":$PATH:" != *":$dir:"* ]]; then
|
if [ -d "$dir" ] && [[ ":$PATH:" != *":$dir:"* ]]; then
|
||||||
@@ -35,7 +60,7 @@ done
|
|||||||
|
|
||||||
# Central TUI dialog and readiness validation tokens (OP-6)
|
# Central TUI dialog and readiness validation tokens (OP-6)
|
||||||
_MAM_DIALOG_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|Press Enter to continue|browser to authenticate|Use arrow keys|Esc to cancel|Resuming the full session|Resume from summary'
|
_MAM_DIALOG_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|Press Enter to continue|browser to authenticate|Use arrow keys|Esc to cancel|Resuming the full session|Resume from summary'
|
||||||
_MAM_READY_TOKENS_CLAUDE='Anthropic|Assistant|Chat|Welcome|projects'
|
_MAM_READY_TOKENS_CLAUDE='Anthropic|Assistant|Chat|Welcome'
|
||||||
|
|
||||||
# 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}"
|
||||||
@@ -98,8 +123,6 @@ _resolve_real_herdr_path() {
|
|||||||
done
|
done
|
||||||
IFS="$save_ifs"
|
IFS="$save_ifs"
|
||||||
[ -n "$real_path" ] || return 1
|
[ -n "$real_path" ] || return 1
|
||||||
_REAL_HERDR_PATH="$real_path"
|
|
||||||
export _REAL_HERDR_PATH
|
|
||||||
printf '%s\n' "$real_path"
|
printf '%s\n' "$real_path"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +211,30 @@ _real_herdr() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
wrapper_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
_sanitize_herdr_agent_name() {
|
||||||
|
local n="$1"
|
||||||
|
if [ -z "$n" ]; then
|
||||||
|
printf 'agent\n'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local s
|
||||||
|
s=$(echo "$n" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g')
|
||||||
|
if [[ ! "$s" =~ ^[a-z] ]]; then s="x-$s"; fi
|
||||||
|
if [ "${#s}" -gt 32 ]; then
|
||||||
|
local h=""
|
||||||
|
if command -v shasum >/dev/null 2>&1; then
|
||||||
|
h=$(printf '%s' "$s" | shasum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
|
||||||
|
elif command -v sha1sum >/dev/null 2>&1; then
|
||||||
|
h=$(printf '%s' "$s" | sha1sum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
|
||||||
|
elif command -v openssl >/dev/null 2>&1; then
|
||||||
|
h=$(printf '%s' "$s" | openssl sha1 2>/dev/null | awk '{print $NF}' | cut -c 1-8)
|
||||||
|
else
|
||||||
|
h=$(python3 -c "import hashlib,sys; print(hashlib.sha1(sys.argv[1].encode()).hexdigest()[:8])" "$s" 2>/dev/null || echo "00000000")
|
||||||
|
fi
|
||||||
|
s="${s:0:23}-${h}"
|
||||||
|
fi
|
||||||
|
printf '%s\n' "${s:0:32}"
|
||||||
|
}
|
||||||
cmd="${1:-}"
|
cmd="${1:-}"
|
||||||
if [ -z "$cmd" ]; then
|
if [ -z "$cmd" ]; then
|
||||||
echo "herdr shim: no command specified" >&2
|
echo "herdr shim: no command specified" >&2
|
||||||
@@ -196,8 +242,30 @@ if [ -z "$cmd" ]; then
|
|||||||
fi
|
fi
|
||||||
shift
|
shift
|
||||||
|
|
||||||
|
|
||||||
case "$cmd" in
|
case "$cmd" in
|
||||||
|
agent)
|
||||||
|
sub="${1:-}"
|
||||||
|
shift || true
|
||||||
|
if [ "$sub" = "prompt" ] && [ $# -ge 2 ]; then
|
||||||
|
t="$1"
|
||||||
|
txt="$2"
|
||||||
|
shift 2
|
||||||
|
at=$(_sanitize_herdr_agent_name "$t")
|
||||||
|
_real_herdr agent prompt "$at" "$txt" "$@" 2>/dev/null || _real_herdr agent prompt "$t" "$txt" "$@"
|
||||||
|
elif [ "$sub" = "get" ] && [ $# -ge 1 ]; then
|
||||||
|
t="$1"
|
||||||
|
shift
|
||||||
|
at=$(_sanitize_herdr_agent_name "$t")
|
||||||
|
_real_herdr agent get "$at" "$@" 2>/dev/null || _real_herdr agent get "$t" "$@"
|
||||||
|
elif [ "$sub" = "read" ] && [ $# -ge 1 ]; then
|
||||||
|
t="$1"
|
||||||
|
shift
|
||||||
|
at=$(_sanitize_herdr_agent_name "$t")
|
||||||
|
_real_herdr agent read "$at" "$@" 2>/dev/null || _real_herdr agent read "$t" "$@"
|
||||||
|
else
|
||||||
|
_real_herdr agent "$sub" "$@"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
has-session)
|
has-session)
|
||||||
sess=""
|
sess=""
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
@@ -213,7 +281,28 @@ case "$cmd" in
|
|||||||
*) shift ;;
|
*) shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
_real_herdr agent get "$sess" >/dev/null
|
if _real_herdr agent get "$(_sanitize_herdr_agent_name "$sess")" >/dev/null 2>&1 || _real_herdr agent get "$sess" >/dev/null 2>&1; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if _real_herdr agent list 2>/dev/null | TARGET_NAME="$sess" python3 -c "
|
||||||
|
import sys, json, os
|
||||||
|
from lib_py.agents.sanitize import sanitize_herdr_agent_name
|
||||||
|
tn = os.environ.get('TARGET_NAME', '')
|
||||||
|
stn = sanitize_herdr_agent_name(tn)
|
||||||
|
try:
|
||||||
|
d = json.loads(sys.stdin.read())
|
||||||
|
agents = d.get('result', {}).get('agents', [])
|
||||||
|
for a in agents:
|
||||||
|
an = a.get('name', '')
|
||||||
|
if an == tn or an == stn:
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
sys.exit(1)
|
||||||
|
"; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
;;
|
;;
|
||||||
new-session)
|
new-session)
|
||||||
name="" ws="" run_cmd=""
|
name="" ws="" run_cmd=""
|
||||||
@@ -275,22 +364,25 @@ print('\t'.join(env_flags) + '\n' + ' '.join(binary_tokens))
|
|||||||
if echo "$name" | grep -qi "agy"; then kind="agy"
|
if echo "$name" | grep -qi "agy"; then kind="agy"
|
||||||
elif echo "$name" | grep -qi "claude"; then kind="claude"
|
elif echo "$name" | grep -qi "claude"; then kind="claude"
|
||||||
elif echo "$name" | grep -qi "hermes"; then kind="hermes"
|
elif echo "$name" | grep -qi "hermes"; then kind="hermes"
|
||||||
|
elif echo "$name" | grep -qi "cline"; then kind="cline"
|
||||||
elif echo "${final_cmd:-}" | grep -qi "agy"; then kind="agy"
|
elif echo "${final_cmd:-}" | grep -qi "agy"; then kind="agy"
|
||||||
elif echo "${final_cmd:-}" | grep -qi "claude"; then kind="claude"
|
elif echo "${final_cmd:-}" | grep -qi "claude"; then kind="claude"
|
||||||
elif echo "${final_cmd:-}" | grep -qi "hermes"; then kind="hermes"
|
elif echo "${final_cmd:-}" | grep -qi "hermes"; then kind="hermes"
|
||||||
|
elif echo "${final_cmd:-}" | grep -qi "cline"; then kind="cline"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Strip duplicate agent binary name/path from final_cmd if present, preventing Go flag.Parse() positional argument interruption
|
# Strip duplicate agent binary name/path from final_cmd if present, preventing positional argument misinterpretation
|
||||||
final_cmd=$(python3 -c "
|
final_cmd=$(python3 -c "
|
||||||
import sys, shlex
|
import sys, shlex
|
||||||
cmd = sys.argv[1]
|
cmd = sys.argv[1]
|
||||||
kind = sys.argv[2]
|
kind = sys.argv[2]
|
||||||
try:
|
try:
|
||||||
tokens = shlex.split(cmd)
|
tokens = shlex.split(cmd)
|
||||||
if tokens and (tokens[0] == kind or tokens[0].endswith('/' + kind)):
|
if tokens:
|
||||||
if len(tokens) > 1 and not tokens[1].startswith('-'):
|
first = tokens[0]
|
||||||
|
if first in ('claude', 'agy', 'hermes', 'cline') or any(first.endswith('/' + a) for a in ('claude', 'agy', 'hermes', 'cline')) or (kind and (first == kind or first.endswith('/' + kind))):
|
||||||
tokens = tokens[1:]
|
tokens = tokens[1:]
|
||||||
print(' '.join(shlex.quote(t) for t in tokens))
|
print(' '.join(shlex.quote(t) for t in tokens))
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -317,8 +409,7 @@ except Exception:
|
|||||||
" 2>/dev/null || echo "")
|
" 2>/dev/null || echo "")
|
||||||
|
|
||||||
ws_id=""
|
ws_id=""
|
||||||
split_arg=""
|
target_pane=""
|
||||||
|
|
||||||
if [ -n "$existing_ws" ]; then
|
if [ -n "$existing_ws" ]; then
|
||||||
# W2a: Determine split direction policy via pane layout
|
# W2a: Determine split direction policy via pane layout
|
||||||
sample_pane=$(_real_herdr pane list 2>/dev/null | TARGET_WS="$existing_ws" python3 -c "
|
sample_pane=$(_real_herdr pane list 2>/dev/null | TARGET_WS="$existing_ws" python3 -c "
|
||||||
@@ -367,46 +458,72 @@ except Exception:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$split_dir" = "right" ] || [ "$split_dir" = "down" ]; then
|
if [ "$split_dir" = "right" ] || [ "$split_dir" = "down" ]; then
|
||||||
ws_id="$existing_ws"
|
split_json=$(_real_herdr pane split --pane "$sample_pane" --direction "$split_dir" --cwd "${ws:-.}" $env_flags --no-focus 2>/dev/null || echo "")
|
||||||
split_arg="--split $split_dir"
|
target_pane=$(echo "$split_json" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
try:
|
||||||
|
d = json.loads(sys.stdin.read())
|
||||||
|
res = d.get('result', {})
|
||||||
|
print(res.get('pane', {}).get('pane_id') or res.get('pane_id', ''))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
" 2>/dev/null || echo "")
|
||||||
elif [ "$split_dir" = "overflow" ]; then
|
elif [ "$split_dir" = "overflow" ]; then
|
||||||
# W2b: Overflow threshold reached — force create fresh workspace
|
# W2b: Overflow threshold reached — force create fresh workspace
|
||||||
existing_ws=""
|
existing_ws=""
|
||||||
else
|
else
|
||||||
ws_id="$existing_ws"
|
split_json=$(_real_herdr pane split --pane "$sample_pane" --direction right --cwd "${ws:-.}" $env_flags --no-focus 2>/dev/null || echo "")
|
||||||
split_arg="--split right"
|
target_pane=$(echo "$split_json" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
try:
|
||||||
|
d = json.loads(sys.stdin.read())
|
||||||
|
res = d.get('result', {})
|
||||||
|
print(res.get('pane', {}).get('pane_id') or res.get('pane_id', ''))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
" 2>/dev/null || echo "")
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$existing_ws" ]; then
|
if [ -z "$existing_ws" ] || [ -z "$target_pane" ]; then
|
||||||
ws_json=$(_real_herdr workspace create --cwd "${ws:-.}" --no-focus 2>/dev/null || echo "")
|
ws_json=$(_real_herdr workspace create --cwd "${ws:-.}" $env_flags --no-focus 2>/dev/null || echo "")
|
||||||
ws_id=$(echo "$ws_json" | python3 -c "
|
target_pane=$(echo "$ws_json" | python3 -c "
|
||||||
import sys, json
|
import sys, json
|
||||||
try:
|
try:
|
||||||
d = json.loads(sys.stdin.read())
|
d = json.loads(sys.stdin.read())
|
||||||
res = d.get('result', {})
|
res = d.get('result', {})
|
||||||
w_obj = res.get('workspace', {})
|
w_obj = res.get('workspace', {})
|
||||||
print(w_obj.get('workspace_id') or res.get('workspace_id', ''))
|
print(res.get('root_pane', {}).get('pane_id') or w_obj.get('root_pane_id') or res.get('root_pane_id') or res.get('pane_id', ''))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
" 2>/dev/null || echo "")
|
" 2>/dev/null || echo "")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -z "$target_pane" ]; then
|
||||||
|
echo "Error: target_pane allocation failed for $name" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "$kind" ]; then
|
||||||
|
echo "Error: unable to determine agent kind for session $name" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# W5/W6: Backoff retries (0.5 -> 1 -> 2), abort immediately on usage or unknown flag errors
|
# W5/W6: Backoff retries (0.5 -> 1 -> 2), abort immediately on usage or unknown flag errors
|
||||||
res=""
|
res=""
|
||||||
success=0
|
success=0
|
||||||
backoffs=(0.5 1 2)
|
backoffs=(0.5 1 2)
|
||||||
|
agent_name=$(_sanitize_herdr_agent_name "$name")
|
||||||
for i in $(seq 0 2); do
|
for i in $(seq 0 2); do
|
||||||
if [ -n "$ws_id" ]; then
|
if [ -n "$final_cmd" ]; then
|
||||||
res=$(eval "_real_herdr agent start \"$name\" --workspace \"$ws_id\" --cwd \"${ws:-.}\" $split_arg $env_flags -- $final_cmd" 2>&1 || true)
|
res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"$kind\" --pane \"$target_pane\" -- $final_cmd" 2>&1 || true)
|
||||||
else
|
else
|
||||||
res=$(eval "_real_herdr agent start \"$name\" --cwd \"${ws:-.}\" $split_arg $env_flags -- $final_cmd" 2>&1 || true)
|
res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"$kind\" --pane \"$target_pane\"" 2>&1 || true)
|
||||||
fi
|
fi
|
||||||
if echo "$res" | grep -q "agent_started"; then
|
if echo "$res" | grep -q "agent_started"; then
|
||||||
success=1
|
success=1
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
if echo "$res" | grep -qiE "^usage:|unknown option|unknown flag"; then
|
if echo "$res" | grep -qiE "^usage:|unknown option|unknown flag|missing required|invalid_agent_name|^error:"; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
if [ "$i" -lt 2 ]; then
|
if [ "$i" -lt 2 ]; then
|
||||||
@@ -438,7 +555,14 @@ except Exception:
|
|||||||
# `herdr session list`), not individual agent panes — `session stop/delete
|
# `herdr session list`), not individual agent panes — `session stop/delete
|
||||||
# "$sess"` with a MAM session name always fails (silently, via `|| true`).
|
# "$sess"` with a MAM session name always fails (silently, via `|| true`).
|
||||||
# Resolve the real pane_id via `agent get` and close just that pane instead.
|
# Resolve the real pane_id via `agent get` and close just that pane instead.
|
||||||
pane_id=$(_real_herdr agent get "$sess" 2>/dev/null | python3 -c "
|
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
||||||
|
pane_id=$(_real_herdr agent get "$agent_target" 2>/dev/null | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
try:
|
||||||
|
print(json.load(sys.stdin).get('result', {}).get('agent', {}).get('pane_id', ''))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
" 2>/dev/null || _real_herdr agent get "$sess" 2>/dev/null | python3 -c "
|
||||||
import sys, json
|
import sys, json
|
||||||
try:
|
try:
|
||||||
print(json.load(sys.stdin).get('result', {}).get('agent', {}).get('pane_id', ''))
|
print(json.load(sys.stdin).get('result', {}).get('agent', {}).get('pane_id', ''))
|
||||||
@@ -448,7 +572,7 @@ except Exception:
|
|||||||
if [ -n "$pane_id" ]; then
|
if [ -n "$pane_id" ]; then
|
||||||
_real_herdr pane close "$pane_id" >/dev/null 2>&1 || true
|
_real_herdr pane close "$pane_id" >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
_real_herdr kill-session -t "$sess" >/dev/null 2>&1 || true
|
_real_herdr kill-session -t "$agent_target" >/dev/null 2>&1 || _real_herdr kill-session -t "$sess" >/dev/null 2>&1 || true
|
||||||
;;
|
;;
|
||||||
list-panes)
|
list-panes)
|
||||||
sess="" format=""
|
sess="" format=""
|
||||||
@@ -477,7 +601,8 @@ except Exception:
|
|||||||
# there is no top-level "pane" key, and no "pid" field at all (only
|
# there is no top-level "pane" key, and no "pid" field at all (only
|
||||||
# "pane_id", herdr's own wN:pN identifier). The real OS pid requires a
|
# "pane_id", herdr's own wN:pN identifier). The real OS pid requires a
|
||||||
# second call to `pane process-info --pane <pane_id>`.
|
# second call to `pane process-info --pane <pane_id>`.
|
||||||
info=$(_real_herdr agent get "$sess" 2>/dev/null || true)
|
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
||||||
|
info=$(_real_herdr agent get "$agent_target" 2>/dev/null || _real_herdr agent get "$sess" 2>/dev/null || true)
|
||||||
pane_id="" cwd="" cmd=""
|
pane_id="" cwd="" cmd=""
|
||||||
if [ -n "$info" ]; then
|
if [ -n "$info" ]; then
|
||||||
parsed=$(python3 -c "
|
parsed=$(python3 -c "
|
||||||
@@ -546,7 +671,8 @@ except Exception:
|
|||||||
*) shift ;;
|
*) shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
_real_herdr agent read "$sess" --source visible --lines 100 2>/dev/null || true
|
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
||||||
|
_real_herdr agent read "$agent_target" --source visible --lines 100 2>/dev/null || _real_herdr agent read "$sess" --source visible --lines 100 2>/dev/null || true
|
||||||
;;
|
;;
|
||||||
send-keys)
|
send-keys)
|
||||||
sess="" key=""
|
sess="" key=""
|
||||||
@@ -568,7 +694,14 @@ except Exception:
|
|||||||
fi
|
fi
|
||||||
# `pane send-keys` requires a real pane_id ("wN:pN"), not an agent name —
|
# `pane send-keys` requires a real pane_id ("wN:pN"), not an agent name —
|
||||||
# resolve it via `agent get` first (agent-level commands accept names).
|
# resolve it via `agent get` first (agent-level commands accept names).
|
||||||
pane_id=$(_real_herdr agent get "$sess" 2>/dev/null | python3 -c "
|
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
||||||
|
pane_id=$(_real_herdr agent get "$agent_target" 2>/dev/null | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
try:
|
||||||
|
print(json.load(sys.stdin).get('result', {}).get('agent', {}).get('pane_id', ''))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
" 2>/dev/null || _real_herdr agent get "$sess" 2>/dev/null | python3 -c "
|
||||||
import sys, json
|
import sys, json
|
||||||
try:
|
try:
|
||||||
print(json.load(sys.stdin).get('result', {}).get('agent', {}).get('pane_id', ''))
|
print(json.load(sys.stdin).get('result', {}).get('agent', {}).get('pane_id', ''))
|
||||||
@@ -578,7 +711,7 @@ except Exception:
|
|||||||
if [ -n "$pane_id" ]; then
|
if [ -n "$pane_id" ]; then
|
||||||
_real_herdr pane send-keys "$pane_id" "$key" >/dev/null 2>&1 || true
|
_real_herdr pane send-keys "$pane_id" "$key" >/dev/null 2>&1 || true
|
||||||
else
|
else
|
||||||
_real_herdr pane send-keys "$sess" "$key" >/dev/null 2>&1 || true
|
_real_herdr pane send-keys "$agent_target" "$key" >/dev/null 2>&1 || _real_herdr pane send-keys "$sess" "$key" >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
set-buffer)
|
set-buffer)
|
||||||
@@ -964,25 +1097,6 @@ mam_workspace_key() {
|
|||||||
printf '%s' "$(mam_abs_workspace "$1")" | tr '/_' '--'
|
printf '%s' "$(mam_abs_workspace "$1")" | tr '/_' '--'
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# mam_session_iso_root <session_name>
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
mam_session_iso_root() {
|
|
||||||
MAM_STATE_JSON="$(load_state_json)" MAM_ISO_SESSION="$1" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
|
||||||
import json, os
|
|
||||||
name = os.environ.get('MAM_ISO_SESSION', '')
|
|
||||||
try:
|
|
||||||
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
|
||||||
except Exception:
|
|
||||||
d = {}
|
|
||||||
for s in (d.get('herdr_sessions') or []):
|
|
||||||
if s.get('name') == name:
|
|
||||||
iso = s.get('isolation')
|
|
||||||
if isinstance(iso, dict) and iso.get('root'):
|
|
||||||
print(iso['root'])
|
|
||||||
break
|
|
||||||
PYEOF
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# derive_session_name <workspace> <agent> [role]
|
# derive_session_name <workspace> <agent> [role]
|
||||||
@@ -1212,8 +1326,7 @@ PYEOF
|
|||||||
# if none). find_workspace_uuid is already a workspace-scoped, 3-tier, race-free
|
# if none). find_workspace_uuid is already a workspace-scoped, 3-tier, race-free
|
||||||
# resolver (per-row own id -> workspace-scoped disk scan -> cwd-matched cache),
|
# resolver (per-row own id -> workspace-scoped disk scan -> cwd-matched cache),
|
||||||
# so recording its result into the row before kill guarantees tier-1 on the next
|
# so recording its result into the row before kill guarantees tier-1 on the next
|
||||||
# resume. Pass session_name to scope resolution to that row (required for
|
# resume. Pass session_name to prefer that specific row's recorded id. Always exits 0.
|
||||||
# isolated sessions — see isolation block / T5). Always exits 0.
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
capture_conversation_id() {
|
capture_conversation_id() {
|
||||||
local agent="$1" workdir="$2" session_name="${3:-}"
|
local agent="$1" workdir="$2" session_name="${3:-}"
|
||||||
@@ -1221,35 +1334,15 @@ capture_conversation_id() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Session isolation — Universal Global Config (Option A, Job 536a6625)
|
# Session isolation — Universal Global Config (Option B, P3-1)
|
||||||
#
|
#
|
||||||
# Config-home isolation (.mam/agent_homes/<uuid>/) was removed in favor of:
|
# Config-home isolation (.mam/agent_homes/<uuid>/) and legacy isolation.root
|
||||||
|
# row consumers were completely deprecated and removed in favor of:
|
||||||
# 1. Universal Global Config: all agents read/write standard ~/.claude, ~/.gemini,
|
# 1. Universal Global Config: all agents read/write standard ~/.claude, ~/.gemini,
|
||||||
# ~/.hermes, ~/.cline user configuration and credential stores.
|
# ~/.hermes, ~/.cline user configuration and credential stores.
|
||||||
# 2. Process Isolation: each agent-workspace pair runs in its own herdr pane.
|
# 2. Process Isolation: each agent-workspace pair runs in its own herdr pane.
|
||||||
# 3. Conversation Isolation: session UUIDs discriminate conversation history.
|
# 3. Conversation Isolation: session UUIDs discriminate conversation history.
|
||||||
#
|
|
||||||
# Stubbed isolation functions kept for backward compatibility:
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
provision_isolation() {
|
|
||||||
local agent="$1" root="$2"
|
|
||||||
printf ''
|
|
||||||
}
|
|
||||||
|
|
||||||
isolation_lever() {
|
|
||||||
case "$1" in
|
|
||||||
claude|agy|hermes|cline) echo "none" ;;
|
|
||||||
*) echo "" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
isolation_env_prefix() {
|
|
||||||
:
|
|
||||||
}
|
|
||||||
|
|
||||||
isolation_cmd_args() {
|
|
||||||
:
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# is_already_stopped <session_name>
|
# is_already_stopped <session_name>
|
||||||
@@ -1411,11 +1504,22 @@ start_watchdog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# wait_for_tui_ready <session_name> <agent>
|
# wait_for_tui_ready <session_name> <agent>
|
||||||
# Waits up to 15 seconds for the agent's TUI to render its welcome screen.
|
# Waits up to 30 seconds for the agent's TUI to render its welcome screen.
|
||||||
wait_for_tui_ready() {
|
wait_for_tui_ready() {
|
||||||
local sess="$1" agent="$2"
|
local sess="$1" agent="$2"
|
||||||
|
# Self-contained resolution: works whether or not caller evaluated facts (C1-(3)).
|
||||||
|
local tokens="${MAM_READY_TOKENS:-}"
|
||||||
|
if [ -z "$tokens" ]; then
|
||||||
|
local _facts=""
|
||||||
|
_facts="$("$(_delegate_py_bin)" -m lib_py.agents facts "$agent" 2>/dev/null)" || _facts=""
|
||||||
|
tokens="$(printf '%s\n' "$_facts" | sed -n 's/^MAM_READY_TOKENS=//p')"
|
||||||
|
[ -n "$tokens" ] && eval "tokens=$tokens"
|
||||||
|
fi
|
||||||
|
if [ -z "$tokens" ]; then
|
||||||
|
echo "wait_for_tui_ready: no ready tokens for agent '$agent'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
local i
|
local i
|
||||||
|
|
||||||
for i in {1..30}; do
|
for i in {1..30}; do
|
||||||
if _pane_dialog_open "$sess"; then
|
if _pane_dialog_open "$sess"; then
|
||||||
if printf '%s\n' "$(_pane_tail "$sess" 5)" | grep -q 'Press Enter to continue'; then
|
if printf '%s\n' "$(_pane_tail "$sess" 5)" | grep -q 'Press Enter to continue'; then
|
||||||
@@ -1428,32 +1532,10 @@ wait_for_tui_ready() {
|
|||||||
local content
|
local content
|
||||||
content=$(_sks_herdr capture-pane -p -t "$sess" 2>/dev/null || echo "")
|
content=$(_sks_herdr capture-pane -p -t "$sess" 2>/dev/null || echo "")
|
||||||
if [ -n "$content" ]; then
|
if [ -n "$content" ]; then
|
||||||
case "$agent" in
|
if echo "$content" | grep -E -q "$tokens" 2>/dev/null; then
|
||||||
claude)
|
echo "✅ $agent TUI detected ready."
|
||||||
if echo "$content" | grep -E -q "$_MAM_READY_TOKENS_CLAUDE" 2>/dev/null; then
|
return 0
|
||||||
echo "✅ Claude TUI detected ready."
|
fi
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
agy)
|
|
||||||
if echo "$content" | grep -q "Antigravity" 2>/dev/null; then
|
|
||||||
echo "✅ Antigravity TUI detected ready."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
hermes)
|
|
||||||
if echo "$content" | grep -q "Hermes" 2>/dev/null; then
|
|
||||||
echo "✅ Hermes TUI detected ready."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
cline)
|
|
||||||
if echo "$content" | grep -E -q "Cline|history|Chat|What can I do|slash commands" 2>/dev/null; then
|
|
||||||
echo "✅ Cline TUI detected ready."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
fi
|
fi
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
@@ -1551,6 +1633,14 @@ _pane_dialog_open() {
|
|||||||
# changed); retry up to 3 times.
|
# changed); retry up to 3 times.
|
||||||
send_keys_safe() {
|
send_keys_safe() {
|
||||||
local sess="$1" text="$2" job_id="${3:-adhoc}"
|
local sess="$1" text="$2" job_id="${3:-adhoc}"
|
||||||
|
|
||||||
|
local agent_target
|
||||||
|
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
||||||
|
# Native herdr 0.8+ fast path: agent prompt handles atomic text + enter submission
|
||||||
|
if _sks_herdr agent prompt "$agent_target" "$text" >/dev/null 2>&1 || _sks_herdr agent prompt "$sess" "$text" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
local marker pre_submit deadline try
|
local marker pre_submit deadline try
|
||||||
# Verification token: last 24 *characters* (not bytes — `tail -c` can split a
|
# Verification token: last 24 *characters* (not bytes — `tail -c` can split a
|
||||||
# multi-byte UTF-8 char, e.g. Korean, producing a marker that can never match
|
# multi-byte UTF-8 char, e.g. Korean, producing a marker that can never match
|
||||||
|
|||||||
@@ -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 sys, json
|
import sys, json, shlex
|
||||||
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
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -16,11 +16,15 @@ def main():
|
|||||||
print(f"ERROR: Unknown agent {agent_name!r}", file=sys.stderr)
|
print(f"ERROR: Unknown agent {agent_name!r}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
# Emit shell-eval friendly facts
|
# Emit shell-eval friendly facts
|
||||||
print(f"AGENT_NAME={adapter.name}")
|
q = shlex.quote
|
||||||
print(f"OWN_KEY={adapter.own_key}")
|
print(f"MAM_AGENT_NAME={q(adapter.name)}")
|
||||||
print(f"INPUT_PROMPT={adapter.input_prompt or ''}")
|
print(f"MAM_OWN_KEY={q(adapter.own_key)}")
|
||||||
print(f"INPUT_PLACEHOLDER={adapter.input_placeholder or ''}")
|
print(f"MAM_INPUT_PROMPT={q(adapter.input_prompt or '')}")
|
||||||
print(f"INPUT_RULE_PATTERN={adapter.input_rule_pattern or ''}")
|
print(f"MAM_INPUT_PLACEHOLDER={q(adapter.input_placeholder or '')}")
|
||||||
|
print(f"MAM_INPUT_RULE_PATTERN={q(adapter.input_rule_pattern or '')}")
|
||||||
|
print(f"MAM_READY_TOKENS={q(adapter.ready_tokens)}")
|
||||||
|
print(f"MAM_EXIT_KEY={q(adapter.exit_key)}")
|
||||||
|
print(f"MAM_DELEGATE_AGENT_KEY={q(adapter.delegate_agent_key)}")
|
||||||
|
|
||||||
elif cmd == 'resolve':
|
elif cmd == 'resolve':
|
||||||
name = sys.argv[2] if len(sys.argv) > 2 else ''
|
name = sys.argv[2] if len(sys.argv) > 2 else ''
|
||||||
@@ -42,6 +46,40 @@ def main():
|
|||||||
print(f"ERROR: {ex}", file=sys.stderr)
|
print(f"ERROR: {ex}", file=sys.stderr)
|
||||||
sys.exit(5)
|
sys.exit(5)
|
||||||
|
|
||||||
|
elif cmd == 'spawn-spec':
|
||||||
|
agent_name = sys.argv[2] if len(sys.argv) > 2 else ''
|
||||||
|
binary = sys.argv[3] if len(sys.argv) > 3 else agent_name
|
||||||
|
uuid = sys.argv[4] if len(sys.argv) > 4 else ''
|
||||||
|
use_wrapper = (sys.argv[5].lower() in ('1', 'true', 'yes')) if len(sys.argv) > 5 else False
|
||||||
|
adapter = get_adapter(agent_name)
|
||||||
|
if not adapter:
|
||||||
|
sys.exit(1)
|
||||||
|
print(adapter.spawn_spec(binary, uuid, use_wrapper=use_wrapper))
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
elif cmd == 'resume-spec':
|
||||||
|
agent_name = sys.argv[2] if len(sys.argv) > 2 else ''
|
||||||
|
binary = sys.argv[3] if len(sys.argv) > 3 else agent_name
|
||||||
|
uuid = sys.argv[4] if len(sys.argv) > 4 else ''
|
||||||
|
workspace = sys.argv[5] if len(sys.argv) > 5 else ''
|
||||||
|
adapter = get_adapter(agent_name)
|
||||||
|
if not adapter:
|
||||||
|
sys.exit(1)
|
||||||
|
from lib_py.agents.base import DiscoveryContext
|
||||||
|
ctx = DiscoveryContext(workspace, agent_name) if workspace else None
|
||||||
|
mat = adapter.verify_artifact(uuid, ctx) if (ctx and uuid) else False
|
||||||
|
print(adapter.resume_spec(binary, uuid, materialized=mat))
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
elif cmd == 'exit-key':
|
||||||
|
agent_name = sys.argv[2] if len(sys.argv) > 2 else ''
|
||||||
|
adapter = get_adapter(agent_name)
|
||||||
|
if adapter:
|
||||||
|
print(adapter.exit_key)
|
||||||
|
sys.exit(0)
|
||||||
|
print('/exit')
|
||||||
|
sys.exit(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)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# agy.py — Antigravity (agy) agent adapter
|
import os, json, sqlite3, shutil
|
||||||
|
from typing import Optional, Any
|
||||||
from lib_py.agents.base import BaseAgentAdapter
|
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext
|
||||||
|
|
||||||
class AgyAgentAdapter(BaseAgentAdapter):
|
class AgyAgentAdapter(BaseAgentAdapter):
|
||||||
@property
|
@property
|
||||||
@@ -11,6 +11,22 @@ class AgyAgentAdapter(BaseAgentAdapter):
|
|||||||
def own_key(self) -> str:
|
def own_key(self) -> str:
|
||||||
return 'agy_conversation_id_own'
|
return 'agy_conversation_id_own'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready_tokens(self) -> str:
|
||||||
|
return 'Antigravity'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def exit_key(self) -> str:
|
||||||
|
return 'Exit'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def delegate_agent_key(self) -> str:
|
||||||
|
return 'antigravity-cli'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identity_cache_fields(self) -> tuple:
|
||||||
|
return ('conversation_id', 'conversation_db', 'conversation_brain_dir')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def input_prompt(self) -> str:
|
def input_prompt(self) -> str:
|
||||||
return '>'
|
return '>'
|
||||||
@@ -22,3 +38,71 @@ class AgyAgentAdapter(BaseAgentAdapter):
|
|||||||
@property
|
@property
|
||||||
def input_rule_pattern(self) -> str:
|
def input_rule_pattern(self) -> str:
|
||||||
return '─{10,}'
|
return '─{10,}'
|
||||||
|
|
||||||
|
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
|
||||||
|
return f"{ctx.home_dir}/.gemini/antigravity-cli/conversations/{uuid}.db"
|
||||||
|
|
||||||
|
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
|
||||||
|
path = self.artifact_path(uuid, ctx)
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return False
|
||||||
|
if ctx.epoch and os.path.getmtime(path) < ctx.epoch:
|
||||||
|
return False
|
||||||
|
if ctx.mode == "discover":
|
||||||
|
lc = f"{ctx.home_dir}/.gemini/antigravity-cli/cache/last_conversations.json"
|
||||||
|
cache_match = False
|
||||||
|
if os.path.exists(lc):
|
||||||
|
try:
|
||||||
|
with open(lc) as f:
|
||||||
|
lc_data = json.load(f)
|
||||||
|
cache_match = (lc_data.get(ctx.cwd) == uuid)
|
||||||
|
except Exception:
|
||||||
|
cache_match = False
|
||||||
|
if not cache_match:
|
||||||
|
if uuid in (ctx.row.get("_sibling_claimed_uuids") or []):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(path)
|
||||||
|
r = conn.execute("SELECT count(*) FROM steps").fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not r or r[0] < 1:
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||||
|
purged = []
|
||||||
|
db = self.artifact_path(uuid, ctx)
|
||||||
|
if os.path.exists(db):
|
||||||
|
os.remove(db)
|
||||||
|
purged.append(db)
|
||||||
|
brain = f"{ctx.home_dir}/.gemini/antigravity-cli/brain/{uuid}"
|
||||||
|
if os.path.isdir(brain):
|
||||||
|
shutil.rmtree(brain, ignore_errors=True)
|
||||||
|
purged.append(brain)
|
||||||
|
return purged
|
||||||
|
|
||||||
|
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
|
||||||
|
return f"{binary} --dangerously-skip-permissions"
|
||||||
|
|
||||||
|
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
|
||||||
|
if materialized and session_uuid:
|
||||||
|
return f"{binary} --dangerously-skip-permissions --conversation {session_uuid}"
|
||||||
|
return f"{binary} --dangerously-skip-permissions"
|
||||||
|
|
||||||
|
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
|
||||||
|
home = os.environ.get("HOME_DIR") or os.environ.get("HOME") or os.path.expanduser("~")
|
||||||
|
return os.path.exists(f"{home}/.gemini/oauth_creds.json") or os.path.exists(f"{home}/.gemini/antigravity-cli/antigravity-oauth-token")
|
||||||
|
|
||||||
|
def discover(self, ctx: DiscoveryContext) -> list:
|
||||||
|
lc = f"{ctx.home_dir}/.gemini/antigravity-cli/cache/last_conversations.json"
|
||||||
|
if os.path.exists(lc):
|
||||||
|
try:
|
||||||
|
with open(lc) as f:
|
||||||
|
cand = json.load(f).get(ctx.workspace)
|
||||||
|
if cand and self.verify_artifact(cand, ctx):
|
||||||
|
return [cand]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# claude.py — Claude Code agent adapter
|
import os, json, glob, subprocess
|
||||||
|
from typing import Optional, Any
|
||||||
from lib_py.agents.base import BaseAgentAdapter
|
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext
|
||||||
|
from lib_py.verify_session import workspace_key
|
||||||
|
|
||||||
class ClaudeAgentAdapter(BaseAgentAdapter):
|
class ClaudeAgentAdapter(BaseAgentAdapter):
|
||||||
@property
|
@property
|
||||||
@@ -11,6 +12,22 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
|
|||||||
def own_key(self) -> str:
|
def own_key(self) -> str:
|
||||||
return 'claude_session_id_own'
|
return 'claude_session_id_own'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready_tokens(self) -> str:
|
||||||
|
return 'Anthropic|Assistant|Chat|Welcome'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def exit_key(self) -> str:
|
||||||
|
return '/exit'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def delegate_agent_key(self) -> str:
|
||||||
|
return 'claude-code'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identity_cache_fields(self) -> tuple:
|
||||||
|
return ('session_id', 'session_jsonl', 'session_size_bytes', 'session_lines')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def input_prompt(self) -> str:
|
def input_prompt(self) -> str:
|
||||||
return '❯'
|
return '❯'
|
||||||
@@ -22,3 +39,83 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
|
|||||||
@property
|
@property
|
||||||
def input_rule_pattern(self) -> str:
|
def input_rule_pattern(self) -> str:
|
||||||
return '─{10,}'
|
return '─{10,}'
|
||||||
|
|
||||||
|
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
|
||||||
|
return f"{ctx.claude_dir}/{ctx.ws_key}/{uuid}.jsonl"
|
||||||
|
|
||||||
|
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
|
||||||
|
path = self.artifact_path(uuid, ctx)
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return False
|
||||||
|
if ctx.epoch and os.path.getmtime(path) < ctx.epoch:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
valid_session = False
|
||||||
|
found_cwd = None
|
||||||
|
with open(path) as f:
|
||||||
|
for _ in range(50):
|
||||||
|
line = f.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
payload = json.loads(line)
|
||||||
|
if payload.get("sessionId") == uuid:
|
||||||
|
valid_session = True
|
||||||
|
if payload.get("cwd"):
|
||||||
|
found_cwd = payload.get("cwd")
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not valid_session:
|
||||||
|
return False
|
||||||
|
if found_cwd and workspace_key(found_cwd) != workspace_key(ctx.cwd):
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||||
|
jsonl = self.artifact_path(uuid, ctx)
|
||||||
|
if os.path.exists(jsonl):
|
||||||
|
os.remove(jsonl)
|
||||||
|
return [jsonl]
|
||||||
|
return []
|
||||||
|
|
||||||
|
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
|
||||||
|
if use_wrapper or not session_uuid:
|
||||||
|
return f"{binary} --dangerously-skip-permissions"
|
||||||
|
return f"{binary} --dangerously-skip-permissions --session-id {session_uuid}"
|
||||||
|
|
||||||
|
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
|
||||||
|
if materialized and session_uuid:
|
||||||
|
return f"{binary} --dangerously-skip-permissions -r {session_uuid}"
|
||||||
|
elif session_uuid:
|
||||||
|
return f"{binary} --dangerously-skip-permissions --session-id {session_uuid}"
|
||||||
|
return f"{binary} --dangerously-skip-permissions"
|
||||||
|
|
||||||
|
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
|
||||||
|
if run_cmd is not None:
|
||||||
|
try:
|
||||||
|
rc, stdout, stderr = run_cmd(['claude', 'auth', 'status'])
|
||||||
|
return rc == 0 and ('"loggedIn":true' in (stdout or '').replace(' ', ''))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
res = subprocess.run(['claude', 'auth', 'status'], capture_output=True, text=True)
|
||||||
|
return res.returncode == 0 and ('"loggedIn":true' in (res.stdout or '').replace(' ', ''))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def discover(self, ctx: DiscoveryContext) -> list:
|
||||||
|
key = workspace_key(ctx.workspace)
|
||||||
|
proj = f"{ctx.claude_dir}/{key}"
|
||||||
|
candidates = []
|
||||||
|
if os.path.isdir(proj):
|
||||||
|
for j in sorted(glob.glob(f"{proj}/*.jsonl"), key=os.path.getmtime, reverse=True):
|
||||||
|
cand = os.path.basename(j)[:-6]
|
||||||
|
if cand and self.verify_artifact(cand, ctx):
|
||||||
|
candidates.append(cand)
|
||||||
|
return candidates
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# cline.py — Cline agent adapter
|
import os, json, shutil, glob
|
||||||
|
from typing import Optional, Any
|
||||||
from lib_py.agents.base import BaseAgentAdapter
|
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext
|
||||||
|
from lib_py.verify_session import workspace_key
|
||||||
|
|
||||||
class ClineAgentAdapter(BaseAgentAdapter):
|
class ClineAgentAdapter(BaseAgentAdapter):
|
||||||
@property
|
@property
|
||||||
@@ -11,6 +12,22 @@ class ClineAgentAdapter(BaseAgentAdapter):
|
|||||||
def own_key(self) -> str:
|
def own_key(self) -> str:
|
||||||
return 'cline_conversation_id_own'
|
return 'cline_conversation_id_own'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready_tokens(self) -> str:
|
||||||
|
return 'Cline|history|Chat|What can I do|slash commands'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def exit_key(self) -> str:
|
||||||
|
return '/exit'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def delegate_agent_key(self) -> str:
|
||||||
|
return 'cline-agent'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identity_cache_fields(self) -> tuple:
|
||||||
|
return ('session_id',)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def input_prompt(self) -> str:
|
def input_prompt(self) -> str:
|
||||||
return '❯'
|
return '❯'
|
||||||
@@ -22,3 +39,62 @@ class ClineAgentAdapter(BaseAgentAdapter):
|
|||||||
@property
|
@property
|
||||||
def input_rule_pattern(self) -> str:
|
def input_rule_pattern(self) -> str:
|
||||||
return '─{10,}'
|
return '─{10,}'
|
||||||
|
|
||||||
|
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
|
||||||
|
return f"{ctx.home_dir}/.cline/data/sessions/{uuid}/{uuid}.json"
|
||||||
|
|
||||||
|
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
|
||||||
|
path = self.artifact_path(uuid, ctx)
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return False
|
||||||
|
if ctx.epoch and os.path.getmtime(path) < ctx.epoch:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
sdata = json.load(f)
|
||||||
|
if sdata.get("session_id") != uuid:
|
||||||
|
return False
|
||||||
|
found_cwd = sdata.get("cwd") or sdata.get("workspace_root")
|
||||||
|
if found_cwd and workspace_key(found_cwd) != workspace_key(ctx.cwd):
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||||
|
purged = []
|
||||||
|
sessions_dir = f"{ctx.home_dir}/.cline/data/sessions/{uuid}"
|
||||||
|
if os.path.isdir(sessions_dir):
|
||||||
|
shutil.rmtree(sessions_dir)
|
||||||
|
purged.append(sessions_dir)
|
||||||
|
return purged
|
||||||
|
|
||||||
|
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
|
||||||
|
return f"{binary} -i"
|
||||||
|
|
||||||
|
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
|
||||||
|
if materialized and session_uuid:
|
||||||
|
return f"{binary} -i --id {session_uuid}"
|
||||||
|
return f"{binary} -i"
|
||||||
|
|
||||||
|
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def discover(self, ctx: DiscoveryContext) -> list:
|
||||||
|
sessions_dir = f"{ctx.home_dir}/.cline/data/sessions"
|
||||||
|
if os.path.isdir(sessions_dir):
|
||||||
|
files = []
|
||||||
|
for folder in glob.glob(f"{sessions_dir}/*"):
|
||||||
|
if os.path.isdir(folder):
|
||||||
|
fn = os.path.basename(folder)
|
||||||
|
jf = f"{folder}/{fn}.json"
|
||||||
|
if os.path.exists(jf):
|
||||||
|
files.append(jf)
|
||||||
|
files.sort(key=os.path.getmtime, reverse=True)
|
||||||
|
candidates = []
|
||||||
|
for j in files:
|
||||||
|
cand = os.path.basename(j)[:-5]
|
||||||
|
if cand and self.verify_artifact(cand, ctx):
|
||||||
|
candidates.append(cand)
|
||||||
|
return candidates
|
||||||
|
return []
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# hermes.py — Hermes agent adapter
|
import os, sys, sqlite3
|
||||||
|
from typing import Optional, Any
|
||||||
from lib_py.agents.base import BaseAgentAdapter
|
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext
|
||||||
|
from lib_py.verify_session import workspace_key
|
||||||
|
|
||||||
class HermesAgentAdapter(BaseAgentAdapter):
|
class HermesAgentAdapter(BaseAgentAdapter):
|
||||||
@property
|
@property
|
||||||
@@ -10,3 +11,86 @@ class HermesAgentAdapter(BaseAgentAdapter):
|
|||||||
@property
|
@property
|
||||||
def own_key(self) -> str:
|
def own_key(self) -> str:
|
||||||
return 'hermes_conversation_id_own'
|
return 'hermes_conversation_id_own'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready_tokens(self) -> str:
|
||||||
|
return 'Hermes'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def exit_key(self) -> str:
|
||||||
|
return '/exit'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def delegate_agent_key(self) -> str:
|
||||||
|
return 'hermes-agent'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identity_cache_fields(self) -> tuple:
|
||||||
|
return ('session_id',)
|
||||||
|
|
||||||
|
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
|
||||||
|
return f"{ctx.home_dir}/.hermes/sessions/session_{uuid}.json"
|
||||||
|
|
||||||
|
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
|
||||||
|
hdb = f"{ctx.home_dir}/.hermes/state.db"
|
||||||
|
if not os.path.exists(hdb):
|
||||||
|
return False
|
||||||
|
if ctx.epoch and os.path.getmtime(hdb) < ctx.epoch:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(hdb)
|
||||||
|
r = conn.execute("SELECT cwd FROM sessions WHERE id=?", (uuid,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not r:
|
||||||
|
return False
|
||||||
|
found_cwd = r[0]
|
||||||
|
if found_cwd and workspace_key(found_cwd) != workspace_key(ctx.cwd):
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||||
|
purged = []
|
||||||
|
json_file = self.artifact_path(uuid, ctx)
|
||||||
|
if os.path.exists(json_file):
|
||||||
|
os.remove(json_file)
|
||||||
|
purged.append(json_file)
|
||||||
|
hdb = f"{ctx.home_dir}/.hermes/state.db"
|
||||||
|
if os.path.exists(hdb):
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(hdb)
|
||||||
|
conn.execute("DELETE FROM sessions WHERE id=?", (uuid,))
|
||||||
|
conn.execute("DELETE FROM messages WHERE session_id=?", (uuid,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
purged.append(f"db records for session: {uuid}")
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write(f"WARN: purge hermes db records failed: {e}\n")
|
||||||
|
return purged
|
||||||
|
|
||||||
|
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
|
||||||
|
return binary
|
||||||
|
|
||||||
|
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
|
||||||
|
if materialized and session_uuid:
|
||||||
|
return f"{binary} --resume {session_uuid}"
|
||||||
|
return binary
|
||||||
|
|
||||||
|
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def discover(self, ctx: DiscoveryContext) -> list:
|
||||||
|
hdb = f"{ctx.home_dir}/.hermes/state.db"
|
||||||
|
if os.path.exists(hdb):
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(hdb)
|
||||||
|
r = conn.execute("SELECT id FROM sessions WHERE cwd=? ORDER BY started_at DESC LIMIT 1", (ctx.workspace,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if r:
|
||||||
|
cand = r[0]
|
||||||
|
if cand and self.verify_artifact(cand, ctx):
|
||||||
|
return [cand]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|||||||
@@ -13,10 +13,24 @@ class SpawnSpec:
|
|||||||
self.env = env or {}
|
self.env = env or {}
|
||||||
|
|
||||||
class DiscoveryContext:
|
class DiscoveryContext:
|
||||||
def __init__(self, workspace: str, agent_name: str, home_dir: Optional[str] = None):
|
def __init__(self, workspace: str, agent_name: str = "", home_dir: Optional[str] = None,
|
||||||
|
claude_dir: Optional[str] = None, epoch: int = 0,
|
||||||
|
row: Optional[Dict[str, Any]] = None, mode: str = "discover"):
|
||||||
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")
|
||||||
|
self.epoch = epoch
|
||||||
|
self.row = row or {}
|
||||||
|
self.mode = mode
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ws_key(self) -> str:
|
||||||
|
return workspace_key(self.workspace)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cwd(self) -> str:
|
||||||
|
return (self.row.get("pane", {}).get("cwd", "") if isinstance(self.row, dict) else "") or self.workspace
|
||||||
|
|
||||||
class BaseAgentAdapter:
|
class BaseAgentAdapter:
|
||||||
@property
|
@property
|
||||||
@@ -27,6 +41,22 @@ class BaseAgentAdapter:
|
|||||||
def own_key(self) -> str:
|
def own_key(self) -> str:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready_tokens(self) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@property
|
||||||
|
def exit_key(self) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@property
|
||||||
|
def delegate_agent_key(self) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identity_cache_fields(self) -> tuple:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def input_prompt(self) -> Optional[str]:
|
def input_prompt(self) -> Optional[str]:
|
||||||
return None
|
return None
|
||||||
@@ -51,3 +81,24 @@ class BaseAgentAdapter:
|
|||||||
|
|
||||||
def verify_session(self, ws: str, uuid: str, row: Optional[Dict[str, Any]] = None, mode: str = "discover") -> bool:
|
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)
|
return verify_session_uuid(ws, self.name, uuid, row=row, mode=mode)
|
||||||
|
|
||||||
|
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def discover(self, ctx: DiscoveryContext) -> list:
|
||||||
|
raise NotImplementedError
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
def sanitize_herdr_agent_name(name: str) -> str:
|
||||||
|
"""
|
||||||
|
Sanitizes an agent name according to herdr 0.8.0 rules:
|
||||||
|
1. Convert to lowercase.
|
||||||
|
2. Replace invalid characters ([^a-z0-9_-]) with '-'.
|
||||||
|
3. Ensure starts with a lowercase letter [a-z], prefixing with 'x-' if needed.
|
||||||
|
4. Truncate to maximum 32 characters using 8-char SHA-1 hash suffix (s[:23] + '-' + sha1[:8]).
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return "agent"
|
||||||
|
|
||||||
|
# 1. Lowercase
|
||||||
|
s = str(name).lower()
|
||||||
|
|
||||||
|
# 2. Replace illegal characters with '-'
|
||||||
|
s = re.sub(r'[^a-z0-9_-]', '-', s)
|
||||||
|
|
||||||
|
# 3. Ensure starts with a letter [a-z]
|
||||||
|
if not s or not s[0].isalpha():
|
||||||
|
s = "x-" + s
|
||||||
|
|
||||||
|
# 4. Truncate to 32 chars if needed using collision-free SHA-1 hash suffix
|
||||||
|
if len(s) > 32:
|
||||||
|
h = hashlib.sha1(s.encode('utf-8')).hexdigest()[:8]
|
||||||
|
s = f"{s[:23]}-{h}"
|
||||||
|
|
||||||
|
return s
|
||||||
|
|
||||||
@@ -27,10 +27,6 @@ def atomic_dump_yaml_main():
|
|||||||
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} bad status {s['status']!r}")
|
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} bad status {s['status']!r}")
|
||||||
if not isinstance(s.get('pane'), dict):
|
if not isinstance(s.get('pane'), dict):
|
||||||
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} missing pane")
|
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} missing pane")
|
||||||
iso = s.get('isolation')
|
|
||||||
if iso is not None:
|
|
||||||
if not isinstance(iso, dict) or not iso.get('uuid') or not iso.get('root'):
|
|
||||||
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} isolation block requires uuid/root")
|
|
||||||
orc_uuids = d.get('orchestrator_uuids')
|
orc_uuids = d.get('orchestrator_uuids')
|
||||||
if orc_uuids is not None:
|
if orc_uuids is not None:
|
||||||
if not isinstance(orc_uuids, list):
|
if not isinstance(orc_uuids, list):
|
||||||
|
|||||||
@@ -79,16 +79,12 @@ 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 = resolve_home(home_dir)
|
from lib_py.agents.registry import get_adapter
|
||||||
c_dir = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR", f"{home}/.claude/projects")
|
from lib_py.agents.base import DiscoveryContext
|
||||||
row = row or {}
|
|
||||||
_iso = row.get("isolation")
|
|
||||||
iso_root = _iso.get("root") if isinstance(_iso, dict) and _iso.get("root") else None
|
|
||||||
|
|
||||||
|
row = row or {}
|
||||||
epoch = row.get("herdr_session_epoch", 0) if mode == "discover" else 0
|
epoch = row.get("herdr_session_epoch", 0) if mode == "discover" else 0
|
||||||
cwd = row.get("pane", {}).get("cwd", "") or ws
|
cwd = row.get("pane", {}).get("cwd", "") or ws
|
||||||
|
|
||||||
@@ -103,104 +99,9 @@ def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=Non
|
|||||||
and not row.get("session_id_verified")):
|
and not row.get("session_id_verified")):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if agent == "claude":
|
adapter = get_adapter(agent)
|
||||||
base = (iso_root + "/projects") if iso_root else c_dir
|
if not adapter:
|
||||||
key = workspace_key(ws)
|
return False
|
||||||
path = f"{base}/{key}/{uuid}.jsonl"
|
|
||||||
if not os.path.exists(path):
|
|
||||||
return False
|
|
||||||
if epoch and os.path.getmtime(path) < epoch:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
valid_session = False
|
|
||||||
found_cwd = None
|
|
||||||
with open(path) as f:
|
|
||||||
for _ in range(50):
|
|
||||||
line = f.readline()
|
|
||||||
if not line:
|
|
||||||
break
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
payload = json.loads(line)
|
|
||||||
if payload.get("sessionId") == uuid:
|
|
||||||
valid_session = True
|
|
||||||
if payload.get("cwd"):
|
|
||||||
found_cwd = payload.get("cwd")
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if not valid_session:
|
|
||||||
return False
|
|
||||||
if found_cwd and workspace_key(found_cwd) != workspace_key(cwd):
|
|
||||||
return False
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
elif agent == "agy":
|
ctx = DiscoveryContext(workspace=ws, agent_name=agent, home_dir=home_dir, claude_dir=claude_dir, epoch=epoch, row=row, mode=mode)
|
||||||
base = f"{iso_root or home}/.gemini/antigravity-cli/conversations"
|
return adapter.verify_artifact(uuid, ctx)
|
||||||
path = f"{base}/{uuid}.db"
|
|
||||||
if not os.path.exists(path):
|
|
||||||
return False
|
|
||||||
if epoch and os.path.getmtime(path) < epoch:
|
|
||||||
return False
|
|
||||||
if mode == "discover":
|
|
||||||
lc = f"{iso_root or home}/.gemini/antigravity-cli/cache/last_conversations.json"
|
|
||||||
cache_match = False
|
|
||||||
if os.path.exists(lc):
|
|
||||||
try:
|
|
||||||
with open(lc) as f:
|
|
||||||
lc_data = json.load(f)
|
|
||||||
cache_match = (lc_data.get(cwd) == uuid)
|
|
||||||
except Exception:
|
|
||||||
cache_match = False
|
|
||||||
if not cache_match:
|
|
||||||
if uuid in (row.get("_sibling_claimed_uuids") or []):
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(path)
|
|
||||||
r = conn.execute("SELECT count(*) FROM steps").fetchone()
|
|
||||||
conn.close()
|
|
||||||
if not r or r[0] < 1:
|
|
||||||
return False
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
elif agent == "hermes":
|
|
||||||
hdb = f"{iso_root or home}/.hermes/state.db"
|
|
||||||
if not os.path.exists(hdb):
|
|
||||||
return False
|
|
||||||
if epoch and os.path.getmtime(hdb) < epoch:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(hdb)
|
|
||||||
r = conn.execute("SELECT cwd FROM sessions WHERE id=?", (uuid,)).fetchone()
|
|
||||||
conn.close()
|
|
||||||
if not r:
|
|
||||||
return False
|
|
||||||
found_cwd = r[0]
|
|
||||||
if found_cwd and workspace_key(found_cwd) != workspace_key(cwd):
|
|
||||||
return False
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
elif agent == "cline":
|
|
||||||
base = (iso_root + "/sessions") if iso_root else f"{home}/.cline/data/sessions"
|
|
||||||
path = f"{base}/{uuid}/{uuid}.json"
|
|
||||||
if not os.path.exists(path):
|
|
||||||
return False
|
|
||||||
if epoch and os.path.getmtime(path) < epoch:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
with open(path) as f:
|
|
||||||
sdata = json.load(f)
|
|
||||||
if sdata.get("session_id") != uuid:
|
|
||||||
return False
|
|
||||||
found_cwd = sdata.get("cwd") or sdata.get("workspace_root")
|
|
||||||
if found_cwd and workspace_key(found_cwd) != workspace_key(cwd):
|
|
||||||
return False
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# workspace_uuid.py — workspace UUID discovery logic
|
# workspace_uuid.py — workspace UUID discovery logic
|
||||||
# Extracted from lib.sh find_workspace_uuid PYEOF block
|
# Extracted from lib.sh find_workspace_uuid PYEOF block
|
||||||
|
|
||||||
import os, sys, json, glob, sqlite3
|
import os, sys, json, sqlite3
|
||||||
from lib_py.verify_session import verify_session_uuid, workspace_key, mam_orchestrator_uuids, mam_row_own_uuid
|
from lib_py.verify_session import verify_session_uuid, mam_orchestrator_uuids, mam_row_own_uuid
|
||||||
|
|
||||||
OWN_KEY = {
|
OWN_KEY = {
|
||||||
'claude': 'claude_session_id_own',
|
'claude': 'claude_session_id_own',
|
||||||
@@ -11,10 +11,6 @@ OWN_KEY = {
|
|||||||
'cline': 'cline_conversation_id_own'
|
'cline': 'cline_conversation_id_own'
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
from lib_py.paths import resolve_home
|
||||||
|
|
||||||
def find_workspace_uuid_main():
|
def find_workspace_uuid_main():
|
||||||
@@ -64,110 +60,25 @@ def find_workspace_uuid_main():
|
|||||||
for s in sessions:
|
for s in sessions:
|
||||||
if s.get('name') != target:
|
if s.get('name') != target:
|
||||||
continue
|
continue
|
||||||
iso = iso_root_of(s)
|
|
||||||
cand = s.get(OWN_KEY.get(agent, ''), None)
|
cand = s.get(OWN_KEY.get(agent, ''), None)
|
||||||
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
|
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
|
||||||
emit(cand)
|
emit(cand)
|
||||||
if iso:
|
|
||||||
key = workspace_key(ws)
|
|
||||||
if agent == 'claude':
|
|
||||||
for j in sorted(glob.glob(f"{iso}/projects/{key}/*.jsonl"), key=os.path.getmtime, reverse=True):
|
|
||||||
cand = os.path.basename(j)[:-6]
|
|
||||||
if cand and verify_session_uuid(ws, agent, cand, s):
|
|
||||||
emit(cand)
|
|
||||||
elif agent == 'agy':
|
|
||||||
for j in sorted(glob.glob(f"{iso}/.gemini/antigravity-cli/conversations/*.db"), key=os.path.getmtime, reverse=True):
|
|
||||||
cand = os.path.basename(j)[:-3]
|
|
||||||
if cand and verify_session_uuid(ws, agent, cand, s):
|
|
||||||
emit(cand)
|
|
||||||
elif agent == 'hermes':
|
|
||||||
hdb = f"{iso}/.hermes/state.db"
|
|
||||||
if os.path.exists(hdb):
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(hdb)
|
|
||||||
r = conn.execute("SELECT id FROM sessions WHERE cwd=? ORDER BY started_at DESC LIMIT 1", (ws,)).fetchone()
|
|
||||||
conn.close()
|
|
||||||
if r:
|
|
||||||
cand = r[0]
|
|
||||||
if verify_session_uuid(ws, agent, cand, s):
|
|
||||||
emit(cand)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
elif agent == 'cline':
|
|
||||||
for folder in sorted(glob.glob(f"{iso}/sessions/*"), key=os.path.getmtime, reverse=True):
|
|
||||||
fn = os.path.basename(folder)
|
|
||||||
if os.path.exists(f"{folder}/{fn}.json"):
|
|
||||||
if verify_session_uuid(ws, agent, fn, s):
|
|
||||||
emit(fn)
|
|
||||||
print('')
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
for s in sessions:
|
for s in sessions:
|
||||||
name = s.get('name', '')
|
name = s.get('name', '')
|
||||||
if agent == 'claude' and name.endswith('-creator-claude'):
|
if name.endswith(f"-creator-{agent}"):
|
||||||
cand = s.get('claude_session_id_own')
|
cand = s.get(OWN_KEY.get(agent, ''))
|
||||||
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
|
|
||||||
emit(cand)
|
|
||||||
if agent == 'agy' and name.endswith('-creator-agy'):
|
|
||||||
cand = s.get('agy_conversation_id_own')
|
|
||||||
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
|
|
||||||
emit(cand)
|
|
||||||
if agent == 'hermes' and name.endswith('-creator-hermes'):
|
|
||||||
cand = s.get('hermes_conversation_id_own')
|
|
||||||
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
|
|
||||||
emit(cand)
|
|
||||||
if agent == 'cline' and name.endswith('-creator-cline'):
|
|
||||||
cand = s.get('cline_conversation_id_own')
|
|
||||||
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
|
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
|
||||||
emit(cand)
|
emit(cand)
|
||||||
|
|
||||||
if agent == 'claude':
|
from lib_py.agents.registry import get_adapter
|
||||||
key = workspace_key(ws)
|
from lib_py.agents.base import DiscoveryContext
|
||||||
proj = f"{claude_project_dir}/{key}"
|
|
||||||
if os.path.isdir(proj):
|
adapter = get_adapter(agent)
|
||||||
for j in sorted(glob.glob(f"{proj}/*.jsonl"), key=os.path.getmtime, reverse=True):
|
if adapter:
|
||||||
cand = os.path.basename(j)[:-6]
|
ctx = DiscoveryContext(workspace=ws, agent_name=agent, home_dir=home, claude_dir=claude_project_dir)
|
||||||
if cand and verify_session_uuid(ws, agent, cand):
|
for cand in adapter.discover(ctx):
|
||||||
emit(cand)
|
emit(cand)
|
||||||
elif agent == 'agy':
|
|
||||||
lc = f"{home}/.gemini/antigravity-cli/cache/last_conversations.json"
|
|
||||||
if os.path.exists(lc):
|
|
||||||
cand = None
|
|
||||||
try:
|
|
||||||
cand = json.load(open(lc)).get(ws)
|
|
||||||
except Exception:
|
|
||||||
cand = None
|
|
||||||
if cand and verify_session_uuid(ws, agent, cand):
|
|
||||||
emit(cand)
|
|
||||||
elif agent == 'hermes':
|
|
||||||
hdb = f"{home}/.hermes/state.db"
|
|
||||||
if os.path.exists(hdb):
|
|
||||||
cand = None
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(hdb)
|
|
||||||
r = conn.execute("SELECT id FROM sessions WHERE cwd=? ORDER BY started_at DESC LIMIT 1", (ws,)).fetchone()
|
|
||||||
conn.close()
|
|
||||||
if r:
|
|
||||||
cand = r[0]
|
|
||||||
except Exception:
|
|
||||||
cand = None
|
|
||||||
if cand and verify_session_uuid(ws, agent, cand):
|
|
||||||
emit(cand)
|
|
||||||
elif agent == 'cline':
|
|
||||||
sessions_dir = f"{home}/.cline/data/sessions"
|
|
||||||
if os.path.isdir(sessions_dir):
|
|
||||||
candidates = []
|
|
||||||
for session_folder in glob.glob(f"{sessions_dir}/*"):
|
|
||||||
if os.path.isdir(session_folder):
|
|
||||||
folder_name = os.path.basename(session_folder)
|
|
||||||
json_file = f"{session_folder}/{folder_name}.json"
|
|
||||||
if os.path.exists(json_file):
|
|
||||||
candidates.append(json_file)
|
|
||||||
candidates.sort(key=os.path.getmtime, reverse=True)
|
|
||||||
for j in candidates:
|
|
||||||
cand = os.path.basename(j)[:-5]
|
|
||||||
if cand and verify_session_uuid(ws, agent, cand):
|
|
||||||
emit(cand)
|
|
||||||
|
|
||||||
ai = d.get('agent_identities') if isinstance(d, dict) else None
|
ai = d.get('agent_identities') if isinstance(d, dict) else None
|
||||||
if not isinstance(ai, dict) or not ai:
|
if not isinstance(ai, dict) or not ai:
|
||||||
@@ -197,22 +108,9 @@ def find_workspace_uuid_main():
|
|||||||
|
|
||||||
ai_agent = ai.get(agent) or {}
|
ai_agent = ai.get(agent) or {}
|
||||||
if ai_agent.get('project_cwd') == ws:
|
if ai_agent.get('project_cwd') == ws:
|
||||||
if agent == 'claude':
|
cand = ai_agent.get('session_id') or ai_agent.get('conversation_id')
|
||||||
cand = ai_agent.get('session_id')
|
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
|
||||||
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
|
emit(cand)
|
||||||
emit(cand)
|
|
||||||
elif agent == 'agy':
|
|
||||||
cand = ai_agent.get('conversation_id')
|
|
||||||
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
|
|
||||||
emit(cand)
|
|
||||||
elif agent == 'hermes':
|
|
||||||
cand = ai_agent.get('session_id') or ai_agent.get('conversation_id')
|
|
||||||
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
|
|
||||||
emit(cand)
|
|
||||||
elif agent == 'cline':
|
|
||||||
cand = ai_agent.get('session_id') or ai_agent.get('conversation_id')
|
|
||||||
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
|
|
||||||
emit(cand)
|
|
||||||
|
|
||||||
print('')
|
print('')
|
||||||
|
|
||||||
|
|||||||
@@ -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: 1.0.0
|
version: 2.0.0
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ DRY_RUN=0
|
|||||||
HERDR_SERVER_OPT=""
|
HERDR_SERVER_OPT=""
|
||||||
SUBMIT_JOB_PROMPT=""
|
SUBMIT_JOB_PROMPT=""
|
||||||
ONBOARD=1
|
ONBOARD=1
|
||||||
ISOLATE=1
|
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -157,12 +156,18 @@ if [ "$AGENT" = "claude" ]; then
|
|||||||
SESSION_UUID="$(mam_gen_uuid)"
|
SESSION_UUID="$(mam_gen_uuid)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
case "$AGENT" in
|
# Retrieve agent facts once
|
||||||
claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --session-id ${SESSION_UUID}" ;;
|
eval "$("$(_delegate_py_bin)" -m lib_py.agents facts "$AGENT" 2>/dev/null || true)"
|
||||||
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;;
|
|
||||||
hermes) CMD_FULL="${RESOLVED_BIN}" ;;
|
CMD_FULL="$("$(_delegate_py_bin)" -m lib_py.agents spawn-spec "$AGENT" "$RESOLVED_BIN" "$SESSION_UUID" "${USE_WRAPPER:-0}" 2>/dev/null || true)"
|
||||||
cline) CMD_FULL="${RESOLVED_BIN} -i" ;;
|
if [ -z "$CMD_FULL" ]; then
|
||||||
esac
|
case "$AGENT" in
|
||||||
|
claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --session-id ${SESSION_UUID}" ;;
|
||||||
|
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;;
|
||||||
|
hermes) CMD_FULL="${RESOLVED_BIN}" ;;
|
||||||
|
cline) CMD_FULL="${RESOLVED_BIN} -i" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
spawn() {
|
spawn() {
|
||||||
if [ -z "${HERDR_SESSION_NAME:-}" ] || [ "$HERDR_SESSION_NAME" = "default" ]; then
|
if [ -z "${HERDR_SESSION_NAME:-}" ] || [ "$HERDR_SESSION_NAME" = "default" ]; then
|
||||||
@@ -172,7 +177,7 @@ spawn() {
|
|||||||
claude)
|
claude)
|
||||||
if { [ -x "$WRAPPER" ] && [ "$(basename "$WRAPPER")" != "claude" ]; } || [ "$USE_WRAPPER" = "1" ]; then
|
if { [ -x "$WRAPPER" ] && [ "$(basename "$WRAPPER")" != "claude" ]; } || [ "$USE_WRAPPER" = "1" ]; then
|
||||||
SESSION_UUID=""
|
SESSION_UUID=""
|
||||||
CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions"
|
CMD_FULL="$("$(_delegate_py_bin)" -m lib_py.agents spawn-spec "$AGENT" "$RESOLVED_BIN" "" "true" 2>/dev/null || echo "${RESOLVED_BIN} --dangerously-skip-permissions")"
|
||||||
nohup "$WRAPPER" >/dev/null 2>&1 &
|
nohup "$WRAPPER" >/dev/null 2>&1 &
|
||||||
disown
|
disown
|
||||||
else
|
else
|
||||||
@@ -239,15 +244,15 @@ fi
|
|||||||
# agent-sessions.yaml 에 append
|
# agent-sessions.yaml 에 append
|
||||||
DELEGATE_JOB_ID=""
|
DELEGATE_JOB_ID=""
|
||||||
if [ -n "$SUBMIT_JOB_PROMPT" ]; then
|
if [ -n "$SUBMIT_JOB_PROMPT" ]; then
|
||||||
delegate_agent=""
|
delegate_agent="${MAM_DELEGATE_AGENT_KEY:-}"
|
||||||
if [ "$AGENT" = "claude" ]; then
|
if [ -z "$delegate_agent" ]; then
|
||||||
delegate_agent="claude-code"
|
case "$AGENT" in
|
||||||
elif [ "$AGENT" = "hermes" ]; then
|
claude) delegate_agent="claude-code" ;;
|
||||||
delegate_agent="hermes-agent"
|
hermes) delegate_agent="hermes-agent" ;;
|
||||||
elif [ "$AGENT" = "cline" ]; then
|
cline) delegate_agent="cline-agent" ;;
|
||||||
delegate_agent="cline-agent"
|
agy) delegate_agent="antigravity-cli" ;;
|
||||||
else
|
*) echo "ERROR: cannot resolve delegate agent key for '$AGENT'" >&2; exit 2 ;;
|
||||||
delegate_agent="antigravity-cli"
|
esac
|
||||||
fi
|
fi
|
||||||
agent_session="herdr:$SESSION_NAME"
|
agent_session="herdr:$SESSION_NAME"
|
||||||
DELEGATE_JOB_ID=$(delegate_submit_job "$SUBMIT_JOB_PROMPT" "$delegate_agent" "$agent_session")
|
DELEGATE_JOB_ID=$(delegate_submit_job "$SUBMIT_JOB_PROMPT" "$delegate_agent" "$agent_session")
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
---
|
---
|
||||||
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, cline, 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, cline, codex, or a human) and observe it asynchronously over an MQTT event channel. Supported roles include orchestrator, worker, and reviewer."
|
||||||
version: 1.1.0
|
version: 2.0.0
|
||||||
author: Multi-Agent System
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos, windows]
|
platforms: [linux, macos, windows]
|
||||||
|
environments: [terminal, herdr]
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [agent, herdr, multi-agent, delegate, mqtt, async, job]
|
||||||
|
related_skills: [multi-agent-mux-create, multi-agent-mux-resume, multi-agent-mux-loop]
|
||||||
|
prereq_skills: [multi-agent-mux-create]
|
||||||
---
|
---
|
||||||
|
|
||||||
# multi-agent-mux-delegate-job — Async Job Delegation over MQTT
|
# multi-agent-mux-delegate-job — Async Job Delegation over MQTT
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ from mqtt_common import (
|
|||||||
|
|
||||||
logger = logging.getLogger("delegate_job.registry")
|
logger = logging.getLogger("delegate_job.registry")
|
||||||
|
|
||||||
TERMINAL_STATUSES = ("completed", "error", "cancelled")
|
|
||||||
VALID_STATUSES = ("pending", "running", "completed", "error", "cancelled")
|
VALID_STATUSES = ("pending", "running", "completed", "error", "cancelled")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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: 1.0.0
|
version: 2.0.0
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
|
|||||||
@@ -87,16 +87,27 @@ fi
|
|||||||
MAM_LOOP_MARKER="${MAM_LOOP_MARKER:-$REPO_ROOT/.mam/loop-guard-active}"
|
MAM_LOOP_MARKER="${MAM_LOOP_MARKER:-$REPO_ROOT/.mam/loop-guard-active}"
|
||||||
_mam_release_guard() { mam_release_loop_lock "$MAM_LOOP_MARKER" || true; }
|
_mam_release_guard() { mam_release_loop_lock "$MAM_LOOP_MARKER" || true; }
|
||||||
|
|
||||||
|
# Runs the delegate-job wrapper in place. Deliberately creates no copy and
|
||||||
|
# installs no trap:
|
||||||
|
# * a copy inside .agents/skills/ pollutes the source tree and leaks on
|
||||||
|
# SIGKILL (B-6). It never protected across turns anyway — the copy is made
|
||||||
|
# per call, so a wrapper broken in turn N is copied broken in turn N+1;
|
||||||
|
# * every call site is a command substitution, so a trap set here fires when
|
||||||
|
# that subshell ends. `$$` is still the parent's pid there, so
|
||||||
|
# _mam_release_guard passed its ownership check and dropped the loop lock
|
||||||
|
# after the first delegated job (D1).
|
||||||
|
# The callers' own "Failed to register ..." branches are unreachable when the
|
||||||
|
# wrapper exits non-zero (set -e aborts the assignment first), so the diagnosis
|
||||||
|
# has to be emitted here.
|
||||||
delegate_job_safe() {
|
delegate_job_safe() {
|
||||||
local orig_script="$REPO_ROOT/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job"
|
local orig_script="$REPO_ROOT/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job"
|
||||||
local tmp_script
|
|
||||||
tmp_script="${orig_script}.${RANDOM}_$$.tmp"
|
|
||||||
cp "$orig_script" "$tmp_script"
|
|
||||||
trap 'rm -f "$tmp_script"' EXIT INT TERM HUP
|
|
||||||
local rc=0
|
local rc=0
|
||||||
bash "$tmp_script" "$@" || rc=$?
|
bash "$orig_script" "$@" || rc=$?
|
||||||
rm -f "$tmp_script"
|
if [ "$rc" -ne 0 ]; then
|
||||||
trap _mam_release_guard EXIT INT TERM HUP
|
log_error "delegate_job_safe failed (exit $rc): $orig_script"
|
||||||
|
log_error " if this loop edits framework skills in place, check that file's syntax:"
|
||||||
|
log_error " bash -n \"$orig_script\""
|
||||||
|
fi
|
||||||
return $rc
|
return $rc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +145,7 @@ case "$_mam_acquire_rc" in
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
trap _mam_release_guard EXIT INT TERM HUP
|
trap _mam_release_guard EXIT INT TERM HUP
|
||||||
|
rm -f "$REPO_ROOT/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job".*.tmp 2>/dev/null || true
|
||||||
|
|
||||||
# --all-reviewer silently takes precedence over an explicit --reviewer list;
|
# --all-reviewer silently takes precedence over an explicit --reviewer list;
|
||||||
# warn so the discarded list isn't mistaken for having been honored (P2-1).
|
# warn so the discarded list isn't mistaken for having been honored (P2-1).
|
||||||
|
|||||||
@@ -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: 1.0.0
|
version: 2.0.0
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
|
|||||||
@@ -433,14 +433,12 @@ def pane_meta(session, srv):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
from lib_py.agents.registry import get_adapter, own_key as _get_own_key, agent_of_row
|
||||||
|
|
||||||
def _pin_and_verify_resume(s, agent, cwd, uuid, degraded=False):
|
def _pin_and_verify_resume(s, agent, cwd, uuid, degraded=False):
|
||||||
own_key = {
|
own_key_field = _get_own_key(agent)
|
||||||
'claude': 'claude_session_id_own',
|
if own_key_field:
|
||||||
'agy': 'agy_conversation_id_own',
|
s[own_key_field] = uuid
|
||||||
'hermes': 'hermes_conversation_id_own',
|
|
||||||
'cline': 'cline_conversation_id_own'
|
|
||||||
}[agent]
|
|
||||||
s[own_key] = uuid
|
|
||||||
s['last_visible_status'] = 'pinned'
|
s['last_visible_status'] = 'pinned'
|
||||||
resume_cmd = ['bash', os.path.join(skills_dir, 'multi-agent-mux-resume', 'scripts', 'resume_session.sh'),
|
resume_cmd = ['bash', os.path.join(skills_dir, 'multi-agent-mux-resume', 'scripts', 'resume_session.sh'),
|
||||||
'--workspace', cwd, '--agent', agent, '--session', s['name'], '--dry-run']
|
'--workspace', cwd, '--agent', agent, '--session', s['name'], '--dry-run']
|
||||||
@@ -458,6 +456,8 @@ def _pin_and_verify_resume(s, agent, cwd, uuid, degraded=False):
|
|||||||
actions.append(f"updated {id_name} id: {uuid}")
|
actions.append(f"updated {id_name} id: {uuid}")
|
||||||
|
|
||||||
|
|
||||||
|
from lib_py.agents.sanitize import sanitize_herdr_agent_name as _sanitize
|
||||||
|
|
||||||
yaml_sessions = d.get('herdr_sessions', [])
|
yaml_sessions = d.get('herdr_sessions', [])
|
||||||
yaml_session_names = {s['name'] for s in yaml_sessions if s.get('name')}
|
yaml_session_names = {s['name'] for s in yaml_sessions if s.get('name')}
|
||||||
alive_set = {(t['name'], t.get('server', 'default')) for t in herdr_sessions}
|
alive_set = {(t['name'], t.get('server', 'default')) for t in herdr_sessions}
|
||||||
@@ -474,7 +474,7 @@ if herdr_confirmed:
|
|||||||
if s.get('status') in ('terminated', 'archived', 'stopped'):
|
if s.get('status') in ('terminated', 'archived', 'stopped'):
|
||||||
continue
|
continue
|
||||||
srv = s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace') or 'default'
|
srv = s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace') or 'default'
|
||||||
if (name, srv) not in alive_set:
|
if (name, srv) not in alive_set and (_sanitize(name), srv) not in alive_set:
|
||||||
s['status'] = 'terminated'
|
s['status'] = 'terminated'
|
||||||
s['terminated_at'] = now_iso
|
s['terminated_at'] = now_iso
|
||||||
s['terminated_at_epoch'] = int(datetime.now(timezone.utc).timestamp())
|
s['terminated_at_epoch'] = int(datetime.now(timezone.utc).timestamp())
|
||||||
@@ -488,7 +488,7 @@ if herdr_confirmed:
|
|||||||
if herdr_confirmed:
|
if herdr_confirmed:
|
||||||
for t in herdr_sessions:
|
for t in herdr_sessions:
|
||||||
name = t['name']
|
name = t['name']
|
||||||
if name in yaml_session_names:
|
if name in yaml_session_names or any(_sanitize(y) == name for y in yaml_session_names):
|
||||||
continue
|
continue
|
||||||
workspace_root = os.environ.get('WORKSPACE_ROOT')
|
workspace_root = os.environ.get('WORKSPACE_ROOT')
|
||||||
if not workspace_root:
|
if not workspace_root:
|
||||||
@@ -537,14 +537,8 @@ if herdr_confirmed:
|
|||||||
ws_root_abs = os.path.realpath(workspace_root)
|
ws_root_abs = os.path.realpath(workspace_root)
|
||||||
if not pane_cwd_abs or not (pane_cwd_abs == ws_root_abs or pane_cwd_abs.startswith(ws_root_abs + os.sep)):
|
if not pane_cwd_abs or not (pane_cwd_abs == ws_root_abs or pane_cwd_abs.startswith(ws_root_abs + os.sep)):
|
||||||
continue
|
continue
|
||||||
if agent == 'claude':
|
_adapter = get_adapter(agent)
|
||||||
cmd_full = 'claude --dangerously-skip-permissions'
|
cmd_full = _adapter.spawn_spec(agent) if _adapter else agent
|
||||||
elif agent == 'agy':
|
|
||||||
cmd_full = 'agy --dangerously-skip-permissions'
|
|
||||||
elif agent == 'hermes':
|
|
||||||
cmd_full = 'hermes'
|
|
||||||
elif agent == 'cline':
|
|
||||||
cmd_full = 'cline -i'
|
|
||||||
server_opt = f"-L {srv} " if srv != 'default' else ""
|
server_opt = f"-L {srv} " if srv != 'default' else ""
|
||||||
# The shim resolves this from the pane's root process. Fall back to now
|
# The shim resolves this from the pane's root process. Fall back to now
|
||||||
# only if that failed: 'now' can merely over-estimate creation time,
|
# only if that failed: 'now' can merely over-estimate creation time,
|
||||||
@@ -594,24 +588,10 @@ if herdr_confirmed:
|
|||||||
actions.append(f"registered: {name}")
|
actions.append(f"registered: {name}")
|
||||||
|
|
||||||
def row_agent(s):
|
def row_agent(s):
|
||||||
cmd = ((s.get('pane') or {}).get('cmd') or '').strip()
|
return agent_of_row(s)
|
||||||
if cmd in ('claude', 'agy', 'hermes', 'cline'):
|
|
||||||
return cmd
|
|
||||||
full = ((s.get('pane') or {}).get('cmd_full') or '')
|
|
||||||
for a in ('claude', 'agy', 'hermes', 'cline'):
|
|
||||||
if a in full:
|
|
||||||
return a
|
|
||||||
name = s.get('name', '')
|
|
||||||
for a in ('claude', 'agy', 'hermes', 'cline'):
|
|
||||||
if name.endswith('-creator-' + a):
|
|
||||||
return a
|
|
||||||
return None
|
|
||||||
|
|
||||||
OWN_KEY_BY_AGENT = {
|
OWN_KEY_BY_AGENT = {
|
||||||
'claude': 'claude_session_id_own',
|
a: _get_own_key(a) for a in ('claude', 'agy', 'hermes', 'cline')
|
||||||
'agy': 'agy_conversation_id_own',
|
|
||||||
'hermes': 'hermes_conversation_id_own',
|
|
||||||
'cline': 'cline_conversation_id_own'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# === drift C0: 지정된 ID 는 발견이 아니라 '확인'만 필요하다 ===
|
# === drift C0: 지정된 ID 는 발견이 아니라 '확인'만 필요하다 ===
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
---
|
---
|
||||||
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: 2.0.0
|
||||||
|
author: godopu
|
||||||
|
license: MIT
|
||||||
|
platforms: [linux, macos]
|
||||||
|
environments: [terminal, herdr]
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [agent, herdr, claude, antigravity, agy, cline, hermes, orchestrator, onboard, isolation]
|
||||||
|
related_skills: [multi-agent-mux-create, multi-agent-mux-resume, multi-agent-mux-monitor]
|
||||||
|
prereq_skills: [multi-agent-mux-create]
|
||||||
---
|
---
|
||||||
|
|
||||||
# multi-agent-mux-orc-onboard
|
# multi-agent-mux-orc-onboard
|
||||||
|
|||||||
@@ -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: 1.0.0
|
version: 2.0.0
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
@@ -67,7 +67,7 @@ SESSION_NAME=<workspace>-creator-<agent> # same convention as multi-agent-mux-c
|
|||||||
# Resolve the isolated herdr server name & load common utils
|
# Resolve the isolated herdr server name & load common utils
|
||||||
source .agents/skills/lib.sh
|
source .agents/skills/lib.sh
|
||||||
|
|
||||||
# 1. Resolve the session id (T5: pass session name for target-row isolation check)
|
# 1. Resolve the session id (pass session name to prefer target-row recorded id)
|
||||||
UUID=$(bash .agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh \
|
UUID=$(bash .agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh \
|
||||||
--workspace "$WORKSPACE" --agent "$AGENT" --session "$SESSION_NAME")
|
--workspace "$WORKSPACE" --agent "$AGENT" --session "$SESSION_NAME")
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ usage() {
|
|||||||
cat <<EOF
|
cat <<EOF
|
||||||
Usage: $0 --workspace <path> --agent <claude|agy|hermes|cline> [--session <name>]
|
Usage: $0 --workspace <path> --agent <claude|agy|hermes|cline> [--session <name>]
|
||||||
Outputs the resolved UUID on stdout (empty if not found).
|
Outputs the resolved UUID on stdout (empty if not found).
|
||||||
--session scopes resolution to that registry row — required for sessions
|
--session prefers that registry row's recorded id; falls back to workspace-wide discovery if it does not verify.
|
||||||
created with --isolate (their conversation lives only in the row's isolation root).
|
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ done
|
|||||||
|
|
||||||
[ -n "$WORKSPACE" ] || { echo "ERROR: --workspace required" >&2; exit 2; }
|
[ -n "$WORKSPACE" ] || { echo "ERROR: --workspace required" >&2; exit 2; }
|
||||||
[ -n "$AGENT" ] || { echo "ERROR: --agent required" >&2; exit 2; }
|
[ -n "$AGENT" ] || { echo "ERROR: --agent required" >&2; exit 2; }
|
||||||
|
case "$AGENT" in
|
||||||
|
claude|agy|hermes|cline) ;;
|
||||||
|
*) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
[ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; exit 2; }
|
[ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; exit 2; }
|
||||||
|
|
||||||
# 1. Resolve the session id
|
# 1. Resolve the session id
|
||||||
@@ -80,29 +84,18 @@ if [ "$(uname)" = "Darwin" ] && [ -f "$RESOLVED_BIN" ]; then
|
|||||||
xattr -d com.apple.quarantine "$RESOLVED_BIN" 2>/dev/null || true
|
xattr -d com.apple.quarantine "$RESOLVED_BIN" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
CLAUDE_ID_FLAG="-r"
|
# Determine CMD_FULL via adapter
|
||||||
if [ "$AGENT" = "claude" ]; then
|
CMD_FULL="$("$(_delegate_py_bin)" -m lib_py.agents resume-spec "$AGENT" "$RESOLVED_BIN" "$UUID" "$WORKSPACE" 2>/dev/null || true)"
|
||||||
_ws_key="$(mam_workspace_key "$WORKSPACE")"
|
if [ -z "$CMD_FULL" ]; then
|
||||||
_iso_root="$(mam_session_iso_root "$SESSION_NAME" 2>/dev/null || true)"
|
case "$AGENT" in
|
||||||
if [ -n "$_iso_root" ]; then
|
claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions -r $UUID" ;;
|
||||||
_proj_dir="$_iso_root/projects"
|
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --conversation $UUID" ;;
|
||||||
else
|
hermes) CMD_FULL="${RESOLVED_BIN} --resume $UUID" ;;
|
||||||
_proj_dir="${CLAUDE_PROJECT_DIR:-$HOME/.claude/projects}"
|
cline) CMD_FULL="${RESOLVED_BIN} -i --id $UUID" ;;
|
||||||
fi
|
*) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;;
|
||||||
if [ ! -f "${_proj_dir}/${_ws_key}/${UUID}.jsonl" ]; then
|
esac
|
||||||
CLAUDE_ID_FLAG="--session-id"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Determine CMD_FULL
|
|
||||||
case "$AGENT" in
|
|
||||||
claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions $CLAUDE_ID_FLAG $UUID" ;;
|
|
||||||
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --conversation $UUID" ;;
|
|
||||||
hermes) CMD_FULL="${RESOLVED_BIN} --resume $UUID" ;;
|
|
||||||
cline) CMD_FULL="${RESOLVED_BIN} -i --id $UUID" ;;
|
|
||||||
*) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Validate binary exists and is executable
|
# Validate binary exists and is executable
|
||||||
if [ -f "$RESOLVED_BIN" ] || [[ "$RESOLVED_BIN" == /* ]] || [[ "$RESOLVED_BIN" == ~/* ]]; then
|
if [ -f "$RESOLVED_BIN" ] || [[ "$RESOLVED_BIN" == /* ]] || [[ "$RESOLVED_BIN" == ~/* ]]; then
|
||||||
if [ ! -x "$RESOLVED_BIN" ]; then
|
if [ ! -x "$RESOLVED_BIN" ]; then
|
||||||
|
|||||||
@@ -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: 1.0.0
|
version: 2.0.0
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
|
|||||||
@@ -122,6 +122,11 @@ def get_job_status(s):
|
|||||||
|
|
||||||
|
|
||||||
sessions_detail = []
|
sessions_detail = []
|
||||||
|
from lib_py.agents.sanitize import sanitize_herdr_agent_name as _sanitize
|
||||||
|
|
||||||
|
def is_alive(name, server):
|
||||||
|
return f"{name}|{server}" in alive or f"{_sanitize(name)}|{server}" in alive
|
||||||
|
|
||||||
for s in d.get('herdr_sessions', []):
|
for s in d.get('herdr_sessions', []):
|
||||||
name = s.get('name', '?')
|
name = s.get('name', '?')
|
||||||
server = s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace') or 'default'
|
server = s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace') or 'default'
|
||||||
@@ -134,7 +139,7 @@ for s in d.get('herdr_sessions', []):
|
|||||||
'name': name,
|
'name': name,
|
||||||
'server': server,
|
'server': server,
|
||||||
'status': s.get('status', '?'),
|
'status': s.get('status', '?'),
|
||||||
'herdr_alive': f"{name}|{server}" in alive,
|
'herdr_alive': is_alive(name, server),
|
||||||
'cmd': pane.get('cmd'),
|
'cmd': pane.get('cmd'),
|
||||||
'role': s.get('role'),
|
'role': s.get('role'),
|
||||||
'resume_state': resume_on_disk(s),
|
'resume_state': resume_on_disk(s),
|
||||||
@@ -219,6 +224,8 @@ def get_job_status(s):
|
|||||||
return (jid, 'unknown')
|
return (jid, 'unknown')
|
||||||
|
|
||||||
|
|
||||||
|
from lib_py.agents.sanitize import sanitize_herdr_agent_name as _sanitize
|
||||||
|
|
||||||
sessions = d.get('herdr_sessions', [])
|
sessions = d.get('herdr_sessions', [])
|
||||||
print(f"agent-sessions status — {drift['timestamp']} (herdr_confirmed={drift['herdr_confirmed']})")
|
print(f"agent-sessions status — {drift['timestamp']} (herdr_confirmed={drift['herdr_confirmed']})")
|
||||||
print("=" * 136)
|
print("=" * 136)
|
||||||
@@ -226,11 +233,14 @@ print(f"{'NAME':<44} {'WORKSPACE':<12} {'YAML':<10} {'HERDR':<6} {'CMD':<6} {'RE
|
|||||||
print("-" * 136)
|
print("-" * 136)
|
||||||
if not sessions:
|
if not sessions:
|
||||||
print("(no sessions registered)")
|
print("(no sessions registered)")
|
||||||
|
def is_alive(name, server):
|
||||||
|
return f"{name}|{server}" in alive or f"{_sanitize(name)}|{server}" in alive
|
||||||
|
|
||||||
for s in sessions:
|
for s in sessions:
|
||||||
name = s.get('name', '?')
|
name = s.get('name', '?')
|
||||||
server = s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace') or 'default'
|
server = s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace') or 'default'
|
||||||
status = s.get('status', '?')
|
status = s.get('status', '?')
|
||||||
herdr = 'alive' if f"{name}|{server}" in alive else 'dead'
|
herdr = 'alive' if is_alive(name, server) else 'dead'
|
||||||
cmd = (s.get('pane') or {}).get('cmd', '?')
|
cmd = (s.get('pane') or {}).get('cmd', '?')
|
||||||
res = resume_on_disk(s)
|
res = resume_on_disk(s)
|
||||||
jid, jstatus = get_job_status(s)
|
jid, jstatus = get_job_status(s)
|
||||||
|
|||||||
@@ -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: 1.0.0
|
version: 2.0.0
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
|
|||||||
@@ -173,13 +173,7 @@ delegate_publish_event "$DELEGATE_JOB_ID" progress "terminating"
|
|||||||
graceful_stop() {
|
graceful_stop() {
|
||||||
local pane_pid exitkey
|
local pane_pid exitkey
|
||||||
pane_pid=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
|
pane_pid=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
|
||||||
case "$AGENT" in
|
exitkey="$("$(_delegate_py_bin)" -m lib_py.agents exit-key "$AGENT" 2>/dev/null || echo "/exit")"
|
||||||
claude) exitkey="/exit" ;;
|
|
||||||
agy) exitkey="Exit" ;;
|
|
||||||
hermes) exitkey="/exit" ;;
|
|
||||||
cline) exitkey="/exit" ;;
|
|
||||||
*) exitkey="/exit" ;;
|
|
||||||
esac
|
|
||||||
echo "graceful: send-keys '$exitkey' to $SESSION_NAME"
|
echo "graceful: send-keys '$exitkey' to $SESSION_NAME"
|
||||||
send_keys_safe "$SESSION_NAME" "$exitkey" "stop$$" || echo "graceful: safe delivery failed (rc=$?) — falling back to kill chain"
|
send_keys_safe "$SESSION_NAME" "$exitkey" "stop$$" || echo "graceful: safe delivery failed (rc=$?) — falling back to kill chain"
|
||||||
_wait_session_gone "$SESSION_NAME" 5 || true
|
_wait_session_gone "$SESSION_NAME" 5 || true
|
||||||
@@ -272,83 +266,21 @@ if captured and not purge:
|
|||||||
target['cline_conversation_id_own'] = captured
|
target['cline_conversation_id_own'] = captured
|
||||||
target['resumable'] = True
|
target['resumable'] = True
|
||||||
|
|
||||||
# --purge-conversation: 워크스페이스 격리된 UUID 의 디스크 artifact 만 삭제 (P0-C)
|
|
||||||
# T6: stop-purge 시 격리 디렉터리 청소 및 경로 가드
|
|
||||||
iso = target.get('isolation')
|
|
||||||
if purge and iso:
|
|
||||||
iso_root = iso.get('root')
|
|
||||||
iso_uuid = iso.get('uuid')
|
|
||||||
if iso_root and iso_uuid:
|
|
||||||
ws_abs = os.path.abspath(ws) if ws else ""
|
|
||||||
expected_homes_dir = os.path.join(ws_abs, '.mam', 'agent_homes')
|
|
||||||
expected_iso_root = os.path.join(expected_homes_dir, iso_uuid)
|
|
||||||
if (os.path.abspath(iso_root) == os.path.abspath(expected_iso_root) and
|
|
||||||
os.path.abspath(iso_root).startswith(os.path.abspath(expected_homes_dir) + os.sep)):
|
|
||||||
if os.path.isdir(iso_root):
|
|
||||||
shutil.rmtree(iso_root)
|
|
||||||
print(f"purged isolated home: {iso_root}", flush=True)
|
|
||||||
else:
|
|
||||||
print(f"WARN: isolated home path check failed: {iso_root}", flush=True)
|
|
||||||
|
|
||||||
if purge and purge_uuid:
|
if purge and purge_uuid:
|
||||||
if agent == 'claude':
|
from lib_py.agents.registry import get_adapter
|
||||||
key = ws.replace('/', '-').replace('_', '-')
|
from lib_py.agents.base import DiscoveryContext
|
||||||
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
|
adapter = get_adapter(agent)
|
||||||
jsonl = f"{claude_project_dir}/{key}/{purge_uuid}.jsonl"
|
if adapter:
|
||||||
if os.path.exists(jsonl):
|
ctx = DiscoveryContext(workspace=ws, agent_name=agent, home_dir=home)
|
||||||
os.remove(jsonl)
|
for item in adapter.purge_artifacts(purge_uuid, ctx):
|
||||||
print(f"purged: {jsonl}", flush=True)
|
print(f"purged: {item}", flush=True)
|
||||||
target['claude_session_id_own'] = None
|
target[adapter.own_key] = None
|
||||||
elif agent == 'agy':
|
|
||||||
db = f"{home}/.gemini/antigravity-cli/conversations/{purge_uuid}.db"
|
|
||||||
if os.path.exists(db):
|
|
||||||
os.remove(db)
|
|
||||||
print(f"purged: {db}", flush=True)
|
|
||||||
brain = f"{home}/.gemini/antigravity-cli/brain/{purge_uuid}"
|
|
||||||
if os.path.isdir(brain):
|
|
||||||
shutil.rmtree(brain, ignore_errors=True)
|
|
||||||
print(f"purged: {brain}", flush=True)
|
|
||||||
target['agy_conversation_id_own'] = None
|
|
||||||
elif agent == 'hermes':
|
|
||||||
json_file = f"{home}/.hermes/sessions/session_{purge_uuid}.json"
|
|
||||||
if os.path.exists(json_file):
|
|
||||||
os.remove(json_file)
|
|
||||||
print(f"purged: {json_file}", flush=True)
|
|
||||||
hdb = f"{home}/.hermes/state.db"
|
|
||||||
if os.path.exists(hdb):
|
|
||||||
try:
|
|
||||||
import sqlite3
|
|
||||||
hconn = sqlite3.connect(hdb)
|
|
||||||
hconn.execute("DELETE FROM sessions WHERE id=?", (purge_uuid,))
|
|
||||||
hconn.execute("DELETE FROM messages WHERE session_id=?", (purge_uuid,))
|
|
||||||
hconn.commit()
|
|
||||||
hconn.close()
|
|
||||||
print(f"purged db records for session: {purge_uuid}", flush=True)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"WARN: purge hermes db records failed: {e}", flush=True)
|
|
||||||
target['hermes_conversation_id_own'] = None
|
|
||||||
elif agent == 'cline':
|
|
||||||
sessions_dir = f"{home}/.cline/data/sessions/{purge_uuid}"
|
|
||||||
if os.path.isdir(sessions_dir):
|
|
||||||
shutil.rmtree(sessions_dir)
|
|
||||||
print(f"purged: {sessions_dir}", flush=True)
|
|
||||||
target['cline_conversation_id_own'] = None
|
|
||||||
# agent_identities 는 cache — 이 워크스페이스 것일 때만 비운다
|
# agent_identities 는 cache — 이 워크스페이스 것일 때만 비운다
|
||||||
ai = (d.get('agent_identities') or {}).get(agent) or {}
|
ai = (d.get('agent_identities') or {}).get(agent) or {}
|
||||||
if ai.get('project_cwd') == ws:
|
if ai.get('project_cwd') == ws:
|
||||||
if agent == 'claude' and ai.get('session_id') == purge_uuid:
|
if adapter and (ai.get('session_id') == purge_uuid or ai.get('conversation_id') == purge_uuid):
|
||||||
ai['session_id'] = None
|
for field in adapter.identity_cache_fields:
|
||||||
ai['session_jsonl'] = None
|
ai[field] = None
|
||||||
ai.pop('session_size_bytes', None)
|
|
||||||
ai.pop('session_lines', None)
|
|
||||||
elif agent == 'agy' and ai.get('conversation_id') == purge_uuid:
|
|
||||||
ai['conversation_id'] = None
|
|
||||||
ai['conversation_db'] = None
|
|
||||||
ai['conversation_brain_dir'] = None
|
|
||||||
elif agent == 'hermes' and ai.get('session_id') == purge_uuid:
|
|
||||||
ai['session_id'] = None
|
|
||||||
elif agent == 'cline' and ai.get('session_id') == purge_uuid:
|
|
||||||
ai['session_id'] = 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)
|
||||||
|
|
||||||
|
|||||||
+59
-30
@@ -1,9 +1,9 @@
|
|||||||
# 🛠️ Multi-Agent Mux 종합 개선 및 미해결 과제 백로그 (`IMPROVEMENTS.md`)
|
# 🛠️ Multi-Agent Mux 종합 개선 및 미해결 과제 백로그 (`IMPROVEMENTS.md`)
|
||||||
|
|
||||||
- **최종 갱신일**: 2026-08-14 (A-4 M0~M1 및 B-8 send_keys_safe agy 우회 제거 조치 완료 반영)
|
- **최종 갱신일**: 2026-08-16 (P3-1/A-4 Phase 2 M2~M7 에이전트 지식 계층 및 Option B / C-3b 완료, Reviewer 피드백 반영, 전체 259/259 회귀 통과 반영)
|
||||||
- **통합 관리 대상**: 기존 `CODEBASE_REVIEW_REPORT.md` + `OPTIMIZATION.md`
|
- **통합 관리 대상**: 기존 `CODEBASE_REVIEW_REPORT.md` + `OPTIMIZATION.md`
|
||||||
- **총 추적 미해결 과제**: **10건** (아키텍처 2건, 엣지케이스 5건, 오케스트레이션 0건, 레거시 잔재 3건)
|
- **총 추적 미해결 과제**: **6건** (아키텍처 1건, 엣지케이스 4건, 오케스트레이션 0건, 레거시 잔재 1건)
|
||||||
- **완료된 과제**: **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)
|
- **완료된 과제**: **19건** (A-1, A-3, A-4, A-5, B-1, B-3, B-4, B-7, B-8, C-1, C-2, C-3b, O-1, O-2, O-3, O-4-OrcOnboard, Herdr-0.8.0-Compat-SanitizeHash, P2-1-DelegateJobSafe-TrapFix, P2-2-C3a-C4-LegacyCleanup)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -13,13 +13,13 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 🔴 아키텍처 결함 (Architecture Flaws — 2건)
|
## 1. 🔴 아키텍처 결함 (Architecture Flaws — 1건)
|
||||||
|
|
||||||
### **A-2: 공개 브로커 + HMAC 인증 Off + 와일드카드 전파**
|
### **A-2: 공개 브로커 + HMAC 인증 Off + 와일드카드 전파**
|
||||||
- **현상**: `mqtt_common.py`의 기본 브로커가 공개 서버(`broker.hivemq.com`)이고, 잡 생성 시 `auth_token` 이 **한 번도 발급되지 않아**(실측 26/26 잡이 `auth_token=None`) `verify_hmac` 의 `if not auth_token: return True` 경로가 항상 타집니다. 발행자는 워크스페이스 지문 토픽을 채택하지 않고 전역 `python/mqtt/jobs/<job_id>/events` 로 발행하며, `reconcile.sh:237` 이 같은 전역 토픽을 구독합니다. (HMAC 구현 자체는 정상입니다 — 토큰이 없어 검증이 공허해지는 것이 원인입니다.)
|
- **현상**: `mqtt_common.py`의 기본 브로커가 공개 서버(`broker.hivemq.com`)이고, 잡 생성 시 `auth_token` 이 **한 번도 발급되지 않아**(실측 26/26 잡이 `auth_token=None`) `verify_hmac` 의 `if not auth_token: return True` 경로가 항상 타집니다. 발행자는 워크스페이스 지문 토픽을 채택하지 않고 전역 `python/mqtt/jobs/<job_id>/events` 로 발행하며, `reconcile.sh:237` 이 같은 전역 토픽을 구독합니다. (HMAC 구현 자체는 정상입니다 — 토큰이 없어 검증이 공허해지는 것이 원인입니다.)
|
||||||
- **파급 효과**: 외부에서 유입되는 malicious `error` 이벤트 수신 시 `reconcile.sh`가 라이브 에이전트 pane을 `kill-session`으로 강제 파괴하는 치명적 보안/안정성 위험이 존재합니다.
|
- **파급 효과**: 외부에서 유입되는 malicious `error` 이벤트 수신 시 `reconcile.sh`가 라이브 에이전트 pane을 `kill-session`으로 강제 파괴하는 치명적 보안/안정성 위험이 존재합니다.
|
||||||
|
|
||||||
### **A-4 (설계 제안): 에이전트 지식 산재 — `BaseAgentAdapter` 어댑터 계층 도입 (Rev.2)**
|
### **A-4 (✅ 완료 — P3-1): 에이전트 지식 산재 — `BaseAgentAdapter` 어댑터 계층 도입 (Rev.2)**
|
||||||
|
|
||||||
> 결함 조치가 아니라 **구조 개선 제안**입니다. 상세 설계·실측 근거는 `.mam/jobs/44062a63/claude-reports/report-final.md` 및 `744ac67a` 를 참조하십시오.
|
> 결함 조치가 아니라 **구조 개선 제안**입니다. 상세 설계·실측 근거는 `.mam/jobs/44062a63/claude-reports/report-final.md` 및 `744ac67a` 를 참조하십시오.
|
||||||
|
|
||||||
@@ -67,15 +67,27 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 🟠 엣지 케이스 및 런타임 버그 (Edge-case Bugs — 5건)
|
## 2. 🟠 엣지 케이스 및 런타임 버그 (Edge-case Bugs — 4건)
|
||||||
|
|
||||||
### **B-5: `df --output` GNU 전용 플래그 사용으로 macOS NFS 감지 실패** — ⚠️ **종결 권고 (재현 불가)**
|
### **B-5: `df --output` GNU 전용 플래그 사용으로 macOS NFS 감지 실패** — ⚠️ **종결 권고 (재현 불가)**
|
||||||
- 원 서술: macOS/BSD 환경에서 `df --output` 구문 오류로 NFS 감지가 실패하고 "NFS 아님"으로 오판되어 SQLite WAL 포맷을 강행합니다.
|
- 원 서술: macOS/BSD 환경에서 `df --output` 구문 오류로 NFS 감지가 실패하고 "NFS 아님"으로 오판되어 SQLite WAL 포맷을 강행합니다.
|
||||||
- **실측(ecef05a3)**: `df --output=target` 은 여전히 `rc=64` 로 실패하나 `ea36e81` 에서 추가된 `df -P` 폴백이 정상 동작합니다(macOS 실측: `/System/Volumes/Data`). **결론("감지 실패")은 더 이상 참이 아니므로 종결을 권고합니다.**
|
- **실측(ecef05a3)**: `df --output=target` 은 여전히 `rc=64` 로 실패하나 `ea36e81` 에서 추가된 `df -P` 폴백이 정상 동작합니다(macOS 실측: `/System/Volumes/Data`). **결론("감지 실패")은 더 이상 참이 아니므로 종결을 권고합니다.**
|
||||||
- **잔여분 → B-11 로 분리 권고**: `mount | grep -E "$mountpoint.*(nfs|cifs|smb|sshfs)"` 가 마운트포인트를 이스케이프 없이 ERE 에 보간하여 경로의 `.` 이 임의 문자로 해석됩니다(이론상 오탐).
|
- **잔여분 → B-11 로 분리 권고**: `mount | grep -E "$mountpoint.*(nfs|cifs|smb|sshfs)"` 가 마운트포인트를 이스케이프 없이 ERE 에 보간하여 경로의 `.` 이 임의 문자로 해석됩니다(이론상 오탐).
|
||||||
|
|
||||||
### **B-6: 스킬 트리에 임시 파일 복사 및 유출**
|
### **B-6: 스킬 트리에 임시 파일 복사 및 유출** — ✅ **완료 (Stage 1)**
|
||||||
- `run_loop.sh::delegate_job_safe`가 래퍼 스크립트를 `.agents/skills/...` 트리 내부에 `.tmp`로 복사하여 버전 관리 트리를 오염시키고 rsync 배포 시 외부로 유출됩니다.
|
- `run_loop.sh::delegate_job_safe`가 래퍼 스크립트를 `.agents/skills/...` 트리 내부에 `.tmp`로 복사하여 버전 관리 트리를 오염시키고 rsync 배포 시 외부로 유출되던 문제를, 임시 사본 생성 없이 원본 스크립트를 직접 인플레이스 실행(`bash "$orig_script" "$@"`)하도록 개선하여 해결했습니다.
|
||||||
|
- 기존에 이미 존재하던 완화 조치(`.gitignore:18`의 `*.tmp`, `deploy/install_mam.sh:126`의 `--exclude='*.tmp'`, `deploy/install.sh:190`의 `*.tmp` skip)에 더해, 소스 트리 내 누출 경로 자체를 소멸시켰습니다.
|
||||||
|
- `run_loop.sh` 기동 시 잔여 `.tmp` 스윕 구문(`rm -f .../multi-agent-mux-delegate-job.*.tmp`) 및 실패 시 진단 로깅을 추가했습니다.
|
||||||
|
|
||||||
|
### **B-12: 명령 치환 서브셸의 EXIT 트랩으로 인한 루프 락 조기 해제 (D1)** — ✅ **완료 (P0)**
|
||||||
|
- `run_loop.sh` 내 `PLAN_JOB_OUTPUT=$(delegate_job_safe submit ...)` 등 11개 호출부가 명령 치환(`$(...)`) 서브셸로 실행될 때, `delegate_job_safe` 내부의 `trap _mam_release_guard EXIT INT TERM HUP` 이 서브셸 종료 시 발화하는 결함입니다.
|
||||||
|
- bash 서브셸의 `$$` 가 부모 PID를 유지하므로 `mam_release_loop_lock` 의 소유권 검사를 통과하여 첫 번째 위임 잡 종료 시점에 `.mam/loop-guard-active` 마커가 삭제되었습니다.
|
||||||
|
- 이로 인해 O-3 Invocation-Aware Scoped Guard의 파일 수정 차단 및 O-2 단일 루프 락이 첫 번째 잡 이후 무력화되는 P0 결함을 `delegate_job_safe` 내 로컬 트랩을 전면 제거함으로써 근본 해결했습니다.
|
||||||
|
- `tests/test_o3_scoped_guard.py` 내 Z-9 테스트를 3종 행위 기반 테스트(`test_z9_loop_lock_survives_delegation`, `test_z9_probe_detects_the_defect`, `test_z9_no_tmp_copy_left_in_skill_tree`, `test_z9_exit_code_and_diagnostics_propagation`)로 교체하여 재발을 방지했습니다.
|
||||||
|
|
||||||
|
### **B-13: 셀프 호스팅 멀티에이전트 루프 중 턴 간 스킬 오염 (In-Flight Tooling Mutation)** — 🟡 **Stage 2 분리 과제**
|
||||||
|
- `/multi-agent-mux-loop` 가 `multi-agent-mux` 자체를 수정/리팩터링할 때, Turn 1에서 Worker가 `.agents/skills/...` 를 수정(문법 오류나 미완성 코드 포함)하면 후속 Turn 2의 Reviewer 잡 제출 시 래퍼가 비정상 종료되어 자가 치유(Corrective/Rebuttal) 단계로 진입하지 못하고 루프가 중단될 수 있는 위험입니다.
|
||||||
|
- Stage 1에서는 실패 시 명확한 구문 진단 로그(`log_error "delegate_job_safe failed (exit $rc)..."`)를 제공하도록 보강하였으며, 근본 해결을 위해 루프 기동 시 1회 임시 디렉터리에 런타임 스냅샷을 동결하는 Stage 2 구현(래퍼 지시문 경로 오버라이드 포함)이 제안되었습니다.
|
||||||
|
|
||||||
### **B-8: `send_keys_safe` agy 경로 검증 이탈**
|
### **B-8: `send_keys_safe` agy 경로 검증 이탈**
|
||||||
- agy 세션 주입 시 주입 실패 여부를 검증하지 않고 무조건 `return 0`을 남겨 실패 시에도 성공으로 보고됩니다.
|
- agy 세션 주입 시 주입 실패 여부를 검증하지 않고 무조건 `return 0`을 남겨 실패 시에도 성공으로 보고됩니다.
|
||||||
@@ -92,23 +104,32 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. ⚪ 레거시 잔재 및 죽은 코드 (Legacy Remnants — 3건)
|
## 4. ⚪ 레거시 잔재 및 죽은 코드 (Legacy Remnants — 1건)
|
||||||
|
|
||||||
### **C-3: 격리 관련 잔재** — ⚠️ **C-3a / C-3b 로 분리 필요 (§6.5 참조)**
|
|
||||||
- **C-3a (즉시 실행 가능)**: `provision_isolation` / `isolation_lever` / `isolation_env_prefix` / `isolation_cmd_args` 4종 **빈 스텁**. 프로덕션 호출자 0건. 이를 고정하던 공허한 테스트 5건(`test_tier1_unit.py` 3, `test_tier2_component.py` 1 등)도 함께 제거 대상.
|
|
||||||
- **C-3b (보류 — A-4 M2 결정 사항)**: `isolation.root` 행 필드 소비자(`verify_session_uuid` 의 `iso_root` 분기, `mam_session_iso_root`, `find_workspace_uuid` 격리 분기, `stop_session.sh:277` purge 가드). **b4a1d094 / 44062a63 에서 의도적으로 되살린 코드**이므로 지우면 그 수정이 회귀합니다.
|
|
||||||
|
|
||||||
### **C-4: 참조 0회 미사용 심볼** — ⚠️ **목록 정정됨 (7종 → 실질 3종)**
|
|
||||||
- **실제 대상 3종**: `_REAL_HERDR_PATH`(대입·export 만), `TERMINAL_STATUSES`(`registry.py:38` 정의만), `ISOLATE`(`create_session.sh:57` 대입만).
|
|
||||||
- **목록에서 제외**: `_HERDR_SHIM_DIR_PATTERN` 은 **사용 중**입니다(`lib.sh:57` 정의 → `lib.sh:79` 사용). 지우면 shim 경로 판정이 깨집니다. `local_herdr` 은 참조 0건으로 **이미 제거**되었습니다.
|
|
||||||
- `provision_isolation` 은 **C-3a 와 중복**이므로 그쪽에서 함께 처리합니다.
|
|
||||||
|
|
||||||
### **C-6: `stop_session.sh` 도움말 문서 구버전 표기**
|
### **C-6: `stop_session.sh` 도움말 문서 구버전 표기**
|
||||||
- 스크립트 도움말에는 `--mode soft|hard` 등이 서술되어 있으나 실제 옵션 파서는 `exit 2`로 거부합니다.
|
- 스크립트 도움말에는 `--mode soft|hard` 등이 서술되어 있으나 실제 옵션 파서는 `exit 2`로 거부합니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 🎉 완료된 과제 (Completed Tasks — 12건)
|
## 5. 🎉 완료된 과제 (Completed Tasks — 19건)
|
||||||
|
|
||||||
|
### **P3-1 (A-4 Phase 2 / Option B / C-3b / M2~M7): 에이전트 지식 계층 어댑터 일원화 및 isolation.root 완전 폐기** — ✅ 완료
|
||||||
|
- 에이전트별 아티팩트 경로, 검증 로직, 재개/시작 스펙, 토큰, 종료 키, 인증(`auth_ok`), 자동 발견(`discover`)을 `BaseAgentAdapter` 및 4개 구체 어댑터(`claude`, `agy`, `hermes`, `cline`)로 이관하고, CLI facts bridge(`shlex.quote`) 및 서브커맨드(`spawn-spec`, `resume-spec`, `exit-key`)를 구축했습니다.
|
||||||
|
- Universal Global Config 전환 후에도 남아있던 `isolation.root` 4개 소비자(`lib.sh`, `verify_session.py`, `workspace_uuid.py`, `stop_session.sh`, `atomic_yaml.py`)를 완전 폐기(Option B)했습니다.
|
||||||
|
- 전용 계약 테스트 스위트 `tests/test_a4_adapter_contract.py` (9/9 PASS) 및 전체 회귀 테스트 **259/259 PASS (100%)** 를 달성했습니다.
|
||||||
|
|
||||||
|
### **P2-2 (C-3a / C-4): 격리 빈 스텁 4종·공허한 테스트 4건·미사용 심볼 3종 제거** — ✅ 완료
|
||||||
|
- `.agents/skills/lib.sh` 의 백워드 호환 빈 스텁 `provision_isolation` / `isolation_lever` / `isolation_env_prefix` / `isolation_cmd_args` 4종(프로덕션 호출자 0건)을 제거하고, 주석 블록에 C-3b(`isolation.root` 행 필드) 경계를 명시해 후속 정리 시 오삭제를 차단했습니다.
|
||||||
|
- 위 스텁의 빈 출력만 재확인하던 공허한 테스트 4건(`tests/test_tier1_unit.py` 3, `tests/test_tier2_component.py` 1)을 제거하고, 그 자리에 `--isolate`/`--no-isolate` 레거시 no-op 플래그의 인자 파서 계약을 고정하는 `test_create_session_legacy_isolate_flags_noop` 1건을 신설했습니다. 신규 테스트는 분기 삭제·한쪽만 삭제·조용한 no-op 화·usage 문서 줄 삭제 4종 변이를 모두 검출함을 변이 검사로 입증했습니다. `test_tier1_unit.py:31` 섹션 헤더도 `(5 Test Cases)` 로 동기화했습니다.
|
||||||
|
- 참조 0회 미사용 심볼 3종을 제거했습니다: `_REAL_HERDR_PATH`(`lib.sh:126-127`, 대입+export만 — `_resolve_real_herdr_path` 의 stdout/rc 반환 채널은 불변임을 실측 확인), `TERMINAL_STATUSES`(`registry.py:38`, `__all__` 미포함으로 임포트 계약 불변), `ISOLATE`(`create_session.sh:57`, `set -u` 하 숨은 확장 불가능).
|
||||||
|
- `_HERDR_SHIM_DIR_PATTERN` / `_HERDR_SKILLS_BIN_PATTERN`(`lib.sh:83-84` → `:105` 사용 중), `VALID_STATUSES`(`registry.py:150-151` 사용 중), `--isolate`/`--no-isolate` 레거시 호환 분기, C-3b 소비자 4곳은 계획대로 미접촉입니다.
|
||||||
|
- 전체 회귀 **256/256 PASS (100%)** 로 입증했습니다 (259 → 256, 순감 3 = 제거 4 − 신설 1).
|
||||||
|
|
||||||
|
### **P2-1 (B-6 / B-12): `delegate_job_safe` 임시 사본 제거 및 서브셸 루프 락 조기 해제 차단 조치** — ✅ 완료
|
||||||
|
- `.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh` 내 `delegate_job_safe` 가 `.agents/skills/...` 경로 내에 `.tmp` 사본을 생성하던 방식을 제거하고 원본 래퍼 스크립트를 인플레이스로 직접 실행(`bash "$orig_script" "$@"`)하도록 개선하여 버전 관리 트리 오염 및 rsync 배포 유출(B-6)을 완전히 해소했습니다.
|
||||||
|
- 명령 치환(`$(delegate_job_safe ...)`) 서브셸 내에 설치되던 `trap _mam_release_guard EXIT` 로 인해 첫 번째 위임 잡 종료 시점에 `.mam/loop-guard-active` 마커가 삭제되어 O-3 Scoped Guard 및 O-2 락이 무력화되던 인접 P0 결함(**B-12 / D1**)을 로컬 트랩 제거를 통해 근본 해결했습니다.
|
||||||
|
- 래퍼 스크립트 실행 실패 시 도달 불가능하던 에러 진단을 `delegate_job_safe` 내부에서 직접 표준오류로 출력(`log_error "delegate_job_safe failed (exit $rc)..."`)하도록 진단 로깅을 보강했습니다.
|
||||||
|
- `tests/test_o3_scoped_guard.py` 내 Z-9 테스트를 4개 세부 행위 기반 테스트(`test_z9_loop_lock_survives_delegation`, `test_z9_probe_detects_the_defect`, `test_z9_no_tmp_copy_left_in_skill_tree`, `test_z9_exit_code_and_diagnostics_propagation`)로 교체 검증했습니다.
|
||||||
|
|
||||||
### **multi-agent-mux-orc-onboard: 오케스트레이터 온보딩 스킬 및 `orchestrator_uuids` 배제 게이트 조치** — ✅ 완료
|
### **multi-agent-mux-orc-onboard: 오케스트레이터 온보딩 스킬 및 `orchestrator_uuids` 배제 게이트 조치** — ✅ 완료
|
||||||
- `.agents/skills/multi-agent-mux-orc-onboard/` 스킬 및 `.agents/skills/multi-agent-mux-orc-onboard/scripts/orc_onboard.sh` 헬퍼 모듈을 생성하여, 메인 오케스트레이터가 프로젝트 맥락 파악 시 자신의 대화 UUID를 포착해 `.mam/agent-sessions.yaml` 및 SQLite DB 내 `orchestrator_uuids` 리스트로 원자적 기록하는 구조를 구축했습니다.
|
- `.agents/skills/multi-agent-mux-orc-onboard/` 스킬 및 `.agents/skills/multi-agent-mux-orc-onboard/scripts/orc_onboard.sh` 헬퍼 모듈을 생성하여, 메인 오케스트레이터가 프로젝트 맥락 파악 시 자신의 대화 UUID를 포착해 `.mam/agent-sessions.yaml` 및 SQLite DB 내 `orchestrator_uuids` 리스트로 원자적 기록하는 구조를 구축했습니다.
|
||||||
@@ -196,6 +217,14 @@
|
|||||||
- bash 3.2 macOS 규격 빈 배열 확장 안전성(`${ARR[@]+"${ARR[@]}"}`) 및 per-iteration budget reset / total budget cap 결함을 완벽히 보완하고 회귀 테스트 `tests/test_o1_rebuttal.py` (10/10 PASS)로 입증했습니다.
|
- bash 3.2 macOS 규격 빈 배열 확장 안전성(`${ARR[@]+"${ARR[@]}"}`) 및 per-iteration budget reset / total budget cap 결함을 완벽히 보완하고 회귀 테스트 `tests/test_o1_rebuttal.py` (10/10 PASS)로 입증했습니다.
|
||||||
- `MULTI_AGENT_RULES.md`, `.ko.md`, `multi-agent-mux-loop/SKILL.md` 문서 연동을 완료했습니다.
|
- `MULTI_AGENT_RULES.md`, `.ko.md`, `multi-agent-mux-loop/SKILL.md` 문서 연동을 완료했습니다.
|
||||||
|
|
||||||
|
### **Herdr-0.8.0-Compat-SanitizeHash: 세션명 32자 SHA-1 해시 접미사 절단 기반 유일성 보장 및 Mock Herdr 0.8.0 에러 정렬 (Commit `14b9de1`)** — ✅ 완료
|
||||||
|
- `.agents/skills/lib_py/agents/sanitize.py` 및 `.agents/skills/lib.sh` 내 `_sanitize_herdr_agent_name()`의 기존 단순 절단 방식(`s[:16]-s[-15:]`)으로 인해 동일 부모 경로 하위의 형제 워크스페이스들이 동일한 세션명으로 축약되던 결함을 **8자리 SHA-1 해시 접미사(`s[:23]-sha1[:8]`)** 결합 방식으로 100% 해소했습니다.
|
||||||
|
- 빈 문자열(`"agent"`), 숫자/특수문자 시작(`"x-"` 접두사), 32자 경계 및 33자 이상 절단 등 11개 엣지 케이스에서 **Bash 심 ↔ Python 모듈 간 100% 바이트 단위 동등성(Parity)**을 확립했습니다.
|
||||||
|
- `tests/conftest.py` Mock Herdr 에러 출력을 실제 Herdr 0.8.0 바이너리 출력 규격(`unknown option:`, `missing required`, `invalid_agent_name`)과 완전 일치하도록 정렬하고, `lib.sh` Early Abort 가드 정규식(`missing required`, `invalid_agent_name`, `^error:`)을 보강하여 불필요한 3회 재시도 루프 지연을 원천 차단했습니다.
|
||||||
|
- `lib.sh`의 `has-session` 분기 내 잔존하던 구형 휴리스틱을 제거하고, `tests/conftest.py` 내의 7곳 구형 절단 사본을 `_match_agent` 헬퍼 함수로 일원화했습니다.
|
||||||
|
- 전용 단위 테스트 `tests/test_sanitize_and_mock_errors.py` (3/3 PASS) 및 전체 회귀 테스트 스위트 (**256/256 PASS, 0 Failures / 0 Errors**)를 검증 완료했습니다.
|
||||||
|
- 멀티에이전트 자율 오케스트레이션 루프(`run_loop.sh --all-reviewer`)를 통해 Creator(`agy`, Job: `94bceedd`) 및 Reviewer(`cline`, Job: `14187d43`) 교차 검증 만장일치 **`[VERDICT: PASS]`** 를 달성했습니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 🧭 우선순위 실행 로드맵 (Prioritized Execution Roadmap)
|
## 6. 🧭 우선순위 실행 로드맵 (Prioritized Execution Roadmap)
|
||||||
@@ -219,19 +248,19 @@
|
|||||||
| **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 로직을 싸게 만듦 **(✅ 완료 — tests/test_a4_adapter_contract.py 3/3 PASS)** | 중 | 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` 우회 제거 및 제출 검증 루프 이관 **(✅ 완료 — tests/test_b8_send_keys_verification.py 1/1 PASS)** | 소 | A-4 M0 |
|
| **P1-2** | **B-8** | agy 주입 시 `return 0` 우회 제거 및 제출 검증 루프 이관 **(✅ 완료 — tests/test_b8_send_keys_verification.py 1/1 PASS)** | 소 | A-4 M0 |
|
||||||
| **P2-1** | **B-6** | 버전 관리 트리 오염 + rsync 배포 유출. `mktemp -d` 로 옮기는 1~2줄 | 소 | — |
|
| **P2-1** | **B-6 / B-12** | 스킬 트리 내 임시 사본 및 서브셸 EXIT 트랩으로 인한 루프 락 조기 해제 차단 **(✅ 완료 — tests/test_o3_scoped_guard.py 27/27 PASS, commit b490713)** | 소 | — |
|
||||||
| **P2-2** | **C-3a + C-4** | 빈 스텁 4종 + 이를 고정하던 **공허한 테스트 5건** + 죽은 심볼 3종 제거 (회귀 시간 단축 효과) | 소 | — |
|
| **P2-2** | **C-3a + C-4** | 빈 스텁 4종 + 공허한 테스트 4건 + 죽은 심볼 3종 제거 및 `--isolate` no-op 회귀 가드 신설 **(✅ 완료 — tests/test_tier1_unit.py + test_tier2_component.py 256/256 PASS)** | 소 | — |
|
||||||
| **P2-3** | **C-6** | 도움말 3줄 정정 | 극소 | — |
|
| **P2-3** | **C-6** | 도움말 3줄 정정 | 극소 | — |
|
||||||
| **P3-1** | **A-4 M2~M7** | 어댑터 본이관. 진행 중 **B-10 · C-3b 처분 결정** | 대 | P1-1 |
|
| **P3-1** | **A-4 M2~M7** | 어댑터 본이관 및 CLI facts 브리지/서브커맨드 구축 **(✅ 완료 — tests/test_a4_adapter_contract.py 9/9 PASS, 전체 259/259 PASS)** | 대 | P1-1 |
|
||||||
| **P3-2** | **B-10** | tier-3 신원 캐시 존치/제거 결정 + PyYAML 의존 완화 | 중 | A-4 M2 |
|
| **P3-2** | **B-10** | tier-3 신원 캐시 존치/제거 결정 + PyYAML 의존 완화 | 중 | A-4 M2 |
|
||||||
| **P3-3** | **C-3b** | `isolation.root` 소비자 처분 결정 | 소 | A-4 M2 |
|
| **P3-3** | **C-3b** | `isolation.root` 4개 소비자 완전 폐기 (Option B 채택) **(✅ 완료 — 전체 259/259 PASS)** | 소 | A-4 M2 |
|
||||||
| **P4-1** | **B-9** | 기본값 한정. `logs_dir` 인자·`DELEGATE_JOB_LOGS_DIR` 두 가지 회피 수단 존재 | 극소 | — |
|
| **P4-1** | **B-9** | 기본값 한정. `logs_dir` 인자·`DELEGATE_JOB_LOGS_DIR` 두 가지 회피 수단 존재 | 극소 | — |
|
||||||
| **P5-1** | **A-2** | 공개 브로커 및 HMAC 검증 보완 (📌 *사용자 지침: 차후 전용 MQTT 브로커 서빙 환경 구축 시점에 진행*) | 중 (3파일) | 전용 브로커 |
|
| **P5-1** | **A-2** | 공개 브로커 및 HMAC 검증 보완 (📌 *사용자 지침: 차후 전용 MQTT 브로커 서빙 환경 구축 시점에 진행*) | 중 (3파일) | 전용 브로커 |
|
||||||
| **종결 권고** | **B-5** | 폴백(`df -P`)으로 이미 해소 — 서술된 실패가 재현되지 않음 | — | — |
|
| **종결 권고** | **B-5** | 폴백(`df -P`)으로 이미 해소 — 서술된 실패가 재현되지 않음 | — | — |
|
||||||
|
|
||||||
**A-2 과제의 후순위 배치 사유**: 사용자 지침에 따라 차후 자체 전용 MQTT 브로커 서빙 환경 구축 시점에 맞춰 진행하기 위해 **최하위(P5-1)**로 배치하였습니다.
|
**A-2 과제의 후순위 배치 사유**: 사용자 지침에 따라 차후 자체 전용 MQTT 브로커 서빙 환경 구축 시점에 맞춰 진행하기 위해 **최하위(P5-1)**로 배치하였습니다.
|
||||||
|
|
||||||
**정리(C 계열)를 P2 에 두는 이유**: (a) C-3a 는 공허한 테스트 5건을 함께 제거해 이후 모든 전체 회귀를 단축하고, (b) C-4 는 **잘못 실행하면 버그를 만듭니다**. 방치할수록 누군가 "쉬운 정리"로 집어 들 확률이 올라갑니다.
|
**정리(C 계열)를 P2 에 두는 이유**: (a) C-3a 는 죽은 코드를 고정하던 테스트를 함께 제거해 C-3a 를 실행 가능하게 만들고, (b) C-4 는 **잘못 실행하면 버그를 만듭니다**. 방치할수록 누군가 "쉬운 정리"로 집어 들 확률이 올라갑니다.
|
||||||
|
|
||||||
### 6.3 병렬 실행 — 파일 소유권 슬롯 (Rev.2 교체)
|
### 6.3 병렬 실행 — 파일 소유권 슬롯 (Rev.2 교체)
|
||||||
|
|
||||||
@@ -287,13 +316,13 @@ CHANGES_DIFF=$(
|
|||||||
### 6.5 ⚠️ 실행 전 반드시 확인할 정정 사항
|
### 6.5 ⚠️ 실행 전 반드시 확인할 정정 사항
|
||||||
|
|
||||||
|
|
||||||
1. **C-3 은 그대로 실행하면 회귀를 만듭니다.** 항목이 성격이 다른 둘을 묶고 있습니다.
|
1. **C-3 (✅ 완료)**:
|
||||||
- **C-3a (즉시 실행 가능)**: `provision_isolation` / `isolation_lever` / `isolation_env_prefix` / `isolation_cmd_args` 4종 빈 스텁 — 프로덕션 호출자 0건. 이를 고정하던 `tests/test_tier1_unit.py` 3건 + `tests/test_tier2_component.py` 1건도 함께 제거 대상.
|
- **C-3a (✅ 완료)**: `provision_isolation` / `isolation_lever` / `isolation_env_prefix` / `isolation_cmd_args` 4종 빈 스텁 및 이를 고정하던 공허한 테스트 4건을 제거하고 `--isolate`/`--no-isolate` no-op 회귀 가드로 대체했습니다.
|
||||||
- **C-3b (보류 — A-4 M2 결정 사항)**: `isolation.root` 소비자(`lib.sh` `verify_session_uuid` 의 `iso_root` 분기, `mam_session_iso_root`, `find_workspace_uuid` 격리 분기, `stop_session.sh:277` purge 가드). **b4a1d094 / 44062a63 에서 방금 의도적으로 되살린 코드**이며, 지우면 그 수정이 되돌아갑니다.
|
- **C-3b (✅ 완료 — P3-1 / Option B)**: `isolation.root` 4개 소비자(`lib.sh` `verify_session_uuid` 의 `iso_root` 분기, `mam_session_iso_root`, `find_workspace_uuid` 격리 분기, `stop_session.sh:277` purge 가드 및 `atomic_yaml.py:30-33` 유효성 검사)를 완전히 폐기하고 Universal Global Config 및 어댑터 기반 단일 경로로 이관했습니다.
|
||||||
2. **C-4 의 "7종" 중 2종은 사실과 다릅니다.**
|
2. **C-4 (✅ 완료)**:
|
||||||
- `_HERDR_SHIM_DIR_PATTERN` — **사용 중입니다** (`lib.sh:57` 정의, `lib.sh:79` 사용). 목록대로 지우면 shim 경로 판정이 깨집니다.
|
- `_HERDR_SHIM_DIR_PATTERN` — **사용 중입니다** (`lib.sh:83` 정의, `lib.sh:105` 사용). 목록대로 지우면 shim 경로 판정이 깨집니다.
|
||||||
- `local_herdr` — 참조 0건, **이미 제거됨**.
|
- `local_herdr` — 참조 0건, **이미 제거됨**.
|
||||||
- 실제 대상은 `_REAL_HERDR_PATH`, `TERMINAL_STATUSES`, `ISOLATE` 3종이며 `provision_isolation` 은 C-3a 와 중복입니다.
|
- 실제 대상 `_REAL_HERDR_PATH`, `TERMINAL_STATUSES`, `ISOLATE` 3종이 안전하게 제거되었습니다(`provision_isolation` 은 C-3a 에서 처리).
|
||||||
3. **A-2 의 원인 표현 정정** — `verify_hmac` 구현 자체는 정상입니다(토큰이 있으면 `hmac.compare_digest` 로 검증). 원인은 **토큰이 아무 데서도 발급되지 않아 검증이 공허해지는 것** + 발행자가 워크스페이스 지문 토픽을 채택하지 않은 것입니다. 수정은 ① 발행자 토픽 교체 ② `reconcile.sh:237` 레거시 전역 구독 제거 ③ `verify_hmac` fail-closed + 토큰 발급 순입니다.
|
3. **A-2 의 원인 표현 정정** — `verify_hmac` 구현 자체는 정상입니다(토큰이 있으면 `hmac.compare_digest` 로 검증). 원인은 **토큰이 아무 데서도 발급되지 않아 검증이 공허해지는 것** + 발행자가 워크스페이스 지문 토픽을 채택하지 않은 것입니다. 수정은 ① 발행자 토픽 교체 ② `reconcile.sh:237` 레거시 전역 구독 제거 ③ `verify_hmac` fail-closed + 토큰 발급 순입니다.
|
||||||
4. **B-5 잔여분** — 폴백으로 감지는 정상화됐으나 `mount | grep -E "$mountpoint.*(nfs|...)"` 가 마운트포인트를 **이스케이프 없이 ERE 에 보간**합니다(경로의 `.` 이 임의 문자로 해석). 이것만 신규 항목(B-11)으로 분리 권고.
|
4. **B-5 잔여분** — 폴백으로 감지는 정상화됐으나 `mount | grep -E "$mountpoint.*(nfs|...)"` 가 마운트포인트를 **이스케이프 없이 ERE 에 보간**합니다(경로의 `.` 이 임의 문자로 해석). 이것만 신규 항목(B-11)으로 분리 권고.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 📝 Multi-Agent Mux 작업 세션 기록 (`LOG.md`)
|
# 📝 Multi-Agent Mux 작업 세션 기록 (`LOG.md`)
|
||||||
|
|
||||||
- **최종 기록일시**: 2026-08-08 00:23 (KST)
|
- **최종 기록일시**: 2026-08-16 10:00 (KST)
|
||||||
- **작업 저장소**: `tmpl/multi-agent-mux` (Branch: `main`)
|
- **작업 저장소**: `tmpl/multi-agent-mux` (Branch: `main`)
|
||||||
- **작업 상태**: 모든 작업 완료, 세션 안전 종료(stopped), 저장소 상태 Clean!
|
- **작업 상태**: 모든 작업 완료, 세션 안전 종료(stopped), 저장소 상태 Clean!
|
||||||
|
|
||||||
@@ -8,7 +8,43 @@
|
|||||||
|
|
||||||
## 📌 1. 금일 작업 내용 요약
|
## 📌 1. 금일 작업 내용 요약
|
||||||
|
|
||||||
### 1) **multi-agent-mux-orc-onboard: 오케스트레이터 온보딩 스킬 및 `orchestrator_uuids` 배제 게이트 구축** — **완료**
|
### 1) **P3-1 (A-4 Phase 2 / Option B / C-3b / M2~M7): 에이전트 지식 계층 마이그레이션 및 isolation.root 완전 폐기** — **완료**
|
||||||
|
- **배경**: 에이전트별 아티팩트 경로, 검증 로직, 재개/시작 스펙, 토큰, 종료 키, 인증(`auth_ok`), 자동 발견(`discover`)이 스크립트 전반에 하드코딩 산재되어 있던 문제(A-4)와, Universal Global Config 전환 후에도 4개 지점에 남아있던 `isolation.root` 소비자(C-3b)를 완전 폐기하고 어댑터 단일 소스로 일원화.
|
||||||
|
- **주요 구현**:
|
||||||
|
- [`.agents/skills/lib_py/agents/base.py`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/agents/base.py): `DiscoveryContext` 확장 (`ws_key`, `cwd`, `home_dir`, `claude_dir`, `epoch`, `row`, `mode`), `BaseAgentAdapter` 추상 인터페이스 정의 (`ready_tokens`, `exit_key`, `delegate_agent_key`, `identity_cache_fields`, `artifact_path`, `verify_artifact`, `purge_artifacts`, `spawn_spec`, `resume_spec`, `auth_ok`, `discover`).
|
||||||
|
- [`.agents/skills/lib_py/agents/adapters/`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/agents/adapters/): `claude.py`, `agy.py`, `hermes.py`, `cline.py` 4개 어댑터 구체 클래스 구현 (Cline `resume_spec` `-i --id` 수정 반영, `auth_ok`, `discover` 구현).
|
||||||
|
- [`.agents/skills/lib_py/agents/__main__.py`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/agents/__main__.py): 8개 `MAM_*` 변수 `shlex.quote` 셸 브리지 완성.
|
||||||
|
- [`.agents/skills/lib_py/verify_session.py`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/verify_session.py): `adapter.verify_artifact(uuid, ctx)` 위임 및 `iso_root` 분기 제거.
|
||||||
|
- [`.agents/skills/lib_py/workspace_uuid.py`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/workspace_uuid.py): `iso_root_of` 제거 및 디스크 스캔을 `adapter.discover(ctx)`로 단일화.
|
||||||
|
- [`.agents/skills/lib_py/atomic_yaml.py`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/atomic_yaml.py): 레거시 `isolation` 유효성 검사 절 제거.
|
||||||
|
- [`.agents/skills/lib.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib.sh): `mam_session_iso_root` 제거, `wait_for_tui_ready` 셀프 컨테인드 토큰 폴백 및 claude `projects` 토큰 제거.
|
||||||
|
- [`.agents/skills/multi-agent-mux-create/scripts/create_session.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-create/scripts/create_session.sh): `CMD_FULL` (어댑터 `spawn_spec`) 및 `delegate_agent` (`delegate_agent_key`) 이관.
|
||||||
|
- [`.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh): `_iso_root` 분기 완전 제거 및 `CMD_FULL` 을 `adapter.resume_spec`으로 이관.
|
||||||
|
- [`.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh): `row_agent` / `_pin_and_verify_resume` / `OWN_KEY_BY_AGENT` / auto-register `cmd_full` 을 어댑터 레이어로 이관.
|
||||||
|
- [`.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh): `exitkey` (`adapter.exit_key`), `adapter.purge_artifacts`, `adapter.identity_cache_fields` 위임 및 레거시 격리 삭제 블록 제거.
|
||||||
|
- [`tests/test_a4_adapter_contract.py`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/tests/test_a4_adapter_contract.py): facts eval 계약, 어댑터 속성 계약, 복합 아티팩트 삭제(purge_artifacts), spawn/resume_spec 계약, auth_ok 계약, discover 계약 테스트 9종 추가.
|
||||||
|
- [`IMPROVEMENTS.md`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/IMPROVEMENTS.md): A-4 및 C-3b 완료 처리 및 19개 과제 완결 상태 동기화.
|
||||||
|
- **검증**: `pytest` 실행 결과 **259 passed in 493s (100%)**.
|
||||||
|
|
||||||
|
### 2) **P2-2 (C-3a / C-4): 레거시 격리 스텁 및 미사용 심볼 제거** — **완료**
|
||||||
|
- **배경**: 격리 구조가 Universal Global Config(Job `536a6625`)로 전환되며 남은 빈 스텁 4종과, 그 빈 출력만 재확인하던 공허한 테스트 4건, 그리고 참조 0회 심볼 3종 정리.
|
||||||
|
- **주요 구현**:
|
||||||
|
- [`.agents/skills/lib.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib.sh): 빈 스텁 4종(`provision_isolation`, `isolation_lever`, `isolation_env_prefix`, `isolation_cmd_args`) 및 `_REAL_HERDR_PATH` 대입·export 제거, 주석에 C-3b 경계 명시.
|
||||||
|
- [`.agents/skills/multi-agent-mux-delegate-job/scripts/registry.py`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-delegate-job/scripts/registry.py): `TERMINAL_STATUSES` 제거.
|
||||||
|
- [`.agents/skills/multi-agent-mux-create/scripts/create_session.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-create/scripts/create_session.sh): `ISOLATE` 제거.
|
||||||
|
- [`tests/test_tier1_unit.py`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/tests/test_tier1_unit.py) / [`tests/test_tier2_component.py`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/tests/test_tier2_component.py): 공허한 테스트 4건 제거 및 `--isolate`/`--no-isolate` no-op 회귀 가드 1건 신설(변이 4종 검출 입증), 섹션 헤더 동기화.
|
||||||
|
- [`IMPROVEMENTS.md`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/IMPROVEMENTS.md): C-4 완결 및 C-3a 완료 반영, C-3b 존속.
|
||||||
|
- **검증**: `pytest tests/ -q` **256 passed (100%)**.
|
||||||
|
|
||||||
|
### 2) **P2-1 (B-6 / B-12): `delegate_job_safe` 임시 사본 제거 및 서브셸 루프 락 조기 해제 차단 조치** — **완료**
|
||||||
|
- **배경**: `run_loop.sh::delegate_job_safe` 가 `.agents/skills/...` 내부에 `.tmp` 사본을 생성하여 트리 오염 및 배포 시 유출(B-6)되던 문제와, 명령 치환 서브셸 내의 `trap _mam_release_guard EXIT` 로 인해 첫 번째 잡 위임 시 루프 락 마커(`.mam/loop-guard-active`)가 조기 삭제되어 O-3 가드레일이 무력화되던 결함(**B-12 / D1**) 조치.
|
||||||
|
- **주요 구현**:
|
||||||
|
- [`.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh): `delegate_job_safe` 를 임시 사본 및 서브셸 트랩 없이 인플레이스로 직접 실행(`bash "$orig_script" "$@"`)하도록 개선하고 실패 시 진단 로깅 추가. 루프 기동 시 기존 잔여 `.tmp` 스윕 구문 추가.
|
||||||
|
- [`IMPROVEMENTS.md`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/IMPROVEMENTS.md): B-6 완료 상태 갱신, B-12 (D1) 결함 명세 및 B-13 (턴 간 스킬 오염) Stage 2 과제 등록.
|
||||||
|
- [`tests/test_o3_scoped_guard.py`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/tests/test_o3_scoped_guard.py): 취약한 문자열 검사 Z-9를 4개 행위 기반 테스트(`test_z9_loop_lock_survives_delegation`, `test_z9_probe_detects_the_defect`, `test_z9_no_tmp_copy_left_in_skill_tree`, `test_z9_exit_code_and_diagnostics_propagation`)로 교체.
|
||||||
|
- **검증**: `pytest tests/ -q` 실행 결과 **259 passed (100%)** 달성.
|
||||||
|
|
||||||
|
### 2) **multi-agent-mux-orc-onboard: 오케스트레이터 온보딩 스킬 및 `orchestrator_uuids` 배제 게이트 구축** — **완료**
|
||||||
- **배경**: 오케스트레이터(`agy`)가 서브 에이전트 생성/정지/복원 시 자기 대화 UUID가 `agent-sessions.yaml` 서브 세션으로 오염 캡처되어 SQLite DB 락(`database is locked`) 및 대화 충돌이 발생하던 결함 조치.
|
- **배경**: 오케스트레이터(`agy`)가 서브 에이전트 생성/정지/복원 시 자기 대화 UUID가 `agent-sessions.yaml` 서브 세션으로 오염 캡처되어 SQLite DB 락(`database is locked`) 및 대화 충돌이 발생하던 결함 조치.
|
||||||
- **주요 구현**:
|
- **주요 구현**:
|
||||||
- [`.agents/skills/multi-agent-mux-orc-onboard/SKILL.md`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md) 및 [`.agents/skills/multi-agent-mux-orc-onboard/scripts/orc_onboard.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-orc-onboard/scripts/orc_onboard.sh): 오케스트레이터의 신원 UUID를 포착하여 `.mam/agent-sessions.yaml` 및 SQLite DB 내 `orchestrator_uuids` 리스트로 원자적 등록하는 스킬 구축.
|
- [`.agents/skills/multi-agent-mux-orc-onboard/SKILL.md`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md) 및 [`.agents/skills/multi-agent-mux-orc-onboard/scripts/orc_onboard.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-orc-onboard/scripts/orc_onboard.sh): 오케스트레이터의 신원 UUID를 포착하여 `.mam/agent-sessions.yaml` 및 SQLite DB 내 `orchestrator_uuids` 리스트로 원자적 등록하는 스킬 구축.
|
||||||
|
|||||||
+138
-56
@@ -1,10 +1,16 @@
|
|||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import shutil
|
import shutil
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
SKILLS_DIR = str(Path(__file__).resolve().parent.parent / ".agents" / "skills")
|
||||||
|
if SKILLS_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, SKILLS_DIR)
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mam_sandbox(tmp_path, monkeypatch):
|
def mam_sandbox(tmp_path, monkeypatch):
|
||||||
"""
|
"""
|
||||||
@@ -160,6 +166,24 @@ def save_state():
|
|||||||
# Save calls immediately so they persist even if we exit early or error out
|
# Save calls immediately so they persist even if we exit early or error out
|
||||||
save_state()
|
save_state()
|
||||||
|
|
||||||
|
def _sanitize_agent_name(name):
|
||||||
|
if not name:
|
||||||
|
return "agent"
|
||||||
|
s = str(name).lower()
|
||||||
|
import re, hashlib
|
||||||
|
s = re.sub(r'[^a-z0-9_-]', '-', s)
|
||||||
|
if not s or not s[0].isalpha():
|
||||||
|
s = "x-" + s
|
||||||
|
if len(s) > 32:
|
||||||
|
h = hashlib.sha1(s.encode('utf-8')).hexdigest()[:8]
|
||||||
|
s = f"{s[:23]}-{h}"
|
||||||
|
return s
|
||||||
|
|
||||||
|
def _match_agent(k, target):
|
||||||
|
if not k or not target:
|
||||||
|
return False
|
||||||
|
return k == target or _sanitize_agent_name(k) == target or _sanitize_agent_name(target) == k or _sanitize_agent_name(k) == _sanitize_agent_name(target)
|
||||||
|
|
||||||
args = sys.argv[1:]
|
args = sys.argv[1:]
|
||||||
while args and args[0] in ("-L", "--server", "-s", "--session"):
|
while args and args[0] in ("-L", "--server", "-s", "--session"):
|
||||||
if len(args) > 1:
|
if len(args) > 1:
|
||||||
@@ -193,6 +217,12 @@ if cmd1 == "workspace":
|
|||||||
elif args[i] == "--cwd":
|
elif args[i] == "--cwd":
|
||||||
cwd = args[i+1]
|
cwd = args[i+1]
|
||||||
i += 2
|
i += 2
|
||||||
|
elif args[i] == "--env":
|
||||||
|
env_val = args[i+1]
|
||||||
|
if "=" in env_val:
|
||||||
|
k, v = env_val.split("=", 1)
|
||||||
|
os.environ[k] = v
|
||||||
|
i += 2
|
||||||
else:
|
else:
|
||||||
i += 1
|
i += 1
|
||||||
workspaces = state.get("workspaces", [])
|
workspaces = state.get("workspaces", [])
|
||||||
@@ -250,7 +280,17 @@ elif cmd1 == "pane":
|
|||||||
print(json.dumps({"result": {"panes": panes_list}}))
|
print(json.dumps({"result": {"panes": panes_list}}))
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
elif cmd2 == "split":
|
elif cmd2 == "split":
|
||||||
# W11: pane split handler
|
# W11: pane split handler with --env support
|
||||||
|
i = 2
|
||||||
|
while i < len(args):
|
||||||
|
if args[i] == "--env":
|
||||||
|
env_val = args[i+1]
|
||||||
|
if "=" in env_val:
|
||||||
|
k, v = env_val.split("=", 1)
|
||||||
|
os.environ[k] = v
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
print(json.dumps({"result": {"type": "pane_split", "pane": {"pane_id": "w1:p_split"}}}))
|
print(json.dumps({"result": {"type": "pane_split", "pane": {"pane_id": "w1:p_split"}}}))
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
elif cmd2 == "layout":
|
elif cmd2 == "layout":
|
||||||
@@ -350,6 +390,8 @@ elif cmd1 == "agent":
|
|||||||
name = args[2]
|
name = args[2]
|
||||||
ws = ""
|
ws = ""
|
||||||
cwd = ""
|
cwd = ""
|
||||||
|
pane = ""
|
||||||
|
agent_type = ""
|
||||||
# Find where -- is
|
# Find where -- is
|
||||||
try:
|
try:
|
||||||
double_dash_idx = args.index("--")
|
double_dash_idx = args.index("--")
|
||||||
@@ -359,8 +401,8 @@ elif cmd1 == "agent":
|
|||||||
agent_cmd = []
|
agent_cmd = []
|
||||||
opts = args[3:]
|
opts = args[3:]
|
||||||
|
|
||||||
# W8: Whitelist herdr 0.7.4 allowed flags
|
# Whitelist herdr 0.8.0 allowed flags for agent start
|
||||||
whitelist = {"--cwd", "--workspace", "--tab", "--split", "--env", "--focus", "--no-focus"}
|
whitelist = {"--kind", "--pane", "--timeout"}
|
||||||
i = 0
|
i = 0
|
||||||
unknown_flags = []
|
unknown_flags = []
|
||||||
while i < len(opts):
|
while i < len(opts):
|
||||||
@@ -369,48 +411,39 @@ elif cmd1 == "agent":
|
|||||||
unknown_flags.append(opt)
|
unknown_flags.append(opt)
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
if opt == "--workspace":
|
if opt == "--kind":
|
||||||
ws = opts[i+1]
|
agent_type = opts[i+1]
|
||||||
i += 2
|
i += 2
|
||||||
elif opt == "--cwd":
|
elif opt == "--pane":
|
||||||
cwd = opts[i+1]
|
pane = opts[i+1]
|
||||||
i += 2
|
i += 2
|
||||||
elif opt == "--env":
|
elif opt == "--timeout":
|
||||||
env_val = opts[i+1]
|
|
||||||
if "=" in env_val:
|
|
||||||
k, v = env_val.split("=", 1)
|
|
||||||
os.environ[k] = v
|
|
||||||
i += 2
|
|
||||||
elif opt in ("--split", "--tab"):
|
|
||||||
i += 2
|
i += 2
|
||||||
else:
|
else:
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
if unknown_flags:
|
if unknown_flags:
|
||||||
print(f"usage: herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--env KEY=VALUE] [--focus|--no-focus] -- <argv...> (unknown flag: {unknown_flags[0]})")
|
sys.stderr.write("unknown option: " + str(unknown_flags[0]) + "\\n")
|
||||||
sys.exit(0)
|
sys.exit(1)
|
||||||
|
|
||||||
# Determine the agent type (claude, agy, hermes, cline)
|
if not agent_type or not pane:
|
||||||
agent_type = "claude"
|
missing_arg = "--pane" if not pane else "--kind"
|
||||||
if agent_cmd:
|
sys.stderr.write("missing required " + missing_arg + "\\n")
|
||||||
if "claude" in agent_cmd[0]:
|
sys.exit(1)
|
||||||
agent_type = "claude"
|
|
||||||
elif "agy" in agent_cmd[0]:
|
# Name validation: ^[a-z][a-z0-9_-]{0,31}$
|
||||||
agent_type = "agy"
|
import re
|
||||||
elif "hermes" in agent_cmd[0]:
|
if not re.match(r'^[a-z][a-z0-9_-]{0,31}$', name):
|
||||||
agent_type = "hermes"
|
import json
|
||||||
elif "cline" in agent_cmd[0]:
|
err_payload = {
|
||||||
agent_type = "cline"
|
"id": "cli:agent:start",
|
||||||
else:
|
"error": {
|
||||||
# guess from name
|
"code": "invalid_agent_name",
|
||||||
if "claude" in name:
|
"message": "agent name must start with a lowercase letter and contain only lowercase letters, digits, '-' or '_' (1-32 characters)"
|
||||||
agent_type = "claude"
|
}
|
||||||
elif "agy" in name:
|
}
|
||||||
agent_type = "agy"
|
sys.stderr.write(json.dumps(err_payload) + "\\n")
|
||||||
elif "hermes" in name:
|
sys.exit(1)
|
||||||
agent_type = "hermes"
|
|
||||||
elif "cline" in name:
|
|
||||||
agent_type = "cline"
|
|
||||||
|
|
||||||
# TUI Welcome Tokens definition to prevent TUI readiness check timeout
|
# TUI Welcome Tokens definition to prevent TUI readiness check timeout
|
||||||
buffer_content = {
|
buffer_content = {
|
||||||
@@ -420,6 +453,16 @@ elif cmd1 == "agent":
|
|||||||
"cline": "Cline Chat Ready"
|
"cline": "Cline Chat Ready"
|
||||||
}.get(agent_type, "Ready")
|
}.get(agent_type, "Ready")
|
||||||
|
|
||||||
|
# Look up cwd from target pane in state if not explicitly passed
|
||||||
|
if not cwd and pane:
|
||||||
|
for p in state.get("panes", []):
|
||||||
|
if p.get("pane_id") == pane:
|
||||||
|
cwd = p.get("cwd", "")
|
||||||
|
ws = p.get("workspace_id", ws)
|
||||||
|
break
|
||||||
|
if not ws and pane and ":" in pane:
|
||||||
|
ws = pane.split(":")[0]
|
||||||
|
|
||||||
agents = state.get("agents", {})
|
agents = state.get("agents", {})
|
||||||
agents[name] = {
|
agents[name] = {
|
||||||
"agent": agent_type,
|
"agent": agent_type,
|
||||||
@@ -427,7 +470,7 @@ elif cmd1 == "agent":
|
|||||||
"cwd": cwd or "TMP_PATH_PLACEHOLDER",
|
"cwd": cwd or "TMP_PATH_PLACEHOLDER",
|
||||||
"workspace_id": ws or "w1",
|
"workspace_id": ws or "w1",
|
||||||
"pid": 9999,
|
"pid": 9999,
|
||||||
"pane_id": f"w1:p_{name}",
|
"pane_id": pane or f"w1:p_{name}",
|
||||||
"command": " ".join(agent_cmd),
|
"command": " ".join(agent_cmd),
|
||||||
"buffer": buffer_content
|
"buffer": buffer_content
|
||||||
}
|
}
|
||||||
@@ -528,8 +571,13 @@ elif cmd1 == "agent":
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
name = args[2]
|
name = args[2]
|
||||||
agents = state.get("agents", {})
|
agents = state.get("agents", {})
|
||||||
if name in agents:
|
matched_k = None
|
||||||
agent_data = agents[name]
|
for k in agents:
|
||||||
|
if _match_agent(k, name):
|
||||||
|
matched_k = k
|
||||||
|
break
|
||||||
|
if matched_k:
|
||||||
|
agent_data = agents[matched_k]
|
||||||
pane_info = {
|
pane_info = {
|
||||||
"pid": agent_data.get("pid", 9999),
|
"pid": agent_data.get("pid", 9999),
|
||||||
"cwd": agent_data.get("cwd", ""),
|
"cwd": agent_data.get("cwd", ""),
|
||||||
@@ -550,34 +598,62 @@ elif cmd1 == "agent":
|
|||||||
}))
|
}))
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
else:
|
else:
|
||||||
sys.stderr.write("Agent " + name + " not found\\\\n")
|
sys.stderr.write("Agent " + name + " not found\\n")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
elif cmd2 == "read":
|
elif cmd2 == "read":
|
||||||
if len(args) < 3:
|
if len(args) < 3:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
name = args[2]
|
name = args[2]
|
||||||
agents = state.get("agents", {})
|
agents = state.get("agents", {})
|
||||||
if name in agents:
|
matched_k = None
|
||||||
buffer_content = agents[name].get("buffer", "Ready")
|
for k in agents:
|
||||||
|
if _match_agent(k, name):
|
||||||
|
matched_k = k
|
||||||
|
break
|
||||||
|
if matched_k:
|
||||||
|
buffer_content = agents[matched_k].get("buffer", "Ready")
|
||||||
print(buffer_content)
|
print(buffer_content)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
else:
|
else:
|
||||||
sys.stderr.write("Agent " + name + " not found\\\\n")
|
sys.stderr.write("Agent " + name + " not found\\n")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
elif cmd2 == "prompt":
|
||||||
|
if len(args) < 4:
|
||||||
|
sys.exit(1)
|
||||||
|
name = args[2]
|
||||||
|
text = args[3]
|
||||||
|
agents = state.get("agents", {})
|
||||||
|
matched_k = None
|
||||||
|
for k in agents:
|
||||||
|
if _match_agent(k, name):
|
||||||
|
matched_k = k
|
||||||
|
break
|
||||||
|
if matched_k:
|
||||||
|
agents[matched_k]["sent_text"] = agents[matched_k].get("sent_text", "") + text
|
||||||
|
agents[matched_k]["buffer"] = agents[matched_k].get("buffer", "") + "\\n" + text + "\\n\\nesc to interrupt"
|
||||||
|
state["agents"] = agents
|
||||||
|
save_state()
|
||||||
|
print(json.dumps({"id": "cli:agent:prompt", "result": {"type": "ok"}}))
|
||||||
|
sys.exit(0)
|
||||||
elif cmd2 == "send":
|
elif cmd2 == "send":
|
||||||
if len(args) < 4:
|
if len(args) < 4:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
name = args[2]
|
name = args[2]
|
||||||
text = args[3]
|
text = args[3]
|
||||||
agents = state.get("agents", {})
|
agents = state.get("agents", {})
|
||||||
if name in agents:
|
matched_k = None
|
||||||
agents[name]["sent_text"] = agents[name].get("sent_text", "") + text
|
for k in agents:
|
||||||
|
if _match_agent(k, name):
|
||||||
|
matched_k = k
|
||||||
|
break
|
||||||
|
if matched_k:
|
||||||
|
agents[matched_k]["sent_text"] = agents[matched_k].get("sent_text", "") + text
|
||||||
if text in ("C-m", "Enter"):
|
if text in ("C-m", "Enter"):
|
||||||
agents[name]["buffer"] = agents[name].get("buffer", "") + "\\\\n\\\\nesc to interrupt"
|
agents[matched_k]["buffer"] = agents[matched_k].get("buffer", "") + "\\\\n\\\\nesc to interrupt"
|
||||||
else:
|
else:
|
||||||
agents[name]["buffer"] = agents[name].get("buffer", "") + "\\\\n" + text
|
agents[matched_k]["buffer"] = agents[matched_k].get("buffer", "") + "\\\\n" + text
|
||||||
if "/exit" in text or "exit" in text or "Exit" in text:
|
if "/exit" in text or "exit" in text or "Exit" in text:
|
||||||
agents[name]["status"] = "stopped"
|
agents[matched_k]["status"] = "stopped"
|
||||||
state["agents"] = agents
|
state["agents"] = agents
|
||||||
save_state()
|
save_state()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
@@ -671,8 +747,13 @@ elif cmd1 == "list-panes":
|
|||||||
if "-t" in args:
|
if "-t" in args:
|
||||||
session_target = args[args.index("-t") + 1]
|
session_target = args[args.index("-t") + 1]
|
||||||
agents = state.get("agents", {})
|
agents = state.get("agents", {})
|
||||||
if session_target in agents:
|
matched_k = None
|
||||||
data = agents[session_target]
|
for k in agents:
|
||||||
|
if _match_agent(k, session_target):
|
||||||
|
matched_k = k
|
||||||
|
break
|
||||||
|
if matched_k:
|
||||||
|
data = agents[matched_k]
|
||||||
pid = data.get("pid", 9999)
|
pid = data.get("pid", 9999)
|
||||||
cwd = data.get("cwd", "TMP_PATH_PLACEHOLDER")
|
cwd = data.get("cwd", "TMP_PATH_PLACEHOLDER")
|
||||||
cmd = data.get("command", "claude")
|
cmd = data.get("command", "claude")
|
||||||
@@ -696,7 +777,7 @@ elif cmd1 == "has-session":
|
|||||||
elif len(args) > 1:
|
elif len(args) > 1:
|
||||||
sess_target = args[1]
|
sess_target = args[1]
|
||||||
agents = state.get("agents", {})
|
agents = state.get("agents", {})
|
||||||
if sess_target in agents:
|
if any(_match_agent(k, sess_target) for k in agents):
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
else:
|
else:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -708,10 +789,11 @@ elif cmd1 == "kill-session":
|
|||||||
elif len(args) > 1:
|
elif len(args) > 1:
|
||||||
sess_target = args[1]
|
sess_target = args[1]
|
||||||
agents = state.get("agents", {})
|
agents = state.get("agents", {})
|
||||||
if sess_target in agents:
|
to_del = [k for k in agents if _match_agent(k, sess_target)]
|
||||||
del agents[sess_target]
|
for k in to_del:
|
||||||
state["agents"] = agents
|
del agents[k]
|
||||||
save_state()
|
state["agents"] = agents
|
||||||
|
save_state()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
elif cmd1 == "ls":
|
elif cmd1 == "ls":
|
||||||
|
|||||||
@@ -45,3 +45,281 @@ def test_agent_of_row_priority():
|
|||||||
# Priority 3: pane.cmd exact match
|
# Priority 3: pane.cmd exact match
|
||||||
row3 = {'pane': {'cmd': 'cline'}}
|
row3 = {'pane': {'cmd': 'cline'}}
|
||||||
assert agent_of_row(row3) == 'cline'
|
assert agent_of_row(row3) == 'cline'
|
||||||
|
|
||||||
|
def test_adapter_required_properties():
|
||||||
|
from lib_py.agents.base import BaseAgentAdapter
|
||||||
|
base = BaseAgentAdapter()
|
||||||
|
for prop in ('name', 'own_key', 'ready_tokens', 'exit_key', 'delegate_agent_key', 'identity_cache_fields'):
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
getattr(base, prop)
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
'claude': ('Anthropic|Assistant|Chat|Welcome', '/exit', 'claude-code', ('session_id', 'session_jsonl', 'session_size_bytes', 'session_lines')),
|
||||||
|
'agy': ('Antigravity', 'Exit', 'antigravity-cli', ('conversation_id', 'conversation_db', 'conversation_brain_dir')),
|
||||||
|
'hermes': ('Hermes', '/exit', 'hermes-agent', ('session_id',)),
|
||||||
|
'cline': ('Cline|history|Chat|What can I do|slash commands', '/exit', 'cline-agent', ('session_id',)),
|
||||||
|
}
|
||||||
|
for agent, (toks, exitk, delk, cache_f) in expected.items():
|
||||||
|
adapter = get_adapter(agent)
|
||||||
|
assert adapter is not None
|
||||||
|
assert adapter.ready_tokens == toks
|
||||||
|
assert adapter.exit_key == exitk
|
||||||
|
assert adapter.delegate_agent_key == delk
|
||||||
|
assert adapter.identity_cache_fields == cache_f
|
||||||
|
|
||||||
|
def test_facts_bridge_eval_contract():
|
||||||
|
import subprocess, sys
|
||||||
|
from pathlib import Path
|
||||||
|
env = os.environ.copy()
|
||||||
|
skills_dir = str(Path(__file__).resolve().parent.parent / ".agents" / "skills")
|
||||||
|
env["PYTHONPATH"] = f"{skills_dir}:{env.get('PYTHONPATH', '')}"
|
||||||
|
for agent in ('claude', 'agy', 'hermes', 'cline'):
|
||||||
|
res = subprocess.run([sys.executable, "-m", "lib_py.agents", "facts", agent], capture_output=True, text=True, env=env)
|
||||||
|
assert res.returncode == 0
|
||||||
|
facts_output = res.stdout
|
||||||
|
|
||||||
|
# Verify eval in bash with set -euo pipefail
|
||||||
|
bash_cmd = f"""
|
||||||
|
set -euo pipefail
|
||||||
|
eval {shlex_quote(facts_output)}
|
||||||
|
echo "AGENT=$MAM_AGENT_NAME|OWN=$MAM_OWN_KEY|TOK=$MAM_READY_TOKENS|EXIT=$MAM_EXIT_KEY|DEL=$MAM_DELEGATE_AGENT_KEY|PH=$MAM_INPUT_PLACEHOLDER"
|
||||||
|
"""
|
||||||
|
res_bash = subprocess.run(["bash", "-c", bash_cmd], capture_output=True, text=True)
|
||||||
|
assert res_bash.returncode == 0, f"Bash eval failed for {agent}:\nStdout: {res_bash.stdout}\nStderr: {res_bash.stderr}"
|
||||||
|
if agent == 'cline':
|
||||||
|
assert "PH=Ask anything..." in res_bash.stdout
|
||||||
|
|
||||||
|
def shlex_quote(s):
|
||||||
|
import shlex
|
||||||
|
return shlex.quote(s)
|
||||||
|
|
||||||
|
def test_purge_artifacts_composite(tmp_path):
|
||||||
|
import sqlite3
|
||||||
|
from lib_py.agents.base import DiscoveryContext
|
||||||
|
ws = str(tmp_path / "ws")
|
||||||
|
home = str(tmp_path / "home")
|
||||||
|
os.makedirs(ws, exist_ok=True)
|
||||||
|
os.makedirs(home, exist_ok=True)
|
||||||
|
|
||||||
|
# 1. Claude
|
||||||
|
claude_adapter = get_adapter('claude')
|
||||||
|
claude_ctx = DiscoveryContext(workspace=ws, agent_name='claude', home_dir=home)
|
||||||
|
c_path = claude_adapter.artifact_path('uuid-c', claude_ctx)
|
||||||
|
os.makedirs(os.path.dirname(c_path), exist_ok=True)
|
||||||
|
with open(c_path, 'w') as f:
|
||||||
|
f.write('{"sessionId": "uuid-c"}')
|
||||||
|
assert os.path.exists(c_path)
|
||||||
|
purged_c = claude_adapter.purge_artifacts('uuid-c', claude_ctx)
|
||||||
|
assert len(purged_c) == 1
|
||||||
|
assert not os.path.exists(c_path)
|
||||||
|
|
||||||
|
# 2. Agy (both DB file and brain dir)
|
||||||
|
agy_adapter = get_adapter('agy')
|
||||||
|
agy_ctx = DiscoveryContext(workspace=ws, agent_name='agy', home_dir=home)
|
||||||
|
agy_db = agy_adapter.artifact_path('uuid-a', agy_ctx)
|
||||||
|
os.makedirs(os.path.dirname(agy_db), exist_ok=True)
|
||||||
|
with open(agy_db, 'w') as f:
|
||||||
|
f.write('mock db')
|
||||||
|
agy_brain = f"{home}/.gemini/antigravity-cli/brain/uuid-a"
|
||||||
|
os.makedirs(agy_brain, exist_ok=True)
|
||||||
|
with open(f"{agy_brain}/note.txt", 'w') as f:
|
||||||
|
f.write('brain note')
|
||||||
|
purged_a = agy_adapter.purge_artifacts('uuid-a', agy_ctx)
|
||||||
|
assert len(purged_a) == 2
|
||||||
|
assert not os.path.exists(agy_db)
|
||||||
|
assert not os.path.exists(agy_brain)
|
||||||
|
|
||||||
|
# 3. Hermes (JSON file and SQLite rows)
|
||||||
|
hermes_adapter = get_adapter('hermes')
|
||||||
|
hermes_ctx = DiscoveryContext(workspace=ws, agent_name='hermes', home_dir=home)
|
||||||
|
h_json = hermes_adapter.artifact_path('uuid-h', hermes_ctx)
|
||||||
|
os.makedirs(os.path.dirname(h_json), exist_ok=True)
|
||||||
|
with open(h_json, 'w') as f:
|
||||||
|
f.write('{}')
|
||||||
|
h_db = f"{home}/.hermes/state.db"
|
||||||
|
os.makedirs(os.path.dirname(h_db), exist_ok=True)
|
||||||
|
conn = sqlite3.connect(h_db)
|
||||||
|
conn.execute("CREATE TABLE IF NOT EXISTS sessions (id TEXT, cwd TEXT)")
|
||||||
|
conn.execute("CREATE TABLE IF NOT EXISTS messages (session_id TEXT, msg TEXT)")
|
||||||
|
conn.execute("INSERT INTO sessions VALUES (?, ?)", ('uuid-h', ws))
|
||||||
|
conn.execute("INSERT INTO messages VALUES (?, ?)", ('uuid-h', 'hello'))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
purged_h = hermes_adapter.purge_artifacts('uuid-h', hermes_ctx)
|
||||||
|
assert len(purged_h) == 2
|
||||||
|
assert not os.path.exists(h_json)
|
||||||
|
conn = sqlite3.connect(h_db)
|
||||||
|
assert conn.execute("SELECT count(*) FROM sessions WHERE id='uuid-h'").fetchone()[0] == 0
|
||||||
|
assert conn.execute("SELECT count(*) FROM messages WHERE session_id='uuid-h'").fetchone()[0] == 0
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# 4. Cline (sessions dir)
|
||||||
|
cline_adapter = get_adapter('cline')
|
||||||
|
cline_ctx = DiscoveryContext(workspace=ws, agent_name='cline', home_dir=home)
|
||||||
|
cline_dir = f"{home}/.cline/data/sessions/uuid-cl"
|
||||||
|
os.makedirs(cline_dir, exist_ok=True)
|
||||||
|
with open(f"{cline_dir}/uuid-cl.json", 'w') as f:
|
||||||
|
f.write('{"session_id": "uuid-cl"}')
|
||||||
|
purged_cl = cline_adapter.purge_artifacts('uuid-cl', cline_ctx)
|
||||||
|
assert len(purged_cl) == 1
|
||||||
|
assert not os.path.exists(cline_dir)
|
||||||
|
|
||||||
|
def test_adapter_spawn_and_resume_specs():
|
||||||
|
claude = get_adapter('claude')
|
||||||
|
assert claude.spawn_spec('claude', 'u1') == 'claude --dangerously-skip-permissions --session-id u1'
|
||||||
|
assert claude.spawn_spec('claude', '', use_wrapper=True) == 'claude --dangerously-skip-permissions'
|
||||||
|
assert claude.resume_spec('claude', 'u1', materialized=True) == 'claude --dangerously-skip-permissions -r u1'
|
||||||
|
assert claude.resume_spec('claude', 'u1', materialized=False) == 'claude --dangerously-skip-permissions --session-id u1'
|
||||||
|
|
||||||
|
agy = get_adapter('agy')
|
||||||
|
assert agy.spawn_spec('agy', 'u1') == 'agy --dangerously-skip-permissions'
|
||||||
|
assert agy.resume_spec('agy', 'u1', materialized=True) == 'agy --dangerously-skip-permissions --conversation u1'
|
||||||
|
|
||||||
|
hermes = get_adapter('hermes')
|
||||||
|
assert hermes.spawn_spec('hermes', 'u1') == 'hermes'
|
||||||
|
assert hermes.resume_spec('hermes', 'u1', materialized=True) == 'hermes --resume u1'
|
||||||
|
|
||||||
|
cline = get_adapter('cline')
|
||||||
|
assert cline.spawn_spec('cline', 'u1') == 'cline -i'
|
||||||
|
assert cline.resume_spec('cline', 'u1', materialized=True) == 'cline -i --id u1'
|
||||||
|
assert cline.resume_spec('cline', 'u1', materialized=False) == 'cline -i'
|
||||||
|
|
||||||
|
def test_adapter_auth_ok(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("HOME_DIR", str(tmp_path))
|
||||||
|
# Claude auth runner
|
||||||
|
claude = get_adapter('claude')
|
||||||
|
assert claude.auth_ok(run_cmd=lambda cmd: (0, '{"loggedIn": true}', '')) is True
|
||||||
|
assert claude.auth_ok(run_cmd=lambda cmd: (1, '{"loggedIn": false}', 'error')) is False
|
||||||
|
|
||||||
|
# Agy auth file check
|
||||||
|
agy = get_adapter('agy')
|
||||||
|
assert agy.auth_ok() is False
|
||||||
|
oauth_file = tmp_path / ".gemini" / "oauth_creds.json"
|
||||||
|
oauth_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
oauth_file.write_text("{}")
|
||||||
|
assert agy.auth_ok() is True
|
||||||
|
|
||||||
|
# Hermes & Cline always True
|
||||||
|
assert get_adapter('hermes').auth_ok() is True
|
||||||
|
assert get_adapter('cline').auth_ok() is True
|
||||||
|
|
||||||
|
def test_adapter_discover(tmp_path):
|
||||||
|
import sqlite3
|
||||||
|
from lib_py.agents.base import DiscoveryContext
|
||||||
|
ws = str(tmp_path / "ws")
|
||||||
|
home = str(tmp_path / "home")
|
||||||
|
os.makedirs(ws, exist_ok=True)
|
||||||
|
os.makedirs(home, exist_ok=True)
|
||||||
|
|
||||||
|
# 1. Claude
|
||||||
|
claude = get_adapter('claude')
|
||||||
|
ctx_c = DiscoveryContext(workspace=ws, agent_name='claude', home_dir=home)
|
||||||
|
c_proj = f"{ctx_c.claude_dir}/{ctx_c.ws_key}"
|
||||||
|
os.makedirs(c_proj, exist_ok=True)
|
||||||
|
with open(f"{c_proj}/u-c1.jsonl", 'w') as f:
|
||||||
|
f.write('{"sessionId": "u-c1"}\n')
|
||||||
|
assert claude.discover(ctx_c) == ['u-c1']
|
||||||
|
|
||||||
|
# 2. Agy
|
||||||
|
agy = get_adapter('agy')
|
||||||
|
ctx_a = DiscoveryContext(workspace=ws, agent_name='agy', home_dir=home)
|
||||||
|
a_db = f"{home}/.gemini/antigravity-cli/conversations/u-a1.db"
|
||||||
|
os.makedirs(os.path.dirname(a_db), exist_ok=True)
|
||||||
|
conn = sqlite3.connect(a_db)
|
||||||
|
conn.execute("CREATE TABLE steps (id INT)")
|
||||||
|
conn.execute("INSERT INTO steps VALUES (1)")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
lc = f"{home}/.gemini/antigravity-cli/cache/last_conversations.json"
|
||||||
|
os.makedirs(os.path.dirname(lc), exist_ok=True)
|
||||||
|
with open(lc, 'w') as f:
|
||||||
|
import json
|
||||||
|
json.dump({ws: 'u-a1'}, f)
|
||||||
|
assert agy.discover(ctx_a) == ['u-a1']
|
||||||
|
|
||||||
|
# 3. Hermes
|
||||||
|
hermes = get_adapter('hermes')
|
||||||
|
ctx_h = DiscoveryContext(workspace=ws, agent_name='hermes', home_dir=home)
|
||||||
|
h_db = f"{home}/.hermes/state.db"
|
||||||
|
os.makedirs(os.path.dirname(h_db), exist_ok=True)
|
||||||
|
conn = sqlite3.connect(h_db)
|
||||||
|
conn.execute("CREATE TABLE sessions (id TEXT, cwd TEXT, started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
|
||||||
|
conn.execute("INSERT INTO sessions (id, cwd) VALUES ('u-h1', ?)", (ws,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
assert hermes.discover(ctx_h) == ['u-h1']
|
||||||
|
|
||||||
|
# 4. Cline
|
||||||
|
cline = get_adapter('cline')
|
||||||
|
ctx_cl = DiscoveryContext(workspace=ws, agent_name='cline', home_dir=home)
|
||||||
|
cl_sess = f"{home}/.cline/data/sessions/u-cl1"
|
||||||
|
os.makedirs(cl_sess, exist_ok=True)
|
||||||
|
with open(f"{cl_sess}/u-cl1.json", 'w') as f:
|
||||||
|
f.write('{"session_id": "u-cl1", "cwd": "' + ws + '"}')
|
||||||
|
assert cline.discover(ctx_cl) == ['u-cl1']
|
||||||
|
|
||||||
|
def test_cli_bridge_subcommands_and_quote_safety():
|
||||||
|
import subprocess, sys
|
||||||
|
from pathlib import Path
|
||||||
|
env = os.environ.copy()
|
||||||
|
skills_dir = str(Path(__file__).resolve().parent.parent / ".agents" / "skills")
|
||||||
|
env["PYTHONPATH"] = f"{skills_dir}:{env.get('PYTHONPATH', '')}"
|
||||||
|
|
||||||
|
# 1. spawn-spec
|
||||||
|
res = subprocess.run([sys.executable, "-m", "lib_py.agents", "spawn-spec", "claude", "/path with spaces/claude", "uuid-test", "0"], capture_output=True, text=True, env=env)
|
||||||
|
assert res.returncode == 0
|
||||||
|
assert res.stdout.strip() == "/path with spaces/claude --dangerously-skip-permissions --session-id uuid-test"
|
||||||
|
|
||||||
|
# 2. resume-spec with single quotes in workspace path
|
||||||
|
res = subprocess.run([sys.executable, "-m", "lib_py.agents", "resume-spec", "claude", "/bin/claude", "uuid-test", "/tmp/bob's ws"], capture_output=True, text=True, env=env)
|
||||||
|
assert res.returncode == 0
|
||||||
|
assert res.stdout.strip() == "/bin/claude --dangerously-skip-permissions --session-id uuid-test"
|
||||||
|
|
||||||
|
# 3. exit-key
|
||||||
|
for agent, expected_key in [('claude', '/exit'), ('agy', 'Exit'), ('hermes', '/exit'), ('cline', '/exit')]:
|
||||||
|
res = subprocess.run([sys.executable, "-m", "lib_py.agents", "exit-key", agent], capture_output=True, text=True, env=env)
|
||||||
|
assert res.returncode == 0
|
||||||
|
assert res.stdout.strip() == expected_key
|
||||||
|
|
||||||
|
def test_delegate_agent_resolution_and_fallback():
|
||||||
|
import subprocess
|
||||||
|
expected_map = {
|
||||||
|
'claude': 'claude-code',
|
||||||
|
'agy': 'antigravity-cli',
|
||||||
|
'hermes': 'hermes-agent',
|
||||||
|
'cline': 'cline-agent',
|
||||||
|
}
|
||||||
|
# 1. Adapter property
|
||||||
|
for agent, expected_key in expected_map.items():
|
||||||
|
adapter = get_adapter(agent)
|
||||||
|
assert adapter.delegate_agent_key == expected_key
|
||||||
|
|
||||||
|
# 2. Shell fallback resolution when MAM_DELEGATE_AGENT_KEY is unset (R1 fallback)
|
||||||
|
for agent, expected_key in expected_map.items():
|
||||||
|
sh_snippet = f'''
|
||||||
|
AGENT="{agent}"
|
||||||
|
MAM_DELEGATE_AGENT_KEY=""
|
||||||
|
delegate_agent="${{MAM_DELEGATE_AGENT_KEY:-}}"
|
||||||
|
if [ -z "$delegate_agent" ]; then
|
||||||
|
case "$AGENT" in
|
||||||
|
claude) delegate_agent="claude-code" ;;
|
||||||
|
hermes) delegate_agent="hermes-agent" ;;
|
||||||
|
cline) delegate_agent="cline-agent" ;;
|
||||||
|
agy) delegate_agent="antigravity-cli" ;;
|
||||||
|
*) echo "ERROR: cannot resolve delegate agent key for '$AGENT'" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
echo "$delegate_agent"
|
||||||
|
'''
|
||||||
|
res = subprocess.run(["bash", "-c", sh_snippet], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0
|
||||||
|
assert res.stdout.strip() == expected_key
|
||||||
|
|
||||||
|
def test_wait_for_tui_ready_missing_tokens_diagnostic(mam_sandbox):
|
||||||
|
import subprocess
|
||||||
|
lib_sh = mam_sandbox / "skills" / "lib.sh"
|
||||||
|
cmd = f'source "{lib_sh}" && wait_for_tui_ready "dummy-sess" "bogus-agent"'
|
||||||
|
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True)
|
||||||
|
assert res.returncode != 0
|
||||||
|
assert "no ready tokens for agent 'bogus-agent'" in res.stderr
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import pytest
|
|||||||
def _create_mock_agent_session(mam_sandbox):
|
def _create_mock_agent_session(mam_sandbox):
|
||||||
"""Helper to register an active agent session in mock herdr."""
|
"""Helper to register an active agent session in mock herdr."""
|
||||||
subprocess.run(["herdr", "agent", "start", "test-creator-claude",
|
subprocess.run(["herdr", "agent", "start", "test-creator-claude",
|
||||||
"--workspace", str(mam_sandbox), "--cwd", str(mam_sandbox),
|
"--kind", "claude", "--pane", "w1:p1",
|
||||||
"--", "claude"], capture_output=True, text=True, cwd=str(mam_sandbox))
|
"--", "claude"], capture_output=True, text=True, cwd=str(mam_sandbox))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ _pane_quiescent() {{ return 0; }}
|
|||||||
_pane_dialog_open() {{ return 1; }}
|
_pane_dialog_open() {{ return 1; }}
|
||||||
|
|
||||||
# Mock _sks_herdr commands
|
# Mock _sks_herdr commands
|
||||||
_sks_herdr() {{ return 0; }}
|
_sks_herdr() {{
|
||||||
|
if [ "${{1:-}}" = "agent" ] && [ "${{2:-}}" = "prompt" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}}
|
||||||
|
|
||||||
# Mock _pane_capture to simulate fixed pane content (no execution token, no pane change)
|
# Mock _pane_capture to simulate fixed pane content (no execution token, no pane change)
|
||||||
_pane_capture() {{ echo "static content"; }}
|
_pane_capture() {{ echo "static content"; }}
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ def test_h1_to_h8_shim_contract(mam_sandbox, mock_herdr, mock_agents):
|
|||||||
tmp_path = mam_sandbox
|
tmp_path = mam_sandbox
|
||||||
lib_path = tmp_path / ".agents" / "skills" / "lib.sh"
|
lib_path = tmp_path / ".agents" / "skills" / "lib.sh"
|
||||||
|
|
||||||
# Test H-1 & H-4: Allowed flags and absolute path preservation
|
# Test H-1 & H-4: Allowed flags and 0.8.0 contract
|
||||||
cmd_str = f"""
|
cmd_str = f"""
|
||||||
source {lib_path}
|
source {lib_path}
|
||||||
_init_herdr_isolation
|
_init_herdr_isolation
|
||||||
herdr new-session -d -s "test-h1-sess" -c "{tmp_path}" "/usr/bin/python3 -c 'print(1)'"
|
herdr new-session -d -s "test-h1-creator-claude" -c "{tmp_path}" "claude --dangerously-skip-permissions"
|
||||||
"""
|
"""
|
||||||
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True, cwd=str(tmp_path))
|
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
assert res.returncode == 0, f"Stderr: {res.stderr}\nStdout: {res.stdout}"
|
assert res.returncode == 0, f"Stderr: {res.stderr}\nStdout: {res.stdout}"
|
||||||
@@ -39,7 +39,8 @@ def test_h1_to_h8_shim_contract(mam_sandbox, mock_herdr, mock_agents):
|
|||||||
agent_start_calls = [c for c in calls if len(c) > 1 and c[0] == "agent" and c[1] == "start"]
|
agent_start_calls = [c for c in calls if len(c) > 1 and c[0] == "agent" and c[1] == "start"]
|
||||||
assert len(agent_start_calls) > 0
|
assert len(agent_start_calls) > 0
|
||||||
|
|
||||||
whitelist = {"--cwd", "--workspace", "--tab", "--split", "--env", "--focus", "--no-focus"}
|
whitelist = {"--kind", "--pane", "--timeout"}
|
||||||
|
forbidden = {"--cwd", "--workspace", "--tab", "--split", "--env", "--focus", "--no-focus"}
|
||||||
for call in agent_start_calls:
|
for call in agent_start_calls:
|
||||||
try:
|
try:
|
||||||
dd_idx = call.index("--")
|
dd_idx = call.index("--")
|
||||||
@@ -49,14 +50,14 @@ def test_h1_to_h8_shim_contract(mam_sandbox, mock_herdr, mock_agents):
|
|||||||
opts = call[3:]
|
opts = call[3:]
|
||||||
argv = []
|
argv = []
|
||||||
|
|
||||||
# H-1: Check no un-whitelisted flags like --kind or --pane
|
# H-1: Check only 0.8.0 allowed flags are present and forbidden flags are absent
|
||||||
for i in range(len(opts)):
|
for i in range(len(opts)):
|
||||||
if opts[i].startswith("--"):
|
if opts[i].startswith("--"):
|
||||||
assert opts[i] in whitelist, f"Forbidden flag in agent start: {opts[i]}"
|
assert opts[i] in whitelist, f"Unexpected flag in agent start: {opts[i]}"
|
||||||
|
assert opts[i] not in forbidden, f"Forbidden 0.7.4 flag in agent start: {opts[i]}"
|
||||||
|
|
||||||
# H-4: Check executable absolute path preserved
|
assert "--kind" in opts, "Missing required --kind in agent start"
|
||||||
if argv:
|
assert "--pane" in opts, "Missing required --pane in agent start"
|
||||||
assert argv[0] == "/usr/bin/python3", f"Executable path mutated: {argv[0]}"
|
|
||||||
|
|
||||||
def test_h9_mock_response_contract_schema(mock_herdr):
|
def test_h9_mock_response_contract_schema(mock_herdr):
|
||||||
"""H-9: Verify mock_herdr responses match tests/fixtures/herdr_contract.json schema."""
|
"""H-9: Verify mock_herdr responses match tests/fixtures/herdr_contract.json schema."""
|
||||||
@@ -98,7 +99,7 @@ def test_h11_to_h13_layout_policy(mam_sandbox, mock_herdr, mock_agents):
|
|||||||
_init_herdr_isolation
|
_init_herdr_isolation
|
||||||
export MAM_MIN_PANE_COLS=60
|
export MAM_MIN_PANE_COLS=60
|
||||||
export MAM_MIN_PANE_ROWS=20
|
export MAM_MIN_PANE_ROWS=20
|
||||||
herdr new-session -d -s "test-h11-sess" -c "{tmp_path}" "python3 -c 'print(1)'"
|
herdr new-session -d -s "test-h11-creator-claude" -c "{tmp_path}" "claude --dangerously-skip-permissions"
|
||||||
"""
|
"""
|
||||||
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True, cwd=str(tmp_path))
|
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
assert res.returncode == 0, f"Stderr: {res.stderr}"
|
assert res.returncode == 0, f"Stderr: {res.stderr}"
|
||||||
@@ -111,7 +112,7 @@ def test_h14_mock_concurrency_lock_invariant(mock_herdr):
|
|||||||
import subprocess
|
import subprocess
|
||||||
procs = []
|
procs = []
|
||||||
for i in range(10):
|
for i in range(10):
|
||||||
p = subprocess.Popen(["python3", "{mock_state.parent / 'bin' / 'herdr'}", "agent", "start", f"agent_{{i}}", "--cwd", "/tmp", "--", "claude"])
|
p = subprocess.Popen(["python3", "{mock_state.parent / 'bin' / 'herdr'}", "agent", "start", f"agent_{{i}}", "--kind", "claude", "--pane", f"w1:p{{i}}"])
|
||||||
procs.append(p)
|
procs.append(p)
|
||||||
for p in procs:
|
for p in procs:
|
||||||
p.wait()
|
p.wait()
|
||||||
|
|||||||
@@ -147,16 +147,171 @@ def test_z8_dead_pid_marker(mam_sandbox):
|
|||||||
assert res["decision"] == "allow"
|
assert res["decision"] == "allow"
|
||||||
|
|
||||||
|
|
||||||
# Z-9: delegate_job_safe restores _mam_release_guard trap
|
def _extract_delegate_job_safe():
|
||||||
def test_z9_delegate_job_safe_restores_trap():
|
|
||||||
run_loop = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "run_loop.sh"
|
run_loop = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "run_loop.sh"
|
||||||
content = run_loop.read_text()
|
content = run_loop.read_text()
|
||||||
# Check inside delegate_job_safe definition that trap - is replaced with trap _mam_release_guard
|
|
||||||
func_start = content.find("delegate_job_safe() {")
|
func_start = content.find("delegate_job_safe() {")
|
||||||
assert func_start != -1
|
assert func_start != -1
|
||||||
func_body = content[func_start:func_start+400]
|
func_end = content.find("\n}\n", func_start)
|
||||||
assert "trap _mam_release_guard EXIT INT TERM HUP" in func_body
|
assert func_end != -1
|
||||||
assert "trap - EXIT INT TERM HUP" not in func_body
|
return content[func_start:func_end + 3]
|
||||||
|
|
||||||
|
|
||||||
|
# Z-9: delegate_job_safe preserves loop lock across delegations without tmp copies/traps (B-6, D1)
|
||||||
|
def test_z9_loop_lock_survives_delegation(tmp_path):
|
||||||
|
loop_lock_sh = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "loop_lock.sh"
|
||||||
|
skill_dir = tmp_path / ".agents" / "skills" / "multi-agent-mux-delegate-job"
|
||||||
|
skill_dir.mkdir(parents=True)
|
||||||
|
stub_wrapper = skill_dir / "multi-agent-mux-delegate-job"
|
||||||
|
stub_wrapper.write_text("#!/usr/bin/env bash\necho 'JOB_ID: 12345678'\n")
|
||||||
|
stub_wrapper.chmod(0o755)
|
||||||
|
|
||||||
|
extracted_func = _extract_delegate_job_safe()
|
||||||
|
script = f"""#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
REPO_ROOT="{tmp_path}"
|
||||||
|
MAM_LOOP_MARKER="$REPO_ROOT/.mam/loop-guard-active"
|
||||||
|
source "{loop_lock_sh}"
|
||||||
|
|
||||||
|
log_error() {{ echo "ERROR: $@" >&2; }}
|
||||||
|
log_info() {{ echo "INFO: $@" >&2; }}
|
||||||
|
|
||||||
|
_mam_release_guard() {{ mam_release_loop_lock "$MAM_LOOP_MARKER" || true; }}
|
||||||
|
|
||||||
|
mam_acquire_loop_lock "$MAM_LOOP_MARKER"
|
||||||
|
trap _mam_release_guard EXIT INT TERM HUP
|
||||||
|
|
||||||
|
{extracted_func}
|
||||||
|
|
||||||
|
PLAN_JOB_OUTPUT=$(delegate_job_safe submit --task test)
|
||||||
|
|
||||||
|
if [ -f "$MAM_LOOP_MARKER" ]; then
|
||||||
|
echo "MARKER: HELD"
|
||||||
|
else
|
||||||
|
echo "MARKER: RELEASED"
|
||||||
|
fi
|
||||||
|
"""
|
||||||
|
res = subprocess.run(["bash", "-c", script], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0
|
||||||
|
assert "MARKER: HELD" in res.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_z9_probe_detects_the_defect(tmp_path):
|
||||||
|
loop_lock_sh = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "loop_lock.sh"
|
||||||
|
skill_dir = tmp_path / ".agents" / "skills" / "multi-agent-mux-delegate-job"
|
||||||
|
skill_dir.mkdir(parents=True)
|
||||||
|
stub_wrapper = skill_dir / "multi-agent-mux-delegate-job"
|
||||||
|
stub_wrapper.write_text("#!/usr/bin/env bash\necho 'JOB_ID: 12345678'\n")
|
||||||
|
stub_wrapper.chmod(0o755)
|
||||||
|
|
||||||
|
defective_func = """
|
||||||
|
delegate_job_safe() {
|
||||||
|
local orig_script="$REPO_ROOT/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job"
|
||||||
|
local tmp_script
|
||||||
|
tmp_script="${orig_script}.${RANDOM}_$$.tmp"
|
||||||
|
cp "$orig_script" "$tmp_script"
|
||||||
|
trap 'rm -f "$tmp_script"' EXIT INT TERM HUP
|
||||||
|
local rc=0
|
||||||
|
bash "$tmp_script" "$@" || rc=$?
|
||||||
|
rm -f "$tmp_script"
|
||||||
|
trap _mam_release_guard EXIT INT TERM HUP
|
||||||
|
return $rc
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
script = f"""#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
REPO_ROOT="{tmp_path}"
|
||||||
|
MAM_LOOP_MARKER="$REPO_ROOT/.mam/loop-guard-active"
|
||||||
|
source "{loop_lock_sh}"
|
||||||
|
|
||||||
|
log_error() {{ echo "ERROR: $@" >&2; }}
|
||||||
|
log_info() {{ echo "INFO: $@" >&2; }}
|
||||||
|
|
||||||
|
_mam_release_guard() {{ mam_release_loop_lock "$MAM_LOOP_MARKER" || true; }}
|
||||||
|
|
||||||
|
mam_acquire_loop_lock "$MAM_LOOP_MARKER"
|
||||||
|
trap _mam_release_guard EXIT INT TERM HUP
|
||||||
|
|
||||||
|
{defective_func}
|
||||||
|
|
||||||
|
PLAN_JOB_OUTPUT=$(delegate_job_safe submit --task test)
|
||||||
|
|
||||||
|
if [ -f "$MAM_LOOP_MARKER" ]; then
|
||||||
|
echo "MARKER: HELD"
|
||||||
|
else
|
||||||
|
echo "MARKER: RELEASED"
|
||||||
|
fi
|
||||||
|
"""
|
||||||
|
res = subprocess.run(["bash", "-c", script], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0
|
||||||
|
assert "MARKER: RELEASED" in res.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_z9_no_tmp_copy_left_in_skill_tree(tmp_path):
|
||||||
|
loop_lock_sh = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "loop_lock.sh"
|
||||||
|
skill_dir = tmp_path / ".agents" / "skills" / "multi-agent-mux-delegate-job"
|
||||||
|
skill_dir.mkdir(parents=True)
|
||||||
|
stub_wrapper = skill_dir / "multi-agent-mux-delegate-job"
|
||||||
|
stub_wrapper.write_text("#!/usr/bin/env bash\necho 'JOB_ID: 12345678'\n")
|
||||||
|
stub_wrapper.chmod(0o755)
|
||||||
|
|
||||||
|
extracted_func = _extract_delegate_job_safe()
|
||||||
|
script = f"""#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
REPO_ROOT="{tmp_path}"
|
||||||
|
MAM_LOOP_MARKER="$REPO_ROOT/.mam/loop-guard-active"
|
||||||
|
source "{loop_lock_sh}"
|
||||||
|
|
||||||
|
log_error() {{ echo "ERROR: $@" >&2; }}
|
||||||
|
log_info() {{ echo "INFO: $@" >&2; }}
|
||||||
|
|
||||||
|
_mam_release_guard() {{ mam_release_loop_lock "$MAM_LOOP_MARKER" || true; }}
|
||||||
|
|
||||||
|
mam_acquire_loop_lock "$MAM_LOOP_MARKER"
|
||||||
|
trap _mam_release_guard EXIT INT TERM HUP
|
||||||
|
|
||||||
|
{extracted_func}
|
||||||
|
|
||||||
|
PLAN_JOB_OUTPUT=$(delegate_job_safe submit --task test)
|
||||||
|
"""
|
||||||
|
res = subprocess.run(["bash", "-c", script], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0
|
||||||
|
tmp_files = list(skill_dir.glob("*.tmp"))
|
||||||
|
assert tmp_files == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_z9_exit_code_and_diagnostics_propagation(tmp_path):
|
||||||
|
loop_lock_sh = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "loop_lock.sh"
|
||||||
|
skill_dir = tmp_path / ".agents" / "skills" / "multi-agent-mux-delegate-job"
|
||||||
|
skill_dir.mkdir(parents=True)
|
||||||
|
stub_wrapper = skill_dir / "multi-agent-mux-delegate-job"
|
||||||
|
stub_wrapper.write_text("#!/usr/bin/env bash\necho 'syntax error' >&2\nexit 7\n")
|
||||||
|
stub_wrapper.chmod(0o755)
|
||||||
|
|
||||||
|
extracted_func = _extract_delegate_job_safe()
|
||||||
|
script = f"""#!/usr/bin/env bash
|
||||||
|
REPO_ROOT="{tmp_path}"
|
||||||
|
MAM_LOOP_MARKER="$REPO_ROOT/.mam/loop-guard-active"
|
||||||
|
source "{loop_lock_sh}"
|
||||||
|
|
||||||
|
log_error() {{ echo "ERROR: $@" >&2; }}
|
||||||
|
log_info() {{ echo "INFO: $@" >&2; }}
|
||||||
|
|
||||||
|
_mam_release_guard() {{ mam_release_loop_lock "$MAM_LOOP_MARKER" || true; }}
|
||||||
|
|
||||||
|
mam_acquire_loop_lock "$MAM_LOOP_MARKER"
|
||||||
|
trap _mam_release_guard EXIT INT TERM HUP
|
||||||
|
|
||||||
|
{extracted_func}
|
||||||
|
|
||||||
|
rc=0
|
||||||
|
delegate_job_safe submit --task test || rc=$?
|
||||||
|
echo "DELEGATE_RC: $rc"
|
||||||
|
"""
|
||||||
|
res = subprocess.run(["bash", "-c", script], capture_output=True, text=True)
|
||||||
|
assert "DELEGATE_RC: 7" in res.stdout
|
||||||
|
assert "delegate_job_safe failed (exit 7):" in res.stderr
|
||||||
|
assert "bash -n" in res.stderr
|
||||||
|
|
||||||
|
|
||||||
# Z-10: Reused PID with stale lstart is NOT blocked
|
# Z-10: Reused PID with stale lstart is NOT blocked
|
||||||
|
|||||||
@@ -219,20 +219,6 @@ def test_o10_revalidate_normal_subagent(mam_sandbox):
|
|||||||
assert run_verify_uuid(str(mam_sandbox), "claude", sub_uuid, row=row, mode="revalidate", env=env)
|
assert run_verify_uuid(str(mam_sandbox), "claude", sub_uuid, row=row, mode="revalidate", env=env)
|
||||||
|
|
||||||
|
|
||||||
# O-11: verify_session_uuid respects isolation root
|
|
||||||
def test_o11_isolation_root_respected(mam_sandbox):
|
|
||||||
orc_uuid = "01eae7cf-1db6-4395-ba48-5fb02f4b6b1f"
|
|
||||||
iso_root = mam_sandbox / "iso"
|
|
||||||
make_claude_transcript(mam_sandbox, orc_uuid, target_dir=iso_root)
|
|
||||||
|
|
||||||
row = {
|
|
||||||
"name": "my-orc",
|
|
||||||
"claude_session_id_own": orc_uuid,
|
|
||||||
"isolation": {"root": str(iso_root), "uuid": "iso-1"},
|
|
||||||
"pane": {"cwd": str(mam_sandbox)}
|
|
||||||
}
|
|
||||||
env = {"AGENT_SESSIONS_YAML": str(mam_sandbox / ".mam" / "agent-sessions.yaml"), "HOME_DIR": str(mam_sandbox)}
|
|
||||||
assert run_verify_uuid(str(mam_sandbox), "claude", orc_uuid, row=row, mode="revalidate", env=env)
|
|
||||||
|
|
||||||
|
|
||||||
# O-12: orc_onboard.sh --uuid explicitly adds UUID
|
# O-12: orc_onboard.sh --uuid explicitly adds UUID
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import subprocess
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import pytest
|
||||||
|
from lib_py.agents.sanitize import sanitize_herdr_agent_name
|
||||||
|
|
||||||
|
def test_sanitize_sibling_workspace_non_collision():
|
||||||
|
"""
|
||||||
|
Verifies that sibling workspaces with identical prefix and suffix
|
||||||
|
do NOT collapse into the same 32-character sanitized herdr agent name.
|
||||||
|
"""
|
||||||
|
workspaces = [
|
||||||
|
"canary-projects-educative-export-tools-creator-claude",
|
||||||
|
"canary-projects-getting-started-a2a-creator-claude",
|
||||||
|
"canary-projects-multi-agent-mux-creator-claude",
|
||||||
|
"canary-projects-pu-riverpod-cookbook-creator-claude",
|
||||||
|
]
|
||||||
|
sanitized = [sanitize_herdr_agent_name(w) for w in workspaces]
|
||||||
|
|
||||||
|
# 1. All lengths <= 32
|
||||||
|
for s in sanitized:
|
||||||
|
assert len(s) <= 32, f"Length exceeds 32: {s}"
|
||||||
|
assert len(s) == 32, f"Expected 32 chars for long names: {s}"
|
||||||
|
|
||||||
|
# 2. All names are unique (0 collisions)
|
||||||
|
assert len(set(sanitized)) == len(workspaces), f"Collisions detected: {sanitized}"
|
||||||
|
|
||||||
|
def test_sanitize_bash_python_parity(mam_sandbox):
|
||||||
|
"""
|
||||||
|
Verifies that bash _sanitize_herdr_agent_name and Python sanitize_herdr_agent_name
|
||||||
|
produce byte-for-byte identical output across various edge cases.
|
||||||
|
"""
|
||||||
|
test_cases = [
|
||||||
|
"canary-projects-multi-agent-mux-creator-claude",
|
||||||
|
"canary-projects-educative-export-tools-creator-claude",
|
||||||
|
"canary-projects-getting-started-a2a-creator-claude",
|
||||||
|
"canary-projects-pu-riverpod-cookbook-creator-claude",
|
||||||
|
"short-name",
|
||||||
|
"UPPER_CASE_123.dots",
|
||||||
|
"123_starts_digit",
|
||||||
|
"_underscore_leading",
|
||||||
|
"",
|
||||||
|
"exact-32-chars-long-name-1234567",
|
||||||
|
"exact-33-chars-long-name-12345678",
|
||||||
|
]
|
||||||
|
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||||
|
|
||||||
|
for case in test_cases:
|
||||||
|
py_res = sanitize_herdr_agent_name(case)
|
||||||
|
cmd = f"""
|
||||||
|
source {lib_path}
|
||||||
|
_sanitize_herdr_agent_name "{case}"
|
||||||
|
"""
|
||||||
|
bash_res = subprocess.check_output(["bash", "-c", cmd]).decode().strip()
|
||||||
|
assert py_res == bash_res, f"Mismatch for '{case}': Python={py_res} vs Bash={bash_res}"
|
||||||
|
|
||||||
|
def test_mock_herdr_error_formatting_and_abort(mock_herdr, mock_agents):
|
||||||
|
"""
|
||||||
|
Verifies that mock herdr error output matches real Herdr 0.8.0 format
|
||||||
|
and triggers lib.sh early abort regex.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
abort_regex = re.compile(r"^usage:|unknown option|unknown flag|missing required|invalid_agent_name|^error:", re.IGNORECASE)
|
||||||
|
mock_bin = str(mock_agents / "herdr")
|
||||||
|
|
||||||
|
# 1. Unknown flag
|
||||||
|
res_unknown = subprocess.run([mock_bin, "agent", "start", "testagent", "--kind", "claude", "--pane", "w1:p1", "--env", "FOO=BAR"], capture_output=True, text=True)
|
||||||
|
assert res_unknown.returncode != 0
|
||||||
|
assert abort_regex.search(res_unknown.stderr) is not None, f"Stderr did not match abort regex: {res_unknown.stderr}"
|
||||||
|
assert "unknown option: --env" in res_unknown.stderr
|
||||||
|
|
||||||
|
# 2. Missing required --pane
|
||||||
|
res_missing = subprocess.run([mock_bin, "agent", "start", "testagent", "--kind", "claude"], capture_output=True, text=True)
|
||||||
|
assert res_missing.returncode != 0
|
||||||
|
assert abort_regex.search(res_missing.stderr) is not None, f"Stderr did not match abort regex: {res_missing.stderr}"
|
||||||
|
assert "missing required --pane" in res_missing.stderr
|
||||||
|
|
||||||
|
# 3. Invalid agent name
|
||||||
|
res_badname = subprocess.run([mock_bin, "agent", "start", "BAD_NAME", "--kind", "claude", "--pane", "w1:p1"], capture_output=True, text=True)
|
||||||
|
assert res_badname.returncode != 0
|
||||||
|
assert abort_regex.search(res_badname.stderr) is not None, f"Stderr did not match abort regex: {res_badname.stderr}"
|
||||||
|
assert "invalid_agent_name" in res_badname.stderr
|
||||||
|
|
||||||
@@ -54,7 +54,8 @@ def test_create_session_full(mam_sandbox, mock_herdr, mock_agents):
|
|||||||
assert len(agents) == 1, "There should be exactly one registered agent in the mock herdr state."
|
assert len(agents) == 1, "There should be exactly one registered agent in the mock herdr state."
|
||||||
|
|
||||||
session_name = list(agents.keys())[0]
|
session_name = list(agents.keys())[0]
|
||||||
assert session_name.endswith("-creator-claude")
|
from lib_py.agents.sanitize import sanitize_herdr_agent_name
|
||||||
|
assert len(session_name) <= 32
|
||||||
assert agents[session_name]["status"] == "running"
|
assert agents[session_name]["status"] == "running"
|
||||||
assert agents[session_name]["agent"] == "claude"
|
assert agents[session_name]["agent"] == "claude"
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ def test_create_session_full(mam_sandbox, mock_herdr, mock_agents):
|
|||||||
|
|
||||||
sessions = reg.get("herdr_sessions", [])
|
sessions = reg.get("herdr_sessions", [])
|
||||||
assert len(sessions) == 1
|
assert len(sessions) == 1
|
||||||
assert sessions[0]["name"] == session_name
|
assert session_name == sanitize_herdr_agent_name(sessions[0]["name"])
|
||||||
|
assert sessions[0]["name"].endswith("-creator-claude")
|
||||||
assert sessions[0]["status"] == "running"
|
assert sessions[0]["status"] == "running"
|
||||||
assert sessions[0]["role"] == "Creator"
|
assert sessions[0]["role"] == "Creator"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ def get_mqtt_common(mam_sandbox):
|
|||||||
|
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# FEATURE 1: Create Session (7 Test Cases)
|
# FEATURE 1: Create Session (5 Test Cases)
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
||||||
def test_create_derive_session_name_standard(mam_sandbox):
|
def test_create_derive_session_name_standard(mam_sandbox):
|
||||||
@@ -49,43 +49,14 @@ def test_create_derive_session_name_weird_characters(mam_sandbox):
|
|||||||
assert res.returncode == 0
|
assert res.returncode == 0
|
||||||
assert res.stdout.strip() == "bc-d-ef-creator-hermes"
|
assert res.stdout.strip() == "bc-d-ef-creator-hermes"
|
||||||
|
|
||||||
def test_create_isolation_lever(mam_sandbox):
|
def test_create_session_legacy_isolate_flags_noop(mam_sandbox):
|
||||||
"""Test isolation_lever outputs for each supported agent."""
|
"""Legacy --isolate/--no-isolate must stay a documented no-op, not an arg-parser error."""
|
||||||
agents = {
|
create_script = mam_sandbox / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||||
"claude": "none",
|
for flag in ["--isolate", "--no-isolate"]:
|
||||||
"cline": "none",
|
res = subprocess.run(["bash", str(create_script), flag, "-h"], capture_output=True, text=True)
|
||||||
"agy": "none",
|
assert res.returncode == 0, f"{flag} rejected by arg parser: {res.stderr}"
|
||||||
"hermes": "none",
|
assert "NOTE: --isolate/--no-isolate is a no-op" in res.stderr
|
||||||
"unknown": ""
|
assert flag in res.stdout, f"{flag} missing from usage() help text"
|
||||||
}
|
|
||||||
for agent, expected in agents.items():
|
|
||||||
res = run_lib_func(mam_sandbox, "isolation_lever", agent)
|
|
||||||
assert res.returncode == 0
|
|
||||||
assert res.stdout.strip() == expected
|
|
||||||
|
|
||||||
def test_create_isolation_env_prefix(mam_sandbox):
|
|
||||||
"""Test isolation_env_prefix format outputs."""
|
|
||||||
res = run_lib_func(mam_sandbox, "isolation_env_prefix", "claude", "/tmp/iso")
|
|
||||||
assert res.returncode == 0
|
|
||||||
assert res.stdout == ""
|
|
||||||
|
|
||||||
res2 = run_lib_func(mam_sandbox, "isolation_env_prefix", "agy", "/tmp/iso")
|
|
||||||
assert res2.returncode == 0
|
|
||||||
assert res2.stdout == ""
|
|
||||||
|
|
||||||
res3 = run_lib_func(mam_sandbox, "isolation_env_prefix", "cline", "/tmp/iso")
|
|
||||||
assert res3.returncode == 0
|
|
||||||
assert res3.stdout == ""
|
|
||||||
|
|
||||||
def test_create_isolation_cmd_args(mam_sandbox):
|
|
||||||
"""Test isolation_cmd_args format outputs."""
|
|
||||||
res = run_lib_func(mam_sandbox, "isolation_cmd_args", "cline", "/tmp/iso")
|
|
||||||
assert res.returncode == 0
|
|
||||||
assert res.stdout == ""
|
|
||||||
|
|
||||||
res2 = run_lib_func(mam_sandbox, "isolation_cmd_args", "claude", "/tmp/iso")
|
|
||||||
assert res2.returncode == 0
|
|
||||||
assert res2.stdout == ""
|
|
||||||
|
|
||||||
def test_create_validate_env_key(mam_sandbox):
|
def test_create_validate_env_key(mam_sandbox):
|
||||||
"""Test _validate_env_key function with valid and blocked environment keys."""
|
"""Test _validate_env_key function with valid and blocked environment keys."""
|
||||||
|
|||||||
@@ -96,16 +96,6 @@ d['herdr_sessions'] = [
|
|||||||
assert res.returncode != 0
|
assert res.returncode != 0
|
||||||
assert "Duplicate running conversation ID" in res.stderr
|
assert "Duplicate running conversation ID" in res.stderr
|
||||||
|
|
||||||
def test_comp_create_isolation_folder_setup(mam_sandbox):
|
|
||||||
"""Verify that provision_isolation runs cleanly as a stub for global config isolation."""
|
|
||||||
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
|
||||||
iso_root = mam_sandbox / "iso_home_test"
|
|
||||||
|
|
||||||
cmd_str = f"source {lib_path} && provision_isolation claude {iso_root}"
|
|
||||||
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True)
|
|
||||||
assert res.returncode == 0
|
|
||||||
assert res.stdout == ""
|
|
||||||
|
|
||||||
def test_comp_create_sqlite_tables_created(mam_sandbox, mock_herdr, mock_agents):
|
def test_comp_create_sqlite_tables_created(mam_sandbox, mock_herdr, mock_agents):
|
||||||
"""Verify that tables exist and contain records after a full create_session.sh run."""
|
"""Verify that tables exist and contain records after a full create_session.sh run."""
|
||||||
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||||
@@ -303,31 +293,9 @@ d['herdr_sessions'] = [{
|
|||||||
|
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# FEATURE 3: Stop Session (5 Test Cases)
|
# FEATURE 3: Stop Session (4 Test Cases)
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
||||||
def test_comp_stop_safe_path_checking(mam_sandbox):
|
|
||||||
"""Verify path guards block directory deletion if isolation path check fails."""
|
|
||||||
# Mock a terminated session where isolation root is set outside .mam folder
|
|
||||||
mutation = """
|
|
||||||
d['herdr_sessions'] = [{
|
|
||||||
'name': 'test-purge-guard-creator-claude',
|
|
||||||
'status': 'running',
|
|
||||||
'pane': {'cwd': 'WS_PLACEHOLDER'},
|
|
||||||
'isolation': {
|
|
||||||
'uuid': 'some-uuid',
|
|
||||||
'root': '/tmp/unauthorized_path_outside_mam'
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
|
||||||
run_mutation(mam_sandbox, mutation)
|
|
||||||
|
|
||||||
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
|
||||||
# Attempt to purge. The python script should print "WARN: isolated home path check failed" and NOT crash
|
|
||||||
res = subprocess.run(["bash", str(script_path), "--session", "test-purge-guard-creator-claude", "--purge-conversation", "--yes"], capture_output=True, text=True)
|
|
||||||
assert res.returncode == 0
|
|
||||||
assert "WARN: isolated home path check failed" in res.stdout
|
|
||||||
|
|
||||||
def test_comp_stop_sqlite_state_update(mam_sandbox):
|
def test_comp_stop_sqlite_state_update(mam_sandbox):
|
||||||
"""Verify that stop_session.sh updates state in SQLite to stopped."""
|
"""Verify that stop_session.sh updates state in SQLite to stopped."""
|
||||||
mutation = """
|
mutation = """
|
||||||
@@ -439,8 +407,8 @@ d['herdr_sessions'] = [{
|
|||||||
# Verify mock herdr calls recorded the keys "/exit" sent
|
# Verify mock herdr calls recorded the keys "/exit" sent
|
||||||
calls = state.get("calls", [])
|
calls = state.get("calls", [])
|
||||||
|
|
||||||
# Should see send-keys call
|
# Should see send-keys or agent prompt call
|
||||||
assert any("send" in call and "/exit" in call for call in calls)
|
assert any(("send" in call or "prompt" in call) and "/exit" in call for call in calls)
|
||||||
|
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|||||||
@@ -342,44 +342,6 @@ def test_t10_resume_workspace_paths(mam_sandbox, mock_herdr, mock_agents):
|
|||||||
assert f"would spawn:" in res.stdout
|
assert f"would spawn:" in res.stdout
|
||||||
|
|
||||||
|
|
||||||
def test_t11_legacy_isolation_row(mam_sandbox, mock_herdr, mock_agents):
|
|
||||||
"""T-11: legacy isolation row resolved from isolation.root"""
|
|
||||||
iso_root = str(mam_sandbox / "iso_root")
|
|
||||||
ws = str(mam_sandbox / "iso_ws")
|
|
||||||
os.makedirs(ws, exist_ok=True)
|
|
||||||
|
|
||||||
ws_key = os.path.realpath(ws).replace("/", "-").replace("_", "-")
|
|
||||||
proj_dir = os.path.join(iso_root, "projects", ws_key)
|
|
||||||
os.makedirs(proj_dir, exist_ok=True)
|
|
||||||
u_val = str(uuid.uuid4())
|
|
||||||
with open(os.path.join(proj_dir, f"{u_val}.jsonl"), "w") as f:
|
|
||||||
f.write(json.dumps({"type": "queue-operation", "sessionId": u_val}) + "\n")
|
|
||||||
f.write(json.dumps({"type": "user", "sessionId": u_val, "cwd": ws}) + "\n")
|
|
||||||
|
|
||||||
mutation = f"""
|
|
||||||
entry = {{
|
|
||||||
'name': 'iso-session',
|
|
||||||
'status': 'stopped',
|
|
||||||
'role': 'creator',
|
|
||||||
'claude_session_id_own': '{u_val}',
|
|
||||||
'isolation': {{'root': '{iso_root}', 'uuid': 'iso-uuid-1'}},
|
|
||||||
'pane': {{'cwd': '{ws}'}}
|
|
||||||
}}
|
|
||||||
d.setdefault('herdr_sessions', []).append(entry)
|
|
||||||
"""
|
|
||||||
res_m = run_mutation(mam_sandbox, mutation)
|
|
||||||
assert res_m.returncode == 0, f"mutation failed: {res_m.stderr}"
|
|
||||||
|
|
||||||
resolve_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resolve_session_id.sh"
|
|
||||||
cmd = [
|
|
||||||
"bash", str(resolve_script),
|
|
||||||
"--workspace", ws,
|
|
||||||
"--agent", "claude",
|
|
||||||
"--session", "iso-session"
|
|
||||||
]
|
|
||||||
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(mam_sandbox))
|
|
||||||
assert res.returncode == 0
|
|
||||||
assert res.stdout.strip() == u_val
|
|
||||||
|
|
||||||
|
|
||||||
def test_t12_other_workspace_assigned_row_revalidate_fails(mam_sandbox):
|
def test_t12_other_workspace_assigned_row_revalidate_fails(mam_sandbox):
|
||||||
|
|||||||
Reference in New Issue
Block a user