Compare commits
16
Commits
29cc4fd904
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e1e2e87de | ||
|
|
342a9a4edf | ||
|
|
5f6065344f | ||
|
|
a47a4683f3 | ||
|
|
8b829647f4 | ||
|
|
0fc162e8cd | ||
|
|
a6fb39ba9f | ||
|
|
9410913da9 | ||
|
|
bd61ef532f | ||
|
|
015075077c | ||
|
|
263a72483a | ||
|
|
5cd72d2df3 | ||
|
|
e4c7c8fe9b | ||
|
|
9c21fa4e4f | ||
|
|
50e2998a20 | ||
|
|
4243da507c |
No files matched your search
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261026T133440Z-c8961b",
|
||||||
|
"base_sha": "29cc4fd90478936f15387a7665e2c95d13a3f14c",
|
||||||
|
"head_sha": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
|
||||||
|
"tree_sha": "98d57bdc60920238c115ece90c90cc694f2744bc",
|
||||||
|
"review_commit": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "f39ae4ba",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-26T17:32:11Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
# PR #73 (Issue #71) 리뷰 — head 5f60653
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (macOS Darwin 25.6, `/bin/bash` 3.2.57, `/usr/bin/python3` 3.9.6, BSD sed/tr)
|
||||||
|
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2`
|
||||||
|
- identity 확인 (시작·종료 시 모두):
|
||||||
|
- HEAD 5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26, tree 98d57bdc60920238c115ece90c90cc694f2744bc
|
||||||
|
- `git status --porcelain --ignored` 비어 있음
|
||||||
|
- `.mam` 없음. 추적 파일 `.mam.env.example` 만 있다.
|
||||||
|
- main HEAD 29cc4fd 그대로.
|
||||||
|
- 범위: 29cc4fd..5f60653, 커밋 14개, 파일 28개 (+953/−176).
|
||||||
|
- 읽은 것: 이슈 #71 본문과 댓글("범위 정리" 1–10항, #72 분리), PR #73 본문.
|
||||||
|
|
||||||
|
## 0. 이번 잡(f39ae4ba)에 대해
|
||||||
|
|
||||||
|
- 이 잡은 같은 루프의 검증 경로 `/2` 다. head 5f60653, tree 98d57bdc 는 앞 잡 0698ffc5(검증 경로 `/1`) 와 같다. 브리프의 확인 항목도 잡 ID와 경로를 빼면 글자까지 같다.
|
||||||
|
- 그래서 아래 1장의 분석(파일:줄 근거, 리뷰어 시험 p1–p12, 변이 m1–m12, base 비교)은 같은 tree 의 `git archive` 사본에서 얻은 결과를 그대로 쓴다.
|
||||||
|
- 이번 경로 `/2` 에서 새로 확인한 것:
|
||||||
|
- 브리프에 붙은 diff(1920줄)가 `git diff 29cc4fd 5f60653` 와 바이트까지 같다. 커밋은 14개다.
|
||||||
|
- `/2` 의 `lib.sh` 에서 `_sanitize_herdr_agent_name` 을 다시 뽑았다. 정의는 하나이고, 앞서 비교한 정의와 같다.
|
||||||
|
- `/2` 의 `lib_py` 를 기준으로 bash↔Python 비교를 다시 돌렸다. 408건에 불일치는 0이다. python3 와 해시 도구가 모두 없는 5건도 기대대로 동작했다.
|
||||||
|
- 테스트 세 묶음을 `/2` 에서 다시 돌렸다. 결과는 아래 2장 표와 같다.
|
||||||
|
- issue71+issue66: 77 passed
|
||||||
|
- A: 384 passed, 1 skipped
|
||||||
|
- B: 543 passed, 3 skipped
|
||||||
|
- 실제 herdr 서버 두 개(pid 11655, 69309)는 실행 전후로 같다.
|
||||||
|
- 시작과 끝에 `/2` 의 HEAD·tree 를 확인했다. `status --ignored` 는 비어 있고 `.mam` 은 없다. main HEAD 는 29cc4fd 그대로다.
|
||||||
|
|
||||||
|
## 1. 확인한 것과 근거
|
||||||
|
|
||||||
|
### 1) 완료 조건과 범위
|
||||||
|
|
||||||
|
| 항목 | 결과 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 attach 형태 | 충족 | 아래 3) |
|
||||||
|
| 2 `default` 도 적음 | 충족 | 아래 3) |
|
||||||
|
| 3 래퍼 attach | 충족 | 아래 3) |
|
||||||
|
| 4 두 필드 삭제 | 충족 | 아래 4) |
|
||||||
|
| 5 원칙(스킬로 시작·종료), 행 없는 stop | 충족 | 아래 4), 5) |
|
||||||
|
| 6 attach 는 예외 | 충족 | attach 만 herdr 명령을 직접 쓴다 |
|
||||||
|
| 7 변환 규칙 하나 | 충족 | 아래 6) |
|
||||||
|
| 8 해시 불가 시 오류 | 충족 | 아래 6) |
|
||||||
|
| 9 마이그레이션 | 충족 | 아래 7) |
|
||||||
|
| 10 stall_watch 한 곳 | 충족 | 아래 8) |
|
||||||
|
|
||||||
|
- 보강 완료 조건도 모두 충족한다.
|
||||||
|
- 새 행에 두 필드가 없다.
|
||||||
|
- 해시 도구가 없으면 python3 로 같은 결과를 내고, python3 도 없으면 오류다.
|
||||||
|
- 옛 행의 마이그레이션은 멱등이고, YAML 과 DB 가 같다.
|
||||||
|
- 행 없는 stop 은 레지스트리를 바꾸지 않는다.
|
||||||
|
- 정리 안내는 stop 스킬 명령이다.
|
||||||
|
- #72(update 스킬)는 끌어들이지 않았다.
|
||||||
|
- 진입점 `python3 -m lib_py.registry_format` 만 두었다.
|
||||||
|
- 그 진입점을 부르는 코드는 없다.
|
||||||
|
- 본문 완료 조건 6(실제 에이전트를 32자 초과 이름으로 띄워 attach)은 수동 확인 항목이다. 브리프 제약(실제 에이전트·실제 herdr 금지)에 따라 하지 않았다.
|
||||||
|
- 대신 형태가 맞는지는 확인했다. 실제 herdr 0.9.5 의 `agent attach --help` 를 격리 HOME 에서 보면 사용법이 `herdr agent attach <TARGET> [OPTIONS]` 이고, 옵션은 `--takeover` 다.
|
||||||
|
|
||||||
|
### 2) 회귀 테스트 tests/test_issue71_herdr_names.py (70개)
|
||||||
|
|
||||||
|
- head (검증 경로): issue71 70개와 issue66 7개를 함께 돌려 77 passed.
|
||||||
|
- base: 29cc4fd 사본에 head 의 테스트 파일을 넣었다. conftest 는 이 PR 에서 바뀌지 않았다.
|
||||||
|
- 결과: **68 failed, 2 passed**. PR 이 말한 숫자와 같다.
|
||||||
|
- 통과하는 2개:
|
||||||
|
- `test_the_names_need_converting_and_the_plain_one_does_not`: 변환 전제를 확인한다.
|
||||||
|
- `test_wrapper_attach_calls_herdr_with_the_herdr_name[PLAIN]`: 변환이 필요 없는 이름이라 base 에서도 맞다.
|
||||||
|
- reconcile 경로 (`test_reconcile_registers_with_the_attach_command`, test :126-139) 는 행 등록 경로에 실제로 닿는다.
|
||||||
|
- mock herdr 에 `cwd=mam_sandbox`, `pane_id` 가 있는 agent 를 넣는다.
|
||||||
|
- 실행 전에 행이 없음을 확인한다 (:132).
|
||||||
|
- 실행 뒤 `last_visible_note == "auto-registered by monitor"` 인 행이 하나인지 확인한다 (:137-138).
|
||||||
|
- 변이 m10 (reconcile 을 옛 형태로) 이 이 테스트 3개를 실패시킨다.
|
||||||
|
|
||||||
|
### 3) attach_command (`lib_py/agents/sanitize.py:34-43`)
|
||||||
|
|
||||||
|
- 형태: `herdr --session <shlex.quote(server or "default")> agent attach <shlex.quote(sanitize(name))>`.
|
||||||
|
- 서버가 None 이나 빈 값이어도 `--session default` 가 된다.
|
||||||
|
- 변환된 이름은 `[a-z0-9_-]` 이므로 quote 를 해도 그대로다.
|
||||||
|
- 서버 `srv x` 는 `'srv x'` 로 감싸진다. 내 시험과 test :51 에서 확인했다.
|
||||||
|
- 쓰는 곳은 네 곳이고, 모두 이 함수를 쓴다:
|
||||||
|
- create: `create_session.sh:566`
|
||||||
|
- resume 의 새 행: `update_yaml_resumed.sh:151`
|
||||||
|
- resume 의 서버 변경: `update_yaml_resumed.sh:161`
|
||||||
|
- reconcile: `reconcile.sh:676`
|
||||||
|
- 마이그레이션도 같은 함수를 쓴다 (`registry_format.py:62`).
|
||||||
|
- resume 의 else 분기는 서버가 바뀔 때만 attach_command 를 다시 쓴다 (:159-161).
|
||||||
|
- 서버가 그대로인 기존 행은 첫 쓰기의 마이그레이션이 이미 새 형태로 바꿔 둔다. 그래서 낡은 값이 남지 않는다.
|
||||||
|
- lib.sh 래퍼 (`lib.sh:605-613`):
|
||||||
|
- `agent attach <t> ...` 의 첫 인자를 `_resolve_herdr_target` 으로 변환한다. get/read/prompt 와 같은 함수다.
|
||||||
|
- 나머지 `"$@"` 는 그대로 넘긴다.
|
||||||
|
- 테스트 :151-159 가 strict mock 에서 `["agent","attach",<herdr 이름>,"--takeover"]` 를 확인한다.
|
||||||
|
- 변이 m4 (원래 이름을 넘김) 는 LONG·UPPER 에서 잡힌다.
|
||||||
|
|
||||||
|
### 4) kill_command·start_command 삭제
|
||||||
|
|
||||||
|
- 코드·문서에는 이 두 키가 남아 있지 않다. 리뷰 트리 전체를 grep 했다.
|
||||||
|
- 남은 곳: 마이그레이션 상수 (`registry_format.py:37`), 테스트의 "옛 행" 픽스처와 부재 단언, 과거 리뷰 보관본 `.agents/reports/*`.
|
||||||
|
- `status --json` 의 `start_command` 키는 빠졌다 (`status.sh:224-228`).
|
||||||
|
- 이 키를 읽는 코드나 테스트는 없다.
|
||||||
|
- status SKILL.md 의 예시도 같이 고쳤다.
|
||||||
|
- 래퍼의 `new-session`/`kill-session` 가짜 명령은 남겨 두는 것이 맞다. 아직 쓰는 곳:
|
||||||
|
- create (`create_session.sh:266-276`, :346)
|
||||||
|
- stop (`stop_session.sh:157`, :307, :321)
|
||||||
|
- reconcile
|
||||||
|
- 사용자에게 보이던 kill-session 안내 두 곳은 stop 스킬 명령으로 바뀌었다:
|
||||||
|
- create exit 43 (`create_session.sh:364`)
|
||||||
|
- id_order (`id_order.py:343-345`)
|
||||||
|
- 바뀐 명령 `stop_session.sh --session X --herdr-session S` 는 행이 없어도 동작한다 (아래 5).
|
||||||
|
- exit 43 은 `_mark_row_rolled_back "running" "rollback-failed"` 로 행을 남기므로, 그 경우는 행 있는 stop 경로를 탄다.
|
||||||
|
|
||||||
|
### 5) 행이 없는 stop (`stop_session.sh:117-191`)
|
||||||
|
|
||||||
|
- **행이 없을 때만 그 경로로 간다.**
|
||||||
|
- 행을 찾는 Python 은 행이 없을 때만 `sys.exit(7)` 을 낸다.
|
||||||
|
- `[ "${MAPPED_RC:-0}" -ne 7 ] || stop_rowless` (:189) 로 7 일 때만 row-less 로 간다.
|
||||||
|
- 다른 실패는 `cannot read the row` 로 exit 1 이다 (:190-193).
|
||||||
|
- 변이 m6 (0 이 아니면 모두 row-less) 은 `test_stop_does_not_take_an_unreadable_row_for_no_row` 에 잡힌다.
|
||||||
|
- **레지스트리를 읽지 못할 때**는 `load_state_json strict` (:170, lib.sh:1598-1631) 가 exit 1 로 끝난다.
|
||||||
|
- 해당 경우: YAML 깨짐(DB 없음), DB 손상, sessions 테이블 없음, 테이블 전부 없음.
|
||||||
|
- 테스트 :514-541 의 4개 매개변수가 이를 확인한다.
|
||||||
|
- 인자 없는 기존 호출은 동작이 같다. strict 일 때만 raise 하거나 exit 한다.
|
||||||
|
- **작업 공간 판정** (:142-151) 은 `realpath` 두 개를 비교한다: `cwd == root or cwd.startswith(root + os.sep)`. 내 추가 시험 (head 사본, `tests/test_zz_review71.py`) 결과:
|
||||||
|
|
||||||
|
| 경우 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| p1 접두어 혼동 (`<ws>-other`) | exit 5, 닫지 않음 |
|
||||||
|
| p2 밖에서 작업 공간 안으로 가는 심볼릭 링크 | 0, 닫음 |
|
||||||
|
| p3 작업 공간 안에서 밖으로 가는 심볼릭 링크 | exit 5 |
|
||||||
|
| p4 후행 슬래시 | 0 |
|
||||||
|
| p5 WORKSPACE_ROOT 를 심볼릭 링크로 지정 | 0 |
|
||||||
|
| p7 32자 초과 이름 (strict mock) | 0 |
|
||||||
|
|
||||||
|
- **서버 결정 순서** (:107-113, :128-130) 는 `--herdr-session` → 환경의 `HERDR_SESSION_NAME` → 작업 공간 서버다.
|
||||||
|
- 환경 값이 비었거나 `default` 이면 작업 공간 서버로 간다. create 의 :185, :253 과 같은 규칙이다.
|
||||||
|
- p6 (환경 값 `default`) 에서 모든 `agent get` 이 작업 공간 서버로 간 것을 확인했다.
|
||||||
|
- 테스트 :487-499 는 환경 서버를 확인한다.
|
||||||
|
- **레지스트리를 바꾸지 않는다.** 모든 row-less 테스트가 YAML/DB 해시를 비교한다 (`_registry_bytes`).
|
||||||
|
- **`--purge-conversation`** 은 행이 없으면 exit 1 이다 (:124-126, test :544-550).
|
||||||
|
- **종료 코드:**
|
||||||
|
- 0 / 1 / 5 / 6 은 SKILL.md 표 (:58-67) 와 헤더 주석 (:30-36), 코드 (:134, :154, :161, :163) 가 같다.
|
||||||
|
- `test_literal_exit_rule_on_base_scripts` 는 `{0..6}` 이다.
|
||||||
|
- skill_code_blocks 의 `entry_points` 도 5·6 을 더했다.
|
||||||
|
|
||||||
|
### 6) 이름 변환 (`lib.sh:44-82`)
|
||||||
|
|
||||||
|
- 정의는 하나다 (`lib.sh:44`). test :171 이 확인한다.
|
||||||
|
- shim 은 `{ printf shebang; declare -f _sanitize_herdr_agent_name; cat <<'EOF' … } > "$tmp_file"` (:179-1393) 으로 같은 정의를 받는다.
|
||||||
|
- bash 3.2 의 `declare -f` 출력을 다시 source 한 사본도 아래 비교에 넣었다.
|
||||||
|
- 이 호스트의 `bash` 는 `/bin/bash` 3.2 하나뿐이라, 테스트의 shim 도 3.2 로 돈다.
|
||||||
|
- has-session 의 `lambda s: s.lower()[:32]` 대체와 `os.path.abspath(".agents/skills")` import 는 없어졌다.
|
||||||
|
- 대신 lib.sh 가 계산한 `TARGET_SAN` 을 넘긴다 (:633-641).
|
||||||
|
- **macOS 실측 비교:** 이름 34개를 Python `sanitize_herdr_agent_name` 과 비교했다.
|
||||||
|
- 이름: ASCII, 대문자, 32/33/40/200자, 숫자·`_`·`-n`·`-e`·`--help` 로 시작, `\n`(가운데·앞·뒤·연속), `\t`, `\r`, `*?[a]`, `\`, `%s`, `Ü`, `İstanbul…`, `日本語`, 이모지, `K`(U+212A), `ß`, `Dž`, `Ω…` 긴 이름, NBSP 등.
|
||||||
|
- 원본 정의와 `declare -f` 사본 2 벌 × 환경 6 개 (`LANG=en_US.UTF-8`, `LC_ALL=C`, 로캘 없음, `LC_ALL=UTF-8`+`LC_CTYPE=C`, 해시 도구 없이 python3 만 + UTF-8 또는 C) = **408 건, 불일치 0**.
|
||||||
|
- ASCII 가 아닌 이름은 바이트 단위 치환을 쓰지 않고 python3 의 문자 단위 `str.lower()`+치환을 쓰므로 Python 과 같다 (:51-56).
|
||||||
|
- **해시 도구별:** `/sbin/sha1sum` 만, openssl(LibreSSL) 만, 깨진 shasum(빈 출력)에서 openssl 로 넘어가는 경우 모두 Python 과 같았다 (`mam-wt-issue-71-very-lo-54e536a7`).
|
||||||
|
- **python3 도 해시 도구도 없을 때:**
|
||||||
|
- 짧은 ASCII 이름(대문자, 끝의 `\n` 포함)은 정상이다.
|
||||||
|
- 32자 초과는 `no shasum, sha1sum, openssl or python3` 오류로 rc 1 이다.
|
||||||
|
- ASCII 가 아닌 이름은 `not ASCII and there is no python3` 오류로 rc 1 이다.
|
||||||
|
- `00000000` 은 어디에도 없다.
|
||||||
|
- 호출하는 곳은 실패를 넘긴다: `|| return 1`, `|| exit 1`, `wait_for_agent_idle` 은 `|| return 3`, delegate-job 은 진단만 건너뛴다.
|
||||||
|
- spawn 의 `agent_name` 계산은 루프 앞 (:688) 으로 옮겨졌다. 그래서 이름을 못 구하면 spawn 하지 않는다. test :267 이 확인한다.
|
||||||
|
|
||||||
|
### 7) 레지스트리 형식 2 (`registry_format.py`, `atomic_yaml.py:118-137, :192`)
|
||||||
|
|
||||||
|
- **순서:** 잠금 → `BEGIN IMMEDIATE` (:79-87) → DB 또는 YAML 읽기 → 마이그레이션 (:121-126) → 호출자 변경 (:144-149) → 검증 → DB 쓰기 → commit → YAML 쓰기.
|
||||||
|
- 마이그레이션은 잠금 안에서, 호출자 변경 전에 돈다.
|
||||||
|
- **모든 행을 바꾼다.** `migrate` 는 이름이 있는 모든 행에서 두 키를 지우고 attach_command 를 다시 만든다.
|
||||||
|
- stopped·terminated 도 포함한다. 변이 m8 (running 만) 은 6 failed 로 잡힌다.
|
||||||
|
- **YAML 도 반드시 다시 쓴다.** `force_yaml = True` (:126) 이고, 변이 m2 는 6 failed 로 잡힌다.
|
||||||
|
- **멱등이다.** test :327-338 이 확인하고, 진입점을 두 번 부르면 `2 -> 2, rows changed: 0` 에 바이트도 같다 (test :342-362).
|
||||||
|
- **YAML 교체가 실패한 뒤의 복구:** DB 가 2 이고 YAML 이 2 미만이면 다음 쓰기에서 `force_yaml` 이 된다 (:127-137).
|
||||||
|
- 진입점으로 하든 아무 쓰기로 하든 복구된다 (test :379-411).
|
||||||
|
- 변이 m3 은 2 failed 로 잡힌다.
|
||||||
|
- **다른 쓰기 경로:** 레지스트리 YAML/DB 를 쓰는 코드는 `atomic_yaml` 하나뿐이다.
|
||||||
|
- grep 결과 `safe_dump`, `INTO state/sessions` 는 이 파일에만 있다. hermes.py 의 DELETE 는 에이전트 자신의 DB 다.
|
||||||
|
- orc_onboard 와 autopin 도 `atomic_dump_yaml(_main)` 을 거친다.
|
||||||
|
- 호출자 변경 가운데 `d` 를 통째로 새로 만드는 것은 없다. 만들어도 다음 쓰기가 다시 마이그레이션하므로 형식 키를 잃지 않는다.
|
||||||
|
- **읽기 경로:** `load_state_json` 과 `lib_py.state` 는 바꾸지 않았다. strict 인자만 더했다.
|
||||||
|
- **미래 형식 (p8):** `registry_format: 3` 이면 진입점은 `3 -> 3, rows changed: 0` 이다. 쓰기 뒤에도 키는 3 으로 남고, 행(옛 키 포함)도 그대로다. 내려 쓰지 않는다.
|
||||||
|
- **비정상 값 (p9):** `"2"`, `True`, `None` 은 1 로 보고 2 로 바꾼다. `0` 은 `0 -> 2` 로 출력된다.
|
||||||
|
- **진입점 계약:**
|
||||||
|
- 성공은 stdout 한 줄 `registry-format: <from> -> <to>, rows changed: <n>` 에 exit 0 이다.
|
||||||
|
- 레지스트리가 없으면 exit 1 이다 (test :423).
|
||||||
|
- DB 손상 (p10) 은 stderr `registry-format: error: file is not a database` 에 exit 1 이고 stdout 은 비어 있다.
|
||||||
|
- 검증 거부 (p11) 는 `registry-format: error: VALIDATE: … bad status` 에 exit 1 이다.
|
||||||
|
- 모두 docstring 과 같다.
|
||||||
|
- **macOS persistent WAL:** YAML/DB 바이트 비교 테스트(진입점 재실행, row-less stop)가 이 호스트에서 모두 통과했다.
|
||||||
|
|
||||||
|
### 8) stall_watch
|
||||||
|
|
||||||
|
- `StallWatch.__init__` 이 `self.herdr_name` 을 한 번 구한다 (:229).
|
||||||
|
- 그 값을 `HerdrSource(..., name=self.herdr_name)` (:230) 와 `wait_once` (:332) 에 같이 쓴다.
|
||||||
|
- `HerdrSource` 는 keyword-only `name` 이 필수다. 원래 이름으로 대신 쓰는 경로는 없다.
|
||||||
|
- 다른 호출자는 `test_issue60_linux_fixes.py:99` 하나이고, `name="t"` 를 넘기도록 바뀌었다.
|
||||||
|
- `tests/test_issue66_stall_watch_sanitized_name.py` 는 diff 가 없고, 7 passed 다.
|
||||||
|
|
||||||
|
### 9) 바뀐 기존 테스트
|
||||||
|
|
||||||
|
- `test_tier2_component`: `HERDR_SESSION_NAME=` 포함 여부 대신 새 attach 형태 전체가 같은지와 두 키가 없는지를 본다.
|
||||||
|
- 서버 전파와 라벨이 새지 않는지를 보는 원래 의도는 유지되고, 단언은 더 엄격해졌다.
|
||||||
|
- `test_issue50_pane_lock:173`: create 가 쓴 행이라는 증거를 `start_command` 대신 `session_id_source` 로 본다.
|
||||||
|
- reconcile 의 행 (`reconcile.sh:666-680`) 에는 이 키가 없다. 그래서 증거로서 같은 구분을 한다.
|
||||||
|
- `test_issue21_worktree_prereqs:584`: 제어 루트 경로가 새지 않는지를 attach_command 에서 본다. 의도가 같다.
|
||||||
|
- `test_tier1_unit` / `test_tier3_integration`:
|
||||||
|
- "agent 를 추론할 수 없으면 exit 2" 를 이제 **행이 있는** 세션으로 확인한다. 행이 없으면 row-less 경로로 가기 때문이다.
|
||||||
|
- 의도가 유지된다.
|
||||||
|
- `test_issue63`, `test_issue23_24` S9: 안내 문구를 새 명령으로 바꿨다.
|
||||||
|
- `test_layout`, `test_issue42`: heredoc 시작 위치와 종료 코드 집합을 따라 고쳤다.
|
||||||
|
- `tests/data/skill_code_blocks.json`:
|
||||||
|
- 블록 33개 가운데 sha 가 바뀐 것은 문서 내용이 바뀐 두 블록이다: create :134 (YAML 행 예시), status :74 (JSON 예시).
|
||||||
|
- 나머지는 줄 이동과 evidence 줄 번호다.
|
||||||
|
- `entry_points` 의 stop 종료 코드에 5·6 을 더했다.
|
||||||
|
- 문서 diff 와 맞다.
|
||||||
|
- **S11** (`test_issue23_24_startup_path.py:294-316`):
|
||||||
|
- 비교 기준을 `0fc162e` 로 두었다. `0fc162e..HEAD` 사이 lib.sh 변경은 5f60653 의 `load_state_json` 하나뿐이고, shim heredoc 과 sanitize 함수는 바뀌지 않았다.
|
||||||
|
- merge commit 으로 병합하면 0fc162e 가 main 이력에 남으므로 계속 맞다.
|
||||||
|
- 기준을 찾지 못하면 `pytest.skip` 한다. 이는 base(525df2b) 때부터의 동작이다. git 이 없는 설치본이나 squash 병합에서 건너뛰는 것은 기존 설계 그대로라고 본다.
|
||||||
|
- 검증 경로(git worktree)에서는 **skip 이 아니라 통과**했다 (`-k s11`: 1 passed).
|
||||||
|
- squash 하면 이 확인이 사라지므로 PR 본문의 "merge commit 필수" 를 지켜야 한다.
|
||||||
|
|
||||||
|
### 10) macOS 호환
|
||||||
|
|
||||||
|
- 바뀐 Python 6개 파일이 `/usr/bin/python3` 3.9.6 에서 compile 된다.
|
||||||
|
- `attach_command` 와 `migrate` 가 3.9 에서 실행된다.
|
||||||
|
- 바뀐 셸 줄에 `declare -A`, `mapfile`, `${x,,}`, `sed -i`, `stat -c`, `readlink -f`, `local -n` 이 없다.
|
||||||
|
- 이름 변환은 BSD tr/sed 와 bash 3.2 에서 위 6) 처럼 확인했다.
|
||||||
|
|
||||||
|
## 2. 실행한 테스트
|
||||||
|
|
||||||
|
- 실행 조건:
|
||||||
|
- 모두 `env -i PATH=/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin`, 임시 HOME/XDG_DATA_HOME 에서 돌렸다.
|
||||||
|
- `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider --basetemp=<스크래치>` 를 썼다.
|
||||||
|
- `.mam.env` 는 export 하지 않았다.
|
||||||
|
- 실제 herdr 서버 두 개는 테스트 전후로 같다: pid 11655 `herdr server`, 69309 `herdr --session multi-agent-mux server`, 시작 시각도 같다.
|
||||||
|
|
||||||
|
| 대상 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 검증 경로: test_issue71_herdr_names + test_issue66_stall_watch_sanitized_name | **77 passed** (`/1`, `/2` 모두) |
|
||||||
|
| base 29cc4fd 사본 + head 의 issue71 테스트 | **68 failed, 2 passed** |
|
||||||
|
| 검증 경로 A: tier1_unit, tier2_component, tier3_integration, issue23_24_startup_path, issue50_pane_lock, issue21_worktree_prereqs, issue63_same_cwd_ids, issue60_linux_fixes, layout, issue42_skill_blocks | **384 passed, 1 skipped** (`/1`, `/2` 모두; skip: issue42 의 `.claude/skills is untracked; absent in a worktree`, 환경 요인) |
|
||||||
|
| 검증 경로 B (stop·reconcile·create·resume 인접): b4_session_created, b19_headless_reconcile_fixes, c1_tui_readiness, issue15_registry_isolation, issue17_workspace_root_isolation, issue31_purge_residual, issue24_resume_dialogs, issue40_auto_pin, issue45_restore, issue6_fail_closed, issue9_server_env, issue64_ro_connect, uuid_target, workspace_scope, issue57_logs_follow_registry, issue30_stall_watch, issue30_stall_watch_stop, issue50_dispatch_herdr, issue60_opencode_v2, issue36_prompt_transport, a4_adapter_contract, issue26_ints | **543 passed, 3 skipped** (`/1`, `/2` 모두) |
|
||||||
|
| S11 단독 (검증 경로) | 1 passed (skip 아님) |
|
||||||
|
| 리뷰어 추가 시험 (head 사본, p1–p12) | 14 passed. 1개는 내 기대값 오류다 (형식 0 → "0 -> 2" 출력, 동작은 정상) |
|
||||||
|
| bash↔Python 이름 변환 비교 | 408 건 불일치 0, 도구 없음 5 건 모두 기대대로 (`/2` 의 lib.sh·lib_py 로 다시 실행) |
|
||||||
|
|
||||||
|
변이 (head 사본, issue71 70개):
|
||||||
|
|
||||||
|
| # | 변이 | 결과 |
|
||||||
|
|---|---|---|
|
||||||
|
| m1 | 작업 공간 판정을 `startswith(root)` 로 (접두어 혼동) | **살아남음** (리뷰어 p1 은 잡음) |
|
||||||
|
| m2 | 마이그레이션 뒤 YAML 강제 쓰기 끔 | 6 failed |
|
||||||
|
| m3 | YAML 복구 분기 끔 | 2 failed |
|
||||||
|
| m4 | 래퍼 attach 에 원래 이름 | 2 failed |
|
||||||
|
| m5 | 줄바꿈 → `-` 제거 | 6 failed |
|
||||||
|
| m6 | rc≠0 이면 모두 row-less | 1 failed |
|
||||||
|
| m7 | strict 에서 state 테이블 오류만 무시 | **살아남음** (아래 N3) |
|
||||||
|
| m8 | running 행만 마이그레이션 | 6 failed |
|
||||||
|
| m9 | 서버 없으면 `--session ''` | 3 failed |
|
||||||
|
| m10 | reconcile 을 옛 attach 형태로 | 3 failed |
|
||||||
|
| m11 | 행 없는 stop 이 환경 서버를 무시 | 1 failed |
|
||||||
|
| m12 | 해시 실패 시 `00000000` | 4 failed |
|
||||||
|
|
||||||
|
## 3. 차단 결함
|
||||||
|
|
||||||
|
없음.
|
||||||
|
|
||||||
|
## 4. 비차단
|
||||||
|
|
||||||
|
### N1: 행 없는 stop 이 제어 루트 밖 워크트리의 세션은 닫지 못한다
|
||||||
|
|
||||||
|
- 상황:
|
||||||
|
- `worktree_prepare.sh:229-236` 은 creator 를 `WORKSPACE_ROOT=$MAIN` 과 `--workspace $WT` 로 만든다.
|
||||||
|
- `$WT` 는 `--path` 로 받은 임의의 경로이고, 보통 제어 루트 밖이다. 루프의 `mam-wt-*` 나 `$TMPDIR/mam-review/…` 가 그 예다.
|
||||||
|
- 결과:
|
||||||
|
- 그런 create 가 행을 등록하기 전에 죽으면, id_order 의 안내 (`id_order.py:343-345`) 는 stop 명령을 준다.
|
||||||
|
- 그 stop 은 `realpath(cwd)` 가 `WORKSPACE_ROOT` 밖이므로 exit 5 로 거부한다 (`stop_session.sh:151-154`). 다른 작업 공간 테스트 (test :467) 와 같은 경로다.
|
||||||
|
- 거부는 안전하다(아무것도 바꾸지 않음). 하지만 이 경우에는 안내된 스킬 명령으로 정리할 수 없다.
|
||||||
|
- 권고: 둘 중 하나.
|
||||||
|
- (a) row-less 판정에 "이 제어 루트에 묶인 워크트리(`git worktree list`)" 도 넣는다.
|
||||||
|
- (b) exit 5 메시지에 그 경우의 정리 방법을 적는다.
|
||||||
|
|
||||||
|
### N2: 접두어 혼동을 고정하는 테스트가 없다
|
||||||
|
|
||||||
|
- m1 이 PR 테스트에서 살아남는다. `<ws>-other` 를 cwd 로 하는 한 줄짜리 시험을 더하기를 권한다. 내 p1 이 그 형태다.
|
||||||
|
|
||||||
|
### N3: strict 읽기에서 state 테이블만 없는 DB 는 걸리지 않는다
|
||||||
|
|
||||||
|
- m7 이 살아남은 이유는 테스트의 `no-tables` 가 sessions 테이블 오류로도 잡히기 때문이다.
|
||||||
|
- state 만 없는 DB 에서도 stop 은 sessions 테이블로 행을 정확히 찾으므로 영향은 없다.
|
||||||
|
|
||||||
|
### N4: 이 PR 이 건드리지 않은 attach 안내가 남아 있다 (모두 base 부터 있던 것)
|
||||||
|
|
||||||
|
- `create_session.sh:770`: `Attach: herdr session attach $SESSION_NAME`. 서버를 적지 않고, 이름도 변환하지 않는다. 행의 `attach_command` 를 출력하기를 권한다.
|
||||||
|
- `multi-agent-mux-delegate-job:1021`: `HERDR_SESSION_NAME=… herdr agent attach $sess (lib.sh를 source한 셸에서)`. 래퍼가 이제 이름을 변환하므로 동작은 하지만, 새 형태와 다르다.
|
||||||
|
- `deploy/install_mam.sh:330`: `HERDR_SERVER_NAME=… herdr agent attach`. 없는 환경 변수 이름이다.
|
||||||
|
|
||||||
|
### N5: 래퍼 attach 는 대상이 첫 인자라고 가정한다
|
||||||
|
|
||||||
|
- `herdr agent attach --takeover <name>` 순서로 쓰면 래퍼는 `--takeover` 를 대상으로 찾다가 exit 1 을 낸다.
|
||||||
|
- herdr 의 사용법(`<TARGET> [OPTIONS]`)과 문서의 순서대로라면 문제가 없다.
|
||||||
|
|
||||||
|
### N6: YAML 이 아직 없는 작업 공간에서는 행 없는 stop 이 동작하지 않는다
|
||||||
|
|
||||||
|
- 처음 만든 작업 공간에서 첫 create 가 행 등록 전에 죽어 YAML 이 아직 없으면, stop 은 행 없는 경로 전에 `YAML not found` 로 exit 1 한다 (:101).
|
||||||
|
- 드문 경우다.
|
||||||
|
|
||||||
|
### N7: 정리할 만한 작은 것들
|
||||||
|
|
||||||
|
- `create_session.sh:519` 의 `server_opt` 는 쓰는 곳이 없다. base 에서도 그랬다.
|
||||||
|
- shim 내용이 바뀌었으므로, 같은 제어 루트를 쓰는 옛 버전 체크아웃과 새 버전 체크아웃이 섞이면 `.mam/shim/herdr` 를 서로 덮어쓴다. #45 때와 같은 전환기 비용이다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261026T133440Z-c8961b",
|
||||||
|
"base_sha": "29cc4fd90478936f15387a7665e2c95d13a3f14c",
|
||||||
|
"head_sha": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
|
||||||
|
"tree_sha": "98d57bdc60920238c115ece90c90cc694f2744bc",
|
||||||
|
"review_commit": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
|
||||||
|
"reviewer": "planner-reviewer-codex-01",
|
||||||
|
"job_id": "eb60f37d",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-26T17:32:11Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Review report — PR #73 / Issue #71
|
||||||
|
|
||||||
|
Reviewed validation snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2`: head `5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26`, tree `98d57bdc60920238c115ece90c90cc694f2744bc`, base `29cc4fd90478936f15387a7665e2c95d13a3f14c`. The snapshot was clean; `git diff --check` reported no whitespace errors. No source files were changed.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
No blocking or non-blocking defects found in this pass.
|
||||||
|
|
||||||
|
The earlier rowless-stop finding about `HERDR_SESSION_NAME=default` was re-adjudicated in job `f40aa3bd` and withdrawn. That environment value is treated as unset by create: `create_session.sh:179-187,251-256` chooses the workspace server unless the caller supplies `--herdr-session`. Rowless stop follows the same resolution (`stop_session.sh:109-130`; `lib.sh:1681-1712`). The prior probe put an agent on `default` without the explicit option, a state that invocation of create would not produce. Deliberate default-server selection remains possible with `--herdr-session default` and the stop command accepts the same explicit option.
|
||||||
|
|
||||||
|
## Review checks
|
||||||
|
|
||||||
|
- `attach_command` is centralized in `lib_py/agents/sanitize.py` and used by create (`create_session.sh:512,566`), resume (`update_yaml_resumed.sh:119,151,161`), and reconcile (`reconcile.sh:569,676`). Coverage checks default server rendering, shell quoting, normalized names, and attach argument forwarding.
|
||||||
|
- Runtime references to removed `start_command` and `kill_command` fields are gone; only migration compatibility docs/code and assertions remain. The shim’s `new-session` and `kill-session` routes are still needed by the lifecycle scripts. Cleanup guidance now directs to the stop skill (`create_session.sh:364`, `lib_py/agents/id_order.py:342-345`).
|
||||||
|
- Rowless stop validates strict registry readability (`stop_session.sh:169-173`), refuses purge without a row (`124-126`), resolves cwd/root canonically before checking containment (`145-154`), and leaves registry bytes untouched (`156-164`). The safety and exit-code cases are covered by the new tests and the stop skill table (`SKILL.md:58-67`).
|
||||||
|
- The bash sanitizer is defined once (`lib.sh:44-82`), uses verified hash fallbacks, and errors when no conversion/hash tool is available. On macOS Bash 3.2, names containing `İ`, `-n`, and a trailing newline matched Python under `LC_ALL=C` and `LC_ALL=C.UTF-8`.
|
||||||
|
- Registry migration updates all named rows and format metadata (`registry_format.py:35-66`) before caller mutation under `BEGIN IMMEDIATE` (`atomic_yaml.py:78-88`); stale YAML is repaired from DB state (`127-138`). `atomic_dump_yaml` is the only sanctioned registry YAML writer. Future versions are not downgraded. The migration entry point’s output/error contract is documented and implemented in `registry_format.py:17-25,69-88`.
|
||||||
|
- StallWatch computes the normalized herdr name once and passes it to its source (`stall_watch.py:229`); its unchanged test passes.
|
||||||
|
- S11’s baseline `0fc162e` exists and is an ancestor of this head. Python 3.9.6 and Bash 3.2.57 are the host versions used. The scope excludes Issue #72’s update skill.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Commands used the required `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp` outside the validation checkout.
|
||||||
|
|
||||||
|
- Head combined review run (all requested focused and adjacent suites): **461 passed, 1 skipped** in 1295.97s. This included `test_issue71_herdr_names.py`, `test_issue66_stall_watch_sanitized_name.py`, tier 1/2/3, Issues 23/24, 50, 21, 63, 60, layout, and skill blocks.
|
||||||
|
- Base Issue #71 regression run from the temporary base snapshot: **68 failed, 2 passed** in 121.85s, matching the expected base behavior.
|
||||||
|
- Unicode/name parity probe under C and C.UTF-8: outputs matched Python.
|
||||||
|
- `git diff --check`: clean.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261026T133440Z-c8961b",
|
||||||
|
"base_sha": "29cc4fd90478936f15387a7665e2c95d13a3f14c",
|
||||||
|
"head_sha": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
|
||||||
|
"tree_sha": "98d57bdc60920238c115ece90c90cc694f2744bc",
|
||||||
|
"review_commit": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
|
||||||
|
"reviewer": "reviewer-agy-01",
|
||||||
|
"job_id": "0a1bb7e7",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-26T17:32:11Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# PR #73 (`fix/issue-71`) Review Report — `agy` (`herdr:reviewer-agy-01`)
|
||||||
|
|
||||||
|
- **Job ID**: `0a1bb7e7`
|
||||||
|
- **Loop ID**: `loop-20261026T133440Z-c8961b` (iteration 2)
|
||||||
|
- **Issue / PR**: Issue #71 / PR #73 (`fix/issue-71`, 14 commits)
|
||||||
|
- **Base Commit**: `29cc4fd90478936f15387a7665e2c95d13a3f14c` (`main`, `v4.10.1`)
|
||||||
|
- **Head Commit**: `5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26` (tree `98d57bdc60920238c115ece90c90cc694f2744bc`)
|
||||||
|
- **Review Worktree**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2`
|
||||||
|
- **Host Environment**: macOS Darwin arm64 (`Darwin 25.3.0`), `/bin/bash 3.2.57(1)-release`, `/usr/bin/python3 3.9.6`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
PR #73 resolves Issue #71 across all 10 scope items and review refinements (`A`–`F`):
|
||||||
|
1. Unifies `attach_command` construction in [`lib_py/agents/sanitize.py::attach_command`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib_py/agents/sanitize.py#L34-L42) (`herdr --session <server> agent attach <sanitized_name>`) across `create_session.sh`, `update_yaml_resumed.sh` (both branches), and `reconcile.sh`, and teaches the `lib.sh` shim's `herdr agent attach` branch to resolve its target via `_resolve_herdr_target`.
|
||||||
|
2. Removes `kill_command` and `start_command` from newly written rows, `status.sh --json`, and `SKILL.md` examples, and replaces direct `herdr kill-session` cleanup hints in `create_session.sh` (exit 43) and `id_order.py` with `stop_session.sh --session <name> --herdr-session <srv>`.
|
||||||
|
3. Adds row-less session closing (`stop_rowless`) to [`stop_session.sh`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh#L123-L165) with strict registry readability gating (`load_state_json strict`), workspace boundary enforcement (`os.path.realpath` prefix-safe check, exit 5 on foreign workspace), server selection parity with `create_session.sh`, and registry immutability.
|
||||||
|
4. Consolidates `_sanitize_herdr_agent_name` into a single definition in [`lib.sh`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib.sh#L40-L85) injected into the generated shim via `declare -f`, achieving exact parity with Python's `sanitize_herdr_agent_name` across ASCII, non-ASCII/multibyte, option-like (`-n`, `-e`), and newline-containing names, while failing closed (nonzero, stderr) when no hash tool or `python3` is available instead of emitting `00000000` or truncated names.
|
||||||
|
5. Introduces registry format version 2 (`registry_format: 2`) in [`lib_py/registry_format.py`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib_py/registry_format.py#L1-L92) and [`lib_py/atomic_yaml.py`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib_py/atomic_yaml.py#L121-L138), migrating under the writer lock (`BEGIN IMMEDIATE`) and repairing stale YAML copies on retry.
|
||||||
|
6. Computes `herdr_name` once in `StallWatch.__init__` and passes it as a required keyword-only argument `name` to `HerdrSource`.
|
||||||
|
|
||||||
|
All 10 review checklist items, macOS `/bin/bash 3.2.57` and `/usr/bin/python3 3.9.6` compatibility checks, negative regression on `base` (`68 failed, 2 passed`), and 461 functional/integration tests on `head` (`461 passed, 1 skipped, 0 failed`) pass on macOS Darwin arm64.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Checklist Verification (Items 1–10)
|
||||||
|
|
||||||
|
### 1. Completion Criteria & Scope Discipline (Issue #71 & Scope Comment Items 1–10)
|
||||||
|
- **Verified (`PASS`)**:
|
||||||
|
- Every requirement in Issue #71 and the "범위 정리" comment (items 1–10) is implemented and covered by regression tests.
|
||||||
|
- Out-of-scope Issue #72 (`multi-agent-mux-update` skill) is not pulled into this PR; only the `python3 -m lib_py.registry_format --yaml <path>` entry point specified in item 9 is provided for #72 to call. `MAM_VERSION` (`4.10.1`) and `VERSIONS.md` are untouched as intended.
|
||||||
|
|
||||||
|
### 2. Regression Suite `tests/test_issue71_herdr_names.py` (Base vs Head & Reconcile Pane Precondition)
|
||||||
|
- **Verified (`PASS`)**:
|
||||||
|
- **Base (`29cc4fd90478936f15387a7665e2c95d13a3f14c`)**: Checked out into an isolated temporary directory outside the review worktree and ran `tests/test_issue71_herdr_names.py` (70 tests): **68 failed, 2 passed in 104.17s** (the only 2 passing tests on `base` are `test_the_names_need_converting_and_the_plain_one_does_not` and `test_wrapper_attach_calls_herdr_with_the_herdr_name[lab-plain-creator-claude]`, matching the PR description).
|
||||||
|
- **Head (`5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26`)**: All **70 passed** (including all 4 `hashers` parameterizations: `shasum`, `sha1sum`, `openssl`, `python3`).
|
||||||
|
- **Reconcile test precondition**: [`test_reconcile_registers_with_the_attach_command`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/tests/test_issue71_herdr_names.py#L128-L141) seeds `mock_herdr` with `agents={name: {"agent": "claude", "status": "running", "cwd": str(mam_sandbox), "workspace_id": "w1", "pid": 9999, "pane_id": "w1:p71", "command": "claude", "buffer": "Ready"}}` and asserts `len(rows) == 1` and `rows[0]["last_visible_note"] == "auto-registered by monitor"` before checking `_assert_new_form(rows[0], rows[0]["herdr_session"])`.
|
||||||
|
|
||||||
|
### 3. `attach_command` (`lib_py/agents/sanitize.py`) & `lib.sh` Wrapper `agent attach`
|
||||||
|
- **Verified (`PASS`)**:
|
||||||
|
- [`lib_py/agents/sanitize.py:34-42`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib_py/agents/sanitize.py#L34-L42) defines:
|
||||||
|
```python
|
||||||
|
def attach_command(server, name):
|
||||||
|
return "herdr --session %s agent attach %s" % (
|
||||||
|
shlex.quote(server or "default"), shlex.quote(sanitize_herdr_agent_name(name)))
|
||||||
|
```
|
||||||
|
- Always writes `--session default` when `server` is `"default"` or empty/falsy, and applies `shlex.quote` to both the server and sanitized agent name.
|
||||||
|
- Used by all three writers:
|
||||||
|
- `create`: [`create_session.sh:512, 566`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-create/scripts/create_session.sh#L512-L566)
|
||||||
|
- `resume` (new row and existing row): [`update_yaml_resumed.sh:119, 150, 160`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh#L119-L160)
|
||||||
|
- `reconcile`: [`reconcile.sh:569, 676`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh#L569-L676)
|
||||||
|
- In [`lib.sh:605-613`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib.sh#L605-L613), the shim's `agent attach` branch shifts `<target>` (`t="$1"; shift`), resolves `tgt=$(_resolve_herdr_target "$t" || true)`, fails with exit 1 if empty, and invokes `_real_herdr agent attach "$tgt" "$@"`, preserving trailing flags such as `--takeover`.
|
||||||
|
|
||||||
|
### 4. Removal of `kill_command` & `start_command` and Updated Cleanup Hints
|
||||||
|
- **Verified (`PASS`)**:
|
||||||
|
- `git grep -E 'kill_command|start_command'` across the repository confirms no production code, skill doc, or active schema assertion reads or writes `kill_command` or `start_command` (the only remaining mentions are in `registry_format.py` to drop them during v1 -> v2 migration and in `test_issue71_herdr_names.py` / `test_tier2_component.py` asserting their absence).
|
||||||
|
- [`status.sh:221-231`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-status/scripts/status.sh#L221-L231) and [`multi-agent-mux-status/SKILL.md:94-101`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-status/SKILL.md#L94-L101) no longer include `start_command`, and nothing in the codebase reads `start_command` from `status.sh --json`.
|
||||||
|
- The shim's `new-session` and `kill-session` translation branches in `lib.sh` are properly retained because `create_session.sh`, `resume_session.sh`, `stop_session.sh`, and `reconcile.sh` call them internally.
|
||||||
|
- Both user-facing cleanup hints now point to `stop_session.sh`:
|
||||||
|
- [`create_session.sh:364`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-create/scripts/create_session.sh#L364): `bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session $SESSION_NAME --herdr-session ${HERDR_SESSION_NAME:-default}`
|
||||||
|
- [`lib_py/agents/id_order.py:359-360`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib_py/agents/id_order.py#L359-L360): `bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session {m['session']} --herdr-session {srv}`
|
||||||
|
|
||||||
|
### 5. Row-less `stop` (`stop_session.sh`) & Strict Registry Read (`load_state_json strict`)
|
||||||
|
- **Verified (`PASS`)**:
|
||||||
|
- **Strict registry read gate**: [`stop_session.sh:170-193`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh#L170-L193) calls `STATE_JSON=$(load_state_json strict)` first. In [`lib.sh:1597-1631`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib.sh#L1597-L1631), `load_state_json strict` re-raises `sqlite3.OperationalError` on missing `state` or `sessions` tables as well as corrupt DB / malformed YAML, printing to `stderr` and exiting `1`. Furthermore, `MAPPED_DATA` exits `7` **only** when `d.get('herdr_sessions', [])` was validly iterated and had no matching dict row; non-dict rows (`herdr_sessions: [- 42]`) exit `1` (`cannot read the row`).
|
||||||
|
- **Workspace boundary check**: [`stop_rowless` (`stop_session.sh:145-155`)](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh#L145-L155) resolves both `PANE_CWD` and `WORKSPACE_ROOT` via `os.path.realpath` and checks `cwd == root or cwd.startswith(root + os.sep)`. Verified on macOS that this prevents prefix confusion (`/tmp/ws` vs `/tmp/ws-other`), handles symlinks (`/var` -> `/private/var`), and normalizes trailing slashes; out-of-workspace sessions are refused with exit code `5` before any `kill-session`.
|
||||||
|
- **Server selection order**: Matches `create_session.sh:184-188`: `--herdr-session` (`HERDR_SERVER_OPT`) -> ambient `HERDR_SESSION_NAME` (when non-empty and `!= "default"`) -> workspace server via `resolve_herdr_session`.
|
||||||
|
- **`--purge-conversation` and exit codes**: Refuses `--purge-conversation` on a row-less session with exit `1`, exits `6` if `herdr has-session` still succeeds after `kill-session`, never mutates `agent-sessions.yaml` or `agent-sessions.db`, and matches [`multi-agent-mux-stop/SKILL.md:59-67`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-stop/SKILL.md#L59-L67).
|
||||||
|
|
||||||
|
### 6. Single Sanitize Rule in `lib.sh` & macOS BSD / Locale Parity
|
||||||
|
- **Verified (`PASS`)**:
|
||||||
|
- [`lib.sh:40-85`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib.sh#L40-L85) has the single definition of `_sanitize_herdr_agent_name`. [`_init_herdr_isolation` (`lib.sh:179-182`)](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib.sh#L179-L182) writes `declare -f _sanitize_herdr_agent_name` into the generated shim (`$wrapper_dir/herdr`).
|
||||||
|
- Removed the duplicate definition in the shim heredoc, removed `"00000000"` and `${s:0:32}` fallbacks, and removed `has-session`'s relative-path `sys.path.insert(0, os.path.abspath(".agents/skills"))` + `lambda s: s.lower()[:32]` fallback (`lib.sh:633-647` now computes `ssess=$(_sanitize_herdr_agent_name "$sess") || exit 1` in bash and passes `TARGET_SAN="$ssess"`).
|
||||||
|
- **macOS `/bin/bash 3.2.57`, BSD `tr`/`sed`, and locale (`LC_ALL=C` and `LC_ALL=en_US.UTF-8`) verification**:
|
||||||
|
- Pure-ASCII check uses `LC_ALL=C tr -d '\000-\177'`. For ASCII inputs, `printf '%s' "$n" | tr '\n' '-' | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g'` avoids `echo` flag parsing (`-n`, `-e`, `-En`) and converts `\n` to `-` before `sed` or command substitution `$(...)` can strip trailing newlines.
|
||||||
|
- For non-ASCII inputs (`İ`, `é`, `ü`, `K`, CJK/Hangul, emoji), it delegates lowercasing and `re.sub(r"[^a-z0-9_-]", "-", ...)` to `python3` so character-by-character replacement and Unicode case folding (`\u0130`.lower() -> `'i\u0307'` -> `'i-'`) match `lib_py.agents.sanitize.sanitize_herdr_agent_name` byte-for-byte, and fails closed (exit 1, stderr) if `python3` is unavailable.
|
||||||
|
|
||||||
|
### 7. Registry Format Version 2 (`lib_py/registry_format.py`, `lib_py/atomic_yaml.py`)
|
||||||
|
- **Verified (`PASS`)**:
|
||||||
|
- Inside [`atomic_dump_yaml_main` (`lib_py/atomic_yaml.py:121-138`)](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib_py/atomic_yaml.py#L121-L138), after acquiring the flock and `BEGIN IMMEDIATE` and loading `state` + `sessions`, if `from_version < registry_format.CURRENT` (`2`), `registry_format.migrate(d)` runs before the caller's mutation and sets `force_yaml = True`.
|
||||||
|
- Migrates all rows in `herdr_sessions` (`running`, `stopped`, `terminated`, `archived`), drops `kill_command` and `start_command`, recomputes `attach_command`, and sets `d["registry_format"] = 2`.
|
||||||
|
- If the DB was already at format 2 (`elif row:`) but an earlier YAML `os.replace` failed after `conn.commit()`, `force_yaml = registry_format.stored_version(y) < registry_format.CURRENT` detects the stale YAML and rewrites it on the next write.
|
||||||
|
- **Future format preservation**: Verified that when `registry_format` > 2 (e.g., `3`), `stored_version(d)` returns `3`, `from_version < CURRENT` is `False`, and `force_yaml` is `False` — the writer neither downgrades `registry_format` nor clobbers future fields.
|
||||||
|
- **Other registry paths**: Checked all references to `agent-sessions.yaml` across `.agents/skills/`; `atomic_dump_yaml` is the sole writer in production (`create_session.sh`, `update_yaml_resumed.sh`, `stop_session.sh`, `reconcile.sh`, `orc_onboard.sh`), while `load_state_json` (`lib.sh` / `lib_py/state.py`) is strictly read-only.
|
||||||
|
- **CLI entry point**: `PYTHONPATH=.agents/skills python3 -m lib_py.registry_format --yaml <path>` prints `registry-format: <from> -> <to>, rows changed: <n>` and exits `0` (or nonzero with `registry-format: error: ...` on stderr when missing/failing), matching its module docstring contract.
|
||||||
|
|
||||||
|
### 8. `stall_watch.py` Single Sanitization Point
|
||||||
|
- **Verified (`PASS`)**:
|
||||||
|
- In [`.agents/skills/lib_py/stall_watch.py:158-163, 229-230`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib_py/stall_watch.py#L158-L230), `StallWatch.__init__` computes `self.herdr_name = sanitize_herdr_agent_name(args.target)` once and passes `name=self.herdr_name` (required keyword-only parameter) to `HerdrSource`.
|
||||||
|
- `tests/test_issue66_stall_watch_sanitized_name.py` is unmodified in `git diff` and all 7 of its tests pass.
|
||||||
|
|
||||||
|
### 9. Test Updates, `skill_code_blocks.json`, and `test_s11` Anchor (`0fc162e`)
|
||||||
|
- **Verified (`PASS`)**:
|
||||||
|
- `tests/test_issue50_pane_lock.py`, `tests/test_issue21_worktree_prereqs.py`, `tests/test_tier1_unit.py`, `tests/test_tier2_component.py`, and `tests/test_tier3_integration.py` preserve their original test intent after replacing assertions on removed `start_command`/`kill_command` fields.
|
||||||
|
- `tests/data/skill_code_blocks.json` and `tests/test_issue42_skill_blocks.py` pass all 96 skill-block verification tests.
|
||||||
|
- In [`tests/test_issue23_24_startup_path.py:306-335`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/tests/test_issue23_24_startup_path.py#L306-L335), `test_s11_this_branch_does_not_change_the_generated_shim` pins `base = os.environ.get("MAM_BASE_SHA", "0fc162e")` and extracts both `_sanitize_herdr_agent_name() { ... }` and the `cat <<'EOF'` heredoc body in `_heredoc_body`. Commit `0fc162e` is an ancestor of `HEAD` (`5f60653`) on `fix/issue-71`; when merged into `main` via merge commit (as documented in PR #73), `0fc162e` remains reachable in git history so `test_s11` actively verifies the shim rather than skipping. (The `if show.returncode != 0: pytest.skip(...)` guard is pre-existing for shallow/archive checkouts without history.)
|
||||||
|
|
||||||
|
### 10. macOS Compatibility (`/usr/bin/python3 3.9.6`, `/bin/bash 3.2.57`, BSD Userland, SQLite WAL)
|
||||||
|
- **Verified (`PASS`)**:
|
||||||
|
- Parsed all 16 modified/added Python files with `/usr/bin/python3` (`3.9.6`) `ast.parse(..., feature_version=(3, 9))` and imported `lib_py.agents.sanitize`, `lib_py.registry_format`, `lib_py.atomic_yaml`, and `lib_py.stall_watch` under system Python 3.9.6.
|
||||||
|
- Checked all 7 modified bash scripts and the generated `.mam/shim/herdr` with `/bin/bash -n` (`GNU bash 3.2.57(1)-release`); no bash 4+ constructs (`declare -A`, `${var,,}`, `mapfile`, `readarray`) are used.
|
||||||
|
- In `tests/test_issue71_herdr_names.py:514-527`, `test_stop_does_not_take_an_unreadable_registry_for_no_row` unlinks `agent-sessions.db*` / `agent-sessions.db-*` before writing corrupt DB/YAML fixtures, properly accounting for macOS SQLite's persistent `-wal`/`-shm` sidecars.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Test Execution Summary on macOS
|
||||||
|
|
||||||
|
| Suite / Command | Target | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| `git diff --check 29cc4fd..5f60653` | Review worktree `/2` | Clean (exit 0) |
|
||||||
|
| `/bin/bash -n` on all 7 modified `.sh` scripts | `/bin/bash 3.2.57` | All 7 OK (exit 0) |
|
||||||
|
| `/usr/bin/python3` (`3.9.6`) AST + import + `LC_ALL=C`/`en_US.UTF-8` sanitize & path checks | System Python 3.9.6 + `/bin/bash 3.2.57` | `ALL_LOCALE_AND_MULTIBYTE_PARITY_OK`, `FUTURE_FORMAT_PRESERVED_OK`, `STOP_ROWLESS_PATH_BOUNDARY_CHECKS_OK` (exit 0) |
|
||||||
|
| `pytest tests/test_issue71_herdr_names.py` | **Base** (`29cc4fd` in temp dir) | **68 failed, 2 passed** in 104.17s |
|
||||||
|
| `pytest tests/test_issue71_herdr_names.py tests/test_issue66_stall_watch_sanitized_name.py tests/test_layout.py tests/test_issue42_skill_blocks.py` | **Head** (`5f60653`, worktree `/2`) | **152 passed, 1 skipped** in 150.63s (all 70 in `test_issue71` and all 7 in `test_issue66` passed) |
|
||||||
|
| `pytest tests/test_issue60_linux_fixes.py tests/test_tier1_unit.py tests/test_tier2_component.py tests/test_tier3_integration.py tests/test_issue23_24_startup_path.py tests/test_issue50_pane_lock.py tests/test_issue21_worktree_prereqs.py tests/test_issue63_same_cwd_ids.py` | **Head** (`5f60653`) | **309 passed, 0 failed** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Defects & Observations
|
||||||
|
|
||||||
|
### Blocking Defects
|
||||||
|
- None.
|
||||||
|
|
||||||
|
### Non-Blocking Observations
|
||||||
|
- In [`.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:1021`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job#L1021), the informational message after `send-keys` prints `(연결하려면: HERDR_SESSION_NAME=$HERDR_SESSION_NAME herdr agent attach $sess — lib.sh를 source한 셸에서 실행)`. Because PR #73 updated the `lib.sh` shim's `herdr agent attach` handler to resolve `$sess` via `_resolve_herdr_target`, this command works as stated when `lib.sh` is sourced, though a future cleanup could also print the native `herdr --session ... agent attach ...` form.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261026T133440Z-c8961b",
|
||||||
|
"base_sha": "29cc4fd90478936f15387a7665e2c95d13a3f14c",
|
||||||
|
"head_sha": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
|
||||||
|
"tree_sha": "98d57bdc60920238c115ece90c90cc694f2744bc",
|
||||||
|
"review_commit": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
|
||||||
|
"reviewer": "reviewer-grok-01",
|
||||||
|
"job_id": "fd90992c",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-26T17:32:11Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Review: PR #73 / Issue #71 (macOS, snapshot 2)
|
||||||
|
|
||||||
|
Job fd90992c. Reviewer only. Checks ran on the review snapshot. The source worktree and the control-plane checkout were not edited.
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- Snapshot: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2`
|
||||||
|
- Loop: `loop-20261026T133440Z-c8961b`
|
||||||
|
- Base: `29cc4fd90478936f15387a7665e2c95d13a3f14c` (main, v4.10.1)
|
||||||
|
- Head: `5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26`
|
||||||
|
- Tree: `98d57bdc60920238c115ece90c90cc694f2744bc`
|
||||||
|
- 14 commits, base is an ancestor, porcelain 0, no `.mam` in the snapshot, `git diff --check` clean
|
||||||
|
- Diff: 28 files, +953/−176
|
||||||
|
- Host: `/usr/bin/python3` 3.9.6, bash 3.2.57(1)-release, sqlite 3.51.0, `LANG=en_US.UTF-8`
|
||||||
|
|
||||||
|
`tests/test_issue66_stall_watch_sanitized_name.py` has an empty diff against `29cc4fd`. Commit `0fc162e` is an ancestor. The diff file list has no `multi-agent-mux-update` skill. `MAM_VERSION` stays `4.10.1`.
|
||||||
|
|
||||||
|
Issue #71, the "범위 정리" comment (items 1–10), and PR #73 were read. The registry entry point is documented for #72 and is not wired to an update skill.
|
||||||
|
|
||||||
|
## What was checked
|
||||||
|
|
||||||
|
### attach_command
|
||||||
|
|
||||||
|
`lib_py/agents/sanitize.py:34` builds `herdr --session <server> agent attach <sanitized name>`. An empty server becomes `default`, and both the server and the name go through `shlex.quote`, so `default` is written as `--session default` and a server that needs quotes is quoted.
|
||||||
|
|
||||||
|
The three writers call it:
|
||||||
|
|
||||||
|
- create, `create_session.sh:566`
|
||||||
|
- resume, `update_yaml_resumed.sh:151` (new row) and `:161` (existing row whose server is set)
|
||||||
|
- reconcile, `reconcile.sh:676`, on the row whose note is `auto-registered by monitor`
|
||||||
|
|
||||||
|
The shim `agent attach` (`lib.sh:605-613`) resolves the target with `_resolve_herdr_target` and runs `_real_herdr agent attach "$tgt" "$@"`. Options after the name stay in `"$@"`.
|
||||||
|
|
||||||
|
### Removed fields and the stop hints
|
||||||
|
|
||||||
|
`kill_command` and `start_command` remain only as fields the migration pops and as assertions that they are absent. `status.sh` no longer emits `start_command`. The shim still has the fake `new-session` and `kill-session` verbs, which create, stop, and reconcile use. `kill-session` sanitizes the name and exits 1 when that fails.
|
||||||
|
|
||||||
|
Create exit 43 (`create_session.sh:364`) and `id_order.py:330` and `:344` tell the user to run `stop_session.sh --session <name> --herdr-session <server>`. The row-less tests below run that script.
|
||||||
|
|
||||||
|
### Row-less stop
|
||||||
|
|
||||||
|
`stop_rowless` (`stop_session.sh:123`) runs only when the row lookup exits 7 (`:189`). A strict read failure exits 1 before that (`:170`). `--purge-conversation` with no row exits 1 (`:124`). A cwd outside this workspace exits 5. A session that survives `kill-session` exits 6. Those codes match `multi-agent-mux-stop/SKILL.md:58-66`.
|
||||||
|
|
||||||
|
Server order: `--herdr-session`, else a non-default `HERDR_SESSION_NAME` captured before resolve (`:109`, `:128`), else the workspace server. `lib.sh` stamps an unset name as `default`, and the row-less path treats that stamp as unset.
|
||||||
|
|
||||||
|
The cwd check realpaths both sides and accepts the workspace itself or a child (`root + os.sep`). That rejects a sibling prefix and a symlink whose target is outside the workspace, and it treats a trailing slash as the same directory. `/tmp` and `/private/tmp` are the same path on this Mac. The row-less tests compare registry bytes before and after and passed.
|
||||||
|
|
||||||
|
`load_state_json strict` (`lib.sh:1594`) is the bash reader. A missing `state` or `sessions` table raises in strict mode and the function exits 1. The argument-free call still prints `{}`.
|
||||||
|
|
||||||
|
### One name rule
|
||||||
|
|
||||||
|
`lib.sh` has one `_sanitize_herdr_agent_name` (`:44`). `00000000` and `lower()[:32]` are absent. The shim receives the function through `declare -f` (`:181`).
|
||||||
|
|
||||||
|
The function was extracted with `sed` and run under `/bin/bash` 3.2 without sourcing `lib.sh`. Against `sanitize_herdr_agent_name` there were 0 mismatches under `LC_ALL=C` and `LC_ALL=en_US.UTF-8` for empty, `Upper-Case`, `-n`, `-e`, a 33-character name, the long branch name, `İstanbul-creator-claude`, a newline, `a\nb`, café, the kelvin sign, Japanese, Korean `한글`, and `A B`. Korean is two characters; the non-ASCII path uses `python3` `str.lower()` and the same regex, so the replacement follows characters, and the result matched Python.
|
||||||
|
|
||||||
|
With `PATH` limited to `tr`, `sed`, `awk`, and `cut`, the long name exits 1 with `no shasum, sha1sum, openssl or python3 to hash it` and prints no name. `한글` exits 1 with `not ASCII`.
|
||||||
|
|
||||||
|
`/usr/bin/python3` is 3.9.6. The diff adds no `mapfile`, `${var,,}`, `declare -A`, `match`, or `tomllib`. A throwaway SQLite database opened with `PRAGMA journal_mode=WAL`, written, and closed still had a `-wal` (0 bytes) and a `-shm` (32768 bytes).
|
||||||
|
|
||||||
|
### Registry format 2
|
||||||
|
|
||||||
|
`atomic_yaml.py:118-137` migrates under the lock, after `BEGIN IMMEDIATE` and before the caller mutation, when `stored_version < 2`. Migration sets `force_yaml`, so YAML is rewritten even if the mutation changes no session. If the DB is already current and the YAML copy is still older, the next write rewrites the YAML. `migrate` (`registry_format.py:50-66`) updates every named row, including stopped and terminated, and sets `registry_format` to 2. A version already greater than 2 is not less than `CURRENT`, so the writer does not call `migrate`.
|
||||||
|
|
||||||
|
The entry point `python -m lib_py.registry_format --yaml <path>` prints `registry-format: <from> -> <to>, rows changed: <n>` and exits 0, or `registry-format: error:` and a nonzero status when neither file exists. The only `yaml.safe_dump` under `.agents/skills` is the writer. Readers (`lib_py/state.py` and the argument-free bash loader) do not migrate.
|
||||||
|
|
||||||
|
### stall_watch and the edited tests
|
||||||
|
|
||||||
|
`stall_watch.py:229` computes `self.herdr_name` once and passes it into `HerdrSource`. The issue #66 test file is unchanged and is in the passing run.
|
||||||
|
|
||||||
|
Edited assertions keep the old intent: pane-lock uses `session_id_source` (create sets it at `create_session.sh:579`) to tell a create row from a monitor row; the worktree test looks for a leaked `WORKSPACE_ROOT=` in `attach_command`; tier 2 checks the new attach string and the absence of the two dropped keys; tier 1 and tier 3 plant a row with no agent so "cannot infer agent" still exits 2. `skill_code_blocks.json` moved with the docs, and `test_issue42_skill_blocks.py` expects stop exits `0` through `6`. That file passed aside from the skills-link skip.
|
||||||
|
|
||||||
|
S11 (`test_issue23_24_startup_path.py:306-310`) compares the shim with `0fc162e`. That commit is in this checkout, so the comparison ran as part of the passing file. If the object is missing, the test calls `pytest.skip` with a message that names the commit. That skip is intentional: the PR says a squash would drop `0fc162e`, and it asks for a merge commit.
|
||||||
|
|
||||||
|
The reconcile tests plant a mock pane (`test_issue71_herdr_names.py:127-140`) and require one new row. They passed, so registration ran.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Pytest was `PYTHONDONTWRITEBYTECODE=1` with the control-plane venv, `-p no:cacheprovider`, and `--basetemp=/tmp/mamfd9-fd90992c-pytest`. Inherited `HERDR_*`, `MAM_*`, `MQTT_*`, `WORKSPACE_ROOT`, `DELEGATE_JOB_LOGS_DIR`, `PYTHONPATH`, the four `XDG_*` homes, and `AGENT_SESSIONS_YAML` were unset. `AGENT_PYTHON_BIN` pointed at the control-plane venv. The snapshot was the working directory and was not checked out.
|
||||||
|
|
||||||
|
Head, the files named in the brief plus `test_b19_headless_reconcile_fixes.py`, `test_b4_session_created.py`, and `test_issue30_stall_watch.py`: **546 passed, 2 skipped** in 1458.68s, exit 0. `tests/test_issue71_herdr_names.py` collects 70 tests and they are in that pass. The two skips, confirmed with `-rs` on those two tests, are `test_claude_skills_link_resolves_to_agents_skills` (`.claude/skills` absent, `test_issue42_skill_blocks.py:266`) and `test_s6_the_heartbeat_timer_is_not_a_background_child` (Linux `/proc`, `test_issue30_stall_watch.py:340`).
|
||||||
|
|
||||||
|
Base: a `--shared` clone at `/tmp/mamfd9-fd90992c-base`, detached at `29cc4fd`, with only this test file copied in. Same pytest flags, `--basetemp=/tmp/mamfd9-fd90992c-base-pytest`. **68 failed, 2 passed** in 98.42s, exit 1. The two passes are `test_the_names_need_converting_and_the_plain_one_does_not` and the plain-name `test_wrapper_attach_calls_herdr_with_the_herdr_name`. The long and uppercase wrapper cases failed.
|
||||||
|
|
||||||
|
## Residuals (non-blocking)
|
||||||
|
|
||||||
|
- Readers stay read-only. An old registry keeps a format-1 `attach_command` until the next writer, or until `python -m lib_py.registry_format` runs. The module docstring describes that window. The explicit entry point is left for #72.
|
||||||
|
- `VERSIONS.md` is unchanged. The release sentence from the scope comment is in the PR body.
|
||||||
|
- `migrate` skips a row with no `name` (`registry_format.py:57-58`), so that row can keep `kill_command` after the format key becomes 2.
|
||||||
|
- `lib_py/state.py` `load_state_json` still returns `{}` on a read error. Stop uses the bash strict reader.
|
||||||
|
- The SKILL.md exit-0 cell describes a stop or an already-stopped no-op. The pre-flight paragraph and the script header also describe the row-less close as exit 0. The pre-flight sentence names `--herdr-session` and `HERDR_SESSION_NAME`; the code also uses the workspace server when both are absent.
|
||||||
|
- A direct call to `migrate` on a format already greater than 2 would write the key back to 2. The writer does not call it in that case.
|
||||||
|
- S11 skips only when `0fc162e` cannot be resolved. A squash merge would take that path. The PR forbids squash for that reason.
|
||||||
|
|
||||||
|
## Host
|
||||||
|
|
||||||
|
Checked before the suite at 2026-10-27 01:43:34 and again after it at 02:08:54. No process whose first command token was `opencode`. `lsof` of the user opencode database returned exit 1. Database mtime 1792641027, size 36712448. `opencode.log` absent. Config `service.json` mtime 1792246838, size 64. State `service.json` absent. Herdr config mtime 1784456394, size 19. `~/.local/state/mam` directory mtime 1792846843, size 128. `id-order` is a directory, mtime 1792846886, size 128; its entries were not listed. The same two herdr servers were present before and after: pid 11655 (`/opt/homebrew/bin/herdr server`) and pid 69309 (`/opt/homebrew/bin/herdr --session multi-agent-mux server`). No server argv contained the scratch prefix, the snapshot path, or the loop id. The database was not opened.
|
||||||
|
|
||||||
|
After the suite the snapshot was still head `5f60653`, tree `98d57bdc`, porcelain 0, no `.mam`. The control-plane checkout was still `29cc4fd`, porcelain 0.
|
||||||
|
|
||||||
|
Rechecked after this report was written, at 2026-10-27 02:09:37. The snapshot was still head `5f60653`, tree `98d57bdc`, porcelain 0, with no `.mam`. The control plane was still `29cc4fd`, porcelain 0. Opencode first-token count 0, database `lsof` exit 1, and the database, log, service files, herdr config, `~/.local/state/mam`, and `id-order` mtimes and sizes were the same as the pre-suite reading. Herdr servers were still pid 11655 and pid 69309. No server argv contained the scratch prefix, the snapshot path, or the loop id.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
+70
-58
@@ -29,33 +29,54 @@ export WORKSPACE_ROOT
|
|||||||
# Framework semantic version. Single runtime source of truth; kept in lockstep
|
# Framework semantic version. Single runtime source of truth; kept in lockstep
|
||||||
# with VERSIONS.md and the 8 SKILL.md frontmatters by tests/test_version_consistency.py.
|
# with VERSIONS.md and the 8 SKILL.md frontmatters by tests/test_version_consistency.py.
|
||||||
# NOTE: unlike other MAM_* variables this one is intentionally NOT env-overridable.
|
# NOTE: unlike other MAM_* variables this one is intentionally NOT env-overridable.
|
||||||
MAM_VERSION="4.10.1"
|
MAM_VERSION="4.11.0"
|
||||||
export MAM_VERSION
|
export MAM_VERSION
|
||||||
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
|
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
|
||||||
|
|
||||||
|
# The one bash form of lib_py.agents.sanitize.sanitize_herdr_agent_name
|
||||||
|
# (Issue #71). The herdr shim gets this same definition: _init_herdr_isolation
|
||||||
|
# writes it in with `declare -f`. A long name needs its SHA-1 suffix; with no
|
||||||
|
# shasum, sha1sum, openssl or python3 to make it, this fails (stderr, nonzero)
|
||||||
|
# rather than return a name herdr never registered. A name that is not ASCII
|
||||||
|
# is lowercased and replaced by python3 (tr/sed differ from str.lower() there),
|
||||||
|
# and fails the same way without it. Line breaks become '-' before sed (which
|
||||||
|
# works line by line) and before $(...) could drop a trailing one.
|
||||||
_sanitize_herdr_agent_name() {
|
_sanitize_herdr_agent_name() {
|
||||||
local n="$1"
|
local n="$1"
|
||||||
if [ -z "$n" ]; then
|
if [ -z "$n" ]; then
|
||||||
printf 'agent\n'
|
printf 'agent\n'
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
local s
|
local s=""
|
||||||
s=$(echo "$n" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g')
|
if [ -z "$(printf '%s' "$n" | LC_ALL=C tr -d '\000-\177')" ]; then
|
||||||
|
s=$(printf '%s' "$n" | tr '\n' '-' | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g')
|
||||||
|
elif command -v python3 >/dev/null 2>&1; then
|
||||||
|
s=$(python3 -c 'import re,sys; sys.stdout.write(re.sub(r"[^a-z0-9_-]", "-", sys.argv[1].lower()))' "$n") || s=""
|
||||||
|
fi
|
||||||
|
if [ -z "$s" ]; then
|
||||||
|
echo "ERROR: cannot make the herdr name of '$n': it is not ASCII and there is no python3 to convert it" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
if [[ ! "$s" =~ ^[a-z] ]]; then s="x-$s"; fi
|
if [[ ! "$s" =~ ^[a-z] ]]; then s="x-$s"; fi
|
||||||
if [ "${#s}" -gt 32 ]; then
|
if [ "${#s}" -gt 32 ]; then
|
||||||
local h=""
|
local h=""
|
||||||
if command -v shasum >/dev/null 2>&1; then
|
if command -v shasum >/dev/null 2>&1; then
|
||||||
h=$(printf '%s' "$s" | shasum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
|
h=$(printf '%s' "$s" | shasum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
|
||||||
elif command -v sha1sum >/dev/null 2>&1; then
|
fi
|
||||||
|
if [ "${#h}" -ne 8 ] && command -v sha1sum >/dev/null 2>&1; then
|
||||||
h=$(printf '%s' "$s" | sha1sum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
|
h=$(printf '%s' "$s" | sha1sum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
|
||||||
elif command -v openssl >/dev/null 2>&1; then
|
fi
|
||||||
|
if [ "${#h}" -ne 8 ] && command -v openssl >/dev/null 2>&1; then
|
||||||
h=$(printf '%s' "$s" | openssl sha1 2>/dev/null | awk '{print $NF}' | cut -c 1-8)
|
h=$(printf '%s' "$s" | openssl sha1 2>/dev/null | awk '{print $NF}' | cut -c 1-8)
|
||||||
fi
|
fi
|
||||||
if [ -n "$h" ]; then
|
if [ "${#h}" -ne 8 ] && command -v python3 >/dev/null 2>&1; then
|
||||||
s="${s:0:23}-$h"
|
h=$(python3 -c 'import hashlib,sys; print(hashlib.sha1(sys.argv[1].encode("utf-8")).hexdigest()[:8])' "$s" 2>/dev/null || true)
|
||||||
else
|
|
||||||
s="${s:0:32}"
|
|
||||||
fi
|
fi
|
||||||
|
if [ "${#h}" -ne 8 ]; then
|
||||||
|
echo "ERROR: cannot make the herdr name of '$n': no shasum, sha1sum, openssl or python3 to hash it" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
s="${s:0:23}-${h}"
|
||||||
fi
|
fi
|
||||||
printf '%s\n' "$s"
|
printf '%s\n' "$s"
|
||||||
}
|
}
|
||||||
@@ -155,8 +176,10 @@ _init_herdr_isolation() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
local tmp_file="$wrapper_dir/herdr.tmp.$$.$RANDOM"
|
local tmp_file="$wrapper_dir/herdr.tmp.$$.$RANDOM"
|
||||||
cat <<'EOF' > "$tmp_file"
|
{
|
||||||
#!/usr/bin/env bash
|
printf '%s\n' '#!/usr/bin/env bash'
|
||||||
|
declare -f _sanitize_herdr_agent_name
|
||||||
|
cat <<'EOF'
|
||||||
# Herdr-to-Herdr translation shim wrapper
|
# Herdr-to-Herdr translation shim wrapper
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -290,31 +313,6 @@ _real_herdr() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
_sanitize_herdr_agent_name() {
|
|
||||||
local n="$1"
|
|
||||||
if [ -z "$n" ]; then
|
|
||||||
printf 'agent\n'
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
local s
|
|
||||||
s=$(echo "$n" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g')
|
|
||||||
if [[ ! "$s" =~ ^[a-z] ]]; then s="x-$s"; fi
|
|
||||||
if [ "${#s}" -gt 32 ]; then
|
|
||||||
local h=""
|
|
||||||
if command -v shasum >/dev/null 2>&1; then
|
|
||||||
h=$(printf '%s' "$s" | shasum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
|
|
||||||
elif command -v sha1sum >/dev/null 2>&1; then
|
|
||||||
h=$(printf '%s' "$s" | sha1sum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
|
|
||||||
elif command -v openssl >/dev/null 2>&1; then
|
|
||||||
h=$(printf '%s' "$s" | openssl sha1 2>/dev/null | awk '{print $NF}' | cut -c 1-8)
|
|
||||||
else
|
|
||||||
h=$(python3 -c "import hashlib,sys; print(hashlib.sha1(sys.argv[1].encode()).hexdigest()[:8])" "$s" 2>/dev/null || echo "00000000")
|
|
||||||
fi
|
|
||||||
s="${s:0:23}-${h}"
|
|
||||||
fi
|
|
||||||
printf '%s\n' "${s:0:32}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Workspace scoping (ISSUE-3).
|
# Workspace scoping (ISSUE-3).
|
||||||
#
|
#
|
||||||
@@ -441,7 +439,7 @@ sys.exit(1)
|
|||||||
[ -n "$resolved_ws" ] && target_ws="$resolved_ws"
|
[ -n "$resolved_ws" ] && target_ws="$resolved_ws"
|
||||||
fi
|
fi
|
||||||
local sat pid=""
|
local sat pid=""
|
||||||
sat=$(_sanitize_herdr_agent_name "$target")
|
sat=$(_sanitize_herdr_agent_name "$target") || return 1
|
||||||
|
|
||||||
local cand get_ws
|
local cand get_ws
|
||||||
if [ "$#" -ge 2 ]; then
|
if [ "$#" -ge 2 ]; then
|
||||||
@@ -534,7 +532,7 @@ case "$cmd" in
|
|||||||
_resolve_herdr_target() {
|
_resolve_herdr_target() {
|
||||||
local raw="$1"
|
local raw="$1"
|
||||||
local sat
|
local sat
|
||||||
sat=$(_sanitize_herdr_agent_name "$raw")
|
sat=$(_sanitize_herdr_agent_name "$raw") || return 1
|
||||||
if [ -n "$(_herdr_agent_get_scoped "$sat" 2>/dev/null || true)" ]; then
|
if [ -n "$(_herdr_agent_get_scoped "$sat" 2>/dev/null || true)" ]; then
|
||||||
echo "$sat"
|
echo "$sat"
|
||||||
return 0
|
return 0
|
||||||
@@ -604,6 +602,15 @@ sys.exit(1)
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
_real_herdr agent read "$tgt" "$@"
|
_real_herdr agent read "$tgt" "$@"
|
||||||
|
elif [ "$sub" = "attach" ] && [ $# -ge 1 ]; then
|
||||||
|
t="$1"
|
||||||
|
shift
|
||||||
|
tgt=$(_resolve_herdr_target "$t" || true)
|
||||||
|
if [ -z "$tgt" ]; then
|
||||||
|
echo "Error: no in-scope herdr agent for '$t'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
_real_herdr agent attach "$tgt" "$@"
|
||||||
else
|
else
|
||||||
_real_herdr agent "$sub" "$@"
|
_real_herdr agent "$sub" "$@"
|
||||||
fi
|
fi
|
||||||
@@ -623,19 +630,15 @@ sys.exit(1)
|
|||||||
*) shift ;;
|
*) shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
if [ -n "$(_herdr_agent_get_scoped "$(_sanitize_herdr_agent_name "$sess")" 2>/dev/null || true)" ] \
|
ssess=$(_sanitize_herdr_agent_name "$sess") || exit 1
|
||||||
|
if [ -n "$(_herdr_agent_get_scoped "$ssess" 2>/dev/null || true)" ] \
|
||||||
|| [ -n "$(_herdr_agent_get_scoped "$sess" 2>/dev/null || true)" ]; then
|
|| [ -n "$(_herdr_agent_get_scoped "$sess" 2>/dev/null || true)" ]; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if _real_herdr agent list 2>/dev/null | TARGET_NAME="$sess" TARGET_WS="$(_herdr_ws_scope)" python3 -c '
|
if _real_herdr agent list 2>/dev/null | TARGET_NAME="$sess" TARGET_SAN="$ssess" TARGET_WS="$(_herdr_ws_scope)" python3 -c '
|
||||||
import sys, json, os
|
import sys, json, os
|
||||||
try:
|
|
||||||
sys.path.insert(0, os.path.abspath(".agents/skills"))
|
|
||||||
from lib_py.agents.sanitize import sanitize_herdr_agent_name
|
|
||||||
except Exception:
|
|
||||||
sanitize_herdr_agent_name = lambda s: s.lower()[:32] if s else "agent"
|
|
||||||
tn = os.environ.get("TARGET_NAME", "")
|
tn = os.environ.get("TARGET_NAME", "")
|
||||||
stn = sanitize_herdr_agent_name(tn)
|
stn = os.environ.get("TARGET_SAN", "")
|
||||||
tws = os.environ.get("TARGET_WS", "")
|
tws = os.environ.get("TARGET_WS", "")
|
||||||
try:
|
try:
|
||||||
d = json.loads(sys.stdin.read())
|
d = json.loads(sys.stdin.read())
|
||||||
@@ -682,6 +685,7 @@ sys.exit(1)
|
|||||||
*) run_cmd="$1"; shift ;;
|
*) run_cmd="$1"; shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
agent_name=$(_sanitize_herdr_agent_name "$name") || exit 1
|
||||||
if [ -z "$run_cmd" ]; then
|
if [ -z "$run_cmd" ]; then
|
||||||
run_cmd="${SHELL:-/bin/bash}"
|
run_cmd="${SHELL:-/bin/bash}"
|
||||||
fi
|
fi
|
||||||
@@ -896,7 +900,6 @@ except Exception:
|
|||||||
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 "$final_cmd" ]; then
|
if [ -n "$final_cmd" ]; then
|
||||||
res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"$kind\" --pane \"$target_pane\" -- $final_cmd" 2>&1 || true)
|
res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"$kind\" --pane \"$target_pane\" -- $final_cmd" 2>&1 || true)
|
||||||
@@ -967,7 +970,7 @@ except Exception:
|
|||||||
# `herdr session list`), not individual agent panes — `session stop/delete
|
# `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.
|
||||||
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
agent_target=$(_sanitize_herdr_agent_name "$sess") || exit 1
|
||||||
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
|
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
|
||||||
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
|
||||||
@@ -1002,7 +1005,7 @@ 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>`.
|
||||||
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
agent_target=$(_sanitize_herdr_agent_name "$sess") || exit 1
|
||||||
info=$(_real_herdr agent get "$agent_target" 2>/dev/null || _real_herdr agent get "$sess" 2>/dev/null || true)
|
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
|
||||||
@@ -1072,7 +1075,7 @@ except Exception:
|
|||||||
*) shift ;;
|
*) shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
agent_target=$(_sanitize_herdr_agent_name "$sess") || exit 1
|
||||||
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
|
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
|
||||||
if [ -n "$pane_id" ]; then
|
if [ -n "$pane_id" ]; then
|
||||||
_real_herdr pane read "$pane_id" --source visible --lines 100 2>/dev/null \
|
_real_herdr pane read "$pane_id" --source visible --lines 100 2>/dev/null \
|
||||||
@@ -1103,7 +1106,7 @@ 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).
|
||||||
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
agent_target=$(_sanitize_herdr_agent_name "$sess") || exit 1
|
||||||
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
|
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
|
||||||
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
|
||||||
@@ -1387,6 +1390,7 @@ for name, pid in rows:
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
EOF
|
EOF
|
||||||
|
} > "$tmp_file"
|
||||||
chmod +x "$tmp_file" 2>/dev/null || true
|
chmod +x "$tmp_file" 2>/dev/null || true
|
||||||
mv -f "$tmp_file" "$wrapper_dir/herdr" 2>/dev/null || rm -f "$tmp_file" 2>/dev/null || true
|
mv -f "$tmp_file" "$wrapper_dir/herdr" 2>/dev/null || rm -f "$tmp_file" 2>/dev/null || true
|
||||||
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
|
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
|
||||||
@@ -1587,10 +1591,14 @@ herdr() {
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# NOTE: Retained as small inline PYEOF block (39 lines) per Plan Rev.2 (Job 98393a97).
|
# NOTE: Retained as small inline PYEOF block (39 lines) per Plan Rev.2 (Job 98393a97).
|
||||||
# Small blocks have minimal overhead and high local cohesion; extraction into lib_py is reserved for large blocks (>200 lines).
|
# Small blocks have minimal overhead and high local cohesion; extraction into lib_py is reserved for large blocks (>200 lines).
|
||||||
|
# `load_state_json strict` (Issue #71) fails (stderr, exit 1) when the YAML or
|
||||||
|
# DB cannot be read, a DB table missing included, instead of printing {} - for
|
||||||
|
# a caller that acts on a session being absent.
|
||||||
load_state_json() {
|
load_state_json() {
|
||||||
env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
env_python "$AGENT_SESSIONS_YAML" "MAM_STATE_READ=${1:-}" <<'PYEOF'
|
||||||
import os, sys, sqlite3, json, yaml
|
import os, sys, sqlite3, json, yaml
|
||||||
yaml_path = os.environ['YAML_PATH']
|
yaml_path = os.environ['YAML_PATH']
|
||||||
|
strict = os.environ.get('MAM_STATE_READ') == 'strict'
|
||||||
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
||||||
d = {}
|
d = {}
|
||||||
db_sessions = []
|
db_sessions = []
|
||||||
@@ -1603,20 +1611,24 @@ try:
|
|||||||
if row:
|
if row:
|
||||||
d = json.loads(row[0])
|
d = json.loads(row[0])
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass
|
if strict:
|
||||||
|
raise
|
||||||
try:
|
try:
|
||||||
cursor = conn.execute('SELECT data FROM sessions')
|
cursor = conn.execute('SELECT data FROM sessions')
|
||||||
for r in cursor.fetchall():
|
for r in cursor.fetchall():
|
||||||
db_sessions.append(json.loads(r[0]))
|
db_sessions.append(json.loads(r[0]))
|
||||||
d['herdr_sessions'] = db_sessions
|
d['herdr_sessions'] = db_sessions
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass
|
if strict:
|
||||||
|
raise
|
||||||
conn.close()
|
conn.close()
|
||||||
elif os.path.exists(yaml_path):
|
elif os.path.exists(yaml_path):
|
||||||
with open(yaml_path) as f:
|
with open(yaml_path) as f:
|
||||||
d = yaml.safe_load(f) or {}
|
d = yaml.safe_load(f) or {}
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
if strict:
|
||||||
|
print('ERROR: cannot read the registry %s: %s' % (yaml_path, e), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
# Clean lone surrogates to prevent UnicodeEncodeError on stdout print
|
# Clean lone surrogates to prevent UnicodeEncodeError on stdout print
|
||||||
def clean_surrogates(obj):
|
def clean_surrogates(obj):
|
||||||
if isinstance(obj, str):
|
if isinstance(obj, str):
|
||||||
@@ -2787,7 +2799,7 @@ wait_for_agent_idle() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
local target
|
local target
|
||||||
target=$(_sanitize_herdr_agent_name "$sess")
|
target=$(_sanitize_herdr_agent_name "$sess") || return 3
|
||||||
|
|
||||||
local raw_status="" agent_st=""
|
local raw_status="" agent_st=""
|
||||||
raw_status="$(_sks_herdr agent get "$target" 2>/dev/null || _sks_herdr agent get "$sess" 2>/dev/null || true)"
|
raw_status="$(_sks_herdr agent get "$target" 2>/dev/null || _sks_herdr agent get "$sess" 2>/dev/null || true)"
|
||||||
@@ -2912,7 +2924,7 @@ send_keys_safe() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
local agent_target
|
local agent_target
|
||||||
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
agent_target=$(_sanitize_herdr_agent_name "$sess") || return 1
|
||||||
|
|
||||||
# Busy gate: wait up to SKS_BUSY_TIMEOUT only if wait_for_agent_idle returned 3 (unknown)
|
# Busy gate: wait up to SKS_BUSY_TIMEOUT only if wait_for_agent_idle returned 3 (unknown)
|
||||||
if [ "${_SKS_IDLE_RC:-0}" = "3" ]; then
|
if [ "${_SKS_IDLE_RC:-0}" = "3" ]; then
|
||||||
|
|||||||
@@ -341,7 +341,8 @@ def wait(cwd: str, agent: str, session: str, owner_pid: int,
|
|||||||
print(
|
print(
|
||||||
f"ERROR: {m['session']}'s create/resume exited before recording it, "
|
f"ERROR: {m['session']}'s create/resume exited before recording it, "
|
||||||
f"and {seen}; close it with: "
|
f"and {seen}; close it with: "
|
||||||
f"HERDR_SESSION_NAME={srv} herdr kill-session -t {m['session']}",
|
f"bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh "
|
||||||
|
f"--session {m['session']} --herdr-session {srv}",
|
||||||
file=sys.stderr, flush=True,
|
file=sys.stderr, flush=True,
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import re
|
import re
|
||||||
|
import shlex
|
||||||
|
|
||||||
def sanitize_herdr_agent_name(name: str) -> str:
|
def sanitize_herdr_agent_name(name: str) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -29,3 +30,14 @@ def sanitize_herdr_agent_name(name: str) -> str:
|
|||||||
|
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def attach_command(server, name):
|
||||||
|
"""The registry row's attach_command (Issue #71).
|
||||||
|
|
||||||
|
A native herdr command, so it works from any shell: the server is always
|
||||||
|
named (`default` too - herdr's env-free default), and the agent by the name
|
||||||
|
herdr registered (sanitize_herdr_agent_name). create, resume and reconcile
|
||||||
|
all write it from here.
|
||||||
|
"""
|
||||||
|
return "herdr --session %s agent attach %s" % (
|
||||||
|
shlex.quote(server or "default"), shlex.quote(sanitize_herdr_agent_name(name)))
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
import os, sys, tempfile, shutil, glob, subprocess, json, sqlite3, fcntl
|
import os, sys, tempfile, shutil, glob, subprocess, json, sqlite3, fcntl
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import yaml
|
import yaml
|
||||||
|
from lib_py import registry_format
|
||||||
|
|
||||||
def atomic_dump_yaml_main():
|
def atomic_dump_yaml_main():
|
||||||
yaml_path = os.environ['YAML_PATH']
|
yaml_path = os.environ['YAML_PATH']
|
||||||
@@ -114,6 +115,27 @@ def atomic_dump_yaml_main():
|
|||||||
elif 'herdr_sessions' not in d:
|
elif 'herdr_sessions' not in d:
|
||||||
d['herdr_sessions'] = []
|
d['herdr_sessions'] = []
|
||||||
|
|
||||||
|
# Issue #71: an older registry is migrated here, under the lock and
|
||||||
|
# before the caller's mutation. It changes row fields only, so the
|
||||||
|
# YAML is rewritten whether or not the mutation changes anything.
|
||||||
|
from_version = registry_format.stored_version(d)
|
||||||
|
migrated_rows = 0
|
||||||
|
force_yaml = False
|
||||||
|
if from_version < registry_format.CURRENT:
|
||||||
|
d, migrated_rows = registry_format.migrate(d)
|
||||||
|
force_yaml = True
|
||||||
|
elif row:
|
||||||
|
# The DB is current; its YAML copy may not be (a migration whose
|
||||||
|
# YAML rewrite failed after the DB commit). Then rewrite it.
|
||||||
|
y = None
|
||||||
|
if os.path.exists(yaml_path):
|
||||||
|
try:
|
||||||
|
with open(yaml_path) as f:
|
||||||
|
y = yaml.safe_load(f)
|
||||||
|
except Exception:
|
||||||
|
y = None
|
||||||
|
force_yaml = registry_format.stored_version(y) < registry_format.CURRENT
|
||||||
|
|
||||||
old_sessions = get_all_sessions_status(d)
|
old_sessions = get_all_sessions_status(d)
|
||||||
# a mutation that replaces a row the monitor registered for a pane it
|
# a mutation that replaces a row the monitor registered for a pane it
|
||||||
# created itself (create_session.sh) drops that name from old_roles
|
# created itself (create_session.sh) drops that name from old_roles
|
||||||
@@ -167,7 +189,7 @@ def atomic_dump_yaml_main():
|
|||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
if new_sessions != old_sessions:
|
if force_yaml or new_sessions != old_sessions:
|
||||||
if os.path.exists(yaml_path):
|
if os.path.exists(yaml_path):
|
||||||
try:
|
try:
|
||||||
shutil.copy2(yaml_path, yaml_path + '.bak')
|
shutil.copy2(yaml_path, yaml_path + '.bak')
|
||||||
@@ -207,6 +229,7 @@ def atomic_dump_yaml_main():
|
|||||||
_flock_f.close()
|
_flock_f.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
return from_version, registry_format.stored_version(d), migrated_rows
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
atomic_dump_yaml_main()
|
atomic_dump_yaml_main()
|
||||||
@@ -13,7 +13,7 @@ import sys
|
|||||||
from typing import List, Sequence
|
from typing import List, Sequence
|
||||||
|
|
||||||
# Framework version locked with framework constants
|
# Framework version locked with framework constants
|
||||||
FRAMEWORK_VERSION = "4.10.1"
|
FRAMEWORK_VERSION = "4.11.0"
|
||||||
|
|
||||||
CORE_MODULES: Sequence[str] = (
|
CORE_MODULES: Sequence[str] = (
|
||||||
# Standard library modules
|
# Standard library modules
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Registry format version and its migration (Issue #71).
|
||||||
|
|
||||||
|
The session registry (agent-sessions.yaml + agent-sessions.db) carries a
|
||||||
|
top-level `registry_format` key; it lives in the DB `state` row like every
|
||||||
|
other top-level key. A registry without it is format 1.
|
||||||
|
|
||||||
|
1: rows may carry `kill_command` and `start_command`, and `attach_command`
|
||||||
|
reads `HERDR_SESSION_NAME=<server> herdr agent attach <original name>`.
|
||||||
|
2: no `kill_command` / `start_command`; `attach_command` is
|
||||||
|
lib_py.agents.sanitize.attach_command(<row server>, <row name>).
|
||||||
|
|
||||||
|
The writer (lib_py.atomic_yaml, the only code that writes the registry)
|
||||||
|
migrates an older registry under its lock before the caller's mutation, and
|
||||||
|
then rewrites the YAML as well as the DB. Readers (lib_py.state) never
|
||||||
|
migrate; they see the old rows until the first write.
|
||||||
|
|
||||||
|
Entry point (for #72, the update skill):
|
||||||
|
|
||||||
|
python -m lib_py.registry_format --yaml <agent-sessions.yaml>
|
||||||
|
|
||||||
|
runs that migration through the writer (same lock, YAML rewritten), prints one
|
||||||
|
line `registry-format: <from> -> <to>, rows changed: <n>` and exits 0 when the
|
||||||
|
registry was migrated or already current, nonzero on error. Run it with the
|
||||||
|
skills directory on PYTHONPATH (as lib.sh does), e.g.
|
||||||
|
`PYTHONPATH=.agents/skills python3 -m lib_py.registry_format --yaml .mam/agent-sessions.yaml`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import copy
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from lib_py.agents.sanitize import attach_command
|
||||||
|
|
||||||
|
FORMAT_KEY = "registry_format"
|
||||||
|
CURRENT = 2
|
||||||
|
_DROPPED = ("kill_command", "start_command")
|
||||||
|
|
||||||
|
|
||||||
|
def stored_version(d):
|
||||||
|
"""The format a registry dict says it has (1 when it says nothing)."""
|
||||||
|
v = d.get(FORMAT_KEY) if isinstance(d, dict) else None
|
||||||
|
return v if isinstance(v, int) and not isinstance(v, bool) else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _row_server(s):
|
||||||
|
return s.get("herdr_session") or s.get("herdr_server") or "default"
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(d):
|
||||||
|
"""Return (new registry dict, rows changed). Pure and idempotent: `d` is
|
||||||
|
not modified, and migrating the result again changes nothing. Every row is
|
||||||
|
migrated, `stopped` and `terminated` ones included."""
|
||||||
|
out = copy.deepcopy(d) if isinstance(d, dict) else {}
|
||||||
|
changed = 0
|
||||||
|
for s in out.get("herdr_sessions") or []:
|
||||||
|
if not isinstance(s, dict) or not s.get("name"):
|
||||||
|
continue
|
||||||
|
before = dict(s)
|
||||||
|
for k in _DROPPED:
|
||||||
|
s.pop(k, None)
|
||||||
|
s["attach_command"] = attach_command(_row_server(s), s["name"])
|
||||||
|
if s != before:
|
||||||
|
changed += 1
|
||||||
|
out[FORMAT_KEY] = CURRENT
|
||||||
|
return out, changed
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(prog="python -m lib_py.registry_format",
|
||||||
|
description="Migrate the session registry to the current format.")
|
||||||
|
ap.add_argument("--yaml", required=True, help="path of agent-sessions.yaml")
|
||||||
|
a = ap.parse_args(argv)
|
||||||
|
yaml_path = os.path.abspath(a.yaml)
|
||||||
|
db_path = os.path.splitext(yaml_path)[0] + ".db"
|
||||||
|
if not (os.path.exists(yaml_path) or os.path.exists(db_path)):
|
||||||
|
print("registry-format: error: no registry at %s" % yaml_path, file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
from lib_py.atomic_yaml import atomic_dump_yaml_main
|
||||||
|
os.environ["YAML_PATH"] = yaml_path
|
||||||
|
os.environ["AGENT_SESSIONS_MUTATION"] = "pass"
|
||||||
|
try:
|
||||||
|
frm, to, n = atomic_dump_yaml_main()
|
||||||
|
except (Exception, SystemExit) as exc: # the writer refuses with SystemExit
|
||||||
|
print("registry-format: error: %s" % (exc,), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print("registry-format: %d -> %d, rows changed: %d" % (frm, to, n), flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -155,11 +155,12 @@ class HerdrError(Exception):
|
|||||||
class HerdrSource(object):
|
class HerdrSource(object):
|
||||||
"""Every herdr and OS read the judgement needs, in one place."""
|
"""Every herdr and OS read the judgement needs, in one place."""
|
||||||
|
|
||||||
def __init__(self, target, agent, session=None):
|
def __init__(self, target, agent, session=None, *, name):
|
||||||
self.target, self.session = target, session or None
|
self.target, self.session = target, session or None
|
||||||
# herdr knows the agent only by its sanitized name (#66); the kind is
|
# `name` is the name herdr knows the agent by (#66), worked out once
|
||||||
# read off the original name, whose suffix a long name's hash replaces.
|
# by StallWatch (#71); the kind is read off the original name, whose
|
||||||
self.name = sanitize_herdr_agent_name(target)
|
# suffix a long name's hash replaces.
|
||||||
|
self.name = name
|
||||||
self.agent = resolve_agent(agent, target)
|
self.agent = resolve_agent(agent, target)
|
||||||
adapter = get_adapter(self.agent) if self.agent else None
|
adapter = get_adapter(self.agent) if self.agent else None
|
||||||
self.resident = tuple(adapter.resident_children) if adapter else ()
|
self.resident = tuple(adapter.resident_children) if adapter else ()
|
||||||
@@ -225,8 +226,8 @@ class HerdrSource(object):
|
|||||||
class StallWatch(object):
|
class StallWatch(object):
|
||||||
def __init__(self, args, source=None):
|
def __init__(self, args, source=None):
|
||||||
self.args = args
|
self.args = args
|
||||||
self.src = source or HerdrSource(args.target, args.agent, args.herdr_session)
|
|
||||||
self.herdr_name = sanitize_herdr_agent_name(args.target) # for herdr only; logs keep args.target
|
self.herdr_name = sanitize_herdr_agent_name(args.target) # for herdr only; logs keep args.target
|
||||||
|
self.src = source or HerdrSource(args.target, args.agent, args.herdr_session, name=self.herdr_name)
|
||||||
name = resolve_agent(args.agent, args.target)
|
name = resolve_agent(args.agent, args.target)
|
||||||
adapter = get_adapter(name) if name else None
|
adapter = get_adapter(name) if name else None
|
||||||
reliable = bool(adapter.agent_status_reliable) if adapter else True
|
reliable = bool(adapter.agent_status_reliable) if adapter else True
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-create
|
name: multi-agent-mux-create
|
||||||
description: "Create a new agent session (claude, antigravity/agy) in a dedicated herdr session for context-preserving long-running work. Always creates a herdr session — never backgrounds with nohup/disown. Writes the new session to .mam/agent-sessions.yaml. Use when you want to start a fresh agent (no prior UUID) for a new project workspace."
|
description: "Create a new agent session (claude, antigravity/agy) in a dedicated herdr session for context-preserving long-running work. Always creates a herdr session — never backgrounds with nohup/disown. Writes the new session to .mam/agent-sessions.yaml. Use when you want to start a fresh agent (no prior UUID) for a new project workspace."
|
||||||
version: 4.10.1
|
version: 4.11.0
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
@@ -146,12 +146,10 @@ herdr_sessions:
|
|||||||
cmd: claude
|
cmd: claude
|
||||||
cmd_full: claude --dangerously-skip-permissions
|
cmd_full: claude --dangerously-skip-permissions
|
||||||
cwd: /path/to/project
|
cwd: /path/to/project
|
||||||
start_command: "HERDR_SESSION_NAME=<herdr_session> herdr new-session -d -s <SESSION_NAME> -x 140 -y 40 -c <WORKSPACE> <CMD_FULL>"
|
attach_command: "herdr --session <herdr_session> agent attach <herdr name of SESSION_NAME>"
|
||||||
attach_command: "HERDR_SESSION_NAME=<herdr_session> herdr agent attach <SESSION_NAME>"
|
|
||||||
kill_command: "HERDR_SESSION_NAME=<herdr_session> herdr kill-session -t <SESSION_NAME>"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The three commands require `source .agents/skills/lib.sh` first — `new-session`/`kill-session` are tmux-compat pseudo-commands the shim translates, and `HERDR_SESSION_NAME` is what the shim reads to route to the right isolated herdr *session* (real `herdr` has no env-var-based scoping of its own; `herdr_session: default` needs no prefix at all).
|
`attach_command` is a native herdr command and runs from any shell: it always names the server (`default` too) and names the agent as herdr registered it (lower case, at most 32 characters: `lib_py/agents/sanitize.py`). Start and stop sessions only through the create, resume and stop skills.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
@@ -258,5 +256,5 @@ herdr agent read "$SESSION_NAME" --source visible --lines 20 # TUI ready = age
|
|||||||
|
|
||||||
- **Resuming an old conversation** → `multi-agent-mux-resume`
|
- **Resuming an old conversation** → `multi-agent-mux-resume`
|
||||||
- **Killing an existing session** → `multi-agent-mux-stop`
|
- **Killing an existing session** → `multi-agent-mux-stop`
|
||||||
- **Just attaching to an existing session** → `herdr agent attach <name>` (no skill needed)
|
- **Just attaching to an existing session** → the row's `attach_command` (no skill needed)
|
||||||
- **One-shot print mode (claude -p "...")** → no herdr needed; use `claude-code` skill's print mode
|
- **One-shot print mode (claude -p "...")** → no herdr needed; use `claude-code` skill's print mode
|
||||||
@@ -361,7 +361,7 @@ cleanup_herdr_on_error() {
|
|||||||
if [ "$alive" = "1" ]; then
|
if [ "$alive" = "1" ]; then
|
||||||
_mark_row_rolled_back "running" "rollback-failed"
|
_mark_row_rolled_back "running" "rollback-failed"
|
||||||
echo "ERROR: rollback could not kill herdr session '$SESSION_NAME' (kill rc=$kill_rc) - it is still running." >&2
|
echo "ERROR: rollback could not kill herdr session '$SESSION_NAME' (kill rc=$kill_rc) - it is still running." >&2
|
||||||
echo " Clean it up with: HERDR_SESSION_NAME=${HERDR_SESSION_NAME:-default} herdr kill-session -t $SESSION_NAME" >&2
|
echo " Clean it up with: bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session $SESSION_NAME --herdr-session ${HERDR_SESSION_NAME:-default}" >&2
|
||||||
exit 43
|
exit 43
|
||||||
fi
|
fi
|
||||||
_mark_row_rolled_back "terminated" "rolled-back"
|
_mark_row_rolled_back "terminated" "rolled-back"
|
||||||
@@ -428,11 +428,6 @@ PANE_PID=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null ||
|
|||||||
PANE_CWD=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_path}' 2>/dev/null || echo "$WORKSPACE")
|
PANE_CWD=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_path}' 2>/dev/null || echo "$WORKSPACE")
|
||||||
PANE_CMD=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_command}' 2>/dev/null || echo "$AGENT")
|
PANE_CMD=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_command}' 2>/dev/null || echo "$AGENT")
|
||||||
|
|
||||||
# 시작 명령
|
|
||||||
# NOTE: this must match what `spawn()` actually ran above — env-var-driven
|
|
||||||
# herdr server shim (HERDR_SESSION_NAME picked up by the lib.sh shim).
|
|
||||||
START_CMD="HERDR_SESSION_NAME=${HERDR_SESSION_NAME:-default} herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
|
|
||||||
|
|
||||||
# If --onboard is specified, automatically build the onboarding prompt (if session is ready)
|
# If --onboard is specified, automatically build the onboarding prompt (if session is ready)
|
||||||
if [ "$ONBOARD" = "1" ] && [ -z "$SUBMIT_JOB_PROMPT" ] && [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then
|
if [ "$ONBOARD" = "1" ] && [ -z "$SUBMIT_JOB_PROMPT" ] && [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then
|
||||||
SUBMIT_JOB_PROMPT="You are a newly spawned $ROLE Team Leader agent in this workspace. Without making any non-standard pre-preparations or modifying files directly, proceed immediately to align yourself with the project context by performing the following tasks:
|
SUBMIT_JOB_PROMPT="You are a newly spawned $ROLE Team Leader agent in this workspace. Without making any non-standard pre-preparations or modifying files directly, proceed immediately to align yourself with the project context by performing the following tasks:
|
||||||
@@ -508,12 +503,13 @@ fi
|
|||||||
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
|
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
|
||||||
SESSION_NAME="$SESSION_NAME" AGENT="$AGENT" NOW_ISO="$NOW_ISO" \
|
SESSION_NAME="$SESSION_NAME" AGENT="$AGENT" NOW_ISO="$NOW_ISO" \
|
||||||
HERDR_EPOCH="$HERDR_EPOCH" PANE_PID="$PANE_PID" PANE_CWD="$PANE_CWD" \
|
HERDR_EPOCH="$HERDR_EPOCH" PANE_PID="$PANE_PID" PANE_CWD="$PANE_CWD" \
|
||||||
CMD_FULL="$CMD_FULL" START_CMD="$START_CMD" CHILD_PID="$CHILD_PID" \
|
CMD_FULL="$CMD_FULL" CHILD_PID="$CHILD_PID" \
|
||||||
HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-default}" \
|
HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-default}" \
|
||||||
MAM_WS_LABEL="$MAM_WS_LABEL" \
|
MAM_WS_LABEL="$MAM_WS_LABEL" \
|
||||||
SESSION_UUID="$SESSION_UUID" \
|
SESSION_UUID="$SESSION_UUID" \
|
||||||
LAST_VISIBLE_STATUS_OVERRIDE="${LAST_VISIBLE_STATUS_OVERRIDE:-}" \
|
LAST_VISIBLE_STATUS_OVERRIDE="${LAST_VISIBLE_STATUS_OVERRIDE:-}" \
|
||||||
DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" CONTROL_PLANE_JSON="$CP_JSON" <<'PYEOF'
|
DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" CONTROL_PLANE_JSON="$CP_JSON" <<'PYEOF'
|
||||||
|
from lib_py.agents.sanitize import attach_command
|
||||||
name = os.environ['SESSION_NAME']
|
name = os.environ['SESSION_NAME']
|
||||||
agent = os.environ['AGENT']
|
agent = os.environ['AGENT']
|
||||||
role = os.environ['ROLE']
|
role = os.environ['ROLE']
|
||||||
@@ -567,9 +563,7 @@ entry = {
|
|||||||
'cmd_full': os.environ['CMD_FULL'],
|
'cmd_full': os.environ['CMD_FULL'],
|
||||||
'cwd': os.environ['PANE_CWD'],
|
'cwd': os.environ['PANE_CWD'],
|
||||||
},
|
},
|
||||||
'start_command': os.environ['START_CMD'],
|
'attach_command': attach_command(server_name, name),
|
||||||
'attach_command': f'HERDR_SESSION_NAME={server_name} herdr agent attach {name}',
|
|
||||||
'kill_command': f'HERDR_SESSION_NAME={server_name} herdr kill-session -t {name}',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if agent == 'claude':
|
if agent == 'claude':
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-delegate-job
|
name: multi-agent-mux-delegate-job
|
||||||
description: "Delegate a unit of work to any autonomous agent (claude-code, hermes, agy, grok-build, codex, or a human) and observe it asynchronously over an MQTT event channel. Supported roles include orchestrator, worker, and reviewer."
|
description: "Delegate a unit of work to any autonomous agent (claude-code, hermes, agy, grok-build, codex, or a human) and observe it asynchronously over an MQTT event channel. Supported roles include orchestrator, worker, and reviewer."
|
||||||
version: 4.10.1
|
version: 4.11.0
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos, windows]
|
platforms: [linux, macos, windows]
|
||||||
|
|||||||
@@ -895,8 +895,8 @@ finalize_unfinished() {
|
|||||||
FINAL_JOB_STATUS="$final"
|
FINAL_JOB_STATUS="$final"
|
||||||
if [ "$step" = "probe" ]; then
|
if [ "$step" = "probe" ]; then
|
||||||
local sess="${target_sess#herdr:}" target_agent diag="no"
|
local sess="${target_sess#herdr:}" target_agent diag="no"
|
||||||
target_agent=$(_sanitize_herdr_agent_name "$sess")
|
target_agent=$(_sanitize_herdr_agent_name "$sess") || target_agent=""
|
||||||
_within_deadline "$deadline" _agent_still_working "$sess" "$target_agent" && diag="yes"
|
[ -n "$target_agent" ] && _within_deadline "$deadline" _agent_still_working "$sess" "$target_agent" && diag="yes"
|
||||||
echo "liveness: no wire event for ${IDLE_TIMEOUT}s on job $job_id; herdr/pane says busy=$diag (diagnostic only, not evidence)" >&2
|
echo "liveness: no wire event for ${IDLE_TIMEOUT}s on job $job_id; herdr/pane says busy=$diag (diagnostic only, not evidence)" >&2
|
||||||
fi
|
fi
|
||||||
# The answer comes back on fd 3: stdout carries registry.py's close output.
|
# The answer comes back on fd 3: stdout carries registry.py's close output.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-loop
|
name: multi-agent-mux-loop
|
||||||
description: "Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. Automatically orchestrates plan discussion, code changes, and peer reviews until a unanimous PASS is achieved or the maximum iteration limit is reached."
|
description: "Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. Automatically orchestrates plan discussion, code changes, and peer reviews until a unanimous PASS is achieved or the maximum iteration limit is reached."
|
||||||
version: 4.10.1
|
version: 4.11.0
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-monitor
|
name: multi-agent-mux-monitor
|
||||||
description: "Run a long-lived reconciler that watches .mam/agent-sessions.yaml against the actual herdr/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Runs as a persistent loop (`reconcile.sh --subscribe`) that keeps going until it times out, idles out, or is interrupted."
|
description: "Run a long-lived reconciler that watches .mam/agent-sessions.yaml against the actual herdr/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Runs as a persistent loop (`reconcile.sh --subscribe`) that keeps going until it times out, idles out, or is interrupted."
|
||||||
version: 4.10.1
|
version: 4.11.0
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
|
|||||||
@@ -566,7 +566,7 @@ 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
|
from lib_py.agents.sanitize import sanitize_herdr_agent_name as _sanitize, attach_command
|
||||||
|
|
||||||
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')}
|
||||||
@@ -656,7 +656,6 @@ if herdr_confirmed:
|
|||||||
_ambient = os.environ.get('HERDR_SESSION_NAME', '')
|
_ambient = os.environ.get('HERDR_SESSION_NAME', '')
|
||||||
if srv == 'default' and _ambient and _ambient != 'default':
|
if srv == 'default' and _ambient and _ambient != 'default':
|
||||||
srv = _ambient
|
srv = _ambient
|
||||||
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,
|
||||||
# which tightens the stale-transcript guard, whereas 0 disables it and
|
# which tightens the stale-transcript guard, whereas 0 disables it and
|
||||||
@@ -674,9 +673,7 @@ if herdr_confirmed:
|
|||||||
'herdr_server': srv,
|
'herdr_server': srv,
|
||||||
'herdr_workspace': _slug(pm['cwd']),
|
'herdr_workspace': _slug(pm['cwd']),
|
||||||
'pane': {'index': 0, 'pid': pm['pid'], 'cmd': agent, 'cmd_full': cmd_full, 'cwd': pm['cwd']},
|
'pane': {'index': 0, 'pid': pm['pid'], 'cmd': agent, 'cmd_full': cmd_full, 'cwd': pm['cwd']},
|
||||||
'start_command': f'HERDR_SESSION_NAME={srv} herdr new-session -d -s "{name}" -x 140 -y 40 -c "{pm["cwd"]}" "{cmd_full}"',
|
'attach_command': attach_command(srv, name),
|
||||||
'attach_command': f'HERDR_SESSION_NAME={srv} herdr agent attach {name}',
|
|
||||||
'kill_command': f'herdr {server_opt}kill-session -t {name}',
|
|
||||||
'last_visible_status': 'running',
|
'last_visible_status': 'running',
|
||||||
'last_visible_note': 'auto-registered by monitor',
|
'last_visible_note': 'auto-registered by monitor',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-orc-onboard
|
name: multi-agent-mux-orc-onboard
|
||||||
description: "Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture."
|
description: "Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture."
|
||||||
version: 4.10.1
|
version: 4.11.0
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-resume
|
name: multi-agent-mux-resume
|
||||||
description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a herdr session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a herdr session of the matching name, and runs `claude -r <id>` or `agy --conversation <id>` inside. Use when you want to reattach to a previous session's context, or revive a session whose herdr died but the agent's conversation is still on disk."
|
description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a herdr session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a herdr session of the matching name, and runs `claude -r <id>` or `agy --conversation <id>` inside. Use when you want to reattach to a previous session's context, or revive a session whose herdr died but the agent's conversation is still on disk."
|
||||||
version: 4.10.1
|
version: 4.11.0
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
@@ -27,7 +27,7 @@ Three cases this skill handles:
|
|||||||
|
|
||||||
1. **herdr is dead, conversation lives** — `agent-sessions.yaml` has the UUID. The JSONL/db is on disk. Re-spawn the herdr session + run `claude -r <id>` / `agy --conversation <id>`.
|
1. **herdr is dead, conversation lives** — `agent-sessions.yaml` has the UUID. The JSONL/db is on disk. Re-spawn the herdr session + run `claude -r <id>` / `agy --conversation <id>`.
|
||||||
2. **herdr is alive but empty** — You started a session with `multi-agent-mux-create` but haven't sent a message yet (so no session id was assigned). The user can either send their first message (and the id is auto-assigned), or you can read the *workspace's* most recent conversation from `$HOME_DIR/.gemini/antigravity-cli/cache/last_conversations.json` (defaults to `~/.gemini/...`) for agy, or the latest `*.jsonl` in `$CLAUDE_PROJECT_DIR/<workspace-key>/` (defaults to `~/.claude/projects/`) for claude.
|
2. **herdr is alive but empty** — You started a session with `multi-agent-mux-create` but haven't sent a message yet (so no session id was assigned). The user can either send their first message (and the id is auto-assigned), or you can read the *workspace's* most recent conversation from `$HOME_DIR/.gemini/antigravity-cli/cache/last_conversations.json` (defaults to `~/.gemini/...`) for agy, or the latest `*.jsonl` in `$CLAUDE_PROJECT_DIR/<workspace-key>/` (defaults to `~/.claude/projects/`) for claude.
|
||||||
3. **herdr is alive AND the agent inside is already running** — `resume_session.sh` only updates the row and exits 0. It does not attach and does not check the agent's permission flags (#45); attach with `herdr --session <server> agent attach <name>`, where `<server>` is the row's `herdr_session` (the server it was resumed on).
|
3. **herdr is alive AND the agent inside is already running** — `resume_session.sh` only updates the row and exits 0. It does not attach and does not check the agent's permission flags (#45); attach with the row's `attach_command` (`herdr --session <server> agent attach <name>`, where `<server>` is the row's `herdr_session` and `<name>` the name herdr registered).
|
||||||
|
|
||||||
### Resuming a `stopped` session (`stopped → running`)
|
### Resuming a `stopped` session (`stopped → running`)
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ If the resolved id is empty → for Class A agents (`agy`, `hermes`, `opencode`,
|
|||||||
bash .agents/skills/multi-agent-mux-resume/scripts/resume_session.sh --workspace <path> --agent <agent> --session <name>
|
bash .agents/skills/multi-agent-mux-resume/scripts/resume_session.sh --workspace <path> --agent <agent> --session <name>
|
||||||
```
|
```
|
||||||
|
|
||||||
`resume_session.sh` does the whole flow: original-cwd check, control-plane binding, the session-id decision (`lib_py/agents/session_ids.py`), the herdr-alive check, the agent command (adapter `resume-spec`, or `spawn-spec` for a Class A fresh spawn), spawn (just before it, the session's own empty shell left by a server restart is closed: same session id, no agent, nothing running; `lib_py/herdr_restore.py`), start-up dialogs, and the YAML update (`update_yaml_resumed.sh`, which also publishes a `progress "resumed"` event when the row has a `delegate_job_id`). It never attaches; attaching is a separate step that names the server, because the server the script exports does not reach the calling shell and the native `herdr` binary does not route on environment variables: `herdr --session <server> agent attach <name>` (`<server>` = the `--herdr-session` you passed, or the row's `herdr_session`).
|
`resume_session.sh` does the whole flow: original-cwd check, control-plane binding, the session-id decision (`lib_py/agents/session_ids.py`), the herdr-alive check, the agent command (adapter `resume-spec`, or `spawn-spec` for a Class A fresh spawn), spawn (just before it, the session's own empty shell left by a server restart is closed: same session id, no agent, nothing running; `lib_py/herdr_restore.py`), start-up dialogs, and the YAML update (`update_yaml_resumed.sh`, which also publishes a `progress "resumed"` event when the row has a `delegate_job_id`). It never attaches; attaching is a separate step that names the server, because the server the script exports does not reach the calling shell and the native `herdr` binary does not route on environment variables: the row's `attach_command`, `herdr --session <server> agent attach <name>` (`<server>` = the `--herdr-session` you passed, or the row's `herdr_session`; `<name>` = the name herdr registered).
|
||||||
|
|
||||||
| Option | Meaning |
|
| Option | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -136,4 +136,4 @@ herdr --session "$SERVER" agent read "$SESSION_NAME" --source visible --lines 30
|
|||||||
|
|
||||||
- **No saved session yet** → `multi-agent-mux-create`
|
- **No saved session yet** → `multi-agent-mux-create`
|
||||||
- **Killing an existing session** → `multi-agent-mux-stop`
|
- **Killing an existing session** → `multi-agent-mux-stop`
|
||||||
- **Just attaching** → `herdr --session <server> agent attach <name>` (no skill needed; `<server>` = the row's `herdr_session`)
|
- **Just attaching** → the row's `attach_command` (no skill needed; `herdr --session <server> agent attach <name>`, `<name>` = the name herdr registered)
|
||||||
@@ -116,6 +116,7 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
|
|||||||
MAM_WS_LABEL="$MAM_WS_LABEL" MAM_WS_LABEL_EXPLICIT="${MAM_WS_LABEL_EXPLICIT:-0}" \
|
MAM_WS_LABEL="$MAM_WS_LABEL" MAM_WS_LABEL_EXPLICIT="${MAM_WS_LABEL_EXPLICIT:-0}" \
|
||||||
ALREADY_RUNNING="$ALREADY_RUNNING" DROP_STALE_ID="$DROP_STALE_ID" \
|
ALREADY_RUNNING="$ALREADY_RUNNING" DROP_STALE_ID="$DROP_STALE_ID" \
|
||||||
CMD_FULL_OPT="$CMD_FULL_OPT" <<'PYEOF'
|
CMD_FULL_OPT="$CMD_FULL_OPT" <<'PYEOF'
|
||||||
|
from lib_py.agents.sanitize import attach_command
|
||||||
name = os.environ['SESSION_NAME']
|
name = os.environ['SESSION_NAME']
|
||||||
uuid = os.environ['UUID']
|
uuid = os.environ['UUID']
|
||||||
agent = os.environ['AGENT']
|
agent = os.environ['AGENT']
|
||||||
@@ -147,9 +148,7 @@ if target is None:
|
|||||||
'herdr_workspace': wsl,
|
'herdr_workspace': wsl,
|
||||||
'delegate_job_id': None,
|
'delegate_job_id': None,
|
||||||
'pane': {'index': 0, 'pid': int(pane_pid) if pane_pid.isdigit() else 0, 'cmd': agent, 'cwd': ws_root},
|
'pane': {'index': 0, 'pid': int(pane_pid) if pane_pid.isdigit() else 0, 'cmd': agent, 'cwd': ws_root},
|
||||||
'start_command': f'HERDR_SESSION_NAME={server_name} herdr agent attach {name}',
|
'attach_command': attach_command(server_name, name),
|
||||||
'attach_command': f'HERDR_SESSION_NAME={server_name} herdr agent attach {name}',
|
|
||||||
'kill_command': f'HERDR_SESSION_NAME={server_name} herdr kill-session -t {name}',
|
|
||||||
}
|
}
|
||||||
d.setdefault('herdr_sessions', []).append(target)
|
d.setdefault('herdr_sessions', []).append(target)
|
||||||
else:
|
else:
|
||||||
@@ -159,9 +158,7 @@ else:
|
|||||||
if is_explicit or not target.get('herdr_session'):
|
if is_explicit or not target.get('herdr_session'):
|
||||||
target['herdr_session'] = sn
|
target['herdr_session'] = sn
|
||||||
target['herdr_server'] = sn
|
target['herdr_server'] = sn
|
||||||
target['start_command'] = f'HERDR_SESSION_NAME={sn} herdr agent attach {name}'
|
target['attach_command'] = attach_command(sn, name)
|
||||||
target['attach_command'] = f'HERDR_SESSION_NAME={sn} herdr agent attach {name}'
|
|
||||||
target['kill_command'] = f'HERDR_SESSION_NAME={sn} herdr kill-session -t {name}'
|
|
||||||
wsl = os.environ.get('MAM_WS_LABEL', '')
|
wsl = os.environ.get('MAM_WS_LABEL', '')
|
||||||
ws_explicit = os.environ.get('MAM_WS_LABEL_EXPLICIT') == '1'
|
ws_explicit = os.environ.get('MAM_WS_LABEL_EXPLICIT') == '1'
|
||||||
if wsl and (ws_explicit or not target.get('herdr_workspace')):
|
if wsl and (ws_explicit or not target.get('herdr_workspace')):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-status
|
name: multi-agent-mux-status
|
||||||
description: "Read-only instant snapshot of all agent herdr sessions — name, YAML status, herdr alive, pane cmd/cwd, resume UUID on disk, and any drift. No mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up the monitor loop."
|
description: "Read-only instant snapshot of all agent herdr sessions — name, YAML status, herdr alive, pane cmd/cwd, resume UUID on disk, and any drift. No mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up the monitor loop."
|
||||||
version: 4.10.1
|
version: 4.11.0
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
@@ -94,11 +94,10 @@ lab-paper-pdf2md-creator-claude default running alive clau
|
|||||||
"job_id": null,
|
"job_id": null,
|
||||||
"job_status": null,
|
"job_status": null,
|
||||||
"pane_cwd": "/home/.../refer_landing_page",
|
"pane_cwd": "/home/.../refer_landing_page",
|
||||||
"attach_command": "HERDR_SESSION_NAME=default herdr agent attach lab-landing-page-creator-claude",
|
"attach_command": "herdr --session default agent attach lab-landing-page-creator-claude",
|
||||||
"drift_classes": [],
|
"drift_classes": [],
|
||||||
"pane_pid": 12345,
|
"pane_pid": 12345,
|
||||||
"cmd_full": "claude --dangerously-skip-permissions --session-id <uuid>",
|
"cmd_full": "claude --dangerously-skip-permissions --session-id <uuid>",
|
||||||
"start_command": "...",
|
|
||||||
"last_visible_status": "pinned"
|
"last_visible_status": "pinned"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -221,11 +221,10 @@ for s in d.get('herdr_sessions', []):
|
|||||||
'pane_cwd': pane.get('cwd'),
|
'pane_cwd': pane.get('cwd'),
|
||||||
'attach_command': s.get('attach_command'),
|
'attach_command': s.get('attach_command'),
|
||||||
'drift_classes': drift_by_name.get(name, []),
|
'drift_classes': drift_by_name.get(name, []),
|
||||||
# Additive beyond the D8 example — needed by the M1 Detail Pane
|
# Additive beyond the D8 example (pane pid / full launch command /
|
||||||
# (pane pid / full launch command / start command / last banner text).
|
# last banner text).
|
||||||
'pane_pid': pane.get('pid'),
|
'pane_pid': pane.get('pid'),
|
||||||
'cmd_full': pane.get('cmd_full'),
|
'cmd_full': pane.get('cmd_full'),
|
||||||
'start_command': s.get('start_command'),
|
|
||||||
'last_visible_status': s.get('last_visible_status'),
|
'last_visible_status': s.get('last_visible_status'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-stop
|
name: multi-agent-mux-stop
|
||||||
description: "Stop an agent herdr session (claude, antigravity/agy) and update .mam/agent-sessions.yaml. Default stops gracefully and marks status=stopped with conversation preserved for resume. Does NOT delete on-disk conversation artifacts (jsonl/db) — those are preserved unless --purge-conversation is passed. Use when ending a work session, switching to a different one, or cleaning up before a fresh start."
|
description: "Stop an agent herdr session (claude, antigravity/agy) and update .mam/agent-sessions.yaml. Default stops gracefully and marks status=stopped with conversation preserved for resume. Does NOT delete on-disk conversation artifacts (jsonl/db) — those are preserved unless --purge-conversation is passed. Use when ending a work session, switching to a different one, or cleaning up before a fresh start."
|
||||||
version: 4.10.1
|
version: 4.11.0
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
@@ -25,7 +25,6 @@ Stop an agent's herdr session gracefully, resolve and store the conversation ID,
|
|||||||
|
|
||||||
- The herdr session's recorded `pane.pid / cmd / cwd / mcp_attachments` for audit
|
- The herdr session's recorded `pane.pid / cmd / cwd / mcp_attachments` for audit
|
||||||
- The agent's on-disk conversation (claude `*.jsonl`, agy `conversations/*.db`) — so the user can `multi-agent-mux-resume` later
|
- The agent's on-disk conversation (claude `*.jsonl`, agy `conversations/*.db`) — so the user can `multi-agent-mux-resume` later
|
||||||
- The `start_command` so a future `multi-agent-mux-create --session <name>` reproduces the same herdr spec
|
|
||||||
|
|
||||||
The stop command is always **graceful by default**:
|
The stop command is always **graceful by default**:
|
||||||
1. Sends exit keys to the agent TUI (`/exit` for Claude, `/exit` for Agy) and waits 3 seconds.
|
1. Sends exit keys to the agent TUI (`/exit` for Claude, `/exit` for Agy) and waits 3 seconds.
|
||||||
@@ -35,7 +34,7 @@ The stop command is always **graceful by default**:
|
|||||||
|
|
||||||
## Pre-flight
|
## Pre-flight
|
||||||
|
|
||||||
`stop_session.sh` checks this itself: the YAML exists and the session is registered (otherwise exit 1), and a session already `stopped` is a no-op (exit 0).
|
`stop_session.sh` checks this itself: the YAML exists (otherwise exit 1), and a session already `stopped` is a no-op (exit 0). A session with no registry row (its create or resume died before registering it) is still closed, herdr only and with no `--agent`: only when herdr runs it inside this workspace, on the `--herdr-session` server (or `HERDR_SESSION_NAME`). The registry is not changed.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
@@ -59,10 +58,12 @@ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
|
|||||||
| Exit | Meaning |
|
| Exit | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 0 | Stopped; or already `stopped` (no-op, except with `--purge-conversation`, which still purges) |
|
| 0 | Stopped; or already `stopped` (no-op, except with `--purge-conversation`, which still purges) |
|
||||||
| 1 | From the checks before the stop, nothing was changed: YAML not found, or the session is not registered. **The registry update after the stop also exits 1** (the row disappeared from the YAML during the run) |
|
| 1 | From the checks before the stop, nothing was changed: YAML not found, its row could not be read, or the session is neither registered nor on the herdr server. **The registry update after the stop also exits 1** (the row disappeared from the YAML during the run) |
|
||||||
| 2 | Bad arguments (including the retired `--mode` / `--capture-id` / `--graceful`), or `--agent` omitted and not resolvable |
|
| 2 | Bad arguments (including the retired `--mode` / `--capture-id` / `--graceful`), or `--agent` omitted and not resolvable |
|
||||||
| 3 | `--purge-conversation` without `--yes` |
|
| 3 | `--purge-conversation` without `--yes` |
|
||||||
| 4 | Purge aborted: the herdr session, or an agent process still holding the conversation, survived the kill chain. Records preserved: the registry row and the conversation files are untouched, though the session itself may already be down |
|
| 4 | Purge aborted: the herdr session, or an agent process still holding the conversation, survived the kill chain. Records preserved: the registry row and the conversation files are untouched, though the session itself may already be down |
|
||||||
|
| 5 | No registry row, and herdr runs the session outside this workspace: refused, nothing changed |
|
||||||
|
| 6 | No registry row, and its herdr session survived `kill-session`; the registry is unchanged |
|
||||||
| other | The registry update after the stop (YAML / DB write) failed and its code is passed through |
|
| other | The registry update after the stop (YAML / DB write) failed and its code is passed through |
|
||||||
|
|
||||||
Exits 2 and 3, and exit 1 from the pre-stop checks, come before anything is stopped, so nothing was changed. On exit 1 from the registry update, or an `other` exit, the session may already be down while its row still says `running`: check with `multi-agent-mux-status`, then run the stop again (it is idempotent).
|
Exits 2 and 3, and exit 1 from the pre-stop checks, come before anything is stopped, so nothing was changed. On exit 1 from the registry update, or an `other` exit, the session may already be down while its row still says `running`: check with `multi-agent-mux-status`, then run the stop again (it is idempotent).
|
||||||
|
|||||||
@@ -28,9 +28,12 @@
|
|||||||
# 구분은 --purge-conversation 유무로 대체되었습니다.
|
# 구분은 --purge-conversation 유무로 대체되었습니다.
|
||||||
#
|
#
|
||||||
# Exit codes:
|
# Exit codes:
|
||||||
# 0 = success (or already-stopped no-op) | 1 = YAML not found / not registered
|
# 0 = success (or already-stopped no-op; or a session with no row closed)
|
||||||
|
# 1 = YAML not found / its row unreadable / not registered and not on the herdr server
|
||||||
# 2 = invalid args | 3 = interactive confirmation required (--yes 누락)
|
# 2 = invalid args | 3 = interactive confirmation required (--yes 누락)
|
||||||
# 4 = purge aborted (herdr session or an agent process holding the conversation survived the kill chain)
|
# 4 = purge aborted (herdr session or an agent process holding the conversation survived the kill chain)
|
||||||
|
# 5 = no row, and herdr runs the session outside this workspace (refused, nothing changed)
|
||||||
|
# 6 = no row, and its herdr session survived kill-session (registry unchanged)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
@@ -103,6 +106,7 @@ if [ "$PURGE" = "1" ]; then
|
|||||||
trap 'rm -f "$WORKSPACE_ROOT/.mam/purging-$SESSION_NAME"' EXIT
|
trap 'rm -f "$WORKSPACE_ROOT/.mam/purging-$SESSION_NAME"' EXIT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
AMBIENT_HERDR_SESSION="${HERDR_SESSION_NAME:-}"
|
||||||
if [ -n "$HERDR_SERVER_OPT" ]; then
|
if [ -n "$HERDR_SERVER_OPT" ]; then
|
||||||
export HERDR_SESSION_NAME="$HERDR_SERVER_OPT"
|
export HERDR_SESSION_NAME="$HERDR_SERVER_OPT"
|
||||||
else
|
else
|
||||||
@@ -110,20 +114,64 @@ else
|
|||||||
export HERDR_SESSION_NAME
|
export HERDR_SESSION_NAME
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --agent 미지정 시 레지스트리 기록으로 해석 (B-21).
|
# Issue #71: a session with no registry row (its create/resume died before
|
||||||
# ① row['agent'] → ② 세션명 접미사 → ③ pane.cmd 순. 셋 다 실패하면
|
# registering it) is closed here - herdr only, the registry is left alone. It
|
||||||
# 종전과 동일하게 exit 2 (헤더 :27-30 의 종료 코드 계약 유지).
|
# has no agent kind, so --agent is not needed. Only a session herdr runs
|
||||||
if [ -z "$AGENT" ]; then
|
# inside this workspace is closed; anything else changes nothing. With no row
|
||||||
AGENT="$(resolve_agent_type_from_registry "$SESSION_NAME")" || AGENT=""
|
# the server is chosen as create_session.sh chose it: --herdr-session, else
|
||||||
[ -n "$AGENT" ] || {
|
# the environment's HERDR_SESSION_NAME, else the workspace's server.
|
||||||
echo "ERROR: cannot infer agent from '$SESSION_NAME'; pass --agent" >&2
|
stop_rowless() {
|
||||||
exit 2
|
if [ "$PURGE" = "1" ]; then
|
||||||
}
|
echo "ERROR: session '$SESSION_NAME' not in $AGENT_SESSIONS_YAML; --purge-conversation needs its row. Nothing changed." >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if [ -z "$HERDR_SERVER_OPT" ] && [ -n "$AMBIENT_HERDR_SESSION" ] && [ "$AMBIENT_HERDR_SESSION" != "default" ]; then
|
||||||
|
export HERDR_SESSION_NAME="$AMBIENT_HERDR_SESSION"
|
||||||
|
fi
|
||||||
|
local info cwd
|
||||||
|
info=$(herdr agent get "$SESSION_NAME" 2>/dev/null) || info=""
|
||||||
|
if [ -z "$info" ]; then
|
||||||
|
echo "ERROR: session '$SESSION_NAME' not in $AGENT_SESSIONS_YAML, and herdr server '${HERDR_SESSION_NAME:-default}' has no agent of that name. Nothing changed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cwd=$(printf '%s' "$info" | python3 -c '
|
||||||
|
import sys, json
|
||||||
|
raw = sys.stdin.read()
|
||||||
|
try:
|
||||||
|
print(json.loads(raw[raw.index("{"):]).get("result", {}).get("agent", {}).get("cwd", "") or "")
|
||||||
|
except Exception:
|
||||||
|
print("")
|
||||||
|
')
|
||||||
|
if ! PANE_CWD="$cwd" python3 -c '
|
||||||
|
import os, sys
|
||||||
|
cwd, root = os.environ.get("PANE_CWD", ""), os.environ.get("WORKSPACE_ROOT", "")
|
||||||
|
if not cwd or not root:
|
||||||
|
sys.exit(1)
|
||||||
|
cwd, root = os.path.realpath(cwd), os.path.realpath(root)
|
||||||
|
sys.exit(0 if cwd == root or cwd.startswith(root + os.sep) else 1)
|
||||||
|
'; then
|
||||||
|
echo "ERROR: refusing to stop '$SESSION_NAME': it is not in $AGENT_SESSIONS_YAML, and herdr runs it in '${cwd:-<unknown>}', outside this workspace ($WORKSPACE_ROOT). Nothing changed." >&2
|
||||||
|
exit 5
|
||||||
|
fi
|
||||||
|
echo "session '$SESSION_NAME' has no registry row: closing its herdr session only (registry unchanged)"
|
||||||
|
herdr kill-session -t "$SESSION_NAME" 2>/dev/null || true
|
||||||
|
_wait_session_gone "$SESSION_NAME" 8 || true
|
||||||
|
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
|
||||||
|
echo "ERROR: herdr session '$SESSION_NAME' is still running after kill-session (registry unchanged)" >&2
|
||||||
|
exit 6
|
||||||
|
fi
|
||||||
|
echo "closed herdr session '$SESSION_NAME' on server '${HERDR_SESSION_NAME:-default}' (no registry row)"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
# 세션이 YAML 에 있는지 + 해당 row 의 워크스페이스 cwd 및 delegate_job_id 추출.
|
# 세션이 YAML 에 있는지 + 해당 row 의 워크스페이스 cwd 및 delegate_job_id 추출.
|
||||||
# JSON 으로 emit — cwd 에 '|' 가 들어가도 안전 (review item 7; 기존 cwd|jid 파서 대체).
|
# JSON 으로 emit — cwd 에 '|' 가 들어가도 안전 (review item 7; 기존 cwd|jid 파서 대체).
|
||||||
MAPPED_DATA=$(MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$SESSION_NAME" python3 -c "
|
# A registry that cannot be read is not "no row" (Issue #71): stop before herdr.
|
||||||
|
STATE_JSON=$(load_state_json strict) || {
|
||||||
|
echo "ERROR: cannot read $AGENT_SESSIONS_YAML, so whether '$SESSION_NAME' has a row is unknown. Nothing changed." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
MAPPED_DATA=$(MAM_STATE_JSON="$STATE_JSON" SESSION_NAME="$SESSION_NAME" python3 -c "
|
||||||
import sys, os, json
|
import sys, os, json
|
||||||
name = os.environ['SESSION_NAME']
|
name = os.environ['SESSION_NAME']
|
||||||
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
||||||
@@ -136,10 +184,24 @@ for s in d.get('herdr_sessions', []):
|
|||||||
'delegate_job_logs_dir': cp.get('delegate_job_logs_dir', '') or ''}))
|
'delegate_job_logs_dir': cp.get('delegate_job_logs_dir', '') or ''}))
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
sys.exit(7)
|
sys.exit(7)
|
||||||
") || {
|
") || MAPPED_RC=$?
|
||||||
echo "ERROR: session '$SESSION_NAME' not in $AGENT_SESSIONS_YAML" >&2
|
# 7 = no row; any other failure (registry unreadable) stops as before.
|
||||||
|
[ "${MAPPED_RC:-0}" -ne 7 ] || stop_rowless
|
||||||
|
if [ "${MAPPED_RC:-0}" -ne 0 ]; then
|
||||||
|
echo "ERROR: cannot read the row of '$SESSION_NAME' from $AGENT_SESSIONS_YAML" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --agent 미지정 시 레지스트리 기록으로 해석 (B-21).
|
||||||
|
# ① row['agent'] → ② 세션명 접미사 → ③ pane.cmd 순. 셋 다 실패하면
|
||||||
|
# 종전과 동일하게 exit 2 (헤더 :27-30 의 종료 코드 계약 유지).
|
||||||
|
if [ -z "$AGENT" ]; then
|
||||||
|
AGENT="$(resolve_agent_type_from_registry "$SESSION_NAME")" || AGENT=""
|
||||||
|
[ -n "$AGENT" ] || {
|
||||||
|
echo "ERROR: cannot infer agent from '$SESSION_NAME'; pass --agent" >&2
|
||||||
|
exit 2
|
||||||
}
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
TARGET_CWD=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("cwd",""))')
|
TARGET_CWD=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("cwd",""))')
|
||||||
DELEGATE_JOB_ID=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("job_id",""))')
|
DELEGATE_JOB_ID=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("job_id",""))')
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다.
|
본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다.
|
||||||
|
|
||||||
[](VERSIONS.md)
|
[](VERSIONS.md)
|
||||||
|
|
||||||
새로운 에이전트는 이 안내서에 기술된 절차를 순차적으로 실행하여 초기 환경을 안정적으로 설정할 수 있습니다.
|
새로운 에이전트는 이 안내서에 기술된 절차를 순차적으로 실행하여 초기 환경을 안정적으로 설정할 수 있습니다.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
This document guides you through the setup and initialization procedures required to adopt the `herdr_agent_orchestration` orchestration and messaging backplane workflow in a new project, enabling a new developer or agent to get up and running quickly.
|
This document guides you through the setup and initialization procedures required to adopt the `herdr_agent_orchestration` orchestration and messaging backplane workflow in a new project, enabling a new developer or agent to get up and running quickly.
|
||||||
|
|
||||||
[](VERSIONS.md)
|
[](VERSIONS.md)
|
||||||
|
|
||||||
A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment.
|
A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Messaging System Technical Analysis & Architecture Report
|
# Messaging System Technical Analysis & Architecture Report
|
||||||
|
|
||||||
Applies to: MAM v4.10.1
|
Applies to: MAM v4.11.0
|
||||||
|
|
||||||
This report provides a comprehensive, deep-dive analysis of the messaging system implemented in the `multi-agent-mux-delegate-job` skill. It covers the MQTT broker architecture, event protocols, job lifecycles, codebase internals, cross-system integration, and a list of known limitations along with production recommendations.
|
This report provides a comprehensive, deep-dive analysis of the messaging system implemented in the `multi-agent-mux-delegate-job` skill. It covers the MQTT broker architecture, event protocols, job lifecycles, codebase internals, cross-system integration, and a list of known limitations along with production recommendations.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[herdr](https://herdr.dev)와 MQTT 위에 만든 **다중 에이전트 오케스트레이션·메시징 백플레인**입니다. 장시간 실행되는 코딩 에이전트(`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`)를 격리된 herdr pane 에서 돌리고, 작업을 추적되는 잡으로 위임하며, 모든 리뷰어가 통과시킬 때까지 planner / creator / reviewer 루프를 돌립니다.
|
[herdr](https://herdr.dev)와 MQTT 위에 만든 **다중 에이전트 오케스트레이션·메시징 백플레인**입니다. 장시간 실행되는 코딩 에이전트(`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`)를 격리된 herdr pane 에서 돌리고, 작업을 추적되는 잡으로 위임하며, 모든 리뷰어가 통과시킬 때까지 planner / creator / reviewer 루프를 돌립니다.
|
||||||
|
|
||||||
[](VERSIONS.md)
|
[](VERSIONS.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A **Multi-Agent Orchestration & Messaging Backplane** built on [herdr](https://herdr.dev) and MQTT. It runs long-lived coding agents (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`) in isolated herdr panes, delegates work to them as tracked jobs, and drives planner / creator / reviewer loops until every reviewer passes the change.
|
A **Multi-Agent Orchestration & Messaging Backplane** built on [herdr](https://herdr.dev) and MQTT. It runs long-lived coding agents (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`) in isolated herdr panes, delegates work to them as tracked jobs, and drives planner / creator / reviewer loops until every reviewer passes the change.
|
||||||
|
|
||||||
[](VERSIONS.md)
|
[](VERSIONS.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+40
-11
@@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
## 📌 현재 버전 개요 (Current Release)
|
## 📌 현재 버전 개요 (Current Release)
|
||||||
|
|
||||||
- **프레임워크 버전**: `v4.10.1`
|
- **프레임워크 버전**: `v4.11.0`
|
||||||
- **최신 릴리스 일시**: 2026-10-25 (KST)
|
- **최신 릴리스 일시**: 2026-10-27 (KST)
|
||||||
- **기준 브랜치**: `main`
|
- **기준 브랜치**: `main`
|
||||||
- **핵심 아키텍처**:
|
- **핵심 아키텍처**:
|
||||||
- **Terminal 상태 단방향 불변성 (`engine.py`, Issue #6 Phase 1)**: `completed`, `error`, `cancelled` 터미널 상태 진입 후 후속 이벤트나 업데이트에 의한 덮어쓰기를 엄격히 차단(Terminal Immutability). `registry.py close` 서브커맨드 및 fail-closed 조기 종료 도입.
|
- **Terminal 상태 단방향 불변성 (`engine.py`, Issue #6 Phase 1)**: `completed`, `error`, `cancelled` 터미널 상태 진입 후 후속 이벤트나 업데이트에 의한 덮어쓰기를 엄격히 차단(Terminal Immutability). `registry.py close` 서브커맨드 및 fail-closed 조기 종료 도입.
|
||||||
@@ -24,23 +24,52 @@
|
|||||||
|
|
||||||
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
|
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
|
||||||
|
|
||||||
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.10.1`로 동기화되어 배포됩니다.
|
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.11.0`로 동기화되어 배포됩니다.
|
||||||
|
|
||||||
| 스킬명 | 버전 | 역할 및 주요 책임 | 상태 |
|
| 스킬명 | 버전 | 역할 및 주요 책임 | 상태 |
|
||||||
| :--- | :---: | :--- | :---: |
|
| :--- | :---: | :--- | :---: |
|
||||||
| **`multi-agent-mux-create`** | `4.10.1` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
|
| **`multi-agent-mux-create`** | `4.11.0` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
|
||||||
| **`multi-agent-mux-stop`** | `4.10.1` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
|
| **`multi-agent-mux-stop`** | `4.11.0` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
|
||||||
| **`multi-agent-mux-resume`** | `4.10.1` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
|
| **`multi-agent-mux-resume`** | `4.11.0` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
|
||||||
| **`multi-agent-mux-status`** | `4.10.1` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
|
| **`multi-agent-mux-status`** | `4.11.0` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
|
||||||
| **`multi-agent-mux-monitor`** | `4.10.1` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
|
| **`multi-agent-mux-monitor`** | `4.11.0` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
|
||||||
| **`multi-agent-mux-delegate-job`** | `4.10.1` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
|
| **`multi-agent-mux-delegate-job`** | `4.11.0` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
|
||||||
| **`multi-agent-mux-loop`** | `4.10.1` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
|
| **`multi-agent-mux-loop`** | `4.11.0` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
|
||||||
| **`multi-agent-mux-orc-onboard`** | `4.10.1` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
|
| **`multi-agent-mux-orc-onboard`** | `4.11.0` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 버전별 상세 변경 내역 (Changelog)
|
## 📋 버전별 상세 변경 내역 (Changelog)
|
||||||
|
|
||||||
|
### 🚀 `v4.11.0` — attach 명령은 herdr 에 등록된 이름을 쓰고, 세션 시작·종료는 스킬로만 한다 (Issue #71) (2026-10-27)
|
||||||
|
|
||||||
|
> **마이너 (MINOR Release)**: 레지스트리의 `attach_command` 가 원래 세션 이름으로 herdr 를 불러, 이름이 herdr 의 규칙(소문자 `[a-z0-9_-]`, 32자 이하)을 벗어나면 붙지 못했다. 이제 attach 명령은 한 함수가 만들고, 레지스트리 행에서 `kill_command`·`start_command` 를 지웠으며, 레지스트리에 형식 버전(`registry_format: 2`)을 더했다. `status --json` 의 키가 빠지고 stop 의 동작(행 없는 세션 닫기)이 늘어 MINOR 로 올린다. PR #73.
|
||||||
|
|
||||||
|
#### ⚠️ 호환성
|
||||||
|
- 레지스트리 행에 `kill_command`·`start_command` 가 더 이상 없다. `status --json` 에서도 `start_command` 키가 빠졌다.
|
||||||
|
- `attach_command` 는 `herdr --session <server> agent attach <herdr 에 등록된 이름>` 이다. `default` 서버도 적는다.
|
||||||
|
- 옛 레지스트리(형식 키 없음 = 1)는 첫 쓰기 때 2 로 바뀐다. 첫 쓰기 전까지 status 에 옛 행이 보일 수 있다.
|
||||||
|
- 세션 시작은 create·resume, 종료는 stop 스킬로 한다. attach 만 보기 전용이라 herdr 명령을 직접 쓴다.
|
||||||
|
|
||||||
|
#### 🌟 주요 개선 사항
|
||||||
|
1. **attach 명령 하나 (`lib_py/agents/sanitize.py` `attach_command`)**: create·resume·reconcile 이 모두 이 함수를 쓴다. 값은 `shlex.quote` 로 감싼다. lib.sh 래퍼의 `herdr agent attach <원래 이름>` 도 이름을 바꿔 부르고, 대상 뒤의 옵션(`--takeover`)은 그대로 넘긴다.
|
||||||
|
2. **행이 없는 세션의 stop (`stop_session.sh`)**:
|
||||||
|
- 레지스트리에 행이 없으면 herdr 세션만 닫고 레지스트리는 바꾸지 않는다. herdr 가 기록한 cwd 가 이 작업 공간 안일 때만 닫는다.
|
||||||
|
- 다른 작업 공간의 세션은 exit 5, 서버에 없으면 exit 1, 닫은 뒤에도 살아 있으면 exit 6 이다.
|
||||||
|
- 레지스트리를 읽지 못하면(YAML 깨짐, DB 손상, 테이블 없음) "행 없음" 으로 보지 않고 exit 1 로 멈춘다(`load_state_json strict`).
|
||||||
|
- 사용자에게 보여 주던 날 `kill-session` 안내 두 곳(create exit 43, `id_order.py`)은 stop 스킬 명령으로 바꿨다.
|
||||||
|
3. **이름 변환 규칙 하나 (`lib.sh`)**: `_sanitize_herdr_agent_name` 정의를 하나로 합쳤고 shim 은 `declare -f` 로 같은 함수를 받는다. `has-session` 의 `lower()[:32]` 대체 경로를 지웠다. 해시 도구가 없으면 python3 로, python3 도 없으면 오류로 멈춘다(`00000000` 을 붙이지 않는다). ASCII 가 아닌 이름·`-n`·줄바꿈이 든 이름도 Python 함수와 결과가 같다.
|
||||||
|
4. **레지스트리 형식 버전 2 (`lib_py/registry_format.py`, `lib_py/atomic_yaml.py`)**: 레지스트리를 쓰는 함수가 잠금(`BEGIN IMMEDIATE`) 안에서, 호출자의 변경 전에 모든 행을 바꾸고 YAML 도 다시 쓴다. 업데이트 뒤 부를 진입점: `PYTHONPATH=.agents/skills python3 -m lib_py.registry_format --yaml <agent-sessions.yaml>` (#72 에서 쓴다).
|
||||||
|
5. **stall_watch**: herdr 이름을 `StallWatch` 에서 한 번만 구해 `HerdrSource` 에 넘긴다.
|
||||||
|
|
||||||
|
#### ✅ 품질 검증
|
||||||
|
- **회귀**: `tests/test_issue71_herdr_names.py`(70개). base(v4.10.1)에서 68개가 실패한다.
|
||||||
|
- **리뷰 (`--all-reviewer`, 작성자 PR 의 리뷰 전용 루프)**: 1회차 3/4 PASS. codex 의 지적(행 없는 stop 의 `HERDR_SESSION_NAME=default` 처리)은 create 와 같은 서버 선택 규칙이라는 반론으로 철회됐다. 2회차(@5f60653) **4/4 PASS**. 리뷰어들이 macOS bash 3.2 에서 이름 변환이 Python 함수와 같은 것을 `LC_ALL=C`·UTF-8 양쪽에서 확인했다. 리포트는 `.agents/reports/<session>/` 에 있다.
|
||||||
|
- **전체 스위트 (macOS, Python 3.9.6, @5f60653)**: 2173 passed, 56 skipped, 2 failed. 실패는 부하에 민감한 타이밍 테스트 둘(`test_issue14_delivery_ack.py::test_wrapper_idle_timeout_grace_ignores_pane_busy_token`, `test_issue30_stall_watch.py::test_s13_unreliable_agent_status_turns_idle_off_but_keeps_blocked`)이고, 단독으로 3번 모두, 두 파일 전체로도 통과했다(60 passed, 1 skipped). 실제 `~/.claude`·`/private/tmp/claude-<uid>`·herdr 서버 pane 목록·opencode 의 data·state·config 에 이 실행이 남긴 것은 없었다.
|
||||||
|
- **작성자 확인 (Linux, Python 3.14)**: 2211 passed, 20 skipped, 실패 0.
|
||||||
|
- **알려진 비차단**: `name` 이 없는 행은 형식 변환에서 건너뛰므로 두 필드가 남을 수 있다. delegate-job 래퍼의 위임 안내 문구(`작업이 세션 ...에 전송되었습니다`)는 아직 옛 attach 형태(`HERDR_SESSION_NAME=... herdr agent attach <원래 이름>`)를 보여 준다.
|
||||||
|
- **병합**: merge commit 으로 병합한다. `test_issue23_24_startup_path.py` S11 이 이 브랜치의 커밋 `0fc162e` 를 기준으로 쓴다.
|
||||||
|
|
||||||
### 🚀 `v4.10.1` — stall_watch 가 herdr 에 변환된 에이전트 이름을 쓴다 (Issue #66) (2026-10-25)
|
### 🚀 `v4.10.1` — stall_watch 가 herdr 에 변환된 에이전트 이름을 쓴다 (Issue #66) (2026-10-25)
|
||||||
|
|
||||||
> **패치 (PATCH Release)**: 세션 이름이 herdr 의 에이전트 이름 규칙(소문자 `[a-z0-9_-]`, 영문자로 시작, 32자 이하)을 벗어나면, 그 세션에 위임한 잡의 `stall_watch` 가 pane 을 찾지 못하고(`stall_watch:agent_not_found`) 멈춤 알림이 꺼졌다. 이제 herdr 를 부를 때만 `sanitize_herdr_agent_name()` 의 결과를 쓴다. 옵션·동작 계약은 바뀌지 않는다. PR #70.
|
> **패치 (PATCH Release)**: 세션 이름이 herdr 의 에이전트 이름 규칙(소문자 `[a-z0-9_-]`, 영문자로 시작, 32자 이하)을 벗어나면, 그 세션에 위임한 잡의 `stall_watch` 가 pane 을 찾지 못하고(`stall_watch:agent_not_found`) 멈춤 알림이 꺼졌다. 이제 herdr 를 부를 때만 `sanitize_herdr_agent_name()` 의 결과를 쓴다. 옵션·동작 계약은 바뀌지 않는다. PR #70.
|
||||||
|
|||||||
@@ -61,21 +61,21 @@
|
|||||||
{
|
{
|
||||||
"skill": "multi-agent-mux-create",
|
"skill": "multi-agent-mux-create",
|
||||||
"start": 134,
|
"start": 134,
|
||||||
"end": 152,
|
"end": 150,
|
||||||
"lang": "yaml",
|
"lang": "yaml",
|
||||||
"sha": "2dd608d04ae0",
|
"sha": "efba73c1d7f7",
|
||||||
"class": "c",
|
"class": "c",
|
||||||
"kind": "example",
|
"kind": "example",
|
||||||
"evidence": [
|
"evidence": [
|
||||||
"multi-agent-mux-create/scripts/create_session.sh:518-545"
|
"multi-agent-mux-create/scripts/create_session.sh:514-541"
|
||||||
],
|
],
|
||||||
"mismatch": null,
|
"mismatch": null,
|
||||||
"note": "Output format: the row create_session.sh writes"
|
"note": "Output format: the row create_session.sh writes"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"skill": "multi-agent-mux-create",
|
"skill": "multi-agent-mux-create",
|
||||||
"start": 158,
|
"start": 156,
|
||||||
"end": 160,
|
"end": 158,
|
||||||
"lang": "bash",
|
"lang": "bash",
|
||||||
"sha": "613003708f19",
|
"sha": "613003708f19",
|
||||||
"class": "c",
|
"class": "c",
|
||||||
@@ -88,8 +88,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"skill": "multi-agent-mux-create",
|
"skill": "multi-agent-mux-create",
|
||||||
"start": 204,
|
"start": 202,
|
||||||
"end": 208,
|
"end": 206,
|
||||||
"lang": "bash",
|
"lang": "bash",
|
||||||
"sha": "38836d31bf38",
|
"sha": "38836d31bf38",
|
||||||
"class": "c",
|
"class": "c",
|
||||||
@@ -103,8 +103,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"skill": "multi-agent-mux-create",
|
"skill": "multi-agent-mux-create",
|
||||||
"start": 231,
|
"start": 229,
|
||||||
"end": 253,
|
"end": 251,
|
||||||
"lang": "bash",
|
"lang": "bash",
|
||||||
"sha": "d90cfd2555fd",
|
"sha": "d90cfd2555fd",
|
||||||
"class": "b",
|
"class": "b",
|
||||||
@@ -245,7 +245,7 @@
|
|||||||
"class": "c",
|
"class": "c",
|
||||||
"kind": "example",
|
"kind": "example",
|
||||||
"evidence": [
|
"evidence": [
|
||||||
"multi-agent-mux-monitor/scripts/reconcile.sh:708"
|
"multi-agent-mux-monitor/scripts/reconcile.sh:705"
|
||||||
],
|
],
|
||||||
"mismatch": null,
|
"mismatch": null,
|
||||||
"note": "drift B example"
|
"note": "drift B example"
|
||||||
@@ -259,7 +259,7 @@
|
|||||||
"class": "c",
|
"class": "c",
|
||||||
"kind": "example",
|
"kind": "example",
|
||||||
"evidence": [
|
"evidence": [
|
||||||
"multi-agent-mux-monitor/scripts/reconcile.sh:735"
|
"multi-agent-mux-monitor/scripts/reconcile.sh:732"
|
||||||
],
|
],
|
||||||
"mismatch": null,
|
"mismatch": null,
|
||||||
"note": "drift C example (indented fence)"
|
"note": "drift C example (indented fence)"
|
||||||
@@ -385,7 +385,7 @@
|
|||||||
"class": "c",
|
"class": "c",
|
||||||
"kind": "example",
|
"kind": "example",
|
||||||
"evidence": [
|
"evidence": [
|
||||||
"multi-agent-mux-status/scripts/status.sh:411"
|
"multi-agent-mux-status/scripts/status.sh:410"
|
||||||
],
|
],
|
||||||
"mismatch": null,
|
"mismatch": null,
|
||||||
"note": "table output example"
|
"note": "table output example"
|
||||||
@@ -393,15 +393,15 @@
|
|||||||
{
|
{
|
||||||
"skill": "multi-agent-mux-status",
|
"skill": "multi-agent-mux-status",
|
||||||
"start": 74,
|
"start": 74,
|
||||||
"end": 106,
|
"end": 105,
|
||||||
"lang": "json",
|
"lang": "json",
|
||||||
"sha": "cfea3a10dc8d",
|
"sha": "8e55751f5e8d",
|
||||||
"class": "c",
|
"class": "c",
|
||||||
"kind": "example",
|
"kind": "example",
|
||||||
"evidence": [
|
"evidence": [
|
||||||
"multi-agent-mux-status/scripts/status.sh:31-36",
|
"multi-agent-mux-status/scripts/status.sh:31-36",
|
||||||
"multi-agent-mux-status/scripts/status.sh:209-232",
|
"multi-agent-mux-status/scripts/status.sh:209-231",
|
||||||
"multi-agent-mux-monitor/scripts/reconcile.sh:816"
|
"multi-agent-mux-monitor/scripts/reconcile.sh:813"
|
||||||
],
|
],
|
||||||
"mismatch": [
|
"mismatch": [
|
||||||
"S1 (fixed): the example used rows/yaml_entries/unregistered; the real keys are reconcile.sh's drift object plus sessions_detail"
|
"S1 (fixed): the example used rows/yaml_entries/unregistered; the real keys are reconcile.sh's drift object plus sessions_detail"
|
||||||
@@ -410,14 +410,14 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"skill": "multi-agent-mux-stop",
|
"skill": "multi-agent-mux-stop",
|
||||||
"start": 42,
|
"start": 41,
|
||||||
"end": 55,
|
"end": 54,
|
||||||
"lang": "bash",
|
"lang": "bash",
|
||||||
"sha": "301c347ff881",
|
"sha": "301c347ff881",
|
||||||
"class": "c",
|
"class": "c",
|
||||||
"kind": "invocation",
|
"kind": "invocation",
|
||||||
"evidence": [
|
"evidence": [
|
||||||
"multi-agent-mux-stop/scripts/stop_session.sh:1-33"
|
"multi-agent-mux-stop/scripts/stop_session.sh:1-36"
|
||||||
],
|
],
|
||||||
"mismatch": [
|
"mismatch": [
|
||||||
"T2 (fixed): the --purge-conversation example lacked --yes and would exit 3"
|
"T2 (fixed): the --purge-conversation example lacked --yes and would exit 3"
|
||||||
@@ -426,22 +426,22 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"skill": "multi-agent-mux-stop",
|
"skill": "multi-agent-mux-stop",
|
||||||
"start": 81,
|
"start": 82,
|
||||||
"end": 85,
|
"end": 86,
|
||||||
"lang": "-",
|
"lang": "-",
|
||||||
"sha": "a973676cb2d1",
|
"sha": "a973676cb2d1",
|
||||||
"class": "c",
|
"class": "c",
|
||||||
"kind": "diagram",
|
"kind": "diagram",
|
||||||
"evidence": [
|
"evidence": [
|
||||||
"multi-agent-mux-stop/scripts/stop_session.sh:183-197"
|
"multi-agent-mux-stop/scripts/stop_session.sh:245-259"
|
||||||
],
|
],
|
||||||
"mismatch": null,
|
"mismatch": null,
|
||||||
"note": "state machine"
|
"note": "state machine"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"skill": "multi-agent-mux-stop",
|
"skill": "multi-agent-mux-stop",
|
||||||
"start": 102,
|
"start": 103,
|
||||||
"end": 104,
|
"end": 105,
|
||||||
"lang": "bash",
|
"lang": "bash",
|
||||||
"sha": "4189d73d6896",
|
"sha": "4189d73d6896",
|
||||||
"class": "c",
|
"class": "c",
|
||||||
@@ -454,8 +454,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"skill": "multi-agent-mux-stop",
|
"skill": "multi-agent-mux-stop",
|
||||||
"start": 127,
|
"start": 128,
|
||||||
"end": 145,
|
"end": 146,
|
||||||
"lang": "bash",
|
"lang": "bash",
|
||||||
"sha": "bcaa2a03484e",
|
"sha": "bcaa2a03484e",
|
||||||
"class": "b",
|
"class": "b",
|
||||||
@@ -503,9 +503,9 @@
|
|||||||
"base_sha": "a7ed3e902cae",
|
"base_sha": "a7ed3e902cae",
|
||||||
"class": "a",
|
"class": "a",
|
||||||
"evidence": [
|
"evidence": [
|
||||||
"multi-agent-mux-stop/scripts/stop_session.sh:98",
|
"multi-agent-mux-stop/scripts/stop_session.sh:101",
|
||||||
"multi-agent-mux-stop/scripts/stop_session.sh:139-142",
|
"multi-agent-mux-stop/scripts/stop_session.sh:123-193",
|
||||||
"multi-agent-mux-stop/scripts/stop_session.sh:183-187"
|
"multi-agent-mux-stop/scripts/stop_session.sh:245-249"
|
||||||
],
|
],
|
||||||
"mismatch": [
|
"mismatch": [
|
||||||
"T1: the doc offered --force-no-yaml, which no script accepts"
|
"T1: the doc offered --force-no-yaml, which no script accepts"
|
||||||
@@ -568,7 +568,7 @@
|
|||||||
"base_sha": "f71be0f26fb8",
|
"base_sha": "f71be0f26fb8",
|
||||||
"class": "a",
|
"class": "a",
|
||||||
"evidence": [
|
"evidence": [
|
||||||
"multi-agent-mux-create/scripts/create_session.sh:491-610"
|
"multi-agent-mux-create/scripts/create_session.sh:486-604"
|
||||||
],
|
],
|
||||||
"mismatch": [],
|
"mismatch": [],
|
||||||
"replacement": "bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace <path> --agent <agent> --role <role>",
|
"replacement": "bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace <path> --agent <agent> --role <role>",
|
||||||
@@ -596,10 +596,12 @@
|
|||||||
"2",
|
"2",
|
||||||
"3",
|
"3",
|
||||||
"4",
|
"4",
|
||||||
|
"5",
|
||||||
|
"6",
|
||||||
"other"
|
"other"
|
||||||
],
|
],
|
||||||
"passthrough": {
|
"passthrough": {
|
||||||
"1": "multi-agent-mux-stop/scripts/stop_session.sh:322"
|
"1": "multi-agent-mux-stop/scripts/stop_session.sh:384"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"multi-agent-mux-create/scripts/create_session.sh": {
|
"multi-agent-mux-create/scripts/create_session.sh": {
|
||||||
@@ -617,7 +619,7 @@
|
|||||||
"other"
|
"other"
|
||||||
],
|
],
|
||||||
"passthrough": {
|
"passthrough": {
|
||||||
"4": "multi-agent-mux-create/scripts/create_session.sh:550"
|
"4": "multi-agent-mux-create/scripts/create_session.sh:546"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -581,7 +581,7 @@ def test_t3_1_create_persists_and_forwards_a_foreign_binding(mam_sandbox, mock_h
|
|||||||
for k, v in zip(FORWARDED_ENV, list(_binding(tmp).values())[:3]):
|
for k, v in zip(FORWARDED_ENV, list(_binding(tmp).values())[:3]):
|
||||||
assert f"{k}={v}" in flags, flags
|
assert f"{k}={v}" in flags, flags
|
||||||
assert "WORKSPACE_ROOT=" not in row["pane"]["cmd_full"]
|
assert "WORKSPACE_ROOT=" not in row["pane"]["cmd_full"]
|
||||||
assert "WORKSPACE_ROOT=" not in row["start_command"]
|
assert "WORKSPACE_ROOT=" not in row["attach_command"]
|
||||||
_assert_no_env_in_agent_args(mock_herdr)
|
_assert_no_env_in_agent_args(mock_herdr)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -299,10 +299,11 @@ def test_s11_this_branch_does_not_change_the_generated_shim(mam_sandbox):
|
|||||||
true at runtime. Issue #45 changed the heredoc on purpose (the bootstrap
|
true at runtime. Issue #45 changed the heredoc on purpose (the bootstrap
|
||||||
starts the server with restore-on-restart off, and its review R1 passes
|
starts the server with restore-on-restart off, and its review R1 passes
|
||||||
REAL_HERDR to the config check), and so did the real-server leak fix
|
REAL_HERDR to the config check), and so did the real-server leak fix
|
||||||
(only a directory that is itself *-shim is skipped), so the base is
|
(only a directory that is itself *-shim is skipped). Issue #71 changed it
|
||||||
that commit."""
|
too (one sanitize definition written in with `declare -f`, agent attach
|
||||||
|
resolved, no silent wrong name), so the base is that commit."""
|
||||||
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
base = os.environ.get("MAM_BASE_SHA", "525df2b")
|
base = os.environ.get("MAM_BASE_SHA", "0fc162e")
|
||||||
show = subprocess.run(["git", "show", "%s:.agents/skills/lib.sh" % base],
|
show = subprocess.run(["git", "show", "%s:.agents/skills/lib.sh" % base],
|
||||||
capture_output=True, text=True, cwd=repo)
|
capture_output=True, text=True, cwd=repo)
|
||||||
if show.returncode != 0:
|
if show.returncode != 0:
|
||||||
@@ -316,16 +317,21 @@ def test_s11_this_branch_does_not_change_the_generated_shim(mam_sandbox):
|
|||||||
|
|
||||||
|
|
||||||
def _heredoc_body(text):
|
def _heredoc_body(text):
|
||||||
|
"""What lib.sh writes into the shim: the sanitize function it declares in
|
||||||
|
(Issue #71), then the heredoc."""
|
||||||
lines = text.splitlines(True)
|
lines = text.splitlines(True)
|
||||||
|
fn = [i for i, line in enumerate(lines) if line.startswith("_sanitize_herdr_agent_name() {")]
|
||||||
|
assert len(fn) == 1, "one _sanitize_herdr_agent_name definition in lib.sh"
|
||||||
|
fn_end = next(j for j in range(fn[0], len(lines)) if lines[j].rstrip("\n") == "}")
|
||||||
start = None
|
start = None
|
||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
if line.strip() == "cat <<'EOF' > \"$tmp_file\"":
|
if line.strip() == "cat <<'EOF'":
|
||||||
start = i + 1
|
start = i + 1
|
||||||
break
|
break
|
||||||
assert start is not None, "shim heredoc not found in lib.sh"
|
assert start is not None, "shim heredoc not found in lib.sh"
|
||||||
for j in range(start, len(lines)):
|
for j in range(start, len(lines)):
|
||||||
if lines[j].rstrip("\n") == "EOF":
|
if lines[j].rstrip("\n") == "EOF":
|
||||||
return "".join(lines[start:j])
|
return "".join(lines[fn[0]:fn_end + 1] + lines[start:j])
|
||||||
raise AssertionError("unterminated shim heredoc")
|
raise AssertionError("unterminated shim heredoc")
|
||||||
|
|
||||||
|
|
||||||
@@ -1272,7 +1278,9 @@ def test_s9_a_kill_that_left_the_session_alive_is_reported(mam_sandbox):
|
|||||||
"rolled back" anyway, leaving a `running` row against a failed job."""
|
"rolled back" anyway, leaving a `running` row against a failed job."""
|
||||||
res, published, yaml_text = _run_rollback(mam_sandbox, kill_rc=1, still_alive=True)
|
res, published, yaml_text = _run_rollback(mam_sandbox, kill_rc=1, still_alive=True)
|
||||||
assert res.returncode == 43, res.stdout + res.stderr
|
assert res.returncode == 43, res.stdout + res.stderr
|
||||||
assert "test-sess" in res.stderr and "kill-session" in res.stderr
|
# Issue #71: the clean-up hint is the stop skill, naming the server
|
||||||
|
assert "Clean it up with: bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session test-sess --herdr-session default" in res.stderr
|
||||||
|
assert "kill-session -t" not in res.stderr
|
||||||
assert "rollback-failed" in yaml_text, yaml_text
|
assert "rollback-failed" in yaml_text, yaml_text
|
||||||
assert "status: running" in yaml_text, "a session that is still up must not be called gone"
|
assert "status: running" in yaml_text, "a session that is still up must not be called gone"
|
||||||
assert "delegate_job_id: null" in yaml_text, yaml_text
|
assert "delegate_job_id: null" in yaml_text, yaml_text
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ def test_literal_exit_rule_on_base_scripts():
|
|||||||
"""The counting rule: heredocs and comments skipped, so create's header `exit 4`
|
"""The counting rule: heredocs and comments skipped, so create's header `exit 4`
|
||||||
and stop's `sys.exit(7)` are not counted."""
|
and stop's `sys.exit(7)` are not counted."""
|
||||||
assert literal_exit_codes("multi-agent-mux-resume/scripts/resume_session.sh") == {"0", "1", "2"}
|
assert literal_exit_codes("multi-agent-mux-resume/scripts/resume_session.sh") == {"0", "1", "2"}
|
||||||
assert literal_exit_codes("multi-agent-mux-stop/scripts/stop_session.sh") == {"0", "1", "2", "3", "4"}
|
assert literal_exit_codes("multi-agent-mux-stop/scripts/stop_session.sh") == {"0", "1", "2", "3", "4", "5", "6"}
|
||||||
assert literal_exit_codes("multi-agent-mux-create/scripts/create_session.sh") == {"0", "1", "2", "3", "42", "43", "44", "45"}
|
assert literal_exit_codes("multi-agent-mux-create/scripts/create_session.sh") == {"0", "1", "2", "3", "42", "43", "44", "45"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -170,7 +170,10 @@ def test_a1_create_replaces_the_monitors_registration_of_its_own_pane(mam_sandbo
|
|||||||
assert f"NOTE: the monitor registered {name} while it was being created" in res.stdout
|
assert f"NOTE: the monitor registered {name} while it was being created" in res.stdout
|
||||||
rows = [r for r in _rows(yaml_path) if r["name"] == name]
|
rows = [r for r in _rows(yaml_path) if r["name"] == name]
|
||||||
assert len(rows) == 1 and rows[0]["status"] == "running" and rows[0]["role"] == "Creator"
|
assert len(rows) == 1 and rows[0]["status"] == "running" and rows[0]["role"] == "Creator"
|
||||||
assert rows[0].get("last_visible_note") != "auto-registered by monitor" and rows[0].get("start_command")
|
# create's own row: only create records how the claude id was chosen
|
||||||
|
# (the monitor's registration and the seeded row have no session_id_source)
|
||||||
|
assert rows[0].get("last_visible_note") != "auto-registered by monitor"
|
||||||
|
assert rows[0].get("session_id_source") in ("assigned", "pending-discovery")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("kind", ["real", "old-adoption", "adoption-with-job", "other-server"])
|
@pytest.mark.parametrize("kind", ["real", "old-adoption", "adoption-with-job", "other-server"])
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ def test_the_1x_capture_still_reads_empty():
|
|||||||
|
|
||||||
@pytest.mark.parametrize("name", HOME_SCREENS)
|
@pytest.mark.parametrize("name", HOME_SCREENS)
|
||||||
def test_stall_watch_sees_the_2x_home_box_empty(name, monkeypatch):
|
def test_stall_watch_sees_the_2x_home_box_empty(name, monkeypatch):
|
||||||
src = stall_watch.HerdrSource("t", "opencode")
|
src = stall_watch.HerdrSource("t", "opencode", name="t")
|
||||||
monkeypatch.setattr(src, "_herdr", lambda *args: _pane(name))
|
monkeypatch.setattr(src, "_herdr", lambda *args: _pane(name))
|
||||||
assert src.input_empty() is True
|
assert src.input_empty() is True
|
||||||
|
|
||||||
|
|||||||
@@ -1090,7 +1090,9 @@ def test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_
|
|||||||
assert marker_a.exists()
|
assert marker_a.exists()
|
||||||
assert ("srv-x", "orphan-a-agy") in probes
|
assert ("srv-x", "orphan-a-agy") in probes
|
||||||
err = capsys.readouterr().err
|
err = capsys.readouterr().err
|
||||||
assert "HERDR_SESSION_NAME=srv-x herdr kill-session -t orphan-a-agy" in err
|
# Issue #71: the hint is a stop-skill command naming the server
|
||||||
|
assert "bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session orphan-a-agy --herdr-session srv-x" in err
|
||||||
|
assert "kill-session" not in err
|
||||||
|
|
||||||
# No authoritative answer from herdr -> kept and blocks as well
|
# No authoritative answer from herdr -> kept and blocks as well
|
||||||
unknown_sessions.add("orphan-a-agy")
|
unknown_sessions.add("orphan-a-agy")
|
||||||
@@ -1137,7 +1139,7 @@ def test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_
|
|||||||
assert probes == [("", "same-agy")]
|
assert probes == [("", "same-agy")]
|
||||||
assert json.loads(same.read_text())["owner_pid"] == live.pid
|
assert json.loads(same.read_text())["owner_pid"] == live.pid
|
||||||
err = capsys.readouterr().err
|
err = capsys.readouterr().err
|
||||||
assert "its herdr session is alive" in err and "kill-session -t same-agy" in err
|
assert "its herdr session is alive" in err and "bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session same-agy --herdr-session " in err
|
||||||
unknown_sessions.add("same-agy")
|
unknown_sessions.add("same-agy")
|
||||||
assert wait_same() == 1
|
assert wait_same() == 1
|
||||||
assert json.loads(same.read_text())["owner_pid"] == live.pid
|
assert json.loads(same.read_text())["owner_pid"] == live.pid
|
||||||
@@ -1188,7 +1190,7 @@ def test_id_order_create_waits_for_orphan_herdr_session_of_a_killed_create(
|
|||||||
|
|
||||||
res = create_b()
|
res = create_b()
|
||||||
assert res.returncode == 1, res.stdout + res.stderr
|
assert res.returncode == 1, res.stdout + res.stderr
|
||||||
assert name_a in res.stderr and "kill-session -t " + name_a in res.stderr
|
assert name_a in res.stderr and "bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session " + name_a + " --herdr-session orphan-ws" in res.stderr
|
||||||
assert "its herdr session is alive" in res.stderr
|
assert "its herdr session is alive" in res.stderr
|
||||||
assert name_b not in _read_rows(mam_sandbox)
|
assert name_b not in _read_rows(mam_sandbox)
|
||||||
assert name_b not in json.loads(Path(mock_herdr).read_text()).get("agents", {})
|
assert name_b not in json.loads(Path(mock_herdr).read_text()).get("agents", {})
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
"""Issue #71: herdr agent names in the registry and the lib.sh shim.
|
||||||
|
|
||||||
|
- attach_command is `herdr --session <server> agent attach <herdr name>` from
|
||||||
|
one builder (create, resume, reconcile); kill_command/start_command are gone.
|
||||||
|
- the shim's `agent attach` resolves its target like get/read/prompt.
|
||||||
|
- one bash sanitize rule, equal to the Python one, with no silent wrong name.
|
||||||
|
- an old registry is migrated by the writer (YAML and DB), with an entry point.
|
||||||
|
- stop closes a session that has no registry row, only inside this workspace.
|
||||||
|
- stall_watch works out the herdr name once.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
SKILLS = REPO / ".agents" / "skills"
|
||||||
|
sys.path.insert(0, str(SKILLS))
|
||||||
|
|
||||||
|
from lib_py.agents.sanitize import sanitize_herdr_agent_name # noqa: E402
|
||||||
|
|
||||||
|
LONG = "lab-issue71-a-very-long-branch-name-creator-claude" # > 32 characters
|
||||||
|
UPPER = "Lab-Upper-creator-claude"
|
||||||
|
PLAIN = "lab-plain-creator-claude" # needs no conversion
|
||||||
|
UNI = "\u0130stanbul-creator-claude" # str.lower() differs from tr here
|
||||||
|
NL = "newline-creator-claude\n" # $(...) would drop the line break
|
||||||
|
STOP = "bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_names_need_converting_and_the_plain_one_does_not():
|
||||||
|
assert len(LONG) > 32 and sanitize_herdr_agent_name(LONG) != LONG
|
||||||
|
assert sanitize_herdr_agent_name(UPPER) == UPPER.lower()
|
||||||
|
assert sanitize_herdr_agent_name(PLAIN) == PLAIN
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", [LONG, UPPER, PLAIN])
|
||||||
|
@pytest.mark.parametrize("server", ["default", "mam-x", None])
|
||||||
|
def test_attach_command_names_the_server_and_the_herdr_name(name, server):
|
||||||
|
from lib_py.agents.sanitize import attach_command
|
||||||
|
want = "herdr --session %s agent attach %s" % (server or "default", sanitize_herdr_agent_name(name))
|
||||||
|
assert attach_command(server, name) == want
|
||||||
|
|
||||||
|
|
||||||
|
def test_attach_command_quotes_a_server_that_needs_it():
|
||||||
|
from lib_py.agents.sanitize import attach_command
|
||||||
|
assert attach_command("my server", PLAIN) == "herdr --session 'my server' agent attach " + PLAIN
|
||||||
|
|
||||||
|
|
||||||
|
# --- writers -----------------------------------------------------------------
|
||||||
|
|
||||||
|
def _rows(sandbox):
|
||||||
|
return yaml.safe_load((sandbox / ".mam" / "agent-sessions.yaml").read_text())["herdr_sessions"]
|
||||||
|
|
||||||
|
|
||||||
|
def _db_rows(sandbox):
|
||||||
|
conn = sqlite3.connect(str(sandbox / ".mam" / "agent-sessions.db"))
|
||||||
|
try:
|
||||||
|
return {r[0]: json.loads(r[1]) for r in conn.execute("SELECT name, data FROM sessions")}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _set_mock(mock_herdr, **kw):
|
||||||
|
state = json.loads(Path(mock_herdr).read_text())
|
||||||
|
state.update(kw)
|
||||||
|
Path(mock_herdr).write_text(json.dumps(state))
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_new_form(row, server):
|
||||||
|
assert row["attach_command"] == "herdr --session %s agent attach %s" % (
|
||||||
|
server, sanitize_herdr_agent_name(row["name"]))
|
||||||
|
assert "kill_command" not in row and "start_command" not in row
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", [LONG, UPPER, PLAIN, UNI, NL])
|
||||||
|
@pytest.mark.parametrize("server", ["default", "mam-iso71"])
|
||||||
|
def test_create_writes_the_attach_command(mam_sandbox, mock_herdr, mock_agents, name, server):
|
||||||
|
_set_mock(mock_herdr, strict_agent_names=True)
|
||||||
|
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||||
|
res = subprocess.run(["bash", str(script), "--workspace", str(mam_sandbox), "--agent", "claude",
|
||||||
|
"--role", "creator", "--session", name, "--herdr-session", server, "--no-onboard"],
|
||||||
|
capture_output=True, text=True, cwd=str(mam_sandbox), timeout=300)
|
||||||
|
assert res.returncode == 0, res.stdout + res.stderr
|
||||||
|
(row,) = [r for r in _rows(mam_sandbox) if r["name"] == name]
|
||||||
|
_assert_new_form(row, server)
|
||||||
|
agents = json.loads(Path(mock_herdr).read_text())["agents"]
|
||||||
|
assert sanitize_herdr_agent_name(name) in agents, "the attach name is the one herdr registered"
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(sandbox, rows):
|
||||||
|
lib = sandbox / ".agents" / "skills" / "lib.sh"
|
||||||
|
mutation = "d['herdr_sessions'] = " + repr(rows) + "\n"
|
||||||
|
res = subprocess.run(["bash", "-c", 'source "$1" && atomic_dump_yaml "$AGENT_SESSIONS_YAML"', "_", str(lib)],
|
||||||
|
input=mutation, capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0, res.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def _resume_update(sandbox, name, server):
|
||||||
|
script = sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "update_yaml_resumed.sh"
|
||||||
|
return subprocess.run(["bash", str(script), "--session", name, "--uuid", "11111111-2222-3333-4444-555555555555",
|
||||||
|
"--agent", "claude", "--herdr-session", server, "--workspace", str(sandbox)],
|
||||||
|
capture_output=True, text=True, cwd=str(sandbox), timeout=120)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", [LONG, UPPER, PLAIN])
|
||||||
|
@pytest.mark.parametrize("server", ["default", "mam-iso71"])
|
||||||
|
@pytest.mark.parametrize("has_row", [True, False])
|
||||||
|
def test_resume_writes_the_attach_command(mam_sandbox, mock_herdr, mock_agents, name, server, has_row):
|
||||||
|
if has_row:
|
||||||
|
_seed(mam_sandbox, [{"name": name, "status": "stopped", "role": "creator", "herdr_session": "old-srv",
|
||||||
|
"herdr_server": "old-srv", "pane": {"cwd": str(mam_sandbox), "cmd": "claude"}}])
|
||||||
|
res = _resume_update(mam_sandbox, name, server)
|
||||||
|
assert res.returncode == 0, res.stdout + res.stderr
|
||||||
|
(row,) = [r for r in _rows(mam_sandbox) if r["name"] == name]
|
||||||
|
assert row["status"] == "running"
|
||||||
|
_assert_new_form(row, server)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", [LONG, UPPER, PLAIN])
|
||||||
|
def test_reconcile_registers_with_the_attach_command(mam_sandbox, mock_herdr, mock_agents, name):
|
||||||
|
# The pane must exist, or reconcile never reaches the registration.
|
||||||
|
_set_mock(mock_herdr, agents={name: {
|
||||||
|
"agent": "claude", "status": "running", "cwd": str(mam_sandbox), "workspace_id": "w1",
|
||||||
|
"pid": 9999, "pane_id": "w1:p71", "command": "claude", "buffer": "Ready"}})
|
||||||
|
assert not any(r["name"] == name for r in _rows(mam_sandbox))
|
||||||
|
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
||||||
|
res = subprocess.run(["bash", str(script), "--once"], capture_output=True, text=True,
|
||||||
|
cwd=str(mam_sandbox), timeout=300)
|
||||||
|
assert res.returncode == 0, res.stdout + res.stderr
|
||||||
|
rows = [r for r in _rows(mam_sandbox) if r["name"] == name]
|
||||||
|
assert len(rows) == 1, "reconcile wrote no row: " + res.stdout + res.stderr
|
||||||
|
assert rows[0]["last_visible_note"] == "auto-registered by monitor"
|
||||||
|
_assert_new_form(rows[0], rows[0]["herdr_session"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- shim: agent attach --------------------------------------------------------
|
||||||
|
|
||||||
|
def _run_lib(sandbox, body, env=None):
|
||||||
|
lib = sandbox / ".agents" / "skills" / "lib.sh"
|
||||||
|
return subprocess.run(["bash", "-c", 'source "$1"\n' + body, "_", str(lib)], capture_output=True,
|
||||||
|
text=True, cwd=str(sandbox), env=dict(os.environ, **(env or {})), timeout=120)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", [LONG, UPPER, PLAIN])
|
||||||
|
def test_wrapper_attach_calls_herdr_with_the_herdr_name(mam_sandbox, mock_herdr, name):
|
||||||
|
herdr_name = sanitize_herdr_agent_name(name)
|
||||||
|
_set_mock(mock_herdr, strict_agent_names=True, agents={herdr_name: {
|
||||||
|
"agent": "claude", "status": "running", "cwd": str(mam_sandbox), "workspace_id": "w1",
|
||||||
|
"pid": 9999, "pane_id": "w1:p71", "command": "claude", "buffer": "Ready"}})
|
||||||
|
res = _run_lib(mam_sandbox, 'herdr agent attach "$0" --takeover'.replace("$0", name))
|
||||||
|
calls = [c for c in json.loads(Path(mock_herdr).read_text())["calls"] if c[:2] == ["agent", "attach"]]
|
||||||
|
assert calls == [["agent", "attach", herdr_name, "--takeover"]], (calls, res.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
# --- one sanitize rule ----------------------------------------------------------
|
||||||
|
|
||||||
|
NAMES = ["", "plain", "Upper-Case", "9lead", "-dash", "has space/and.dot", "x" * 32, "y" * 33,
|
||||||
|
LONG, LONG.upper(), "creator-claude-fix-issue-99-long-branch", "-n", "-e", "a\\nb",
|
||||||
|
NL, "a\nb", "\n", "x\r\ny", "Up\nper"]
|
||||||
|
UNI_NAMES = [UNI, "caf\u00e9-creator-claude", "\u00fc" * 40, "kelvin-\u212a", "\u65e5\u672c-creator-claude",
|
||||||
|
"\u0130" * 20 + "-creator-claude", "\u00fc\n"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_lib_sh_has_one_definition():
|
||||||
|
text = (SKILLS / "lib.sh").read_text()
|
||||||
|
assert text.count("_sanitize_herdr_agent_name() {") == 1
|
||||||
|
assert "00000000" not in text
|
||||||
|
assert "lambda s: s.lower()[:32]" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_generated_shim_carries_the_same_definition(mam_sandbox):
|
||||||
|
res = _run_lib(mam_sandbox, "declare -f _sanitize_herdr_agent_name")
|
||||||
|
assert res.returncode == 0, res.stderr
|
||||||
|
shim = (mam_sandbox / ".mam" / "shim" / "herdr").read_text()
|
||||||
|
assert shim.startswith("#!/usr/bin/env bash\n")
|
||||||
|
assert shim.count("_sanitize_herdr_agent_name ()") == 1
|
||||||
|
assert res.stdout.strip() in shim
|
||||||
|
|
||||||
|
|
||||||
|
def _definition(tmp_path):
|
||||||
|
"""The function lib.sh defines, as bash prints it (sourced with a tmp workspace)."""
|
||||||
|
res = subprocess.run(["bash", "-c", 'source "$1" >/dev/null 2>&1; declare -f _sanitize_herdr_agent_name',
|
||||||
|
"_", str(SKILLS / "lib.sh")], capture_output=True, text=True,
|
||||||
|
env=dict(os.environ, WORKSPACE_ROOT=str(tmp_path / "defn-ws")))
|
||||||
|
assert res.returncode == 0 and res.stdout, res.stderr
|
||||||
|
return res.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def _tools_dir(tmp_path, tools):
|
||||||
|
d = tmp_path / ("bin-" + "-".join(tools))
|
||||||
|
d.mkdir()
|
||||||
|
for t in tools:
|
||||||
|
src = shutil.which(t)
|
||||||
|
assert src, t
|
||||||
|
(d / t).symlink_to(src)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_with_path(defn, path_dir, name):
|
||||||
|
bash = shutil.which("bash")
|
||||||
|
return subprocess.run([bash, "--noprofile", "--norc", "-c", defn + '\n_sanitize_herdr_agent_name "$1"', "_", name],
|
||||||
|
capture_output=True, text=True, env={"PATH": str(path_dir), "LC_ALL": "C"})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("hashers", [["shasum"], ["sha1sum"], ["openssl"], ["python3"]])
|
||||||
|
def test_each_hasher_alone_matches_python(tmp_path, hashers):
|
||||||
|
if not shutil.which(hashers[0]):
|
||||||
|
pytest.skip(hashers[0] + " not installed")
|
||||||
|
defn = _definition(tmp_path)
|
||||||
|
d = _tools_dir(tmp_path, ["tr", "sed", "awk", "cut"] + hashers)
|
||||||
|
for name in NAMES:
|
||||||
|
res = _sanitize_with_path(defn, d, name)
|
||||||
|
assert res.returncode == 0, (name, res.stderr)
|
||||||
|
assert res.stdout == sanitize_herdr_agent_name(name) + "\n", name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("hashers", [["python3"], ["shasum", "python3"]])
|
||||||
|
def test_a_name_that_is_not_ascii_matches_python(tmp_path, hashers):
|
||||||
|
if not shutil.which(hashers[0]):
|
||||||
|
pytest.skip(hashers[0] + " not installed")
|
||||||
|
defn = _definition(tmp_path)
|
||||||
|
d = _tools_dir(tmp_path, ["tr", "sed", "awk", "cut"] + hashers)
|
||||||
|
for name in UNI_NAMES:
|
||||||
|
res = _sanitize_with_path(defn, d, name)
|
||||||
|
assert res.returncode == 0, (name, res.stderr)
|
||||||
|
assert res.stdout == sanitize_herdr_agent_name(name) + "\n", name
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_name_that_is_not_ascii_needs_python3(tmp_path):
|
||||||
|
defn = _definition(tmp_path)
|
||||||
|
d = _tools_dir(tmp_path, ["tr", "sed", "awk", "cut"])
|
||||||
|
for name in UNI_NAMES:
|
||||||
|
res = _sanitize_with_path(defn, d, name)
|
||||||
|
assert res.returncode != 0 and res.stdout == "" and "not ASCII" in res.stderr, (name, res.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_hasher_at_all_is_an_error_not_a_name(tmp_path):
|
||||||
|
defn = _definition(tmp_path)
|
||||||
|
d = _tools_dir(tmp_path, ["tr", "sed", "awk", "cut"])
|
||||||
|
res = _sanitize_with_path(defn, d, LONG)
|
||||||
|
assert res.returncode != 0 and res.stdout == "" and "ERROR" in res.stderr
|
||||||
|
# a short name needs no hash and still works
|
||||||
|
res = _sanitize_with_path(defn, d, UPPER)
|
||||||
|
assert res.returncode == 0 and res.stdout == UPPER.lower() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _no_hash_bin(tmp_path):
|
||||||
|
"""Stubs: shasum/sha1sum/openssl fail; python3 runs, except the name hash."""
|
||||||
|
d = tmp_path / "nohash"
|
||||||
|
d.mkdir()
|
||||||
|
for t in ("shasum", "sha1sum", "openssl"):
|
||||||
|
(d / t).write_text("#!/bin/sh\nexit 1\n")
|
||||||
|
(d / t).chmod(0o755)
|
||||||
|
(d / "python3").write_text('#!/bin/sh\ncase "$*" in *hashlib.sha1*) exit 1 ;; esac\nexec %s "$@"\n'
|
||||||
|
% shutil.which("python3"))
|
||||||
|
(d / "python3").chmod(0o755)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawn_does_not_go_on_without_the_name(mam_sandbox, mock_herdr, tmp_path):
|
||||||
|
d = _no_hash_bin(tmp_path)
|
||||||
|
body = 'export PATH="%s:$PATH"\nherdr new-session -d -s "%s" -c "%s" claude' % (d, LONG, mam_sandbox)
|
||||||
|
res = _run_lib(mam_sandbox, body)
|
||||||
|
assert res.returncode != 0, res.stdout + res.stderr
|
||||||
|
assert "cannot make the herdr name" in res.stderr
|
||||||
|
calls = json.loads(Path(mock_herdr).read_text())["calls"]
|
||||||
|
assert not [c for c in calls if c[:2] in (["agent", "start"], ["pane", "split"], ["workspace", "create"])], calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookups_report_failure_without_the_name(mam_sandbox, mock_herdr, tmp_path):
|
||||||
|
d = _no_hash_bin(tmp_path)
|
||||||
|
for verb in ('has-session -t "%s"', 'agent get "%s"', 'kill-session -t "%s"'):
|
||||||
|
res = _run_lib(mam_sandbox, 'export PATH="%s:$PATH"\nherdr %s' % (d, verb % LONG))
|
||||||
|
assert res.returncode != 0 and "cannot make the herdr name" in res.stderr, (verb, res.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
# --- registry migration ----------------------------------------------------------
|
||||||
|
|
||||||
|
def _old_rows(ws):
|
||||||
|
def row(name, status, srv):
|
||||||
|
return {"name": name, "status": status, "role": "creator", "herdr_session": srv, "herdr_server": srv,
|
||||||
|
"pane": {"index": 0, "pid": 1, "cmd": "claude", "cwd": str(ws)},
|
||||||
|
"start_command": "HERDR_SESSION_NAME=%s herdr new-session -d -s %s" % (srv, name),
|
||||||
|
"attach_command": "HERDR_SESSION_NAME=%s herdr agent attach %s" % (srv, name),
|
||||||
|
"kill_command": "HERDR_SESSION_NAME=%s herdr kill-session -t %s" % (srv, name)}
|
||||||
|
return [row(LONG, "running", "mam-x"), row(UPPER, "stopped", "default"), row(PLAIN, "terminated", "mam-y")]
|
||||||
|
|
||||||
|
|
||||||
|
def _write_old_registry(ws, with_db):
|
||||||
|
mam = ws / ".mam"
|
||||||
|
mam.mkdir(parents=True, exist_ok=True)
|
||||||
|
top = {"orchestrator_uuids": ["u-1"], "snapshot": {"taken_at": "2026-10-01T00:00:00Z"}}
|
||||||
|
d = dict(top, herdr_sessions=_old_rows(ws))
|
||||||
|
(mam / "agent-sessions.yaml").write_text(yaml.safe_dump(d, sort_keys=False))
|
||||||
|
if with_db:
|
||||||
|
conn = sqlite3.connect(str(mam / "agent-sessions.db"))
|
||||||
|
conn.execute("CREATE TABLE state (id INTEGER PRIMARY KEY, data TEXT)")
|
||||||
|
conn.execute("CREATE TABLE sessions (name TEXT PRIMARY KEY, status TEXT, pane_cwd TEXT, data JSON)")
|
||||||
|
conn.execute("INSERT INTO state VALUES (1, ?)", (json.dumps(top),))
|
||||||
|
for s in d["herdr_sessions"]:
|
||||||
|
conn.execute("INSERT INTO sessions VALUES (?, ?, ?, ?)", (s["name"], s["status"], s["pane"]["cwd"], json.dumps(s)))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return mam / "agent-sessions.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def _entry(yaml_path):
|
||||||
|
return subprocess.run([sys.executable, "-m", "lib_py.registry_format", "--yaml", str(yaml_path)],
|
||||||
|
capture_output=True, text=True, env=dict(os.environ, PYTHONPATH=str(SKILLS)), timeout=60)
|
||||||
|
|
||||||
|
|
||||||
|
def _state(ws):
|
||||||
|
conn = sqlite3.connect(str(ws / ".mam" / "agent-sessions.db"))
|
||||||
|
try:
|
||||||
|
return json.loads(conn.execute("SELECT data FROM state WHERE id=1").fetchone()[0])
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrate_is_pure_and_idempotent(tmp_path):
|
||||||
|
from lib_py import registry_format
|
||||||
|
old = {"herdr_sessions": _old_rows(tmp_path), "snapshot": {}}
|
||||||
|
before = json.dumps(old, sort_keys=True)
|
||||||
|
new, n = registry_format.migrate(old)
|
||||||
|
assert json.dumps(old, sort_keys=True) == before, "the input is not modified"
|
||||||
|
assert n == 3 and new[registry_format.FORMAT_KEY] == registry_format.CURRENT == 2
|
||||||
|
for s in new["herdr_sessions"]:
|
||||||
|
_assert_new_form(s, s["herdr_session"])
|
||||||
|
again, n2 = registry_format.migrate(new)
|
||||||
|
assert n2 == 0 and again == new
|
||||||
|
assert registry_format.stored_version({}) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("with_db", [True, False])
|
||||||
|
def test_entry_point_migrates_yaml_and_db_once(tmp_path, with_db):
|
||||||
|
ws = tmp_path / "ws"
|
||||||
|
yaml_path = _write_old_registry(ws, with_db)
|
||||||
|
res = _entry(yaml_path)
|
||||||
|
assert res.returncode == 0, res.stderr
|
||||||
|
assert res.stdout == "registry-format: 1 -> 2, rows changed: 3\n"
|
||||||
|
|
||||||
|
y = yaml.safe_load(yaml_path.read_text())
|
||||||
|
db = _db_rows(ws)
|
||||||
|
assert y["registry_format"] == 2 and _state(ws)["registry_format"] == 2
|
||||||
|
assert y["orchestrator_uuids"] == ["u-1"] and y["snapshot"]["taken_at"] == "2026-10-01T00:00:00Z"
|
||||||
|
assert {s["name"]: s for s in y["herdr_sessions"]} == db, "YAML and DB agree"
|
||||||
|
for s in y["herdr_sessions"]:
|
||||||
|
_assert_new_form(s, s["herdr_session"])
|
||||||
|
assert {s["name"]: s["status"] for s in y["herdr_sessions"]} == {LONG: "running", UPPER: "stopped", PLAIN: "terminated"}
|
||||||
|
|
||||||
|
text = yaml_path.read_text()
|
||||||
|
res = _entry(yaml_path)
|
||||||
|
assert res.returncode == 0, res.stderr
|
||||||
|
assert res.stdout == "registry-format: 2 -> 2, rows changed: 0\n"
|
||||||
|
assert yaml_path.read_text() == text and _db_rows(ws) == db
|
||||||
|
|
||||||
|
|
||||||
|
def test_any_write_migrates_and_rewrites_the_yaml(own_workspace_root, tmp_path):
|
||||||
|
"""A mutation that changes nothing still leaves YAML and DB in the new form (point A)."""
|
||||||
|
ws = tmp_path / "ws"
|
||||||
|
yaml_path = _write_old_registry(ws, with_db=True)
|
||||||
|
res = subprocess.run(["bash", "-c", 'source "$1" && atomic_dump_yaml "$2"', "_", str(SKILLS / "lib.sh"), str(yaml_path)],
|
||||||
|
input="pass\n", capture_output=True, text=True, timeout=60)
|
||||||
|
assert res.returncode == 0, res.stderr
|
||||||
|
y = yaml.safe_load(yaml_path.read_text())
|
||||||
|
assert y["registry_format"] == 2
|
||||||
|
assert {s["name"]: s for s in y["herdr_sessions"]} == _db_rows(ws)
|
||||||
|
for s in y["herdr_sessions"]:
|
||||||
|
_assert_new_form(s, s["herdr_session"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("retry", ["entry-point", "any-write"])
|
||||||
|
def test_a_retry_repairs_the_yaml_a_failed_migration_left_behind(own_workspace_root, tmp_path, monkeypatch, retry):
|
||||||
|
"""The DB is committed before the YAML is replaced; if that fails, the next run rewrites the YAML."""
|
||||||
|
from lib_py import registry_format
|
||||||
|
ws = tmp_path / "ws"
|
||||||
|
yaml_path = _write_old_registry(ws, with_db=True)
|
||||||
|
real_replace, failed = os.replace, []
|
||||||
|
|
||||||
|
def replace_once(src, dst, *a, **kw):
|
||||||
|
if os.path.abspath(str(dst)) == str(yaml_path) and not failed:
|
||||||
|
failed.append(dst)
|
||||||
|
raise OSError("injected: YAML not replaced")
|
||||||
|
return real_replace(src, dst, *a, **kw)
|
||||||
|
|
||||||
|
monkeypatch.setenv("YAML_PATH", "")
|
||||||
|
monkeypatch.setenv("AGENT_SESSIONS_MUTATION", "")
|
||||||
|
monkeypatch.setattr(os, "replace", replace_once)
|
||||||
|
assert registry_format.main(["--yaml", str(yaml_path)]) == 1 and failed
|
||||||
|
monkeypatch.setattr(os, "replace", real_replace)
|
||||||
|
assert _state(ws)["registry_format"] == 2
|
||||||
|
assert registry_format.stored_version(yaml.safe_load(yaml_path.read_text())) == 1
|
||||||
|
|
||||||
|
if retry == "entry-point":
|
||||||
|
res = _entry(yaml_path)
|
||||||
|
else:
|
||||||
|
res = subprocess.run(["bash", "-c", 'source "$1" && atomic_dump_yaml "$2"', "_", str(SKILLS / "lib.sh"), str(yaml_path)],
|
||||||
|
input="pass\n", capture_output=True, text=True, timeout=60)
|
||||||
|
assert res.returncode == 0, res.stderr
|
||||||
|
y = yaml.safe_load(yaml_path.read_text())
|
||||||
|
assert y["registry_format"] == 2
|
||||||
|
assert {s["name"]: s for s in y["herdr_sessions"]} == _db_rows(ws), "YAML and DB agree"
|
||||||
|
for s in y["herdr_sessions"]:
|
||||||
|
_assert_new_form(s, s["herdr_session"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_new_registry_is_written_current(own_workspace_root, tmp_path):
|
||||||
|
yaml_path = tmp_path / "fresh" / ".mam" / "agent-sessions.yaml"
|
||||||
|
yaml_path.parent.mkdir(parents=True)
|
||||||
|
res = subprocess.run(["bash", "-c", 'source "$1" && atomic_dump_yaml "$2"', "_", str(SKILLS / "lib.sh"), str(yaml_path)],
|
||||||
|
input="d['herdr_sessions'] = []\n", capture_output=True, text=True, timeout=60)
|
||||||
|
assert res.returncode == 0, res.stderr
|
||||||
|
assert yaml.safe_load(yaml_path.read_text())["registry_format"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_entry_point_fails_without_a_registry(tmp_path):
|
||||||
|
res = _entry(tmp_path / "none" / "agent-sessions.yaml")
|
||||||
|
assert res.returncode != 0 and "error" in res.stderr and res.stdout == ""
|
||||||
|
|
||||||
|
|
||||||
|
# --- stop: a session with no registry row ----------------------------------------
|
||||||
|
|
||||||
|
def _registry_bytes(sandbox):
|
||||||
|
out = {}
|
||||||
|
for f in ("agent-sessions.yaml", "agent-sessions.db"):
|
||||||
|
p = sandbox / ".mam" / f
|
||||||
|
out[f] = hashlib.sha256(p.read_bytes()).hexdigest() if p.exists() else None
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _stop(sandbox, name, *extra, server="default", env=None):
|
||||||
|
script = sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||||
|
flag = ["--herdr-session", server] if server else []
|
||||||
|
return subprocess.run(["bash", str(script), "--session", name] + flag + list(extra),
|
||||||
|
capture_output=True, text=True, cwd=str(sandbox), timeout=120,
|
||||||
|
env=dict(os.environ, **(env or {})))
|
||||||
|
|
||||||
|
|
||||||
|
def _agent(cwd, pane):
|
||||||
|
return {"agent": "claude", "status": "running", "cwd": str(cwd), "workspace_id": "w1",
|
||||||
|
"pid": 9999, "pane_id": pane, "command": "claude", "buffer": "Ready"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def registry_with_a_row(mam_sandbox):
|
||||||
|
_seed(mam_sandbox, [{"name": "other-creator-claude", "status": "stopped", "role": "creator",
|
||||||
|
"pane": {"cwd": str(mam_sandbox), "cmd": "claude"}}])
|
||||||
|
return _registry_bytes(mam_sandbox)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_closes_a_rowless_session_of_this_workspace(mam_sandbox, mock_herdr, registry_with_a_row):
|
||||||
|
# no agent suffix and no row: there is no agent kind, and none is needed
|
||||||
|
_set_mock(mock_herdr, agents={"orphan71": _agent(mam_sandbox / "sub", "w1:p71")})
|
||||||
|
res = _stop(mam_sandbox, "orphan71")
|
||||||
|
assert res.returncode == 0, res.stdout + res.stderr
|
||||||
|
assert "orphan71" not in json.loads(Path(mock_herdr).read_text())["agents"]
|
||||||
|
assert _registry_bytes(mam_sandbox) == registry_with_a_row
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_refuses_a_rowless_session_of_another_workspace(mam_sandbox, mock_herdr, registry_with_a_row, tmp_path_factory):
|
||||||
|
elsewhere = tmp_path_factory.mktemp("elsewhere")
|
||||||
|
_set_mock(mock_herdr, agents={"foreign71-creator-claude": _agent(elsewhere, "w1:p72")})
|
||||||
|
res = _stop(mam_sandbox, "foreign71-creator-claude")
|
||||||
|
assert res.returncode == 5, res.stdout + res.stderr
|
||||||
|
assert "outside this workspace" in res.stderr
|
||||||
|
assert "foreign71-creator-claude" in json.loads(Path(mock_herdr).read_text())["agents"]
|
||||||
|
calls = json.loads(Path(mock_herdr).read_text())["calls"]
|
||||||
|
assert not [c for c in calls if c[:2] == ["pane", "close"]], calls
|
||||||
|
assert _registry_bytes(mam_sandbox) == registry_with_a_row
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_reports_a_rowless_session_that_is_not_there(mam_sandbox, mock_herdr, registry_with_a_row):
|
||||||
|
_set_mock(mock_herdr, strict_agent_names=True)
|
||||||
|
res = _stop(mam_sandbox, "absent71")
|
||||||
|
assert res.returncode == 1, res.stdout + res.stderr
|
||||||
|
assert "has no agent of that name" in res.stderr
|
||||||
|
assert _registry_bytes(mam_sandbox) == registry_with_a_row
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_without_a_row_uses_the_environments_server(mam_sandbox, mock_herdr, registry_with_a_row):
|
||||||
|
"""No --herdr-session: the server is HERDR_SESSION_NAME, as create chose it, not one derived from the workspace."""
|
||||||
|
_set_mock(mock_herdr, agents={"orphan73": _agent(mam_sandbox, "w1:p74")}, calls=[])
|
||||||
|
res = _stop(mam_sandbox, "orphan73", server=None, env={"HERDR_SESSION_NAME": "wanted-env-server"})
|
||||||
|
assert res.returncode == 0, res.stdout + res.stderr
|
||||||
|
assert "wanted-env-server" in res.stdout
|
||||||
|
calls = json.loads(Path(mock_herdr).read_text())["calls"]
|
||||||
|
# every call on an agent or pane names that server (`session list` and `config check` name none)
|
||||||
|
targeted = [c for c in calls if "agent" in c[:3] or "pane" in c[:3]]
|
||||||
|
assert targeted and all(c[:2] == ["--session", "wanted-env-server"] for c in targeted), calls
|
||||||
|
assert ["--session", "wanted-env-server", "agent", "get", "orphan73"] in calls
|
||||||
|
assert _registry_bytes(mam_sandbox) == registry_with_a_row
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_does_not_take_an_unreadable_row_for_no_row(mam_sandbox, mock_herdr):
|
||||||
|
"""Only "no such row" leads to the row-less path; a lookup that fails otherwise stops."""
|
||||||
|
db = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||||
|
if db.exists():
|
||||||
|
db.unlink()
|
||||||
|
(mam_sandbox / ".mam" / "agent-sessions.yaml").write_text("herdr_sessions:\n- 42\n")
|
||||||
|
_set_mock(mock_herdr, agents={"orphan74": _agent(mam_sandbox, "w1:p75")})
|
||||||
|
res = _stop(mam_sandbox, "orphan74")
|
||||||
|
assert res.returncode == 1, res.stdout + res.stderr
|
||||||
|
assert "cannot read the row" in res.stderr
|
||||||
|
assert "orphan74" in json.loads(Path(mock_herdr).read_text())["agents"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("broken", ["yaml-without-db", "db", "no-sessions-table", "no-tables"])
|
||||||
|
def test_stop_does_not_take_an_unreadable_registry_for_no_row(mam_sandbox, mock_herdr, broken):
|
||||||
|
"""load_state_json's {} on a read error is not "no row": a registered session is not closed row-less."""
|
||||||
|
name = "orphan75-creator-claude"
|
||||||
|
_seed(mam_sandbox, [{"name": name, "status": "running", "role": "creator", "agent": "claude",
|
||||||
|
"pane": {"cwd": str(mam_sandbox), "cmd": "claude"}}])
|
||||||
|
mam = mam_sandbox / ".mam"
|
||||||
|
if broken == "yaml-without-db":
|
||||||
|
for f in mam.glob("agent-sessions.db*"):
|
||||||
|
f.unlink()
|
||||||
|
(mam / "agent-sessions.yaml").write_text("herdr_sessions: [\n - {name: " + name + "\n")
|
||||||
|
elif broken == "db":
|
||||||
|
for f in mam.glob("agent-sessions.db-*"):
|
||||||
|
f.unlink()
|
||||||
|
(mam / "agent-sessions.db").write_bytes(b"this is not a database" * 64)
|
||||||
|
else:
|
||||||
|
# a missing table is not an empty registry
|
||||||
|
conn = sqlite3.connect(str(mam / "agent-sessions.db"))
|
||||||
|
conn.execute("DROP TABLE sessions")
|
||||||
|
if broken == "no-tables":
|
||||||
|
conn.execute("DROP TABLE state")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
_set_mock(mock_herdr, agents={name: _agent(mam_sandbox, "w1:p76")})
|
||||||
|
res = _stop(mam_sandbox, name)
|
||||||
|
assert res.returncode == 1, res.stdout + res.stderr
|
||||||
|
assert "cannot read" in res.stderr and "no registry row" not in res.stdout
|
||||||
|
assert name in json.loads(Path(mock_herdr).read_text())["agents"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_without_a_row_purges_nothing(mam_sandbox, mock_herdr, registry_with_a_row):
|
||||||
|
_set_mock(mock_herdr, agents={"orphan72": _agent(mam_sandbox, "w1:p73")})
|
||||||
|
res = _stop(mam_sandbox, "orphan72", "--purge-conversation", "--yes")
|
||||||
|
assert res.returncode == 1, res.stdout + res.stderr
|
||||||
|
assert "orphan72" in json.loads(Path(mock_herdr).read_text())["agents"]
|
||||||
|
assert _registry_bytes(mam_sandbox) == registry_with_a_row
|
||||||
|
|
||||||
|
|
||||||
|
# --- stall_watch -----------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_stall_watch_works_out_the_herdr_name_once(monkeypatch):
|
||||||
|
from lib_py import stall_watch
|
||||||
|
seen = []
|
||||||
|
real = stall_watch.sanitize_herdr_agent_name
|
||||||
|
monkeypatch.setattr(stall_watch, "sanitize_herdr_agent_name", lambda n: seen.append(n) or real(n))
|
||||||
|
|
||||||
|
class Args(object):
|
||||||
|
target, agent, herdr_session, registry_dir, job = LONG, None, "mam-x", "/nonexistent", "j"
|
||||||
|
|
||||||
|
sw = stall_watch.StallWatch(Args())
|
||||||
|
assert seen == [LONG]
|
||||||
|
assert sw.herdr_name == sw.src.name == sanitize_herdr_agent_name(LONG)
|
||||||
|
assert sw.src.target == LONG and sw.src.agent == "claude"
|
||||||
@@ -239,7 +239,8 @@ def test_lib_sh_no_local_in_shim_heredoc():
|
|||||||
with open(lib_path, "r", encoding="utf-8") as f:
|
with open(lib_path, "r", encoding="utf-8") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
start_idx = content.find("cat <<'EOF' > \"$tmp_file\"")
|
# Issue #71: the shim is written as { printf ...; declare -f ...; cat <<'EOF' ... } > "$tmp_file"
|
||||||
|
start_idx = content.find("cat <<'EOF'\n")
|
||||||
end_idx = content.find("\nEOF\n", start_idx)
|
end_idx = content.find("\nEOF\n", start_idx)
|
||||||
assert start_idx != -1 and end_idx != -1
|
assert start_idx != -1 and end_idx != -1
|
||||||
|
|
||||||
|
|||||||
@@ -217,7 +217,11 @@ def test_stop_is_already_stopped_not_found(mam_sandbox):
|
|||||||
assert res.returncode == 1
|
assert res.returncode == 1
|
||||||
|
|
||||||
def test_stop_session_invalid_agent_suffix(mam_sandbox):
|
def test_stop_session_invalid_agent_suffix(mam_sandbox):
|
||||||
"""Test stop_session.sh fails when agent cannot be inferred from session name."""
|
"""Test stop_session.sh fails when agent cannot be inferred from session name.
|
||||||
|
Issue #71: a session with no row needs no agent kind (stop closes it herdr
|
||||||
|
only), so the registered row here carries no agent, suffix or pane.cmd."""
|
||||||
|
(mam_sandbox / ".mam" / "agent-sessions.yaml").write_text(
|
||||||
|
"herdr_sessions:\n- name: bad-session-name\n status: running\n pane: {}\n")
|
||||||
script_path = mam_sandbox / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
script_path = mam_sandbox / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||||
res = subprocess.run(["bash", str(script_path), "--session", "bad-session-name"], capture_output=True, text=True)
|
res = subprocess.run(["bash", str(script_path), "--session", "bad-session-name"], capture_output=True, text=True)
|
||||||
assert res.returncode == 2
|
assert res.returncode == 2
|
||||||
|
|||||||
@@ -214,13 +214,13 @@ def test_comp_create_herdr_session_default_preserved(mam_sandbox, mock_herdr, mo
|
|||||||
assert s["name"] == "custom-proj-default-claude"
|
assert s["name"] == "custom-proj-default-claude"
|
||||||
assert s["herdr_session"] == "default"
|
assert s["herdr_session"] == "default"
|
||||||
assert s["herdr_server"] == "default"
|
assert s["herdr_server"] == "default"
|
||||||
assert "HERDR_SESSION_NAME=default" in s["start_command"]
|
# Issue #71: the native form, `default` named too; no start/kill_command
|
||||||
assert "HERDR_SESSION_NAME=default" in s["attach_command"]
|
assert s["attach_command"] == "herdr --session default agent attach custom-proj-default-claude"
|
||||||
assert "HERDR_SESSION_NAME=default" in s["kill_command"]
|
assert "start_command" not in s and "kill_command" not in s
|
||||||
|
|
||||||
|
|
||||||
def test_comp_create_herdr_session_yaml_propagation(mam_sandbox, mock_herdr, mock_agents):
|
def test_comp_create_herdr_session_yaml_propagation(mam_sandbox, mock_herdr, mock_agents):
|
||||||
"""Verify HERDR_SESSION_NAME propagation into start_command / herdr_session YAML field."""
|
"""Verify HERDR_SESSION_NAME propagation into attach_command / herdr_session YAML field."""
|
||||||
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||||
res = subprocess.run([
|
res = subprocess.run([
|
||||||
"bash", str(script),
|
"bash", str(script),
|
||||||
@@ -242,9 +242,8 @@ def test_comp_create_herdr_session_yaml_propagation(mam_sandbox, mock_herdr, moc
|
|||||||
assert s["name"] == "custom-proj-creator-claude"
|
assert s["name"] == "custom-proj-creator-claude"
|
||||||
assert s["herdr_session"] == "isolated-suite-01"
|
assert s["herdr_session"] == "isolated-suite-01"
|
||||||
assert s["herdr_server"] == "isolated-suite-01"
|
assert s["herdr_server"] == "isolated-suite-01"
|
||||||
assert "HERDR_SESSION_NAME=isolated-suite-01" in s["start_command"]
|
assert s["attach_command"] == "herdr --session isolated-suite-01 agent attach custom-proj-creator-claude"
|
||||||
assert "HERDR_SESSION_NAME=isolated-suite-01" in s["attach_command"]
|
assert "start_command" not in s and "kill_command" not in s
|
||||||
assert "HERDR_SESSION_NAME=isolated-suite-01" in s["kill_command"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_comp_create_herdr_workspace_parsing_and_env_fallback(mam_sandbox, mock_herdr, mock_agents):
|
def test_comp_create_herdr_workspace_parsing_and_env_fallback(mam_sandbox, mock_herdr, mock_agents):
|
||||||
@@ -332,9 +331,9 @@ def test_comp_create_herdr_workspace_yaml_propagation(mam_sandbox, mock_herdr, m
|
|||||||
assert s["herdr_session"] == "isolated-sock-01"
|
assert s["herdr_session"] == "isolated-sock-01"
|
||||||
assert s["herdr_server"] == "isolated-sock-01"
|
assert s["herdr_server"] == "isolated-sock-01"
|
||||||
assert s["herdr_workspace"] == "distinct-ws-label"
|
assert s["herdr_workspace"] == "distinct-ws-label"
|
||||||
assert "distinct-ws-label" not in s["start_command"]
|
|
||||||
assert "distinct-ws-label" not in s["attach_command"]
|
assert "distinct-ws-label" not in s["attach_command"]
|
||||||
assert "distinct-ws-label" not in s["kill_command"]
|
assert s["attach_command"] == "herdr --session isolated-sock-01 agent attach custom-ws-creator-claude"
|
||||||
|
assert "start_command" not in s and "kill_command" not in s
|
||||||
|
|
||||||
|
|
||||||
def test_create_does_not_inherit_a_stale_workspace_label(mam_sandbox, mock_herdr, mock_agents):
|
def test_create_does_not_inherit_a_stale_workspace_label(mam_sandbox, mock_herdr, mock_agents):
|
||||||
@@ -573,8 +572,8 @@ d['herdr_sessions'] = [{{
|
|||||||
assert s["status"] == "running"
|
assert s["status"] == "running"
|
||||||
assert s["herdr_session"] == "NEW-HERDR-SESSION"
|
assert s["herdr_session"] == "NEW-HERDR-SESSION"
|
||||||
assert s["herdr_server"] == "NEW-HERDR-SESSION"
|
assert s["herdr_server"] == "NEW-HERDR-SESSION"
|
||||||
assert "HERDR_SESSION_NAME=NEW-HERDR-SESSION" in s["attach_command"]
|
assert s["attach_command"] == "herdr --session NEW-HERDR-SESSION agent attach test-proj-creator-claude"
|
||||||
assert "HERDR_SESSION_NAME=NEW-HERDR-SESSION" in s["kill_command"]
|
assert "start_command" not in s and "kill_command" not in s
|
||||||
|
|
||||||
|
|
||||||
def test_comp_resume_herdr_workspace_propagation(mam_sandbox, mock_herdr, mock_agents):
|
def test_comp_resume_herdr_workspace_propagation(mam_sandbox, mock_herdr, mock_agents):
|
||||||
|
|||||||
@@ -393,6 +393,10 @@ def test_integration_invalid_arguments_exit_statuses(mam_sandbox, mock_herdr, mo
|
|||||||
assert "ERROR: --session required" in res.stderr
|
assert "ERROR: --session required" in res.stderr
|
||||||
|
|
||||||
# 3. stop_session.sh cannot infer agent from weird session name
|
# 3. stop_session.sh cannot infer agent from weird session name
|
||||||
|
# (Issue #71: a session with no row needs no agent kind, so the row is
|
||||||
|
# registered here, with no agent, suffix or pane.cmd)
|
||||||
|
(tmp_path / ".mam" / "agent-sessions.yaml").write_text(
|
||||||
|
"herdr_sessions:\n- name: bad-name\n status: running\n pane: {}\n")
|
||||||
res = subprocess.run(["bash", str(stop_script), "--session", "bad-name"], capture_output=True, text=True, cwd=str(tmp_path))
|
res = subprocess.run(["bash", str(stop_script), "--session", "bad-name"], capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
assert res.returncode == 2
|
assert res.returncode == 2
|
||||||
assert "ERROR: cannot infer agent" in res.stderr
|
assert "ERROR: cannot infer agent" in res.stderr
|
||||||
|
|||||||
Reference in new issue
Block a user